Async / wait en C #: concept, design interne, astuces utiles

Bonjour. Cette fois, nous allons parler d'un sujet que tous les adeptes qui se respectent du langage C # ont commencé à comprendre - la programmation asynchrone à l'aide de Task ou, chez les gens ordinaires, async / wait. Microsoft a fait du bon travail - pour utiliser l'asynchronie dans la plupart des cas, il suffit de connaître la syntaxe et aucun autre détail. Mais si vous allez en profondeur, le sujet est assez volumineux et complexe. Il a été déclaré par beaucoup, chacun dans son propre style. Il y a beaucoup d'articles sympas sur ce sujet, mais il y a encore beaucoup d'idées fausses autour de lui. Nous essaierons de corriger la situation et de mâcher le matériel autant que possible, sans sacrifier ni la profondeur ni la compréhension.



Sujets / chapitres couverts:

  1. Le concept d'asynchronie - les avantages de l'asynchronie et les mythes sur un thread "bloqué"
  2. TAP. Syntaxe et conditions de compilation - prérequis pour écrire une méthode de compilation
  3. Travailler avec l'utilisation de TAP - la mécanique et le comportement du programme en code asynchrone (libérer des threads, démarrer des tâches et attendre qu'elles se terminent)
  4. Dans les coulisses: la machine à états - un aperçu des transformations du compilateur et des classes qu'il génère
  5. Les origines de l'asynchronie. Le dispositif des méthodes asynchrones standard - méthodes asynchrones pour travailler avec les fichiers et le réseau de l'intérieur
  6. Les classes et astuces TAP sont des astuces utiles qui peuvent vous aider à gérer et à accélérer un programme à l'aide de TAP

Concept asynchrone


L'asynchronie en soi est loin d'être nouvelle. L'asynchronie implique généralement l'exécution d'une opération dans un style qui n'implique pas le blocage du thread appelant, c'est-à-dire le démarrage de l'opération sans attendre sa fin. Le blocage n'est pas aussi mauvais qu'il est décrit. On peut rencontrer des affirmations selon lesquelles les threads bloqués perdent du temps CPU, fonctionnent plus lentement et provoquent la pluie. Cette dernière semble-t-elle peu probable? En fait, les 2 points précédents sont les mêmes.

Au niveau du planificateur du système d'exploitation, lorsqu'un thread est dans un état «bloqué», un temps processeur précieux ne lui sera pas alloué. Les appels du planificateur tombent généralement sur des opérations qui provoquent des blocages, des interruptions de temporisation et d'autres interruptions. Autrement dit, lorsque, par exemple, le contrôleur de disque termine l'opération de lecture et lance une interruption appropriée, le planificateur démarre. Il décidera de démarrer un thread qui a été bloqué par cette opération, ou un autre avec une priorité plus élevée.

Un travail lent semble encore plus absurde. En effet, en fait, le travail est le même. Seule l'opération asynchrone ajoutera un peu plus de surcharge.

Le défi de la pluie n'est généralement pas quelque chose de cette région.

Le principal problème de blocage est la consommation déraisonnable de ressources informatiques. Même si nous oublions le temps de créer un thread et de travailler avec un pool de threads, chaque thread bloqué consomme plus d'espace. Eh bien, il existe des scénarios où un seul thread peut effectuer certains travaux (par exemple, un thread d'interface utilisateur). En conséquence, je ne voudrais pas qu'il soit occupé par une tâche qu'un autre thread peut effectuer, sacrifiant la performance des opérations qui lui sont exclusives.

L'asynchronie est un concept très large et peut être réalisé de plusieurs manières.
Les éléments suivants peuvent être distingués dans l'histoire de .NET :

  1. EAP (Event-based Asynchronous Pattern) - comme son nom l'indique, la randonnée est basée sur les événements qui se déclenchent lorsque l'opération est terminée et la méthode habituelle qui appelle cette opération
  2. APM (modèle de programmation asynchrone) - basé sur 2 méthodes. La méthode BeginSmth renvoie l'interface IAsyncResult. La méthode EndSmth accepte IAsyncResult (si l'opération n'est pas terminée au moment de l'appel EndSmth, le thread est bloqué)
  3. TAP (Task-based Asynchronous Pattern) est le même asynchrone / wait (à proprement parler, ces mots sont apparus après l'approche et les types Task et Task <TResult> sont apparus, mais async / wait a considérablement amélioré ce concept)

Cette dernière approche a connu un tel succès que tout le monde a réussi à oublier les précédentes. Donc, ce sera à propos de lui.

Modèle asynchrone basé sur les tâches. Conditions de syntaxe et de compilation


La méthode asynchrone standard de style TAP est très facile à écrire.

Pour ce faire, vous avez besoin de :

  1. Pour que la valeur de retour soit Task, Task <T> ou void (non recommandé, discuté plus tard). En C # 7 sont venus les types de tâches (discutés dans le dernier chapitre). En C # 8, IAsyncEnumerable <T> et IAsyncEnumerator <T> sont ajoutés à cette liste.
  2. Pour que la méthode soit marquée avec le mot-clé async et contienne wait inside. Ces mots clés sont associés. De plus, si la méthode contient wait, assurez-vous de la marquer comme asynchrone, l'inverse n'est pas vrai, mais c'est inutile
  3. Pour la décence, respectez la convention de suffixe Async. Bien sûr, le compilateur ne considérera pas cela comme une erreur. Si vous êtes un développeur très décent, vous pouvez ajouter des surcharges avec un CancellationToken (discuté dans le dernier chapitre)

Pour de telles méthodes, le compilateur fait un travail sérieux. Et ils deviennent complètement méconnaissables dans les coulisses, mais plus à ce sujet plus tard.

Il a été mentionné que la méthode devrait contenir le mot-clé wait. Il (le mot) indique la nécessité d'une attente asynchrone pour l'exécution de la tâche, qui est l'objet de tâche auquel elle est appliquée.

L'objet de tâche a également certaines conditions pour que l'attente puisse lui être appliquée:

  1. Le type attendu doit avoir une méthode GetAwaiter () publique (ou interne), il peut également s'agir d'une méthode d'extension. Cette méthode renvoie un objet d'attente.
  2. L'objet wait doit implémenter l'interface INotifyCompletion, qui nécessite l'implémentation de la méthode void OnCompleted (Action continuation). Il doit également avoir la propriété d'instance bool IsCompleted, la méthode void GetResult (). Il peut s'agir d'une structure ou d'une classe.

L'exemple ci-dessous montre comment rendre un int attendu et même jamais exécuté.

Extension int
public class Program { public static async Task Main() { await 1; } } public static class WeirdExtensions { public static AnyTypeAwaiter GetAwaiter(this int number) => new AnyTypeAwaiter(); public class AnyTypeAwaiter : INotifyCompletion { public bool IsCompleted => false; public void OnCompleted(Action continuation) { } public void GetResult() { } } } 



Travailler avec TAP


Il est difficile d'entrer dans la jungle sans comprendre comment quelque chose devrait fonctionner. Considérez TAP en termes de comportement du programme.

En terminologie: la méthode asynchrone en question, dont le code sera considéré, j'appellerai la méthode asynchrone , et les méthodes asynchrones appelées à l'intérieur, j'appellerai l' opération asynchrone .

Prenons l'exemple le plus simple, en tant qu'opération asynchrone, nous prenons Task.Delay, qui retarde pendant le temps spécifié sans bloquer le flux.

 public static async Task DelayOperationAsync() //   { BeforeCall(); Task task = Task.Delay(1000); //  AfterCall(); await task; AfterAwait(); } 

L'exécution de la méthode en termes de comportement est la suivante.

  1. Tout le code qui précède l'invocation de l'opération asynchrone est exécuté. Dans ce cas, il s'agit de la méthode BeforeCall
  2. Un appel d'opération asynchrone est en cours. À ce stade, le thread n'est ni libéré ni bloqué. Cette opération renvoie le résultat - l'objet de tâche mentionné (généralement Task), qui est stocké dans une variable locale
  3. Le code est exécuté après avoir appelé l'opération asynchrone, mais avant d'attendre (attendre). Dans l'exemple - AfterCall
  4. En attente de fin sur l'objet de tâche (qui est stocké dans une variable locale) - attendre la tâche.

    Si l'opération asynchrone est terminée à ce stade, l'exécution continue de manière synchrone, dans le même thread.

    Si l'opération asynchrone n'est pas terminée, le code est enregistré qui doit être appelé à la fin de l'opération asynchrone (la soi-disant continuation), et le flux retourne au pool de threads et devient disponible pour utilisation.
  5. L'exécution des opérations après l'attente - AfterAwait - est effectuée soit immédiatement, dans le même thread, lorsque l'opération au moment de l'attente a été terminée, soit, à la fin de l'opération, un nouveau thread est pris qui continuera (enregistré à l'étape précédente)


Dans les coulisses. Machine d'état


En fait, notre méthode est transformée par le compilateur en une méthode stub dans laquelle la classe générée - la machine d'état - est initialisée. Ensuite, il (la machine) démarre et l'objet Task utilisé à l'étape 2 est renvoyé par la méthode.

La méthode MoveNext de la machine à états est particulièrement intéressante. Cette méthode fait ce qu'elle était avant la conversion dans la méthode asynchrone. Il casse le code entre chaque appel en attente. Chaque partie est réalisée dans un certain état de la machine. La méthode MoveNext elle-même est attachée à l'objet d'attente en tant que continuation. La préservation de l'État garantit l'exécution de la partie de celui-ci qui a logiquement suivi l'attente.

Comme on dit, il vaut mieux voir 1 fois que d'entendre 100 fois, donc je vous recommande fortement de vous familiariser avec l'exemple ci-dessous. J'ai réécrit un peu le code, amélioré le nommage des variables et commenté généreusement.

Code source
 public static async Task Delays() { Console.WriteLine(1); await Task.Delay(1000); Console.WriteLine(2); await Task.Delay(1000); Console.WriteLine(3); await Task.Delay(1000); Console.WriteLine(4); await Task.Delay(1000); Console.WriteLine(5); await Task.Delay(1000); } 


Méthode du talon
 [AsyncStateMachine(typeof(DelaysStateMachine))] [DebuggerStepThrough] public Task Delays() { DelaysStateMachine stateMachine = new DelaysStateMachine(); stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create(); stateMachine.currentState = -1; AsyncTaskMethodBuilder builder = stateMachine.taskMethodBuilder; taskMethodBuilder.Start(ref stateMachine); return stateMachine.taskMethodBuilder.Task; } 


Machine d'état
 [CompilerGenerated] private sealed class DelaysStateMachine : IAsyncStateMachine { //  ,     await   //       await'a public int currentState; public AsyncTaskMethodBuilder taskMethodBuilder; //   private TaskAwaiter taskAwaiter; //  ,             ""  public int paramInt; private int localInt; private void MoveNext() { int num = currentState; try { TaskAwaiter awaiter5; TaskAwaiter awaiter4; TaskAwaiter awaiter3; TaskAwaiter awaiter2; TaskAwaiter awaiter; switch (num) { default: localInt = paramInt; //  await Console.WriteLine(1); //  await awaiter5 = Task.Delay(1000).GetAwaiter(); //  await if (!awaiter5.IsCompleted) //  await. ,    { num = (currentState = 0); // ,      taskAwaiter = awaiter5; //    ,        DelaysStateMachine stateMachine = this; //    taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter5, ref stateMachine); //                 return; } goto Il_AfterFirstAwait; //  ,   ,    case 0: //            ,        .   ,          awaiter5 = taskAwaiter; //   taskAwaiter = default(TaskAwaiter); //   num = (currentState = -1); //  goto Il_AfterFirstAwait; //       case 1: //  ,      ,    ,     . awaiter4 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterSecondAwait; case 2: // ,     . awaiter3 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterThirdAwait; case 3: //    awaiter2 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterFourthAwait; case 4: //    { awaiter = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); break; } Il_AfterFourthAwait: awaiter2.GetResult(); Console.WriteLine(5); //     awaiter = Task.Delay(1000).GetAwaiter(); //   if (!awaiter.IsCompleted) { num = (currentState = 4); taskAwaiter = awaiter; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } break; Il_AfterFirstAwait: //  ,        awaiter5.GetResult(); //       Console.WriteLine(2); //  ,     await awaiter4 = Task.Delay(1000).GetAwaiter(); //    if (!awaiter4.IsCompleted) { num = (currentState = 1); taskAwaiter = awaiter4; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter4, ref stateMachine); return; } goto Il_AfterSecondAwait; Il_AfterThirdAwait: awaiter3.GetResult(); Console.WriteLine(4); //     awaiter2 = Task.Delay(1000).GetAwaiter(); //   if (!awaiter2.IsCompleted) { num = (currentState = 3); taskAwaiter = awaiter2; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine); return; } goto Il_AfterFourthAwait; Il_AfterSecondAwait: awaiter4.GetResult(); Console.WriteLine(3); //     awaiter3 = Task.Delay(1000).GetAwaiter(); //   if (!awaiter3.IsCompleted) { num = (currentState = 2); taskAwaiter = awaiter3; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine); return; } goto Il_AfterThirdAwait; } awaiter.GetResult(); } catch (Exception exception) { currentState = -2; taskMethodBuilder.SetException(exception); return; } currentState = -2; taskMethodBuilder.SetResult(); //    ,   ,       } void IAsyncStateMachine.MoveNext() {...} [DebuggerHidden] private void SetStateMachine(IAsyncStateMachine stateMachine) {...} void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) {...} } 


Je me concentre sur la phrase "à ce stade n'a pas été exécuté de manière synchrone." Une opération asynchrone peut également suivre un chemin d'exécution synchrone. La condition principale pour que la méthode asynchrone actuelle soit exécutée de manière synchrone, c'est-à-dire sans changer le thread, est la fin de l'opération asynchrone au moment de la vérification IsCompleted .

Cet exemple illustre clairement ce comportement.
 static async Task Main() { Console.WriteLine(Thread.CurrentThread.ManagedThreadId); //1 Task task = Task.Delay(1000); Thread.Sleep(1700); await task; Console.WriteLine(Thread.CurrentThread.ManagedThreadId); //1 } 


À propos du contexte de synchronisation. La méthode AwaitUnsafeOnCompleted utilisée dans la machine aboutit finalement à un appel à la méthode Task.SetContinuationForAwait . Dans cette méthode, le contexte de synchronisation actuel SynchronizationContext.Current est récupéré. Le contexte de synchronisation peut être interprété comme un type de flux. S'il est également spécifique (par exemple, le contexte du thread d'interface utilisateur), une continuation est créée à l'aide de la classe SynchronizationContextAwaitTaskContinuation . Cette classe pour démarrer la continuation appelle la méthode Post sur le contexte enregistré, ce qui garantit que la continuation est exécutée dans le contexte exact où la méthode a été exécutée. La logique spécifique pour exécuter la continuation dépend de la méthode Post dans un contexte qui, pour le dire légèrement, n'est pas connu pour la vitesse. S'il n'y avait pas de contexte de synchronisation (ou s'il a été indiqué que peu importe pour nous dans quel contexte l'exécution continuera à l'aide de ConfigureAwait (false), ce qui sera discuté dans le dernier chapitre), la poursuite sera effectuée par le thread du pool.

Les origines de l'asynchronie. Les méthodes asynchrones standard de l'appareil


Nous avons examiné comment une méthode utilisant asynchrone et attend les regards et ce qui se passe dans les coulisses. Cette information n'est pas rare. Mais il est important de comprendre la nature des opérations asynchrones. Parce que, comme nous l'avons vu dans la machine à états, les opérations asynchrones sont appelées dans le code, à moins que leur résultat ne soit traité de manière plus astucieuse. Cependant, que se passe-t-il à l'intérieur des opérations asynchrones elles-mêmes? Probablement la même chose, mais cela ne peut pas se produire à l'infini.

Une tâche importante consiste à comprendre la nature de l'asynchronie. Quand on essaie de comprendre l'asynchronie, il y a une alternance d'états "maintenant clair" et "maintenant encore incompréhensible". Et cette alternance se fera jusqu'à ce que la source de l'asynchronie soit comprise.

Lorsque nous travaillons avec l'asynchronie, nous opérons sur des tâches. Ce n'est pas du tout la même chose qu'un flux. Une tâche peut être effectuée par plusieurs threads et un thread peut effectuer de nombreuses tâches.

Asynchrony commence généralement par une méthode qui retourne Task (par exemple), mais n'est pas étiquetée avec async et n'utilise donc pas wait inside. Cette méthode ne tolère aucune modification du compilateur; elle est exécutée telle quelle.

Alors, regardons quelques-unes des racines de l'asynchronie.

  1. Task.Run, new Task (..). Start (), Factory.StartNew et similaires. La façon la plus simple de démarrer l'exécution asynchrone. Ces méthodes créent simplement un nouvel objet de tâche, en passant un délégué comme l'un des paramètres. La tâche est transférée au planificateur, ce qui lui permet d'être exécutée par l'un des threads du pool. La tâche terminée attendue est renvoyée. En règle générale, cette approche est utilisée pour démarrer le calcul (lié au processeur) dans un thread séparé.
  2. TaskCompletionSource. Une classe d'assistance qui permet de contrôler l'objet de tâche. Conçu pour ceux qui ne peuvent pas affecter un délégué à la mise en œuvre et utilise des mécanismes plus sophistiqués pour contrôler l'achèvement. Il possède une API très simple - SetResult, SetError, etc., qui met à jour la tâche en conséquence. Cette tâche est disponible via la propriété Task. Peut-être qu'à l'intérieur, vous créerez des discussions, aurez une logique complexe pour leur interaction ou leur achèvement par événement. Un peu plus de détails sur cette classe seront dans la dernière section.

Dans un paragraphe supplémentaire, vous pouvez faire les méthodes des bibliothèques standard. Il s'agit notamment de lire / écrire des fichiers, de travailler avec un réseau, etc. En règle générale, ces méthodes populaires et courantes utilisent des appels système qui varient sur différentes plates-formes, et leur appareil est extrêmement divertissant. Pensez à travailler avec des fichiers et le réseau.

Fichiers


Remarque importante - si vous souhaitez travailler avec des fichiers, vous devez spécifier useAsync = true lors de la création de FileStream.

Tout est organisé dans des fichiers de manière non triviale et déroutante. La classe FileStream est déclarée comme partielle. Et en plus, il existe 6 modules complémentaires spécifiques à la plate-forme. Ainsi, sous Unix, l'accès asynchrone à un fichier arbitraire, en règle générale, utilise une opération synchrone dans un thread séparé. Dans Windows, il existe des appels système pour un fonctionnement asynchrone, qui, bien sûr, sont utilisés. Cela conduit à des différences de travail sur différentes plates-formes. Sources .

Unix

Le comportement standard lors de l'écriture ou de la lecture consiste à effectuer l'opération de manière synchrone, si le tampon le permet et que le flux n'est pas occupé par une autre opération:

1. Le flux n'est pas occupé par une autre opération

La classe Filestream possède un objet hérité de SemaphoreSlim avec les paramètres (1, 1) - c'est-à-dire une section critique - le fragment de code protégé par ce sémaphore ne peut être exécuté que par un seul thread à la fois. Ce sémaphore est utilisé à la fois pour la lecture et l'écriture. Autrement dit, il est impossible de produire simultanément la lecture et l'écriture. Dans ce cas, le blocage sur le sémaphore ne se produit pas. La méthode this._asyncState.WaitAsync () est appelée dessus, ce qui retourne l'objet de tâche (il n'y a pas de verrou ou d'attente, ce serait le cas si le mot-clé attendait était appliqué au résultat de la méthode). Si cet objet de tâche n'est pas terminé, c'est-à-dire que le sémaphore est capturé, la continuation (Task.ContinueWith) dans laquelle l'opération est effectuée est attachée à l'objet d'attente renvoyé. Si l'objet est libre, vous devez vérifier les éléments suivants

2. Le tampon permet

Ici, le comportement dépend déjà de la nature de l'opération.

Pour l'enregistrement - il est vérifié que la taille des données pour l'écriture + la position dans le fichier est inférieure à la taille du tampon, qui par défaut est de 4096 octets. Autrement dit, nous devons écrire 4096 octets depuis le début, 2048 octets avec un décalage de 2048, et ainsi de suite. Si tel est le cas, l'opération est effectuée de manière synchrone, sinon la continuation est attachée (Task.ContinueWith). La suite utilise un appel système synchrone régulier. Lorsque le tampon est plein, il est écrit sur le disque de manière synchrone.
Pour la lecture - il est vérifié s'il y a suffisamment de données dans le tampon afin de renvoyer toutes les données nécessaires. Sinon, alors, encore une fois, une continuation (Task.ContinueWith) avec un appel système synchrone.

Au fait, il y a un détail intéressant. Si une donnée occupe la totalité du tampon, elles seront écrites directement dans le fichier, sans la participation du tampon. Dans le même temps, il y a une situation où il y aura plus de données que la taille de la mémoire tampon, mais elles passeront toutes par elle. Cela se produit s'il y a déjà quelque chose dans le tampon. Ensuite, nos données seront divisées en 2 parties, l'une remplira le tampon jusqu'à la fin et les données seront écrites dans le fichier, la seconde sera écrite dans le tampon si elle y pénètre ou directement dans le fichier si ce n'est pas le cas. Donc, si nous créons un flux et y écrivons 4097 octets, ils apparaîtront immédiatement dans le fichier, sans appeler Dispose. Si nous écrivons 4095, alors rien ne sera dans le fichier.

Windows

Sous Windows, l'algorithme pour utiliser le tampon et écrire directement est très similaire. Mais une différence significative est observée directement dans les appels d'écriture et de lecture du système asynchrone. Parlant sans approfondir les appels système, il existe une telle structure chevauchée. Il a un domaine important pour nous - HANDLE hEvent. Il s'agit d'un événement de réinitialisation manuelle qui passe en état d'alarme à la fin d'une opération. Retour à l'implémentation. L'écriture directe, ainsi que l'écriture dans le tampon, utilise des appels système asynchrones, qui utilisent la structure ci-dessus comme paramètre. Lors de l'enregistrement, un objet FileStreamCompletionSource est créé - un héritier de TaskCompletionSource, dans lequel IOCallback est spécifié. Il est appelé par le thread libre du pool une fois l'opération terminée. Dans le rappel, la structure chevauchée est analysée et l'objet Task est mis à jour en conséquence. C’est de la magie.

Réseau


Il est difficile de décrire tout ce que j'ai vu comprendre la source. Mon chemin était de HttpClient à Socket et à SocketAsyncContext pour Unix. Le schéma général est le même que pour les fichiers. Pour Windows, la structure chevauchée mentionnée est utilisée et l'opération est effectuée de manière asynchrone. Sous Unix, les opérations réseau utilisent également des fonctions de rappel.

Et une petite explication. Un lecteur attentif remarquera que lors de l'utilisation d'appels asynchrones entre un appel et un rappel, il existe un certain vide qui fonctionne d'une manière ou d'une autre avec les données. Ici, il vaut la peine d'être clarifié pour être complet. Sur l'exemple des fichiers, le contrôleur de disque effectue des opérations directes avec le disque par le contrôleur de disque, c'est lui qui donne les signaux de déplacement des têtes vers le secteur souhaité, etc. Le processeur est libre pour le moment. La communication avec le disque s'effectue via les ports d'entrée / sortie. Ils indiquent le type d'opération, l'emplacement des données sur le disque, etc. Ensuite, le contrôleur et le disque sont engagés dans cette opération et à la fin du travail, ils génèrent une interruption. En conséquence, un appel système asynchrone fournit uniquement des informations aux ports d'entrée / sortie, tandis que l'appel synchrone attend également les résultats, mettant le flux dans un état de blocage. Ce schéma ne prétend pas être absolument exact (pas à propos de cet article), mais donne une compréhension conceptuelle du travail.

Maintenant, la nature du processus est claire. Mais quelqu'un peut demander, que faire de l'asynchronie? Il est impossible d'écrire asynchrone sur une méthode pour toujours.

Tout d'abord. Une demande peut être faite en tant que service. Dans ce cas, le point d'entrée - Principal - est écrit à partir de zéro par vous. Jusqu'à récemment, Main ne pouvait pas être asynchrone; dans la version 7 de la langue, cette fonctionnalité a été ajoutée. Mais cela ne change rien radicalement, juste le compilateur génère le Main habituel, et à partir de la méthode asynchrone, une méthode statique est créée, qui est appelée dans Main et son achèvement est attendu de manière synchrone. Donc, très probablement, vous avez des actions de longue durée. Pour une raison quelconque, en ce moment, beaucoup de gens commencent à réfléchir à la façon de créer des threads pour cette entreprise: via Task, ThreadPool ou Thread en général manuellement, car il devrait y avoir une différence dans quelque chose. La réponse est simple - bien sûr Task. Si vous utilisez l'approche TAP, n'interférez pas avec la création manuelle de threads. Cela s'apparente à l'utilisation de HttpClient pour presque toutes les demandes, et le POST se fait indépendamment via Socket.

Deuxièmement. Applications Web. Chaque demande entrante entraîne l'extraction d'un nouveau thread de ThreadPool pour traitement. La piscine, bien sûr, est grande, mais pas infinie. Dans le cas où il y a beaucoup de demandes, il se peut qu'il n'y ait pas assez de threads du tout et toutes les nouvelles demandes seront mises en file d'attente pour le traitement. Cette situation s'appelle la famine. Mais dans le cas de l'utilisation de contrôleurs asynchrones, comme indiqué précédemment, le flux retourne au pool et peut être utilisé pour traiter de nouvelles demandes. Ainsi, le débit du serveur est considérablement augmenté.

Nous avons examiné le processus asynchrone du tout début à la fin. Et armés d'une compréhension de toute cette asynchronie, qui contredit la nature humaine, nous considérerons quelques astuces utiles lorsque vous travaillez avec du code asynchrone.

Cours et astuces utiles lorsque vous travaillez avec TAP


La diversité statique de la classe Task.


La classe Task possède plusieurs méthodes statiques utiles. Voici les principaux.

  1. Task.WhenAny (..) est un combinateur qui prend IEnumerable / params des objets de tâche et renvoie un objet de tâche qui se terminera lorsque la première tâche qui se termine est terminée. Autrement dit, il vous permet d'attendre l'une des nombreuses tâches en cours d'exécution
  2. Task.WhenAll (..) - combinateur, accepte IEnumerable / params des objets de tâche et renvoie un objet de tâche, qui sera terminé à la fin de toutes les tâches transférées
  3. Task.FromResult<T>(T value) — , .
  4. Task.Delay(..) —
  5. Task.Yield() — . , . , ,

ConfigureAwait


Naturellement, la fonction «avancée» la plus populaire. Cette méthode appartient à la classe Task et vous permet de spécifier si nous devons continuer dans le même contexte où l'opération asynchrone a été appelée. Par défaut, sans utiliser cette méthode, le contexte est mémorisé et poursuivi en utilisant la méthode Post mentionnée. Cependant, comme nous l'avons dit, la poste est un plaisir très cher. Par conséquent, si les performances sont à la première place et que nous constatons que la poursuite ne mettra pas à jour l'interface utilisateur, vous pouvez la spécifier sur l'objet d'attente .ConfigureAwait (false) . Cela signifie que peu importe où la suite sera effectuée.

Maintenant sur le problème. Comme on dit, effrayant n'est pas l'ignorance, mais la fausse connaissance.

Il m'est arrivé d'observer le code d'une application web, où chaque appel asynchrone était décoré de cet accélérateur. Cela n'a d'autre effet que le dégoût visuel. L'application Web ASP.NET Core standard n'a pas de contexte unique (sauf si vous les écrivez vous-même, bien sûr). Ainsi, la méthode Post n'y est pas appelée de toute façon.

TaskCompletionSource <T>


Une classe qui facilite la gestion d'un objet Task. Une classe a de nombreuses opportunités, mais est plus utile lorsque nous voulons terminer une tâche avec une action, dont la fin se produit sur un événement. En général, la classe a été créée pour adapter les anciennes méthodes asynchrones à TAP, mais comme nous l'avons vu, elle n'est pas seulement utilisée pour cela. Un petit exemple de travail avec cette classe:

Exemple
 public static Task<string> GetSomeDataAsync() { TaskCompletionSource<string> tcs = new TaskCompletionSource<string>(); FileSystemWatcher watcher = new FileSystemWatcher { Path = Directory.GetCurrentDirectory(), NotifyFilter = NotifyFilters.LastAccess, EnableRaisingEvents = true }; watcher.Changed += (o, e) => tcs.SetResult(e.FullPath); return tcs.Task; } 


Cette classe crée un wrapper asynchrone pour obtenir le nom du fichier auquel on a accédé dans le dossier actuel.

CancellationTokenSource


Vous permet d'annuler une opération asynchrone. Le plan général ressemble à l'utilisation d'un TaskCompletionSource. Tout d'abord, var cts = new CancellationTokenSource () est créé , qui, soit dit en passant, est IDisposable, puis cts.Token est transmis aux opérations asynchrones . De plus, suivant une certaine logique, dans certaines conditions, la méthode cts.Cancel () est appelée . Il peut également s'abonner à un événement ou à toute autre chose.

L'utilisation d'un CancellationToken est une bonne pratique. Lorsque vous écrivez votre méthode asynchrone qui fonctionne en arrière-plan, disons dans un temps infini, vous pouvez simplement insérer une ligne dans le corps de la boucle: cancelToken.ThrowIfCancellationRequested () , qui lèvera une exceptionOperationCanceledException . Cette exception est traitée comme une annulation de l'opération et n'est pas enregistrée en tant qu'exception dans l'objet de tâche. En outre, la propriété IsCanceled sur l'objet Task deviendra vraie.

Longue course


Il y a souvent des situations, en particulier lors de l'écriture de services, lorsque vous créez plusieurs tâches qui fonctionneront tout au long de la durée de service ou tout simplement pendant une très longue période. Comme nous nous en souvenons, l'utilisation d'un pool de threads est à juste titre la surcharge de création d'un thread. Cependant, si un flux est rarement créé (même une fois par heure), ces coûts sont nivelés et vous pouvez créer en toute sécurité des flux distincts. Pour ce faire, lors de la création d'une tâche, vous pouvez spécifier une option spéciale:

Task.Factory.StartNew (action, TaskCreationOptions.LongRunning )

Quoi qu'il en soit, je vous conseille de regarder toutes les surcharges Task.Factory.StartNew , il existe de nombreuses façons de configurer de manière flexible la tâche pour répondre à des besoins spécifiques.

Exceptions


En raison de la nature non déterministe de l'exécution de code asynchrone, la question des exceptions est très pertinente. Ce serait dommage si vous ne pouviez pas attraper l'exception et qu'elle a été jetée dans le fil de gauche, tuant le processus. Une classe ExceptionDispatchInfo a été créée pour intercepter une exception dans un thread et la jeter dedans . Pour intercepter l'exception, la méthode statique ExceptionDispatchInfo.Capture (ex) est utilisée, qui renvoie ExceptionDispatchInfo.Un lien vers cet objet peut être transmis à n'importe quel thread, qui appelle ensuite la méthode Throw () pour le jeter. Le lancement lui-même ne se produit PAS à l'endroit de l'appel d'opération asynchrone, mais au lieu d'utilisation de l'opérateur d'attente. Et comme vous le savez, l'attente ne peut être appliquée à l'annulation. Ainsi, si le contexte existait, il lui sera transmis par la méthode Post. Sinon, il sera excité dans le flux de la piscine. Et c'est presque 100% bonjour à l'effondrement de l'application. Et nous arrivons ici à la pratique du fait que nous devons utiliser la tâche ou la tâche <T>, mais pas nul.

Et encore une chose. Le planificateur a un événement TaskScheduler.UnobservedTaskException qui se déclenche lorsqu'une exception UnobservedTaskException est levée. Cette exception est levée lors de la récupération de place lorsque le GC essaie de collecter un objet de tâche qui a une exception non gérée.

IAsyncEnumerable


Avant C # 8 et .NET Core 3.0, il n'était pas possible d'utiliser un itérateur de rendement dans une méthode asynchrone, ce qui compliquait la vie et lui faisait renvoyer la tâche <IEnumerable <T>> de cette méthode, c'est-à-dire il n'y avait aucun moyen de parcourir la collection jusqu'à ce qu'elle soit entièrement reçue. Maintenant, il y a une telle opportunité. Apprenez-en plus ici . Pour cela, le type de retour doit être IAsyncEnumerable <T> (ou IAsyncEnumerator <T> ). Pour parcourir une telle collection, vous devez utiliser la boucle foreach avec le mot-clé wait. En outre, les méthodes WithCancellation et ConfigureAwait peuvent être appelées sur le résultat de l'opération , indiquant le CancelationToken utilisé et la nécessité de continuer dans le même contexte.

Comme prévu, tout se fait le plus paresseusement possible.
Voici un exemple et la conclusion qu'il donne.

Exemple
 public class Program { public static async Task Main() { Stopwatch sw = new Stopwatch(); sw.Start(); IAsyncEnumerable<int> enumerable = AsyncYielding(); Console.WriteLine($"Time after calling: {sw.ElapsedMilliseconds}"); await foreach (var element in enumerable.WithCancellation(..).ConfigureAwait(false)) { Console.WriteLine($"element: {element}"); Console.WriteLine($"Time: {sw.ElapsedMilliseconds}"); } } static async IAsyncEnumerable<int> AsyncYielding() { foreach (var uselessElement in Enumerable.Range(1, 3)) { Task task = Task.Delay(TimeSpan.FromSeconds(uselessElement)); Console.WriteLine($"Task run: {uselessElement}"); await task; yield return uselessElement; } } } 


Conclusion:

Temps après l'appel: 0
Exécution de la tâche: 1
élément: 1
Heure: 1033 Exécution de la
tâche: 2
élément: 2
Heure: 3034 Exécution de la
tâche: 3
élément: 3
Heure: 6035


Threadpool


Cette classe est activement utilisée lors de la programmation avec TAP. Par conséquent, je donnerai les détails minimum de sa mise en œuvre. À l'intérieur, ThreadPool a un tableau de files d'attente: une pour chaque thread + une globale. Lors de l'ajout d'un nouveau travail au pool, le thread qui a initié l'ajout est pris en compte. Dans le cas où il s'agit d'un thread du pool, le travail est placé dans sa propre file d'attente de ce thread, s'il s'agissait d'un autre thread - dans le global. Lorsqu'un thread est sélectionné pour fonctionner, sa file d'attente locale apparaît en premier. S'il est vide, le thread prend les travaux du global. S'il est vide, il commence à voler aux autres. De plus, vous ne devriez jamais vous fier à l'ordre du travail, car, en fait, il n'y a pas d'ordre. Le nombre par défaut de threads dans un pool dépend de nombreux facteurs, notamment la taille de l'espace d'adressage. S'il y a plus de demandes d'exécution,que le nombre de threads disponibles, les demandes sont mises en file d'attente.

Les threads d'un pool de threads sont des threads d'arrière-plan (propriété isBackground = true). Ce type de thread ne prend pas en charge la durée de vie du processus si tous les threads de premier plan sont terminés.

Le thread système surveille l'état du descripteur d'attente. Lorsque l'opération d'attente se termine, le rappel transféré est exécuté par le thread du pool (rappelez-vous les fichiers dans Windows).

Type de tâche


Mentionné précédemment, ce type (structure ou classe) peut être utilisé comme valeur de retour de la méthode asynchrone. Un type de générateur doit être associé à ce type à l'aide de l' attribut [AsyncMethodBuilder (..)] . Ce type doit avoir les caractéristiques mentionnées ci-dessus afin de pouvoir lui appliquer le mot-clé wait. Il peut être paramétré pour les méthodes qui ne renvoient pas de valeur et paramétré pour celles qui retournent.

Le générateur lui-même est une classe ou une structure dont le cadre est illustré dans l'exemple ci-dessous. La méthode SetResult a un paramètre de type T pour un type de tâche paramétré par T. Pour les types non paramétrés, la méthode n'a pas de paramètres.

Interface Builder requise
 class MyTaskMethodBuilder<T> { public static MyTaskMethodBuilder<T> Create(); public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine; public void SetStateMachine(IAsyncStateMachine stateMachine); public void SetException(Exception exception); public void SetResult(T result); public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine; public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine; public MyTask<T> Task { get; } } 


Le principe du travail du point de vue de l'écriture de votre type de tâche sera décrit ci-dessous. La plupart de cela a déjà été décrit lors de l'analyse du code généré par le compilateur.

Le compilateur utilise tous ces types pour générer une machine à états. Le compilateur sait quels constructeurs utiliser pour les types qu'il connaît, nous spécifions ici ce qui sera utilisé lors de la génération du code. Si la machine d'état est une structure, elle sera compressée lors de l'appel à SetStateMachine , le générateur peut mettre en cache la copie compressée si nécessaire. Le générateur doit appeler stateMachine.MoveNext dans la méthode Start ou après son appel afin de démarrer l'exécution et de faire avancer la machine d'état. Après avoir appelé Start, la propriété Task sera renvoyée par la méthode. Je vous recommande de revenir à la méthode stub et d'afficher ces étapes.

Si la machine d'état se termine avec succès, la méthode SetResult est appelée , sinon SetException . Si la machine d'état atteint wait , la méthode GetAwaiter () du type de tâche est exécutée . Si l'objet d'attente implémente l'interface ICriticalNotifyCompletion et IsCompleted = false, la machine d'état utilise builder.AwaitUnsafeOnCompleted (ref waiter, ref stateMachine) . La méthode AwaitUnsafeOnCompleted doit appeler waiter.OnCompleted (action) , l'action doit appeler stateMachine.MoveNextlorsque l'objet d'attente se termine. De même pour l'interface INotifyCompletion et la méthode builder.AwaitOnCompleted .

C'est à vous de décider comment l'utiliser. Mais je vous conseille de penser à 514 fois avant de l'appliquer en production, et non de vous faire dorloter. Ce qui suit est un exemple d'utilisation. J'ai esquissé juste un proxy pour un constructeur standard qui affiche à la console quelle méthode a été appelée et à quelle heure. Soit dit en passant, l'asynchrone Main () ne veut pas prendre en charge un type d'attente personnalisé (je crois que plus d'un projet de production a été désespérément corrompu en raison de cette absence de Microsoft). Si vous le souhaitez, vous pouvez modifier l'enregistreur proxy en utilisant un enregistreur normal et en enregistrant plus d'informations.

Tâche de proxy de journalisation
 public class Program { public static void Main() { Console.WriteLine("Start"); JustMethod().Task.Wait(); //   Console.WriteLine("Stop"); } public static async LogTask JustMethod() { await DelayWrapper(1000); } public static LogTask DelayWrapper(int milliseconds) => new LogTask { Task = Task.Delay(milliseconds)}; } [AsyncMethodBuilder(typeof(LogMethodBuilder))] public class LogTask { public Task Task { get; set; } public TaskAwaiter GetAwaiter() => Task.GetAwaiter(); } public class LogMethodBuilder { private AsyncTaskMethodBuilder _methodBuilder = AsyncTaskMethodBuilder.Create(); private LogTask _task; public static LogMethodBuilder Create() { Console.WriteLine($"Method: Create; {DateTime.Now :O}"); return new LogMethodBuilder(); } public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: Start; {DateTime.Now :O}"); _methodBuilder.Start(ref stateMachine); } public void SetStateMachine(IAsyncStateMachine stateMachine) { Console.WriteLine($"Method: SetStateMachine; {DateTime.Now :O}"); _methodBuilder.SetStateMachine(stateMachine); } public void SetException(Exception exception) { Console.WriteLine($"Method: SetException; {DateTime.Now :O}"); _methodBuilder.SetException(exception); } public void SetResult() { Console.WriteLine($"Method: SetResult; {DateTime.Now :O}"); _methodBuilder.SetResult(); } public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: AwaitOnCompleted; {DateTime.Now :O}"); _methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine); } public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: AwaitUnsafeOnCompleted; {DateTime.Now :O}"); _methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); } public LogTask Task { get { Console.WriteLine($"Property: Task; {DateTime.Now :O}"); return _task ??= new LogTask {Task = _methodBuilder.Task}; } set => _task = value; } } 


Conclusion: Méthode de

démarrage
: Créer; 2019-10-09T17: 55: 13.7152733 + 03: 00
Méthode: Démarrer; 2019-10-09T17: 55: 13.7262226 + 03: 00
Méthode: AwaitUnsafeOnCompleted; 2019-10-09T17: 55: 13.7275206 + 03: 00
Propriété: tâche; 2019-10-09T17: 55: 13.7292005 + 03: 00
Méthode: SetResult; 2019-10-09T17: 55: 14.7297967 + 03: 00
Stop


C'est tout, merci à tous.

Source: https://habr.com/ru/post/fr470830/


All Articles