Comment éviter de violer le principe DRY lorsque vous devez avoir des versions de code asynchrones et synchronisées?

15

Je travaille sur un projet qui doit prendre en charge les versions asynchrone et sync d'une même logique / méthode. Donc, par exemple, je dois avoir:

public class Foo
{
   public bool IsIt()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return conn.Query<bool>("SELECT IsIt FROM SomeTable");
      }
   }

   public async Task<bool> IsItAsync()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return await conn.QueryAsync<bool>("SELECT IsIt FROM SomeTable");
      }
   }
}

La logique asynchrone et sync de ces méthodes est identique à tous égards, sauf que l'une est asynchrone et l'autre non. Existe-t-il un moyen légitime d'éviter de violer le principe DRY dans ce type de scénario? J'ai vu que les gens disent que vous pouvez utiliser GetAwaiter (). GetResult () sur une méthode asynchrone et l'invoquer à partir de votre méthode de synchronisation? Ce fil est-il sûr dans tous les scénarios? Existe-t-il une autre meilleure façon de procéder ou suis-je obligé de reproduire la logique?

Marko
la source
1
Pouvez-vous nous en dire un peu plus sur ce que vous faites ici? Plus précisément, comment réalisez-vous l'asynchronie dans votre méthode asynchrone? Le processeur de travail à latence élevée est-il lié au processeur ou aux E / S?
Eric Lippert
"l'un est asynchrone et l'autre ne l'est pas" est la différence dans le code de la méthode actuelle ou simplement dans l'interface? (De toute évidence, pour une interface simple, vous pouvez return Task.FromResult(IsIt());)
Alexei Levenkov
En outre, les versions synchrone et asynchrone sont probablement à haute latence. Dans quelles circonstances l'appelant utilisera-t-il la version synchrone, et comment cet appelant va-t-il atténuer le fait que l'appelé pourrait prendre des milliards de nanosecondes? L'appelant de la version synchrone ne se soucie-t-il pas qu'il suspend l'interface utilisateur? N'y a-t-il pas d'interface utilisateur? Parlez-nous davantage du scénario.
Eric Lippert
@EricLippert J'ai ajouté un exemple factice spécifique juste pour vous donner une idée de la façon dont 2 bases de codes sont identiques à part le fait que l'une est asynchrone. Vous pouvez facilement imaginer un scénario où cette méthode est beaucoup plus complexe et où des lignes et des lignes de code doivent être dupliquées.
Marko
@AlexeiLevenkov La différence est en effet dans le code, j'ai ajouté du code factice pour le faire une démonstration.
Marko

Réponses:

15

Vous avez posé plusieurs questions dans votre question. Je vais les décomposer légèrement différemment de vous. Mais permettez-moi d'abord de répondre directement à la question.

Nous voulons tous un appareil photo léger, de haute qualité et bon marché, mais comme le dit le proverbe, vous ne pouvez en obtenir que deux au maximum. Vous êtes dans la même situation ici. Vous voulez une solution qui soit efficace, sûre et partage du code entre les chemins synchrone et asynchrone. Vous n'en obtiendrez que deux.

Permettez-moi de vous expliquer pourquoi. Commençons par cette question:


J'ai vu que les gens disent que vous pouvez utiliser GetAwaiter().GetResult()une méthode asynchrone et l'invoquer à partir de votre méthode de synchronisation? Ce fil est-il sûr dans tous les scénarios?

Le point de cette question est "puis-je partager les chemins synchrones et asynchrones en faisant simplement que le chemin synchrone fasse une attente synchrone sur la version asynchrone?"

Permettez-moi d'être très clair sur ce point car il est important:

VOUS DEVEZ IMMÉDIATEMENT ARRÊTER DE PRENDRE TOUT CONSEIL DE CES PERSONNES .

C'est un très mauvais conseil. Il est très dangereux d'extraire de façon synchrone un résultat d'une tâche asynchrone, sauf si vous avez la preuve que la tâche s'est terminée normalement ou anormalement .

La raison pour laquelle ce conseil est extrêmement mauvais est, bien, envisager ce scénario. Vous voulez tondre la pelouse, mais votre lame de tondeuse à gazon est cassée. Vous décidez de suivre ce workflow:

  • Commandez une nouvelle lame sur un site Web. Il s'agit d'une opération asynchrone à latence élevée.
  • Attendez de manière synchrone - c'est-à-dire, dormez jusqu'à ce que vous ayez la lame en main .
  • Vérifiez régulièrement la boîte aux lettres pour voir si la lame est arrivée.
  • Retirez la lame de la boîte. Vous l'avez maintenant en main.
  • Installez la lame dans la tondeuse.
  • Tondre la pelouse.

Ce qui se produit? Vous dormez pour toujours car l'opération de vérification du courrier est désormais déclenchée par un événement qui se produit après l'arrivée du courrier .

Il est extrêmement facile d'entrer dans cette situation lorsque vous attendez de manière synchrone une tâche arbitraire. Cette tâche peut avoir un travail planifié dans le futur du thread qui attend maintenant , et maintenant ce futur n'arrivera jamais parce que vous l'attendez.

Si vous faites une attente asynchrone, tout va bien! Vous vérifiez régulièrement le courrier, et pendant que vous attendez, vous faites un sandwich ou faites vos impôts ou autre chose; vous continuez à travailler pendant que vous attendez.

N'attendez jamais de façon synchrone. Si la tâche est terminée, elle n'est pas nécessaire . Si la tâche n'est pas terminée mais planifiée pour s'exécuter à partir du thread en cours, elle est inefficace car le thread en cours pourrait servir d'autres travaux au lieu d'attendre. Si la tâche n'est pas terminée et que l'exécution est planifiée sur le thread en cours, elle est suspendue pour attendre de manière synchrone. Il n'y a aucune bonne raison d'attendre de manière synchrone, à nouveau, sauf si vous savez déjà que la tâche est terminée .

Pour plus d'informations sur ce sujet, voir

https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html

Stephen explique le scénario du monde réel beaucoup mieux que moi.


Considérons maintenant "l'autre direction". Pouvons-nous partager du code en faisant simplement que la version asynchrone fasse la version synchrone sur un thread de travail?

C'est peut - être et probablement probablement une mauvaise idée, pour les raisons suivantes.

  • Il est inefficace si le fonctionnement synchrone est un travail d'E / S à latence élevée. Cela engage essentiellement un travailleur et le fait dormir jusqu'à ce qu'une tâche soit terminée. Les fils sont incroyablement chers . Ils consomment un million d'octets d'espace d'adressage minimum par défaut, ils prennent du temps, ils prennent les ressources du système d'exploitation; vous ne voulez pas graver un fil faisant un travail inutile.

  • L'opération synchrone peut ne pas être écrite pour être thread-safe.

  • C'est une technique plus raisonnable si le travail à latence élevée est liée processeur, mais si elle est alors vous ne voulez probablement pas remettre tout simplement hors d'un thread de travail. Vous souhaitez probablement utiliser la bibliothèque parallèle de tâches pour la paralléliser à autant de processeurs que possible, vous voulez probablement une logique d'annulation et vous ne pouvez pas simplement faire faire la version synchrone, car alors ce serait déjà la version asynchrone .

Lectures complémentaires; encore une fois, Stephen l'explique très clairement:

Pourquoi ne pas utiliser Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-using.html

Plus de scénarios "faire et ne pas faire" pour Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html


Qu'est-ce que cela nous laisse alors? Les deux techniques de partage de code conduisent à des blocages ou à de grandes inefficacités. La conclusion à laquelle nous arrivons est que vous devez faire un choix. Voulez-vous un programme qui est efficace et correct et qui ravit l'appelant, ou voulez-vous enregistrer quelques frappes de touches entraînées par la duplication d'une petite quantité de code entre les chemins synchrone et asynchrone? Vous n'obtenez pas les deux, j'ai peur.

Eric Lippert
la source
Pouvez-vous expliquer pourquoi le retour Task.Run(() => SynchronousMethod())dans la version asynchrone n'est pas ce que l'OP veut?
Guillaume Sasdy
2
@GuillaumeSasdy: L'affiche originale a répondu à la question de savoir quel est le travail: il est lié aux OI. Il est inutile d'exécuter des tâches liées aux E / S sur un thread de travail!
Eric Lippert
D'accord je comprends. Je pense que vous avez raison, et la réponse @StriplingWarrior l'explique encore plus.
Guillaume Sasdy
2
@Marko presque toute solution de contournement qui bloque le thread finira par consommer (sous une charge élevée) tous les threads du pool de threads (qui étaient généralement utilisés pour terminer les opérations asynchrones). En conséquence, tous les threads seront en attente et aucun ne sera disponible pour permettre aux opérations d'exécuter une partie "opération asynchrone terminée" du code. La plupart des solutions de contournement sont correctes dans les scénarios de faible charge (car il y a beaucoup de threads, même 2-3 sont bloqués dans le cadre de chaque opération)… Et si vous pouvez garantir que l'achèvement de l'opération asynchrone s'exécute sur un nouveau thread de système d'exploitation (pas un pool de threads), il peut même fonctionner pour tous les cas (vous payez le prix fort donc)
Alexei Levenkov
2
Parfois, il n'y a vraiment pas d'autre moyen que "simplement faire la version synchrone sur un thread de travail". Par exemple, c'est la façon dont Dns.GetHostEntryAsyncest implémenté dans .NET, et FileStream.ReadAsyncpour certains types de fichiers. Le système d'exploitation ne fournit simplement aucune interface asynchrone, donc le runtime doit le simuler (et ce n'est pas spécifique au langage - par exemple, le runtime Erlang exécute une arborescence entière de processus de travail , avec plusieurs threads à l'intérieur de chacun, pour fournir des E / S de disque non bloquantes et un nom résolution).
Joker_vD
6

Il est difficile de donner une réponse unique à cette question. Malheureusement, il n'y a pas de moyen simple et parfait de réutiliser entre du code asynchrone et synchrone. Mais voici quelques principes à considérer:

  1. Le code asynchrone et synchrone est souvent fondamentalement différent. Le code asynchrone doit généralement inclure un jeton d'annulation, par exemple. Et souvent, cela finit par appeler différentes méthodes (comme votre exemple appelle l' Query()une et QueryAsync()l'autre), ou par établir des connexions avec des paramètres différents. Ainsi, même lorsqu'il est structurellement similaire, il existe souvent suffisamment de différences de comportement pour mériter qu'elles soient traitées comme un code distinct avec des exigences différentes. Notez les différences entre les implémentations Async et Sync des méthodes dans la classe File, par exemple: aucun effort n'est fait pour leur faire utiliser le même code
  2. Si vous fournissez une signature de méthode asynchrone dans le but d'implémenter une interface, mais que vous avez une implémentation synchrone (c'est-à-dire qu'il n'y a rien intrinsèquement asynchrone sur ce que fait votre méthode), vous pouvez simplement revenir Task.FromResult(...).
  3. Tout élément logique synchrone qui est le même entre les deux méthodes peut être extrait vers une méthode d'assistance distincte et exploité dans les deux méthodes.

Bonne chance.

StriplingWarrior
la source
-2

Facile; demander au synchrone d'appeler celui asynchrone. Il existe même une méthode pratique Task<T>pour y parvenir:

public class Foo
{
   public bool IsIt()
   {
      var task = IsItAsync(); //no await here; we want the Task

      //Some tasks end up scheduled to run before you get them;
      //don't try to run them a second time
      if((int)task.Status > (int)TaskStatus.Created)
          //this call will block the current thread,
          //and unlike Run()/Wait() will prefer the current 
          //thread's TaskScheduler instead of a new thread.
          task.RunSynchronously(); 

      //if IsItAsync() can throw exceptions,
      //you still need a Wait() call to bring those back from the Task
      try{ 
          task.Wait();
          return task.Result;
      }
      catch(Exception ex) 
      { 
          //Handle IsItAsync() exceptions here;
          //remember to return something if you don't rethrow              
      }
   }

   public async Task<bool> IsItAsync()
   {
      // Some async logic
   }
}
KeithS
la source
1
Voir ma réponse pour savoir pourquoi c'est une pire pratique que vous ne devez jamais faire.
Eric Lippert
1
Votre réponse concerne GetAwaiter (). GetResult (). J'ai évité ça. La réalité est que parfois vous devez faire en sorte qu'une méthode s'exécute de manière synchrone afin de l'utiliser dans une pile d'appels qui ne peut pas être rendue asynchrone. Si les attentes de synchronisation ne doivent jamais être effectuées, alors en tant qu'ancien membre de l'équipe C #, dites-moi pourquoi vous avez mis non pas une mais deux façons d'attendre de manière synchrone une tâche asynchrone dans le TPL.
KeithS
La personne qui poserait cette question serait Stephen Toub, pas moi. Je n'ai travaillé que du côté linguistique.
Eric Lippert