Que se passe-t-il exactement lorsqu'un thread attend une tâche dans une boucle while?

10

Après avoir traité le modèle asynchrone / attente de C # pendant un certain temps maintenant, je me suis soudain rendu compte que je ne savais pas vraiment comment expliquer ce qui se passait dans le code suivant:

async void MyThread()
{
    while (!_quit)
    {
        await GetWorkAsync();
    }
}

GetWorkAsync()est supposé renvoyer un fichier attendu Taskqui peut ou non provoquer un changement de thread lorsque la continuation est exécutée.

Je ne serais pas confus si l'attente n'était pas dans une boucle. Je m'attendrais naturellement à ce que le reste de la méthode (c'est-à-dire la poursuite) s'exécute potentiellement sur un autre thread, ce qui est bien.

Cependant, à l'intérieur d'une boucle, le concept de «le reste de la méthode» me brouille un peu.

Qu'arrive-t-il au "reste de la boucle" si le thread est activé en continuation ou s'il n'est pas activé? Sur quel thread la prochaine itération de la boucle est-elle exécutée?

Mes observations montrent (non vérifiées de façon concluante) que chaque itération commence sur le même thread (l'original) tandis que la suite s'exécute sur un autre. Est-ce vraiment possible? Si oui, s'agit-il alors d'un degré de parallélisme inattendu qui doit être pris en compte vis-à-vis de la sécurité des threads de la méthode GetWorkAsync?

MISE À JOUR: Ma question n'est pas un doublon, comme suggéré par certains. Le while (!_quit) { ... }modèle de code n'est qu'une simplification de mon code actuel. En réalité, mon thread est une boucle longue durée qui traite sa file d'attente d'entrée d'éléments de travail à intervalles réguliers (toutes les 5 secondes par défaut). La vérification de la condition de sortie réelle n'est pas non plus une simple vérification de champ comme le suggère l'exemple de code, mais plutôt une vérification de la gestion des événements.

aoven
la source
1
Voir aussi Comment produire et attendre la mise en œuvre du flux de contrôle dans .NET? pour de très bonnes informations sur la façon dont tout cela est câblé ensemble.
John Wu
@John Wu: Je n'ai pas encore vu ce fil SO. Beaucoup de pépites d'informations intéressantes là-bas. Merci!
aoven

Réponses:

6

Vous pouvez le vérifier sur Try Roslyn . Votre méthode d'attente est réécrite dans void IAsyncStateMachine.MoveNext()la classe asynchrone générée.

Ce que vous verrez est quelque chose comme ceci:

            if (this.state != 0)
                goto label_2;
            //set up the state machine here
            label_1:
            taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter);
            label_2:
            if (!OuterClass._quit)
            {
               taskAwaiter = GetWorkAsync().GetAwaiter();
               //state machine stuff here
            }
            goto label_1;

Fondamentalement, peu importe le fil sur lequel vous vous trouvez; la machine d'état peut reprendre correctement en remplaçant votre boucle par une structure if / goto équivalente.

Cela dit, les méthodes asynchrones ne s'exécutent pas nécessairement sur un thread différent. Voir l'explication d'Eric Lippert "Ce n'est pas magique" pour expliquer comment vous pouvez travailler async/awaitsur un seul thread.

Mason Wheeler
la source
2
Je semble sous-estimer l'étendue de la réécriture que le compilateur fait sur mon code asynchrone. En substance, il n'y a pas de "boucle" après la réécriture! C'était la partie manquante pour moi. Génial et merci aussi pour le lien "Essayez Roslyn"!
aoven
GOTO est la construction de boucle d' origine . N'oublions pas ça.
2

Premièrement, Servy a écrit du code dans une réponse à une question similaire, sur laquelle cette réponse est basée:

/programming/22049339/how-to-create-a-cancellable-task-loop

La réponse de Servy comprend une ContinueWith()boucle similaire utilisant des constructions TPL sans utilisation explicite des mots clés asyncet await; donc pour répondre à votre question, réfléchissez à ce que pourrait être votre code lorsque votre boucle est déroulée à l'aideContinueWith()

    private static Task GetWorkWhileNotQuit()
    {
        var tcs = new TaskCompletionSource<bool>();

        Task previous = Task.FromResult(_quit);
        Action<Task> continuation = null;
        continuation = t =>
        {
            if (!_quit)
            {
                previous = previous.ContinueWith(_ => GetWorkAsync())
                    .Unwrap()
                    .ContinueWith(_ => previous.ContinueWith(continuation));
            }
            else
            {
                tcs.SetResult(_quit);
            }
        };
        previous.ContinueWith(continuation);
        return tcs.Task;
    }

Cela prend un certain temps pour vous envelopper, mais en résumé:

  • continuationreprésente une fermeture pour "l'itération en cours"
  • previousreprésente l' Taskétat contenant "l'itération précédente" (c'est-à-dire qu'il sait quand l'itération est terminée et est utilisé pour démarrer la suivante ..)
  • En supposant que GetWorkAsync()renvoie a Task, cela signifie ContinueWith(_ => GetWorkAsync())retournera d' Task<Task>où l'appel à Unwrap()obtenir la «tâche intérieure» (c'est-à-dire le résultat réel de GetWorkAsync()).

Donc:

  1. Initialement, il n'y a pas d' itération précédente , il lui est donc simplement attribué une valeur de Task.FromResult(_quit) - son état commence par Task.Completed == true.
  2. Le continuationest exécuté pour la première fois à l' aideprevious.ContinueWith(continuation)
  3. les continuationmises previousà jour de fermeture pour refléter l'état d'achèvement de_ => GetWorkAsync()
  4. Une fois _ => GetWorkAsync()terminé, il "continue avec" _previous.ContinueWith(continuation)- c'est-à-dire appeler à continuationnouveau le lambda
    • Évidemment, à ce stade, previousa été mis à jour avec l'état de _ => GetWorkAsync()sorte que le continuationlambda est appelé lors du GetWorkAsync()retour.

Le continuationlambda vérifie toujours l'état de _quitso, s'il _quit == falsen'y a plus de continuations, et TaskCompletionSourceobtient la valeur de _quit, et tout est terminé.

Quant à votre observation concernant la poursuite de l'exécution dans un autre thread, ce n'est pas quelque chose que le mot async- awaitclé / ferait pour vous, selon ce blog "Les tâches ne sont (toujours) pas des threads et async n'est pas parallèle" . - https://blogs.msdn.microsoft.com/benwilli/2015/09/10/tasks-are-still-not-threads-and-async-is-not-parallel/

Je dirais qu'il vaut en effet la peine d'examiner de plus près votre GetWorkAsync()méthode en ce qui concerne le filetage et la sécurité du fil. Si vos diagnostics révèlent qu'il s'est exécuté sur un autre thread à la suite de votre code asynchrone / attente répété, alors quelque chose dans ou lié à cette méthode doit provoquer la création d'un nouveau thread ailleurs. (Si c'est inattendu, il y a peut-être un .ConfigureAwaitendroit?)

Ben Cottrell
la source
2
Le code que j'ai montré est (très) simplifié. À l'intérieur de GetWorkAsync (), il y a plusieurs autres attentes. Certains d'entre eux accèdent à la base de données et au réseau, ce qui signifie de vraies E / S. Si je comprends bien, le changement de thread est une conséquence naturelle (bien que non requise) de telles attentes, car le thread initial n'établit aucun contexte de synchronisation qui régirait où les continuations devraient s'exécuter. Ils s'exécutent donc sur un thread de pool de threads. Mon raisonnement est-il faux?
aoven
@aoven Bon point - je n'ai pas considéré les différents types de SynchronizationContext- qui est certainement important car .ContinueWith()utilise le SynchronizationContext pour distribuer la suite; cela expliquerait en effet le comportement que vous voyez si awaitest appelé sur un thread ThreadPool ou un thread ASP.NET. Une suite pourrait certainement être envoyée à un fil différent dans ces cas. D'un autre côté, l'appel awaità un contexte à un seul thread tel qu'un répartiteur WPF ou un contexte Winforms devrait être suffisant pour garantir que la poursuite se produit sur l'original. thread
Ben Cottrell