.NET : outils pour travailler avec le multithreading et l'asynchronie. Partie 1

Je publie l'article original sur Habr, dont la traduction est publiée dans le corporate блоге.

La nécessité de faire quelque chose de manière asynchrone, sans attendre le résultat ici et maintenant, ou de diviser un gros travail entre plusieurs unités qui l'exécutent, existait avant l'avènement des ordinateurs. Avec leur avènement, ce besoin est devenu très tangible. Aujourd'hui, en 2019, je tape cet article sur un ordinateur portable doté d'un processeur Intel Core à 8 cœurs, sur lequel plus d'une centaine de processus s'exécutent en parallèle, et encore plus de threads. A proximité se trouve un téléphone un peu défraîchi, acheté il y a quelques années, il embarque un processeur à 8 cœurs. Les ressources thématiques regorgent d’articles et de vidéos dans lesquels leurs auteurs admirent les smartphones phares de cette année dotés de processeurs à 16 cœurs. MS Azure fournit une machine virtuelle avec un processeur à 20 cœurs et 128 To de RAM pour moins de 2 $/heure. Malheureusement, il est impossible d’en extraire le maximum et d’exploiter cette puissance sans pouvoir gérer l’interaction des threads.

Vocabulaire

Processus - L'objet OS, espace d'adressage isolé, contient des threads.
Fil - un objet OS, la plus petite unité d'exécution, faisant partie d'un processus, les threads partagent de la mémoire et d'autres ressources entre eux au sein d'un processus.
Multitâche - Propriété du système d'exploitation, possibilité d'exécuter plusieurs processus simultanément
Multicœur - une propriété du processeur, la possibilité d'utiliser plusieurs cœurs pour le traitement des données
Multitraitement - une propriété d'un ordinateur, la capacité de travailler simultanément avec plusieurs processeurs physiquement
Multithreading — une propriété d'un processus, la capacité de répartir le traitement des données entre plusieurs threads.
Parallélisme - effectuer plusieurs actions physiquement simultanément par unité de temps
Asynchronie — exécution d'une opération sans attendre la fin de ce traitement ; le résultat de l'exécution peut être traité ultérieurement.

Métaphore

Toutes les définitions ne sont pas bonnes et certaines nécessitent des explications supplémentaires, j'ajouterai donc une métaphore sur la préparation du petit-déjeuner à la terminologie formellement introduite. Préparer le petit-déjeuner dans cette métaphore est un processus.

En préparant le petit-déjeuner le matin, je (Processeur) Je viens à la cuisine (Компьютер). J'ai 2 mains ( noyau). Il y a un certain nombre d'appareils dans la cuisine (IO) : four, bouilloire, grille-pain, réfrigérateur. J'allume le gaz, pose une poêle dessus et verse de l'huile dedans sans attendre qu'elle chauffe (de manière asynchrone, non bloquant-IO-Wait), je sors les œufs du réfrigérateur et les casse dans une assiette, puis je les bats d'une main (Sujet n°1), et deuxieme (Sujet n°2) tenant la plaque (Ressource Partagée). Maintenant, j'aimerais allumer la bouilloire, mais je n'ai pas assez de mains (Manque de discussions) Pendant ce temps, la poêle chauffe (Traitement du résultat) dans laquelle je verse ce que j'ai fouetté. J'attrape la bouilloire, je l'allume et je regarde bêtement l'eau bouillir dedans (Blocage-IO-Attente), même si pendant ce temps il aurait pu laver l'assiette où il fouettait l'omelette.

J'ai cuisiné une omelette avec seulement 2 mains, et je n'en ai pas plus, mais en même temps, au moment de fouetter l'omelette, 3 opérations ont eu lieu à la fois : fouetter l'omelette, tenir l'assiette, chauffer la poêle Le CPU est la partie la plus rapide de l'ordinateur, les IO sont ce qui ralentit le plus souvent, donc souvent une solution efficace consiste à occuper le CPU avec quelque chose tout en recevant des données de IO.

Poursuivant la métaphore :

  • Si, en train de préparer une omelette, j'essayais également de changer de vêtements, ce serait un exemple de multitâche. Une nuance importante : les ordinateurs sont bien meilleurs que les humains dans ce domaine.
  • Une cuisine avec plusieurs chefs, par exemple dans un restaurant - un ordinateur multicœur.
  • De nombreux restaurants dans une aire de restauration dans un centre commercial - centre de données

Outils .NET

.NET fonctionne bien avec les threads, comme avec beaucoup d'autres choses. Avec chaque nouvelle version, il introduit de plus en plus de nouveaux outils pour travailler avec eux, de nouvelles couches d'abstraction sur les threads du système d'exploitation. Lorsqu'ils travaillent avec la construction d'abstractions, les développeurs de frameworks utilisent une approche qui laisse la possibilité, lorsqu'ils utilisent une abstraction de haut niveau, de descendre d'un ou plusieurs niveaux en dessous. Le plus souvent, cela n'est pas nécessaire, en fait, cela ouvre la porte à se tirer une balle dans le pied avec un fusil de chasse, mais parfois, dans de rares cas, cela peut être le seul moyen de résoudre un problème qui n'est pas résolu au niveau d'abstraction actuel. .

Par outils, j'entends à la fois les interfaces de programmation d'applications (API) fournies par le framework et les packages tiers, ainsi que les solutions logicielles complètes qui simplifient la recherche de tout problème lié au code multithread.

Démarrer un fil de discussion

La classe Thread est la classe la plus basique de .NET pour travailler avec des threads. Le constructeur accepte l'un des deux délégués :

  • ThreadStart — Aucun paramètre
  • ParametrizedThreadStart - avec un paramètre de type objet.

Le délégué sera exécuté dans le thread nouvellement créé après avoir appelé la méthode Start. Si un délégué de type ParametrizedThreadStart a été passé au constructeur, alors un objet doit être passé à la méthode Start. Ce mécanisme est nécessaire pour transférer toute information locale vers le flux. Il convient de noter que la création d'un thread est une opération coûteuse et que le thread lui-même est un objet lourd, au moins parce qu'il alloue 1 Mo de mémoire sur la pile et nécessite une interaction avec l'API du système d'exploitation.

new Thread(...).Start(...);

La classe ThreadPool représente le concept de pool. Dans .NET, le pool de threads est une pièce d'ingénierie et les développeurs de Microsoft ont déployé beaucoup d'efforts pour s'assurer qu'il fonctionne de manière optimale dans une grande variété de scénarios.

Concept général:

Dès le démarrage de l'application, elle crée plusieurs threads en réserve en arrière-plan et offre la possibilité de les utiliser. Si les threads sont utilisés fréquemment et en grand nombre, le pool s'agrandit pour répondre aux besoins de l'appelant. Lorsqu'il n'y a pas de threads libres dans le pool au bon moment, il attendra le retour de l'un des threads ou en créera un nouveau. Il s'ensuit que le pool de threads est idéal pour certaines actions à court terme et mal adapté aux opérations exécutées en tant que services tout au long du fonctionnement de l'application.

Pour utiliser un thread du pool, il existe une méthode QueueUserWorkItem qui accepte un délégué de type WaitCallback, qui a la même signature que ParametrizedThreadStart, et le paramètre qui lui est transmis exécute la même fonction.

ThreadPool.QueueUserWorkItem(...);

La méthode de pool de threads moins connue RegisterWaitForSingleObject est utilisée pour organiser les opérations d'E/S non bloquantes. Le délégué passé à cette méthode sera appelé lorsque le WaitHandle passé à la méthode sera « Released ».

ThreadPool.RegisterWaitForSingleObject(...)

.NET a un minuteur de thread et il diffère des minuteurs WinForms/WPF en ce sens que son gestionnaire sera appelé sur un thread extrait du pool.

System.Threading.Timer

Il existe également un moyen plutôt exotique d'envoyer un délégué pour exécution à un thread du pool - la méthode BeginInvoke.

DelegateInstance.BeginInvoke

Je voudrais m'attarder brièvement sur la fonction à laquelle bon nombre des méthodes ci-dessus peuvent être appelées - CreateThread à partir de l'API Win32 Kernel32.dll. Il existe un moyen, grâce au mécanisme des méthodes externes, d'appeler cette fonction. Je n'ai vu un tel appel qu'une seule fois dans un terrible exemple de code hérité, et la motivation de l'auteur qui a fait exactement cela reste encore un mystère pour moi.

Kernel32.dll CreateThread

Affichage et débogage des fils de discussion

Les threads créés par vous, tous les composants tiers et le pool .NET peuvent être affichés dans la fenêtre Threads de Visual Studio. Cette fenêtre n'affichera les informations sur le thread que lorsque l'application est en cours de débogage et en mode Break. Ici, vous pouvez facilement afficher les noms de pile et les priorités de chaque thread, et basculer le débogage vers un thread spécifique. À l'aide de la propriété Priority de la classe Thread, vous pouvez définir la priorité d'un thread, que l'OC et le CLR percevront comme une recommandation lors de la répartition du temps processeur entre les threads.

.NET : outils pour travailler avec le multithreading et l'asynchronie. Partie 1

Bibliothèque parallèle de tâches

La bibliothèque parallèle de tâches (TPL) a été introduite dans .NET 4.0. C'est désormais le standard et l'outil principal pour travailler avec l'asynchronie. Tout code utilisant une approche plus ancienne est considéré comme hérité. L'unité de base de TPL est la classe Task de l'espace de noms System.Threading.Tasks. Une tâche est une abstraction sur un fil. Avec la nouvelle version du langage C#, nous disposons d'une manière élégante de travailler avec les opérateurs Tasks - async/await. Ces concepts ont permis d'écrire du code asynchrone comme s'il était simple et synchrone, ce qui a permis même aux personnes ayant peu de compréhension du fonctionnement interne des threads d'écrire des applications qui les utilisent, des applications qui ne se bloquent pas lors de l'exécution d'opérations longues. Utiliser async/await est le sujet d'un, voire de plusieurs articles, mais je vais essayer d'en comprendre l'essentiel en quelques phrases :

  • async est un modificateur d'une méthode renvoyant Task ou void
  • et wait est un opérateur d'attente de tâche non bloquant.

Encore une fois : l'opérateur wait, dans le cas général (il y a des exceptions), libérera davantage le thread d'exécution en cours, et lorsque la Tâche aura terminé son exécution, et le thread (en fait, il serait plus correct de dire le contexte , mais nous y reviendrons plus tard) continuera à exécuter la méthode. Dans .NET, ce mécanisme est implémenté de la même manière que le rendement, lorsque la méthode écrite se transforme en une classe entière, qui est une machine à états et peut être exécutée en morceaux séparés en fonction de ces états. Toute personne intéressée peut écrire n'importe quel code simple en utilisant async/await, compiler et afficher l'assembly à l'aide de JetBrains dotPeek avec le code généré par le compilateur activé.

Examinons les options de lancement et d'utilisation de Task. Dans l'exemple de code ci-dessous, nous créons une nouvelle tâche qui ne fait rien d'utile (Fil.Sommeil(10000)), mais dans la vraie vie, cela devrait être un travail complexe et gourmand en CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Une tâche est créée avec un certain nombre d'options :

  • LongRunning indique que la tâche ne sera pas terminée rapidement, ce qui signifie qu'il peut être intéressant d'envisager de ne pas retirer de thread du pool, mais d'en créer un séparé pour cette tâche afin de ne pas nuire aux autres.
  • AttachedToParent - Les tâches peuvent être organisées dans une hiérarchie. Si cette option a été utilisée, alors la tâche peut être dans un état où elle est elle-même terminée et attend l'exécution de ses enfants.
  • PreferFairness - signifie qu'il serait préférable d'exécuter les tâches envoyées pour exécution plus tôt avant celles envoyées plus tard. Mais ce n’est qu’une recommandation et les résultats ne sont pas garantis.

Le deuxième paramètre transmis à la méthode est CancellationToken. Pour gérer correctement l'annulation d'une opération après son démarrage, le code en cours d'exécution doit être rempli de vérifications pour l'état CancellationToken. S'il n'y a pas de vérifications, alors la méthode Cancel appelée sur l'objet CancellationTokenSource pourra arrêter l'exécution de la Tâche uniquement avant son démarrage.

Le dernier paramètre est un objet planificateur de type TaskScheduler. Cette classe et ses descendants sont conçus pour contrôler les stratégies de distribution des tâches entre les threads ; par défaut, la tâche sera exécutée sur un thread aléatoire du pool.

L'opérateur wait est appliqué à la tâche créée, ce qui signifie que le code écrit après, s'il y en a un, sera exécuté dans le même contexte (souvent cela signifie sur le même thread) que le code avant wait.

La méthode est marquée comme async void, ce qui signifie qu'elle peut utiliser l'opérateur wait, mais le code appelant ne pourra pas attendre l'exécution. Si une telle fonctionnalité est nécessaire, la méthode doit renvoyer Task. Les méthodes marquées async void sont assez courantes : en règle générale, il s'agit de gestionnaires d'événements ou d'autres méthodes qui fonctionnent sur le principe du feu et de l'oubli. Si vous devez non seulement donner la possibilité d'attendre la fin de l'exécution, mais également renvoyer le résultat, vous devez alors utiliser Task.

Sur la tâche renvoyée par la méthode StartNew, ainsi que sur toute autre, vous pouvez appeler la méthode ConfigureAwait avec le paramètre false, puis l'exécution après wait ne continuera pas sur le contexte capturé, mais sur un contexte arbitraire. Cela doit toujours être fait lorsque le contexte d'exécution n'est pas important pour le code après l'attente. C'est également une recommandation de MS lors de l'écriture de code qui sera livré packagé dans une bibliothèque.

Attardons-nous un peu plus sur la façon dont vous pouvez attendre la fin d'une tâche. Vous trouverez ci-dessous un exemple de code, avec des commentaires sur les cas où l'attente est bien exécutée de manière conditionnelle et quand elle est mal exécutée de manière conditionnelle.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

Dans le premier exemple, nous attendons que la tâche se termine sans bloquer le thread appelant ; nous ne reviendrons au traitement du résultat que lorsqu'il est déjà là ; en attendant, le thread appelant est laissé à lui-même.

Dans la deuxième option, nous bloquons le thread appelant jusqu'à ce que le résultat de la méthode soit calculé. C'est mauvais non seulement parce que nous avons occupé un thread, une ressource si précieuse du programme, avec une simple inactivité, mais aussi parce que si le code de la méthode que nous appelons contient wait, et que le contexte de synchronisation nécessite de revenir au thread appelant après wait, alors nous obtiendrons un blocage : Le thread appelant attend que le résultat de la méthode asynchrone soit calculé, la méthode asynchrone tente en vain de continuer son exécution dans le thread appelant.

Un autre inconvénient de cette approche est la gestion compliquée des erreurs. Le fait est que les erreurs dans le code asynchrone lors de l'utilisation de async/await sont très faciles à gérer - elles se comportent de la même manière que si le code était synchrone. Alors que si nous appliquons un exorcisme d'attente synchrone à une tâche, l'exception d'origine se transforme en AggregateException, c'est-à-dire Pour gérer l'exception, vous devrez examiner le type InnerException et écrire vous-même une chaîne if à l'intérieur d'un bloc catch ou utiliser la construction catch when, au lieu de la chaîne de blocs catch qui est plus familière dans le monde C#.

Les troisième et dernier exemples sont également marqués comme mauvais pour la même raison et contiennent tous les mêmes problèmes.

Les méthodes WhenAny et WhenAll sont extrêmement pratiques pour attendre un groupe de tâches ; elles regroupent un groupe de tâches en une seule, qui se déclenchera soit lorsqu'une tâche du groupe est déclenchée pour la première fois, soit lorsque toutes ont terminé leur exécution.

Arrêter les discussions

Pour diverses raisons, il peut être nécessaire d’arrêter le flux après son démarrage. Il y a un certain nombre de façons de le faire. La classe Thread possède deux méthodes nommées de manière appropriée : fausse couche и Interrompre. L'utilisation du premier est fortement déconseillée, car après l'avoir appelé à tout moment aléatoire, pendant le traitement d'une instruction, une exception sera levée ThreadAbortedException. Vous ne vous attendez pas à ce qu'une telle exception soit levée lors de l'incrémentation d'une variable entière, n'est-ce pas ? Et lorsque l’on utilise cette méthode, c’est une situation bien réelle. Si vous devez empêcher le CLR de générer une telle exception dans une certaine section de code, vous pouvez l'envelopper dans des appels Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Tout code écrit dans un bloc final est encapsulé dans de tels appels. Pour cette raison, dans les profondeurs du code-cadre, vous pouvez trouver des blocs avec un essai vide, mais pas un final vide. Microsoft décourage tellement cette méthode qu'il ne l'inclut pas dans le noyau .net.

La méthode Interruption fonctionne de manière plus prévisible. Il peut interrompre le fil avec une exception ThreadInterruptedException uniquement pendant les moments où le thread est en état d'attente. Il entre dans cet état en étant suspendu en attendant WaitHandle, le verrouillage ou après avoir appelé Thread.Sleep.

Les deux options décrites ci-dessus sont mauvaises en raison de leur imprévisibilité. La solution est d'utiliser une structure Jeton d'annulation et la classe CancellationTokenSource. Le point est le suivant : une instance de la classe CancellationTokenSource est créée et seul celui qui la possède peut arrêter l'opération en appelant la méthode. Annuler. Seul le CancellationToken est transmis à l’opération elle-même. Les propriétaires de CancellationToken ne peuvent pas annuler l'opération eux-mêmes, mais peuvent uniquement vérifier si l'opération a été annulée. Il existe une propriété booléenne pour cela IsCancellationDequested et méthode ThrowIfCancelRequested. Ce dernier lancera une exception TaskCancelledException si la méthode Cancel a été appelée sur l’instance CancellationToken en cours de perroquet. Et c’est la méthode que je recommande d’utiliser. Il s'agit d'une amélioration par rapport aux options précédentes en obtenant un contrôle total sur le moment où une opération d'exception peut être abandonnée.

L'option la plus brutale pour arrêter un thread consiste à appeler la fonction TerminateThread de l'API Win32. Le comportement du CLR après l'appel de cette fonction peut être imprévisible. Sur MSDN, ce qui suit est écrit à propos de cette fonction : « TerminateThread est une fonction dangereuse qui ne doit être utilisée que dans les cas les plus extrêmes. "

Conversion de l'API héritée en fonction des tâches à l'aide de la méthode FromAsync

Si vous avez la chance de travailler sur un projet qui a été lancé après l'introduction des tâches et qui a cessé de causer une horreur silencieuse à la plupart des développeurs, vous n'aurez pas à gérer beaucoup d'anciennes API, à la fois celles de tiers et celles de votre équipe. a torturé dans le passé. Heureusement, l'équipe .NET Framework a pris soin de nous, même si l'objectif était peut-être de prendre soin de nous-mêmes. Quoi qu'il en soit, .NET dispose d'un certain nombre d'outils pour convertir sans douleur le code écrit selon les anciennes approches de programmation asynchrone vers la nouvelle. L'une d'elles est la méthode FromAsync de TaskFactory. Dans l'exemple de code ci-dessous, j'encapsule les anciennes méthodes asynchrones de la classe WebRequest dans une tâche en utilisant cette méthode.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Ceci n'est qu'un exemple et il est peu probable que vous ayez à le faire avec des types intégrés, mais tout ancien projet regorge simplement de méthodes BeginDoSomething qui renvoient les méthodes IAsyncResult et EndDoSomething qui le reçoivent.

Convertir l'API héritée en fonction des tâches à l'aide de la classe TaskCompletionSource

Un autre outil important à considérer est la classe Source d'achèvement de la tâche. En termes de fonctions, d'objectif et de principe de fonctionnement, cela peut rappeler un peu la méthode RegisterWaitForSingleObject de la classe ThreadPool, dont j'ai parlé ci-dessus. En utilisant cette classe, vous pouvez facilement et commodément encapsuler les anciennes API asynchrones dans des tâches.

Vous me direz que j'ai déjà parlé de la méthode FromAsync de la classe TaskFactory destinée à ces fins. Il faudra ici rappeler toute l'histoire du développement des modèles asynchrones en .net que Microsoft a proposé au cours des 15 dernières années : avant le Task-Based Asynchronous Pattern (TAP), il y avait le Asynchronous Programming Pattern (APP), qui il s'agissait de méthodes CommencerFaire quelque chose revient Résultat IAsync et méthodes FinDoSomething qui l'accepte et pour l'héritage de ces années, la méthode FromAsync est tout simplement parfaite, mais au fil du temps, elle a été remplacée par le modèle asynchrone basé sur les événements (EAP), qui supposait qu'un événement serait déclenché une fois l'opération asynchrone terminée.

TaskCompletionSource est parfait pour encapsuler les tâches et les API héritées construites autour du modèle d'événement. L'essence de son travail est la suivante : un objet de cette classe possède une propriété publique de type Task, dont l'état peut être contrôlé via les méthodes SetResult, SetException, etc. de la classe TaskCompletionSource. Aux endroits où l'opérateur wait a été appliqué à cette tâche, elle sera exécutée ou échouera avec une exception en fonction de la méthode appliquée à TaskCompletionSource. Si ce n'est toujours pas clair, regardons cet exemple de code, où une ancienne API EAP est encapsulée dans une tâche à l'aide d'un TaskCompletionSource : lorsque l'événement se déclenche, la tâche sera transférée à l'état Terminé et la méthode qui a appliqué l'opérateur d'attente à cette Tâche reprendra son exécution après avoir reçu l'objet résultat.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

Trucs et astuces TaskCompletionSource

Encapsuler d’anciennes API n’est pas tout ce qui peut être fait à l’aide de TaskCompletionSource. L'utilisation de cette classe ouvre une possibilité intéressante de concevoir diverses API sur des tâches qui n'occupent pas de threads. Et le flux, on s'en souvient, est une ressource coûteuse et leur nombre est limité (principalement par la quantité de RAM). Cette limitation peut être facilement obtenue en développant, par exemple, une application Web chargée avec une logique métier complexe. Considérons les possibilités dont je parle lors de la mise en œuvre d'une astuce telle que le Long-Polling.

En bref, l'essence de l'astuce est la suivante : vous devez recevoir des informations de l'API sur certains événements qui se produisent de son côté, tandis que l'API, pour une raison quelconque, ne peut pas signaler l'événement, mais ne peut renvoyer que l'état. Un exemple en est toutes les API construites sur HTTP avant l'époque de WebSocket ou lorsqu'il était impossible, pour une raison quelconque, d'utiliser cette technologie. Le client peut demander au serveur HTTP. Le serveur HTTP ne peut pas initier lui-même la communication avec le client. Une solution simple consiste à interroger le serveur à l'aide d'un timer, mais cela crée une charge supplémentaire sur le serveur et un délai supplémentaire en moyenne TimerInterval/2. Pour contourner ce problème, une astuce appelée Long Polling a été inventée, qui consiste à retarder la réponse de le serveur jusqu'à ce que le délai d'attente expire ou qu'un événement se produise. Si l'événement s'est produit, alors il est traité, sinon, la demande est renvoyée.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Mais une telle solution s'avérera terrible dès que le nombre de clients attendant l'événement augmentera, car... Chacun de ces clients occupe un thread entier en attente d'un événement. Oui, et nous obtenons un délai supplémentaire de 1 ms lorsque l'événement est déclenché, le plus souvent ce n'est pas significatif, mais pourquoi rendre le logiciel pire qu'il ne peut l'être ? Si nous supprimons Thread.Sleep(1), nous chargerons en vain un cœur de processeur 100 % inactif, tournant dans un cycle inutile. En utilisant TaskCompletionSource, vous pouvez facilement refaire ce code et résoudre tous les problèmes identifiés ci-dessus :

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Ce code n'est pas prêt pour la production, mais juste une démo. Pour l'utiliser dans des cas réels, il faut également, au minimum, gérer la situation où un message arrive à un moment où personne ne l'attend : dans ce cas, la méthode AsseptMessageAsync doit renvoyer une tâche déjà terminée. Si c'est le cas le plus courant, vous pouvez penser à utiliser ValueTask.

Lorsque nous recevons une demande de message, nous créons et plaçons un TaskCompletionSource dans le dictionnaire, puis attendons ce qui se passe en premier : l'intervalle de temps spécifié expire ou un message est reçu.

ValueTask : pourquoi et comment

Les opérateurs async/wait, comme l'opérateur Yield Return, génèrent une machine d'état à partir de la méthode, et il s'agit de la création d'un nouvel objet, ce qui n'est presque toujours pas important, mais dans de rares cas, cela peut créer un problème. Ce cas peut être une méthode qui est appelée très souvent, nous parlons de dizaines et de centaines de milliers d'appels par seconde. Si une telle méthode est écrite de telle manière que, dans la plupart des cas, elle renvoie un résultat contournant toutes les méthodes d'attente, alors .NET fournit un outil pour optimiser cela - la structure ValueTask. Pour que ce soit clair, regardons un exemple de son utilisation : il existe un cache auquel nous allons très souvent. Il contient des valeurs et nous les renvoyons simplement ; sinon, nous passons à une E/S lente pour les obtenir. Je veux faire ce dernier de manière asynchrone, ce qui signifie que l'ensemble de la méthode s'avère asynchrone. Ainsi, la manière évidente d’écrire la méthode est la suivante :

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

En raison de l'envie d'optimiser un peu, et d'une légère crainte de ce que Roslyn va générer lors de la compilation de ce code, vous pouvez réécrire cet exemple comme suit :

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

En effet, la solution optimale dans ce cas serait d'optimiser le hot-path, à savoir obtenir une valeur du dictionnaire sans allocations ni charge inutiles sur le GC, alors que dans les rares cas où nous devons encore accéder aux IO pour les données , tout restera un plus/moins à l'ancienne :

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Regardons de plus près ce morceau de code : s'il y a une valeur dans le cache, nous créons une structure, sinon la vraie tâche sera enveloppée dans une tâche significative. Le code appelant ne se soucie pas du chemin dans lequel ce code a été exécuté : ValueTask, d'un point de vue de la syntaxe C#, se comportera de la même manière qu'une tâche normale dans ce cas.

TaskSchedulers : gérer les stratégies de lancement de tâches

La prochaine API que j'aimerais considérer est la classe Planificateur de tâches et ses dérivés. J'ai déjà mentionné ci-dessus que TPL a la capacité de gérer des stratégies de répartition des tâches entre les threads. De telles stratégies sont définies dans les descendants de la classe TaskScheduler. Presque toutes les stratégies dont vous pourriez avoir besoin peuvent être trouvées dans la bibliothèque. ParallelExtensionsExtrasParallelExtensionsExtras, développé par Microsoft, mais ne fait pas partie de .NET, mais fourni sous forme de package Nuget. Examinons brièvement certains d'entre eux :

  • Planificateur de tâches CurrentThread — exécute des tâches sur le thread actuel
  • Planificateur de tâches LimitedConcurrencyLevel — limite le nombre de tâches exécutées simultanément par le paramètre N, qui est accepté dans le constructeur
  • Planificateur de tâches commandées - est défini comme LimitedConcurrencyLevelTaskScheduler(1), les tâches seront donc exécutées séquentiellement.
  • WorkStealingTaskScheduler - met en oeuvre vol de travail approche de la répartition des tâches. Il s’agit essentiellement d’un ThreadPool distinct. Résout le problème selon lequel dans .NET ThreadPool il s'agit d'une classe statique, une pour toutes les applications, ce qui signifie que sa surcharge ou son utilisation incorrecte dans une partie du programme peut entraîner des effets secondaires dans une autre. De plus, il est extrêmement difficile de comprendre la cause de tels défauts. Que. Il peut s'avérer nécessaire d'utiliser des WorkStealingTaskSchedulers distincts dans certaines parties du programme où l'utilisation de ThreadPool peut être agressive et imprévisible.
  • Planificateur de tâches en file d'attente — vous permet d'effectuer des tâches selon des règles de file d'attente prioritaires
  • Planificateur de threads par tâche - crée un thread distinct pour chaque tâche qui y est exécutée. Peut être utile pour les tâches dont l’exécution prend un temps imprévisible.

Il y a un bon détail article à propos de TaskSchedulers sur le blog Microsoft.

Pour un débogage pratique de tout ce qui concerne les tâches, Visual Studio dispose d'une fenêtre Tâches. Dans cette fenêtre, vous pouvez voir l'état actuel de la tâche et accéder à la ligne de code en cours d'exécution.

.NET : outils pour travailler avec le multithreading et l'asynchronie. Partie 1

Plinq et la classe Parallèle

En plus des tâches et de tout ce qui est dit à leur sujet, il existe deux autres outils intéressants dans .NET : PLinq (Linq2Parallel) et la classe Parallel. Le premier promet l'exécution parallèle de toutes les opérations Linq sur plusieurs threads. Le nombre de threads peut être configuré à l'aide de la méthode d'extension WithDegreeOfParallelism. Malheureusement, le plus souvent Plinq dans son mode par défaut ne dispose pas de suffisamment d'informations sur les composants internes de votre source de données pour fournir un gain de vitesse significatif, par contre, le coût d'essai est très faible : il vous suffit d'appeler la méthode AsParallel avant la chaîne de méthodes Linq et exécuter des tests de performances. De plus, il est possible de transmettre des informations supplémentaires à PLinq sur la nature de votre source de données en utilisant le mécanisme Partitions. Vous pouvez en lire davantage ici и ici.

La classe statique Parallel fournit des méthodes pour parcourir une collection Foreach en parallèle, exécuter une boucle For et exécuter plusieurs délégués en parallèle Invoke. L'exécution du thread en cours sera arrêtée jusqu'à ce que les calculs soient terminés. Le nombre de threads peut être configuré en passant ParallelOptions comme dernier argument. Vous pouvez également spécifier TaskScheduler et CancellationToken à l'aide d'options.

résultats

Lorsque j'ai commencé à rédiger cet article sur la base des éléments de mon rapport et des informations que j'ai recueillies au cours de mon travail ultérieur, je ne m'attendais pas à ce qu'il y en ait autant. Maintenant, lorsque l'éditeur de texte dans lequel je tape cet article me dit avec reproche que la page 15 a disparu, je résumerai les résultats intermédiaires. D'autres astuces, API, outils visuels et pièges seront abordés dans le prochain article.

Conclusions:

  • Vous devez connaître les outils permettant de travailler avec les threads, l'asynchronie et le parallélisme afin d'utiliser les ressources des PC modernes.
  • .NET dispose de nombreux outils différents à ces fins
  • Toutes ne sont pas apparues en même temps, vous pouvez donc souvent en trouver d'anciennes, cependant, il existe des moyens de convertir les anciennes API sans trop d'effort.
  • Travailler avec des threads dans .NET est représenté par les classes Thread et ThreadPool
  • Les méthodes Thread.Abort, Thread.Interrupt et Win32 API TerminateThread sont dangereuses et leur utilisation n'est pas recommandée. Au lieu de cela, il est préférable d'utiliser le mécanisme CancellationToken
  • Le flux est une ressource précieuse et son approvisionnement est limité. Les situations dans lesquelles les threads sont occupés à attendre des événements doivent être évitées. Pour cela, il est pratique d'utiliser la classe TaskCompletionSource
  • Les outils .NET les plus puissants et les plus avancés pour travailler avec le parallélisme et l'asynchronie sont les tâches.
  • Les opérateurs c# async/await implémentent le concept d'attente non bloquante
  • Vous pouvez contrôler la répartition des tâches entre les threads à l'aide des classes dérivées de TaskScheduler.
  • La structure ValueTask peut être utile pour optimiser les hot-paths et le trafic mémoire
  • Les fenêtres Tâches et Threads de Visual Studio fournissent de nombreuses informations utiles pour déboguer du code multithread ou asynchrone
  • Plinq est un outil intéressant, mais il ne contient peut-être pas suffisamment d'informations sur votre source de données, mais cela peut être corrigé à l'aide du mécanisme de partitionnement.
  • A suivre ...

Source: habr.com

Ajouter un commentaire