3
votes

Frais généraux de création de tâches

Je lis un livre "Terrell R. - Concurrency in .NET".

Il y a un bel exemple de code:

Lazy<Task<Person>> person = new Lazy<Task<Person>>(
      () => Task.Run(
        async () =>
        {
            using (var cmd = new SqlCommand(cmdText, conn))
            using (var reader = await cmd.ExecuteReaderAsync())
            {
                // some code...
            }
        }));

L'auteur a dit: p>

Comme l'expression lambda est asynchrone, elle peut être exécutée sur tout thread qui appelle Value, et l'expression s'exécutera dans le contexte.

Si je comprends bien, le Thread arrive à FetchPerson et est bloqué dans l'exécution de Lamda. Est-ce vraiment mauvais? Quelles conséquences?

Comme solution, l'auteur suggère de créer une tâche:

Lazy<Task<Person>> person = new Lazy<Task<Person>>(
     async () =>
     {
         using (var cmd = new SqlCommand(cmdText, conn))
         using (var reader = await cmd.ExecuteReaderAsync())
         {
             // some code...
         }
     });

async Task<Person> FetchPerson()
{
    return await person.Value;
}

Est-ce vraiment correct? C'est une opération IO, mais nous volons le thread CPU de Threadpool.


1 commentaires

Je ne sais pas pourquoi l'auteur suggère d'utiliser Task.Run , mais lorsque vous utilisez async / await, nous ne volons jamais le thread CPU. Lorsque nous appelons Task.Run , nous nous assurons qu'il peut être exécuté sur un pool de threads, mais uniquement sur la première partie d'une méthode avant la première asynchronisation. Une fois qu'il atteint asynchrone, il revient au pool de threads. De plus, une fois que l'attente se termine, il revient au contexte de thread d'origine, donc du point de vue de FetchPerson , peu importe sur quel thread le code est exécuté.


3 Réponses :


0
votes

Je ne comprends pas du tout pourquoi Terrell R. suggère d'utiliser Task.Run . Cela n'a aucune valeur ajoutée. Dans les deux cas, le lambda sera planifié dans le pool de threads. Puisqu'il contient des opérations IO, le thread de travail du pool de threads sera libéré après l'appel IO; une fois l'appel IO terminé, l'instruction suivante continuera sur un thread arbitraire du pool de threads.

Il semble que l'auteur écrit:

l'expression s'exécutera dans le contexte

Oui, l'exécution des appels IO va démarrer dans le contexte de l'appelant, mais se terminera dans un contexte arbitraire, sauf si vous appelez .ConfigureAwait .


3 commentaires

Pourquoi le lambda serait-il "planifié pour le pool de threads" si vous n'appelez pas Task.Run ? Il n'y a pas de thread supplémentaire impliqué ici (sans l'utilisation de Task.Run ).


Le lambda démarrera sur le thread actuel, mais jusqu'au premier wait . Après cela, c'est au planificateur.


Oui. Ainsi, le pool de threads n'est pas impliqué en supposant que le thread actuel a un SynchronizationContext .



1
votes

Comme l'expression lambda est asynchrone, elle peut être exécutée sur n'importe quel thread qui appelle Value, et l'expression s'exécutera dans le contexte.

Le lambda peut être exécuté à partir de n'importe quel thread (sauf si vous faites attention aux types de threads que vous laissez accéder à la valeur de Lazy ), et en tant que tel sera exécuté dans le contexte de ce fil. Ce n'est pas parce que c'est asynchrone , ce serait vrai même si c'était synchrone qu'il s'exécuterait dans le contexte de n'importe quel thread qui l'appelle.

Si je comprends bien, le Thread arrive à FetchPerson et est bloqué dans l'exécution de Lamda.

Le lambda est asynchrone , en tant que tel, il retournera (s'il est correctement implémenté) presque immédiatement. C'est ce que signifie être asynchrone, en tant que tel, il ne bloquera pas le thread appelant.

Est-ce vraiment mauvais? Quelles conséquences?

Si vous implémentez votre méthode asynchrone de manière incorrecte et que vous la faites effectuer un travail synchrone de longue durée, alors oui, vous bloquez ce thread / contexte. Si vous ne le faites pas, vous ne l'êtes pas.

De plus, par défaut, toutes les continuations de vos méthodes asynchrones s'exécuteront dans le contexte d'origine (s'il a un SynchonrizationContext du tout). Dans votre cas, votre code ne repose presque certainement pas sur la réutilisation de ce contexte (parce que vous ne savez pas quels contextes votre appelant pourrait avoir, je ne peux pas imaginer que vous ayez écrit le reste du code pour l'utiliser). Compte tenu de cela, vous pouvez appeler .ConfigureAwait (false) sur tout ce que vous attendez , afin de ne pas utiliser le contexte actuel pour ces suites. Il s'agit simplement d'une amélioration mineure des performances afin de ne pas perdre de temps à planifier le travail sur le contexte d'origine, à attendre tout ce qui en a besoin ou à faire attendre quoi que ce soit d'autre sur ce code lorsque inutilement.

Comme solution, l'auteur suggère de créer une tâche: [...] Est-ce vraiment correct?

Cela ne cassera rien. Il planifiera l'exécution du travail dans un thread de pool de threads, plutôt que dans le contexte d'origine. Cela va avoir des frais généraux supplémentaires pour commencer. Vous pouvez accomplir à peu près la même chose avec moins de frais généraux en ajoutant simplement ConfigureAwait (false) à tout ce que vous attendez .

Ceci est une opération IO, mais nous volons le thread CPU de Threadpool.

Cet extrait de code démarrera l'opération d'E / S sur un thread de pool de threads. Étant donné que la méthode est toujours asynchrone, elle la renverra au pool dès qu'elle le démarrera et obtiendra un nouveau thread du pool pour recommencer à s'exécuter après chaque attente. Ce dernier est probablement approprié pour cette situation, mais déplacer le code pour démarrer l'opération asynchrone initiale vers un thread de pool de threads ne fait qu'ajouter une surcharge pour aucune valeur réelle (car c'est une opération si courte que vous passerez plus d'efforts à la planifier sur un thread thread de pool que de simplement l'exécuter).


0 commentaires

1
votes

Il est vrai que le premier thread à accéder à Value exécutera le lambda. Lazy n'est pas totalement conscient de l'async et des tâches. Il exécutera simplement ce délégué.

Le délégué dans cet exemple s'exécutera sur le thread appelant jusqu'à ce qu'un wait soit atteint. Ensuite, il renverra une Tâche , cette Tâche entre dans le paresseux et le paresseux est entièrement terminé à ce stade.

Le reste de cette tâche s'exécutera comme n'importe quelle autre tâche. Il respectera le SynchronizationContext et le TaskScheduler qui ont été définis lorsque le wait s'est produit (cela fait partie du comportement wait ) . Cela peut en effet conduire ce code à s'exécuter dans un contexte inattendu tel que le thread d'interface utilisateur.

Task.Run est un moyen d'éviter cela. Il déplace le code vers le pool de threads en lui donnant un certain contexte. La surcharge consiste à mettre en file d'attente ce travail dans la piscine. La tâche de pool se terminera au premier wait . Donc, ce n'est pas async-over-sync. Aucun blocage n'est introduit. Le seul changement concerne le travail basé sur le processeur des threads (maintenant de manière déterministe sur le pool de threads).

C'est bien de faire ça. C'est une solution facile, maintenable et à faible risque à un problème pratique. Il existe différentes opinions quant à savoir si cela vaut la peine de le faire ou non. Les frais généraux, selon toute vraisemblance, n'auront pas d'importance. Personnellement, je suis très favorable à ce type de code.

Si vous êtes sûr que tous les appelants de Value s'exécutent dans un contexte approprié, vous n'en avez pas besoin. Mais si vous faites une erreur, c'est un bug sérieux. Vous pouvez donc affirmer qu'il est préférable d'insérer de manière défensive Task.Run . Soyez pragmatique et faites ce qui fonctionne.

Notez également que Task.Run est compatible asynchrone (pour ainsi dire). La tâche qu'elle renvoie va essentiellement dérouler la tâche interne (contrairement à Task.Factory.StartNew ). Il est donc prudent d'imbriquer les tâches comme c'est le cas ici.


0 commentaires