Leçon 6 : les channels
Sections
Réglages de lecture
Voix naturelle enregistrée pour ce cours, la même dans tous les navigateurs.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
La partie 2 du cours porte sur le transfert de données entre morceaux de code concurrents. La leçon 3 a montré ce que fait un seul await ; il y a maintenant des producteurs et des consommateurs qui s’exécutent en même temps, à des vitesses différentes, et les questions changent. Que se passe-t-il quand le producteur va plus vite que le consommateur ? Qui remarque qu’un côté a échoué ? Qu’est-ce qui arrête l’autre côté quand l’un des deux abandonne ?
.NET a quatre réponses, et les quatre leçons suivantes les prennent une à une : les channels ici, TPL Dataflow dans la leçon 7, Rx.NET dans la leçon 8, et IAsyncEnumerable avec une comparaison des quatre dans la leçon 9. Un channel est la plus simple : une file thread-safe avec un écrivain asynchrone à un bout et un lecteur asynchrone à l’autre. Si vous connaissez Java, c’est une BlockingQueue dont put et take ne bloquent pas de thread. Si vous connaissez Reactor, le cours Spring Boot, Spring Cloud et Reactor montre le même problème vu de l’autre côté, avec des signaux de demande au lieu d’une file.
Guitar Alchemist utilise des channels à deux endroits, et les deux sont de bonnes études de cas : ils fonctionnent quand tout va bien, et ils se bloquent ou continuent de travailler pour personne quand ce n’est pas le cas.
Les liens vers GA pointent sur le commit a826864 ; les liens vers le runtime pointent sur le commit 4271d88 de dotnet/runtime, étiqueté v10.0.12.
Exécuter le programme de la leçon
Section intitulée « Exécuter le programme de la leçon »bash code/csharp-advanced/check.sh # toutes les leçons, comparées avec expected/dotnet run --project code/csharp-advanced/Advanced -c Release -- l6 # cette leçon seulement, après check.shLe code se trouve dans Advanced/Lesson6.cs. Toutes les sorties ci-dessous viennent de expected/l6.txt, comparé sous Linux, Windows et macOS. Un programme concurrent est difficile à rendre déterministe, alors le programme ne se fie jamais au temps pour décider de ce qu’il affiche : il retient une tâche avec un TaskCompletionSource, attend un état qui ne peut plus changer, ou affiche une comparaison plutôt qu’un compte. Les quelques lignes qui dépendent de la machine commencent par # et ne sont pas comparées.
Une file à deux bouts
Section intitulée « Une file à deux bouts »Channel<T> n’est qu’une paire : un ChannelWriter<T> et un ChannelReader<T>. Vous donnez l’écrivain au code qui produit et le lecteur au code qui consomme, pour qu’aucun ne puisse faire le travail de l’autre. Les méthodes de fabrique statiques de Channel choisissent l’implémentation d’après les options passées :
== Which channel you getCreateUnbounded<int>() UnboundedChannel<T> CanCount True, CanPeek TrueCreateUnbounded<int>(SingleReader = true) SingleConsumerUnboundedChannel<T> CanCount False, CanPeek TrueCreateBounded<int>(10) BoundedChannel<T> CanCount True, CanPeek TrueCreateBounded<int>(10, SingleReader = true) BoundedChannel<T> CanCount True, CanPeek TrueCreateUnboundedPrioritized<int>() UnboundedPrioritizedChannel<T> CanCount True, CanPeek Trueflowchart LR p1[Producteur 1] -->|WriteAsync| w[ChannelWriter] p2[Producteur 2] -->|WriteAsync| w w --> q[(File, bornée ou non)] q --> r[ChannelReader] r -->|ReadAllAsync| c1[Consommateur 1] r -->|ReadAllAsync| c2[Consommateur 2]- Les channels non bornés acceptent chaque écriture immédiatement. La file grossit tant que le consommateur est plus lent.
- Les channels bornés ont une capacité, et une politique pour quand elle est atteinte.
SingleReader = truepromet qu’un seul consommateur lit à la fois. Un channel non borné reçoit en échange une implémentation plus légère,SingleConsumerUnboundedChannel, qui ne sait pas compter ses éléments :Reader.CountlèveNotSupportedException, ce qui compte pour l’étude de cas de GA plus bas. Un channel borné garde la même implémentation.CreateUnboundedPrioritized, ajouté dans .NET 9, rend les éléments dans l’ordre d’un comparateur au lieu de l’ordre d’écriture.
Les options sont des promesses, pas des vérifications : un channel SingleReader lu par deux consommateurs à la fois n’est pas détecté, il se comporte simplement mal.
Backpressure : un channel borné fait attendre l’écrivain
Section intitulée « Backpressure : un channel borné fait attendre l’écrivain »Un channel borné de capacité 2, dans le mode par défaut, BoundedChannelFullMode.Wait. Le programme écrit quatre accords sans lire, puis en lit un :
static async Task Backpressure(){ Title("Backpressure: a bounded channel of capacity 2 makes the writer wait"); var channel = Channel.CreateBounded<string>(new BoundedChannelOptions(2) { FullMode = BoundedChannelFullMode.Wait }); var writes = new List<Task>(); foreach (var chord in Progression) { var write = channel.Writer.WriteAsync(chord); Line($"WriteAsync({chord}): completed {write.IsCompleted}, Reader.Count {channel.Reader.Count}"); writes.Add(write.AsTask()); }
Line($"TryWrite(Dm7): {channel.Writer.TryWrite("Dm7")}"); var first = await channel.Reader.ReadAsync(); Line($"ReadAsync: {first}; Reader.Count {channel.Reader.Count}, Cmaj7 moved in from the waiting writer"); await writes[2]; Line($"WriteAsync(Cmaj7) has completed; WriteAsync(A7) still waiting {!writes[3].IsCompleted}");
var rest = new List<string> { first }; for (var i = 1; i < Progression.Length; i++) { rest.Add(await channel.Reader.ReadAsync()); }
await Task.WhenAll(writes); Line($"the reader got {string.Join(" ", rest)}, in the order written");}== Backpressure: a bounded channel of capacity 2 makes the writer waitWriteAsync(Dm7): completed True, Reader.Count 1WriteAsync(G7): completed True, Reader.Count 2WriteAsync(Cmaj7): completed False, Reader.Count 2WriteAsync(A7): completed False, Reader.Count 2TryWrite(Dm7): FalseReadAsync: Dm7; Reader.Count 2, Cmaj7 moved in from the waiting writerWriteAsync(Cmaj7) has completed; WriteAsync(A7) still waiting Truethe reader got Dm7 G7 Cmaj7 A7, in the order written- Les deux premières écritures se sont terminées de façon synchrone. Les deux suivantes ont renvoyé un
ValueTasknon terminé : l’écrivain l’attend, et aucun thread n’est bloqué pendant l’attente. TryWriteest la version synchrone, et renvoiefalsequand le channel est plein.- Lire
Dm7a libéré de la place, et le channel y a aussitôt placé l’élément du premier écrivain en attente : le compte est revenu à 2, etWriteAsync(Cmaj7)s’est terminé.A7attend encore la lecture suivante. - Le lecteur a reçu les accords dans l’ordre d’écriture : les écrivains en attente forment aussi une file.
C’est la backpressure : un consommateur lent ralentit le producteur, au lieu de laisser les éléments s’accumuler en mémoire. Avec IAsyncEnumerable, que couvre la leçon 9, elle est automatique parce que le consommateur tire. Avec un channel, c’est un choix que vous faites en le créant.
Quand le channel est plein
Section intitulée « Quand le channel est plein »Wait n’est pas la seule politique. Les trois autres ne font jamais attendre l’écrivain, et jettent un élément à la place. Le programme écrit 1 à 6 dans un channel de capacité 3 avec TryWrite, dans chaque mode, et passe le callback itemDropped de Channel.CreateBounded pour voir ce qui disparaît :
== A full channel: BoundedChannelFullMode, capacity 3, TryWrite 1 to 6Wait TryWrite true true true false false false reader gets 1 2 3 dropped -DropNewest TryWrite true true true true true true reader gets 1 2 6 dropped 3 4 5DropOldest TryWrite true true true true true true reader gets 4 5 6 dropped 1 2 3DropWrite TryWrite true true true true true true reader gets 1 2 3 dropped 4 5 6Waitrefuse l’écriture :TryWriterenvoiefalse, etWriteAsyncattendrait.DropNewestretire l’élément le plus récent déjà dans le channel, et accepte le nouveau : 3, 4 et 5 ont chacun été écrits, puis jetés par l’écriture suivante.DropOldestretire l’élément qui attend depuis le plus longtemps : le lecteur reçoit les trois dernières valeurs. C’est le mode « dernières mesures seulement », comme la position d’un curseur.DropWritejette l’élément en cours d’écriture, et le lecteur garde les trois premiers.
Dans les trois modes qui jettent, TryWrite renvoie true pour un élément jeté. La source du channel le dit dans un commentaire : « Just ignore the item being added but say we added it ». Le cours Reactor est tombé dans le même piège avec DropWrite. Le callback itemDropped, disponible depuis .NET 6, est le seul endroit où vous apprenez la perte ; le channel l’appelle après avoir relâché son verrou, si bien que le callback peut prendre son temps sans bloquer les autres écrivains.
Complétion et erreurs
Section intitulée « Complétion et erreurs »Un producteur signale la fin avec Writer.Complete(). Compléter ne vide pas le channel : le lecteur draine d’abord ce qui a été écrit.
== Completion: the reader drains what was written, then sees the endafter Complete(): Reader.Count 2, Reader.Completion.IsCompleted FalseReadAllAsync: Dm7 G7; Reader.Completion RanToCompletionWaitToReadAsync: False, TryRead: FalseReader.Completion est une tâche qui se termine une fois le channel à la fois complété et vide, et WaitToReadAsync renvoie alors false, c’est ainsi que ReadAllAsync sait quand s’arrêter (ChannelReader.cs#L103-L112).
Un producteur qui échoue peut passer son exception à Complete :
== Completion with an errorReadAllAsync: Dm7 G7, then InvalidOperationException: the chord source failedReadAsync: ChannelClosedException: The channel has been closed. (inner InvalidOperationException: the chord source failed)Reader.Completion: Faulted, InvalidOperationException: the chord source failedWriteAsync after Complete: ChannelClosedException: The channel has been closed. (inner InvalidOperationException: the chord source failed)TryWrite after Complete: False, TryComplete: False, Complete: ChannelClosedException: The channel has been closed.- Le lecteur reçoit quand même les deux accords écrits avant l’échec.
await foreachsurReadAllAsynclève ensuite la propreInvalidOperationExceptiondu producteur. UnReadAsyncdirect lève uneChannelClosedExceptionqui l’enveloppe. La différence vient de la source :WaitToReadAsync, qu’utiliseReadAllAsync, renvoie une tâche en échec avec l’exception stockée (UnboundedChannel.cs#L159-L166), tandis queReadAsyncl’enveloppe (ChannelUtilities.cs#L361-L364). Attrapez les deux quand vous lisez des deux façons.- Écrire dans un channel complété lève
ChannelClosedException;TryWriterenvoiefalse. - Un second
Completelève une exception, etTryCompleterenvoiefalse. Quand plusieurs morceaux de code peuvent compléter le même channel, le producteur et un gestionnaire d’erreurs par exemple, utilisezTryComplete.
La règle à retenir : si personne n’appelle Complete, le lecteur attend indéfiniment. Un channel ne sait pas que son producteur a planté. Les deux études de cas de GA plus bas se ramènent à cela.
Plusieurs producteurs, plusieurs consommateurs
Section intitulée « Plusieurs producteurs, plusieurs consommateurs »Trois producteurs écrivent 100 éléments chacun dans un channel borné de capacité 2, et deux consommateurs le lisent :
static async Task ProducersAndConsumers(){ Title("Three producers, two consumers, one bounded channel"); var channel = Channel.CreateBounded<(int Producer, int Index)>(new BoundedChannelOptions(2) { SingleReader = false, SingleWriter = false }); var received = new List<(int Consumer, int Producer, int Index)>();
var consumers = Enumerable.Range(1, 2).Select(consumer => Task.Run(async () => { await foreach (var (producer, index) in channel.Reader.ReadAllAsync()) { lock (received) { received.Add((consumer, producer, index)); } } })).ToList();
var producers = Enumerable.Range(1, 3).Select(producer => Task.Run(async () => { for (var index = 0; index < 100; index++) { await channel.Writer.WriteAsync((producer, index)); } })).ToList();
try { await Task.WhenAll(producers); channel.Writer.Complete(); } catch (Exception e) { channel.Writer.Complete(e); }
await Task.WhenAll(consumers);
var everyItemOnce = received.Select(r => (r.Producer, r.Index)).Distinct().Count() == 300 && received.Count == 300; var inOrderPerProducerAndConsumer = received.GroupBy(r => (r.Consumer, r.Producer)).All(g => g.Select(r => r.Index).SequenceEqual(g.Select(r => r.Index).Order())); Line($"300 items written, received {received.Count}, each exactly once: {everyItemOnce}"); Line($"each consumer saw each producer's items in the order written: {inOrderPerProducerAndConsumer}"); Machine($"consumer 1 read {received.Count(r => r.Consumer == 1)}, consumer 2 read {received.Count(r => r.Consumer == 2)}");}== Three producers, two consumers, one bounded channel300 items written, received 300, each exactly once: Trueeach consumer saw each producer's items in the order written: TrueChaque élément est arrivé exactement une fois, et chez chaque consommateur, les éléments de chaque producteur ont gardé leur ordre. La répartition entre les consommateurs dépend de l’ordonnancement ; sur la machine de l’auteur :
# consumer 1 read 91, consumer 2 read 209Regardez comment l’écrivain est complété : après la fin de tous les producteurs, et avec l’exception si l’un d’eux a échoué. Un Task.WhenAll(producers) sans ce try/catch laisserait les consommateurs attendre chaque fois qu’un producteur lève une exception.
Annulation
Section intitulée « Annulation »WriteAsync, ReadAsync et WaitToReadAsync acceptent un CancellationToken, qui annule l’attente, pas le channel :
== Cancellation: a write waiting for room, a read waiting for an itemWriteAsync(G7) cancelled: OperationCanceledException, e.CancellationToken == token TrueReader.Count 1: G7 was not written; the reader gets Dm7ReadAsync on an empty channel, cancelled: OperationCanceledException: The operation was canceled.the channel still works: TryWrite(Cmaj7) True, TryRead Cmaj7L’écriture annulée n’a pas mis G7 dans le channel, l’exception porte le jeton qui l’a annulée (la leçon 3 a montré pourquoi c’est utile), et le channel a continué de fonctionner. Pour arrêter tout un pipeline, il faut encore annuler les producteurs et compléter l’écrivain vous-même.
Étude de cas : le générateur de voicings de GA
Section intitulée « Étude de cas : le générateur de voicings de GA »GA génère tous les voicings de guitare d’un manche en faisant glisser une fenêtre de quelques cases le long du manche. VoicingGenerator.GenerateAllVoicingsAsync le fait en parallèle : Parallel.ForEachAsync calcule les fenêtres et écrit la liste des voicings de chaque fenêtre dans un channel non borné, et la méthode, un itérateur asynchrone, lit le channel et rend les voicings à son appelant.
Le projet qui la contient dépend d’ONNX Runtime, d’ILGPU et d’une douzaine de paquets, trop lourd à compiler dans la CI de ce cours. Le programme reproduit plutôt la forme de la méthode ligne à ligne, avec les voicings remplacés par des chaînes, et un WindowProbe qui compte les fenêtres générées et peut en faire échouer une (GaShapeVoicings) :
// La forme de VoicingGenerator.GenerateAllVoicingsAsync de GA, branche parallèle (VoicingGenerator.cs L180-L249) :// des producteurs parallèles écrivent des fenêtres entières dans un channel non borné depuis Task.Run, l'écrivain est complété après la// boucle, et la tâche productrice est attendue après la boucle du lecteur. Le contenu des fenêtres est remplacé par des chaînes.public static async IAsyncEnumerable<string> GaShapeVoicings(int windows, WindowProbe probe, [EnumeratorCancellation] CancellationToken cancellationToken = default){ var channel = Channel.CreateUnbounded<(int WindowIndex, List<string> Voicings)>(new() { SingleReader = true, SingleWriter = false }); probe.Reader = channel.Reader;
var producerTask = Task.Run(async () => { await Parallel.ForEachAsync( Enumerable.Range(0, windows), new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = cancellationToken }, async (window, ct) => { var voicings = probe.Generate(window); await channel.Writer.WriteAsync((window, voicings), ct); });
channel.Writer.Complete(); }, cancellationToken); probe.Producer = producerTask;
await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken)) { foreach (var voicing in result.Voicings) { yield return voicing; } }
await producerTask;}Les deux affectations probe. servent seulement au programme à observer le channel et la tâche productrice de l’extérieur. Le reste est la structure de GA.
Le consommateur s’arrête tôt
Section intitulée « Le consommateur s’arrête tôt »Les propres appelants de GA s’arrêtent tôt. L’outil en ligne de commande sort de la boucle une fois qu’il a affiché assez de voicings (FretboardVoicingsCLI/Program.cs#L577-L580), et les exemples d’utilisation enchaînent .Take(100) (USAGE_EXAMPLES.md#L31-L41). Le programme lit un voicing sur 24 fenêtres, puis sort :
== GA's voicing generator shape: the consumer stops after one voicingone voicing read, then breakproducer completed within 10 s: True; windows generated 24 of 24, left unread in the channel 23Quitter un await foreach libère l’itérateur, ce qui termine la méthode sur son yield return courant : le await producerTask final ne s’exécute jamais. Rien ne prévient les producteurs. Ils ont généré les 24 fenêtres et les ont écrites dans un channel non borné que personne ne lira. Le travail est perdu, et chaque voicing reste en mémoire jusqu’à ce que le channel lui-même soit collecté. Dans GA, où une fenêtre contient des milliers de voicings, c’est tout le coût CPU et mémoire de la génération pour un appelant qui en voulait cent.
Une fenêtre échoue
Section intitulée « Une fenêtre échoue »Maintenant la fenêtre 5 lève une exception :
== GA's voicing generator shape: window 5 throwsproducer: faulted with InvalidOperationException: window 5 failedconsumer finished within 1 s: FalseParallel.ForEachAsync cesse de démarrer des fenêtres et relance l’exception, la tâche productrice passe en échec, et channel.Writer.Complete(), à la ligne suivante, n’est jamais atteint. Le consommateur a lu les fenêtres écrites avant l’échec, puis attend une complétion qui ne viendra jamais. Il n’échoue pas : il se bloque, et l’exception reste non observée dans une tâche que personne n’attend.
L’ordre survit-il ?
Section intitulée « L’ordre survit-il ? »Le commentaire de la méthode dit « Use channels for parallel processing with ordering preserved » (VoicingGenerator.cs#L182). Les fenêtres s’exécutent en parallèle et sont écrites quand elles se terminent, donc le channel les contient dans l’ordre de fin. Le programme retient la fenêtre 0 jusqu’à ce que le lecteur ait reçu quelque chose :
== GA's voicing generator shape: does window order survive?window 0 held until the reader got something: first voicing from window 0 False, 8 windows read TrueLe premier voicing venait d’une autre fenêtre. Sur la machine de l’auteur, l’ordre lu était :
# order read: w2-v0 w1-v0 w3-v0 w4-v0 w5-v0 w0-v0 w6-v0 w7-v0Les commentaires plus bas dans la même méthode le reconnaissent (« We lose strict fret-order, but indexing doesn’t care about order », #L219-L231) : le code est juste, le commentaire de résumé ne l’est pas. L’indice de fenêtre est écrit dans le channel avec chaque liste et jamais utilisé ; ce serait la clé pour rétablir l’ordre si un appelant en avait besoin.
La correction
Section intitulée « La correction »Trois changements, tous dans la méthode (FixedVoicings) :
// Le même générateur avec un channel borné, l'erreur transmise au lecteur, et les producteurs arrêtés// et attendus dès que le lecteur s'arrête : à la fin, sur une erreur, ou quand le consommateur part tôtpublic static async IAsyncEnumerable<string> FixedVoicings(int windows, WindowProbe probe, bool cancelInFinally = true, [EnumeratorCancellation] CancellationToken cancellationToken = default){ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var channel = Channel.CreateBounded<(int WindowIndex, List<string> Voicings)>(new BoundedChannelOptions(4) { SingleReader = true, SingleWriter = false }); probe.Reader = channel.Reader;
var producerTask = Task.Run(async () => { try { await Parallel.ForEachAsync( Enumerable.Range(0, windows), new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = cts.Token }, async (window, ct) => await channel.Writer.WriteAsync((window, probe.Generate(window)), ct)); channel.Writer.Complete(); } catch (Exception e) { channel.Writer.Complete(e); // le lecteur la lève au lieu d'attendre indéfiniment } }); probe.Producer = producerTask;
try { await foreach (var result in channel.Reader.ReadAllAsync(cts.Token)) { foreach (var voicing in result.Voicings) { yield return voicing; } } } finally { if (cancelInFinally) { cts.Cancel(); // le lecteur s'est arrêté : arrêter les producteurs }
await producerTask; // ne lève jamais d'exception : ses erreurs sont passées au channel }}- Un channel borné. Au plus quatre fenêtres attendent le lecteur ; un consommateur lent ralentit désormais la génération.
- Le producteur passe son exception au channel, si bien que le lecteur la lève au lieu d’attendre.
- Un
finallyautour de la boucle de lecture annule les producteurs et les attend, que le lecteur ait fini, échoué ou abandonné. Unfinallydans un itérateur asynchrone s’exécute quand le consommateur le libère, ce que faitawait foreachsurbreak, sur une exception, et à la fin.
== Fixed generator: the consumer stops after one voicingone voicing read, then breakproducer already completed when the loop exited: True, windows generated fewer than 24: True== Fixed generator: window 5 throwsconsumer: faulted with InvalidOperationException: window 5 failedLa sortie anticipée arrête maintenant les producteurs avant que la boucle rende la main, après 8 fenêtres sur la machine de l’auteur, et l’échec atteint le consommateur sous la forme de l’exception d’origine.
Étude de cas : la commande d’indexation de GA
Section intitulée « Étude de cas : la commande d’indexation de GA »IndexVoicingsCommand stocke des voicings dans MongoDB et Qdrant. Des producteurs parallèles calculent l’entité de chaque voicing et l’écrivent dans un channel borné en mode Wait ; un seul consommateur le lit et fait des upserts par lots de 1 000. Le channel est borné et en mode Wait, ce qui est juste : c’est la base de données qui donne le rythme. Chaque côté attrape ses propres exceptions et les journalise. Le programme garde cette structure avec des lots de 4 et une capacité de 8 (GaShapeIndex), et fait échouer le premier upsert, comme le ferait une base de données arrêtée :
== GA's index command shape: the first batch upsert failscommand finished within 1 s: Falseupserts tried 1, Reader.Count 8 (capacity 8), log: Error in DB Writer Consumer: database unavailableLe consommateur a journalisé l’erreur et rendu la main, comme le dit son catch. Les producteurs n’en ont rien su : ils ont rempli le channel jusqu’à sa capacité de 8, et les écrivains suivants attendent une place qu’aucun lecteur ne libérera jamais. Parallel.ForEachAsync ne se termine jamais, la commande n’atteint jamais Writer.Complete(), et la barre de progression s’arrête pour toujours. Le catch par élément des producteurs n’y peut rien, car rien ne lève d’exception : une écriture qui attend n’est pas une erreur.
La correction consiste à laisser l’échec du consommateur remonter jusqu’aux écrivains (FixedIndex) :
catch (Exception ex){ channel.Writer.TryComplete(ex); // les écrivains qui attendent une place reçoivent une ChannelClosedException throw;}Compléter le channel du côté du consommateur fait échouer toutes les écritures en attente et à venir avec une ChannelClosedException. Les producteurs s’arrêtent, la commande attrape cette exception, et attendre le consommateur relance la vraie cause :
== Fixed index command: the first batch upsert failscommand: faulted with IOException: database unavailable, upserts tried 1Si vous connaissez Spring et Reactor
Section intitulée « Si vous connaissez Spring et Reactor »Reactor n’a pas besoin d’une file entre deux étapes : un abonné dit au publisher combien d’éléments il veut, avec request(n). La section backpressure du cours Reactor montre ces signaux. Les channels obtiennent le même effet en faisant attendre l’écrivain. Quand une source ne peut pas ralentir, les deux ont des stratégies, et elles se correspondent de près :
| Channels | Reactor | Java |
|---|---|---|
Channel.CreateBounded(n), mode Wait |
la demande avec request(n) ; limitRate(n) pour la regrouper |
ArrayBlockingQueue.put, qui bloque un thread |
Channel.CreateUnbounded() |
onBackpressureBuffer() |
LinkedBlockingQueue |
DropWrite |
onBackpressureDrop(), ou onBackpressureBuffer(n, onOverflow, BufferOverflowStrategy.DROP_LATEST) |
offer qui renvoie false |
DropOldest |
onBackpressureBuffer(n, onOverflow, BufferOverflowStrategy.DROP_OLDEST) ; onBackpressureLatest() pour une capacité de 1 |
— |
DropNewest |
pas d’équivalent direct | — |
callback itemDropped |
le callback onOverflow ou onBackpressureDrop(Consumer) |
— |
| pas de mode erreur : un channel plein attend ou jette | BufferOverflowStrategy.ERROR signale une erreur de dépassement |
add lève IllegalStateException |
Writer.Complete(exception) |
onError, qui termine la séquence |
rien : un élément « pilule empoisonnée » par convention |
Reader.Completion |
le signal terminal, doOnTerminate |
— |
Les noms viennent de la Javadoc de Flux et de BufferOverflowStrategy : DROP_LATEST jette l’élément qui vient d’arriver, comme DropWrite, et non l’élément le plus récent déjà en tampon. Les bugs de GA ci-dessus ne se produisent pas tels quels avec Reactor, parce qu’une erreur descend le pipeline et qu’une annulation le remonte sans aucun code de votre part. Ils reviennent dès qu’un pipeline Reactor confie ses éléments à une file écrite à la main.
Exercices
Section intitulée « Exercices »- Un channel borné de capacité 2 en mode
DropOldestreçoitDm7,G7,Cmaj7etA7avecTryWrite, puis est complété. Que reçoit le lecteur, et qu’est-ce qui part dans le callbackitemDropped? - La commande d’indexation de GA rassemble 1 000 éléments avant chaque upsert, même quand les producteurs sont lents, si bien que les premières lignes arrivent tard en base. Écrivez
ReadBatchesAsync(reader, max), un itérateur asynchrone qui attend au moins un élément, puis prend ce qui est déjà dans le channel, jusqu’àmax, et rend ce lot. - Dans
FixedVoicings, retirez lects.Cancel()du blocfinally, gardez leawait producerTask, et quittez la boucle après un voicing. Que se passe-t-il, et pourquoi ?
Solutions
-
Le lecteur reçoit
Cmaj7 A7, et le callback reçoitDm7puisG7: chaque écriture au-delà de la capacité chasse l’élément qui attend depuis le plus longtemps.1. DropOldest, capacity 2: reader gets Cmaj7 A7, dropped Dm7 G7 -
WaitToReadAsyncattend le premier élément, etTryReadprend les autres sans attendre (ReadBatchesAsync) :// Exercice 2 : attend au moins un élément, puis prend ce qui est déjà là, jusqu'à maxpublic static async IAsyncEnumerable<List<T>> ReadBatchesAsync<T>(ChannelReader<T> reader, int max,[EnumeratorCancellation] CancellationToken cancellationToken = default){while (await reader.WaitToReadAsync(cancellationToken)){var batch = new List<T>(max);while (batch.Count < max && reader.TryRead(out var item)){batch.Add(item);}yield return batch;}}Avec dix éléments déjà dans le channel et un maximum de 4 :
2. ReadBatchesAsync(max 4) over 1 to 10: [1 2 3 4] [5 6 7 8] [9 10]Quand les producteurs sont lents, les lots rapetissent et partent plus tôt ; quand ils sont rapides, les lots se remplissent. Le consommateur n’attend jamais un lot complet.
-
Quitter la boucle libère l’itérateur, et le bloc
finallyattend une tâche productrice qui ne se termine jamais. Les producteurs continuent d’écrire dans le channel borné jusqu’à ce qu’il soit plein, puis attendent une place ; plus personne ne lit, donc ils attendent indéfiniment, etDisposeAsyncaussi. Lebreakdu consommateur se bloque. Le channel borné a réglé le problème de mémoire, et sans l’annulation il a transformé la fuite en interblocage.3. without cts.Cancel() in finally, leaving after one voicing: DisposeAsync completed within 1 s False, Reader.Count 4 (capacity 4)
À retenir
Section intitulée « À retenir »- Un channel est une file thread-safe avec un écrivain et un lecteur asynchrones. Choisissez borné ou non borné, et pour un channel borné, ce que fait un channel plein.
- Un channel borné en mode
Waitvous donne la backpressure : l’écrivain attend, sans bloquer de thread, que le lecteur fasse de la place. - Les modes qui jettent font renvoyer
trueàTryWritepour les éléments jetés ; passez un callbackitemDroppedsi une perte de données doit se remarquer. Completene jette pas les éléments.Complete(exception)fait relancer cette exception parReadAllAsyncaprès les éléments restants, et lever parReadAsyncuneChannelClosedExceptionqui l’enveloppe.- Si personne ne complète l’écrivain, le lecteur attend indéfiniment : complétez-le sur tous les chemins, échecs compris, et avec
TryCompletequand plusieurs chemins le peuvent. - Un consommateur qui s’arrête, par
break, par une exception ou parTake, n’arrête pas les producteurs. Annulez-les dans unfinally, et complétez le channel du côté du consommateur quand il échoue. SingleReaderetSingleWritersont des promesses qui achètent une implémentation plus rapide ; le channel non borné à lecteur unique ne sait pas compter ses éléments.
- Microsoft Learn : Channels,
Channel.CreateBounded,BoundedChannelFullMode,ChannelWriter<T>.TryComplete,Parallel.ForEachAsync. - Stephen Toub, An Introduction to System.Threading.Channels, sur le blog .NET.
- dotnet/runtime à
v10.0.12(commit4271d88) :BoundedChannel.cs,UnboundedChannel.cs,ChannelUtilities.cs,ChannelReader.cs. - Reactor : Javadoc de
Fluxet deBufferOverflowStrategy, guide de référence de Reactor. - Guitar Alchemist à
a826864:VoicingGenerator.cs,IndexVoicingsCommand.cs,FretboardVoicingsCLI/Program.cs.