Pourquoi toutes les fonctions ne devraient-elles pas être asynchrones par défaut?

107

Le modèle async-await de .net 4.5 est en train de changer de paradigme. C'est presque trop beau pour être vrai.

J'ai porté du code lourd d'E / S sur async-await car le blocage est une chose du passé.

De nombreuses personnes comparent l'async-await à une infestation de zombies et j'ai trouvé que c'était plutôt précis. Le code async aime les autres codes async (vous avez besoin d'une fonction async pour attendre une fonction async). Ainsi, de plus en plus de fonctions deviennent asynchrones et cela ne cesse de croître dans votre base de code.

Changer les fonctions en asynchrone est un travail quelque peu répétitif et sans imagination. Jetez un asyncmot-clé dans la déclaration, encapsulez la valeur de retour Task<>et vous avez presque terminé. La facilité de tout le processus est assez troublante, et très bientôt un script de remplacement de texte automatisera la plupart du «portage» pour moi.

Et maintenant la question ... Si tout mon code devient lentement asynchrone, pourquoi ne pas simplement le rendre asynchrone par défaut?

La raison évidente que je suppose est la performance. Async-await a une surcharge et un code qui n'a pas besoin d'être asynchrone, de préférence pas. Mais si les performances sont le seul problème, certaines optimisations intelligentes peuvent certainement supprimer automatiquement la surcharge lorsqu'elle n'est pas nécessaire. J'ai lu sur l' optimisation du "chemin rapide" , et il me semble qu'elle devrait à elle seule s'en occuper en grande partie.

C'est peut-être comparable au changement de paradigme apporté par les éboueurs. Au début du GC, libérer sa propre mémoire était nettement plus efficace. Mais les masses ont toujours choisi la collecte automatique en faveur d'un code plus sûr, plus simple qui pourrait être moins efficace (et même cela n'est sans doute plus vrai). Peut-être que cela devrait être le cas ici? Pourquoi toutes les fonctions ne devraient-elles pas être asynchrones?

talkol
la source
8
Donnez à l'équipe C # le mérite d'avoir marqué la carte. Comme cela a été fait il y a des centaines d'années, "les dragons gisent ici". Vous pourriez équiper un navire et y aller, vous survivrez probablement avec le soleil brillant et le vent dans le dos. Et parfois non, ils ne sont jamais revenus. Tout comme async / await, SO est rempli de questions d'utilisateurs qui n'ont pas compris comment ils sont sortis de la carte. Même s'ils ont reçu un assez bon avertissement. Maintenant, c'est leur problème, pas le problème de l'équipe C #. Ils ont marqué les dragons.
Hans Passant
1
@Sayse même si vous supprimez la distinction entre les fonctions de synchronisation et d'asynchronisation, les fonctions d'appel qui sont implémentées de manière synchrone seront toujours synchrones (comme votre exemple WriteLine)
talkol
2
«Envelopper la valeur de retour par Task <>» À moins que votre méthode ne doive l'être async(pour remplir un contrat), c'est probablement une mauvaise idée. Vous obtenez les inconvénients de l'async (coût accru des appels de méthode; la nécessité de l'utiliser awaitdans le code d'appel), mais aucun des avantages.
svick
1
C'est une question vraiment intéressante mais peut-être pas très adaptée pour SO. Nous pourrions envisager de le migrer vers les programmeurs.
Eric Lippert
1
Il me manque peut-être quelque chose, mais j'ai exactement la même question. Si async / await vient toujours par paires et que le code doit encore attendre la fin de son exécution, pourquoi ne pas simplement mettre à jour le framework .NET existant et rendre les méthodes qui doivent être asynchrones - asynchrones par défaut SANS créer de mots-clés supplémentaires? Le langage est déjà en train de devenir ce qu'il a été conçu pour échapper - le mot-clé spaghetti. Je pense qu'ils auraient dû arrêter de faire ça après que le "var" ait été suggéré. Maintenant, nous avons "dynamique", asyn / await ... etc ... Pourquoi ne pas juste .NET-ify javascript? ;))
monstro

Réponses:

127

Tout d'abord, merci pour vos aimables paroles. C'est en effet une fonctionnalité géniale et je suis heureux d'en avoir été une petite partie.

Si tout mon code devient lentement asynchrone, pourquoi ne pas simplement le rendre asynchrone par défaut?

Eh bien, vous exagérez; tout votre code ne devient pas asynchrone. Lorsque vous ajoutez deux entiers "simples" ensemble, vous n'attendez pas le résultat. Lorsque vous ajoutez deux futurs entiers ensemble pour obtenir un troisième futur entier - car c'est ce qui Task<int>est, c'est un entier auquel vous allez avoir accès dans le futur - bien sûr, vous attendez probablement le résultat.

La raison principale pour ne pas rendre tout asynchrone est que le but de async / await est de faciliter l'écriture de code dans un monde avec de nombreuses opérations à latence élevée . La grande majorité de vos opérations ne sont pas à forte latence, il n'est donc pas logique de prendre le coup de performance qui atténue cette latence. Au contraire, quelques - unes de vos opérations clés sont à forte latence, et ces opérations sont à l'origine de l'infestation de zombies par async dans tout le code.

si les performances sont le seul problème, certaines optimisations intelligentes peuvent certainement supprimer la surcharge automatiquement lorsqu'elle n'est pas nécessaire.

En théorie, la théorie et la pratique sont similaires. En pratique, ils ne le sont jamais.

Permettez-moi de vous donner trois points contre ce type de transformation suivi d'une passe d'optimisation.

Le premier point est encore: async en C # / VB / F # est essentiellement une forme limitée de passage de continuation . Une énorme quantité de recherche dans la communauté des langages fonctionnels a été consacrée à trouver des moyens d'identifier comment optimiser le code qui utilise beaucoup le style de passage de continuation. L'équipe du compilateur devrait probablement résoudre des problèmes très similaires dans un monde où "async" était la valeur par défaut et où les méthodes non asynchrones devaient être identifiées et désynchronisées. L'équipe C # n'est pas vraiment intéressée à s'attaquer à des problèmes de recherche ouverts, c'est donc de gros points contre là.

Un deuxième point contre est que C # n'a pas le niveau de «transparence référentielle» qui rend ces sortes d'optimisations plus traitables. Par «transparence référentielle», j'entends la propriété dont la valeur d'une expression ne dépend pas lorsqu'elle est évaluée . Les expressions comme 2 + 2sont référentiellement transparentes; vous pouvez faire l'évaluation au moment de la compilation si vous le souhaitez, ou la reporter à l'exécution et obtenir la même réponse. Mais une expression comme x+yne peut pas être déplacée dans le temps car x et y peuvent changer avec le temps .

Async rend beaucoup plus difficile de raisonner sur le moment où un effet secondaire se produira. Avant async, si vous avez dit:

M();
N();

et M()était void M() { Q(); R(); }, et N()était void N() { S(); T(); }, et Ret Sproduisait des effets secondaires, alors vous savez que l'effet secondaire de R se produit avant l'effet secondaire de S. Mais si vous l'avez, async void M() { await Q(); R(); }tout à coup, cela sort par la fenêtre. Vous n'avez aucune garantie que cela R()se produise avant ou après S()(à moins que bien sûr ne M()soit attendu; mais bien sûr, il Taskn'est pas nécessaire d'attendre qu'après N().)

Imaginez maintenant que cette propriété de ne plus savoir dans quel ordre les effets secondaires se produisent s'applique à chaque morceau de code de votre programme, à l' exception de ceux que l'optimiseur parvient à désynchroniser. En gros, vous n'avez plus la moindre idée des expressions qui seront évaluées dans quel ordre, ce qui signifie que toutes les expressions doivent être référentiellement transparentes, ce qui est difficile dans un langage comme C #.

Un troisième point contre est que vous devez alors demander "pourquoi l'async est-il si spécial?" Si vous prétendez que chaque opération devrait en fait être une opération, Task<T>vous devez être en mesure de répondre à la question "pourquoi pas Lazy<T>?" ou "pourquoi pas Nullable<T>?" ou "pourquoi pas IEnumerable<T>?" Parce que nous pourrions tout aussi bien faire cela. Pourquoi ne devrait-il pas être le cas que chaque opération est levée à nullable ? Ou chaque opération est calculée paresseusement et le résultat est mis en cache pour plus tard , ou le résultat de chaque opération est une séquence de valeurs au lieu d'une seule valeur . Vous devez alors essayer d'optimiser les situations où vous savez "oh, cela ne doit jamais être nul, pour que je puisse générer un meilleur code", et ainsi de suite.

Le fait est que ce n'est pas clair pour moi que ce Task<T>soit vraiment si spécial pour justifier autant de travail.

Si ce genre de choses vous intéresse, je vous recommande d'étudier les langages fonctionnels comme Haskell, qui ont une transparence référentielle beaucoup plus forte et permettent toutes sortes d'évaluation dans le désordre et font une mise en cache automatique. Haskell a également un soutien beaucoup plus fort dans son système de type pour les types de "levées monadiques" auxquelles j'ai fait allusion.

Eric Lippert
la source
Avoir des fonctions asynchrones appelées sans attendre n'a pas de sens pour moi (dans le cas courant). Si nous supprimions cette fonctionnalité, le compilateur pourrait décider par lui-même si une fonction est asynchrone ou non (appelle-t-il await?). Ensuite, nous pourrions avoir une syntaxe identique pour les deux cas (asynchrone et sync), et n'utiliser qu'attendre dans les appels comme différenciateur. Infestation de zombies résolue :)
talkol
J'ai continué la discussion dans les programmeurs par votre demande: programmers.stackexchange.com/questions/209872/…
talkol
@EricLippert - Très belle réponse comme toujours :) J'étais curieux de savoir si vous pouviez clarifier "latence élevée"? Y a-t-il une plage générale en millisecondes ici? J'essaie simplement de déterminer où se trouve la limite inférieure pour utiliser async parce que je ne veux pas en abuser.
Travis J
7
@TravisJ: Les conseils sont les suivants: ne bloquez pas le thread de l'interface utilisateur pendant plus de 30 ms. Plus que cela et vous courez le risque que la pause soit perceptible par l'utilisateur.
Eric Lippert
1
Ce qui me met au défi, c'est que le fait que quelque chose soit fait de manière synchrone ou asynchrone est un détail de mise en œuvre qui peut changer. Mais le changement dans cette implémentation force un effet d'entraînement à travers le code qui l'appelle, le code qui l'appelle, et ainsi de suite. Nous finissons par changer de code à cause du code dont il dépend, ce que nous nous efforçons normalement d'éviter. Ou nous utilisons async/awaitparce que quelque chose de caché sous des couches d'abstraction peut être asynchrone.
Scott Hannen
23

Pourquoi toutes les fonctions ne devraient-elles pas être asynchrones?

La performance est l'une des raisons, comme vous l'avez mentionné. Notez que l'option "chemin rapide" que vous avez associée améliore les performances dans le cas d'une tâche terminée, mais elle nécessite encore beaucoup plus d'instructions et de temps système par rapport à un appel de méthode unique. En tant que tel, même avec le «chemin rapide» en place, vous ajoutez beaucoup de complexité et de surcharge à chaque appel de méthode asynchrone.

La compatibilité descendante, ainsi que la compatibilité avec d'autres langages (y compris les scénarios d'interopérabilité), deviendraient également problématiques.

L'autre est une question de complexité et d'intention. Les opérations asynchrones ajoutent de la complexité - dans de nombreux cas, les fonctionnalités du langage masquent cela, mais il existe de nombreux cas où la création de méthodes asyncajoute définitivement de la complexité à leur utilisation. Cela est particulièrement vrai si vous n'avez pas de contexte de synchronisation, car les méthodes asynchrones peuvent facilement finir par causer des problèmes de threading inattendus.

De plus, il existe de nombreuses routines qui ne sont pas, par nature, asynchrones. Celles-ci ont plus de sens en tant qu'opérations synchrones. Forcer Math.Sqrtà être Task<double> Math.SqrtAsyncserait ridicule, par exemple, car il n'y a aucune raison pour que cela soit asynchrone. Au lieu de asyncfaire passer votre application, vous awaitfinirez par se propager partout .

Cela briserait aussi complètement le paradigme actuel, ainsi que causerait des problèmes avec les propriétés (qui ne sont en fait que des paires de méthodes. Seraient-ils aussi asynchrones?), Et auraient d'autres répercussions tout au long de la conception du framework et du langage.

Si vous faites beaucoup de travail lié aux E / S, vous aurez tendance à trouver que l'utilisation asyncgénéralisée est un excellent ajout, un grand nombre de vos routines le seront async. Cependant, lorsque vous commencez à travailler avec le processeur, en général, faire les choses asyncn'est en fait pas bon - cela cache le fait que vous utilisez des cycles de processeur sous une API qui semble être asynchrone, mais qui n'est pas nécessairement vraiment asynchrone.

Reed Copsey
la source
Exactement ce que j'allais écrire (performances), la compatibilité ascendante pourrait être une autre chose, les dll doivent être utilisées avec des langues plus anciennes qui ne prennent pas en charge async /
await
Faire de sqrt async n'est pas ridicule si nous
supprimons
@talkol Je suppose que je changerais cela - pourquoi chaque appel de fonction devrait -il prendre la complexité de l'asynchronie?
Reed Copsey
2
@talkol Je dirais que ce n'est pas nécessairement vrai - l'asynchronie peut elle-même ajouter des bogues qui sont pires que le blocage ...
Reed Copsey
1
@talkol Comment est await FooAsync()plus simple que Foo()? Et au lieu d'un petit effet domino de temps en temps, vous avez un énorme effet domino tout le temps et vous appelez cela une amélioration?
svick
4

Outre les performances, l'async peut avoir un coût de productivité. Sur le client (WinForms, WPF, Windows Phone) c'est une aubaine pour la productivité. Mais sur le serveur, ou dans d'autres scénarios non liés à l'interface utilisateur, vous payez de la productivité. Vous ne voulez certainement pas y aller asynchrone par défaut. Utilisez-le lorsque vous avez besoin des avantages de l'évolutivité.

Utilisez-le lorsque vous êtes au sweet spot. Dans d'autres cas, ne le faites pas.

usr
la source
2
+1 - Une simple tentative d'avoir une carte mentale du code exécutant 5 opérations asynchrones en parallèle avec une séquence aléatoire d'achèvement sera pour la plupart des gens une douleur suffisante pour une journée. Raisonner le comportement du code asynchrone (et donc intrinsèquement parallèle) est beaucoup plus difficile que le bon vieux code synchrone ...
Alexei Levenkov
2

Je crois qu'il y a une bonne raison de rendre toutes les méthodes asynchrones si elles ne sont pas nécessaires - extensibilité. Les méthodes de création sélective async ne fonctionnent que si votre code n'évolue jamais et que vous savez que la méthode A () est toujours liée au processeur (vous la gardez synchronisée) et la méthode B () est toujours liée aux E / S (vous la marquez comme asynchrone).

Mais que faire si les choses changent? Oui, A () fait des calculs, mais à un moment donné dans le futur, vous avez dû y ajouter une journalisation, ou des rapports, ou un rappel défini par l'utilisateur avec une implémentation qui ne peut pas prédire, ou l'algorithme a été étendu et inclut maintenant non seulement les calculs du processeur, mais aussi des E / S? Vous devrez convertir la méthode en asynchrone, mais cela briserait l'API et tous les appelants de la pile devraient également être mis à jour (et ils peuvent même être des applications différentes de différents fournisseurs). Ou vous devrez ajouter une version asynchrone à côté de la version sync, mais cela ne fait pas beaucoup de différence - l'utilisation de la version sync bloquerait et n'est donc guère acceptable.

Ce serait formidable s'il était possible de rendre la méthode de synchronisation existante asynchrone sans changer l'API. Mais dans la réalité, nous n'avons pas une telle option, je crois, et utiliser la version asynchrone même si elle n'est pas actuellement nécessaire est le seul moyen de garantir que vous ne rencontrerez jamais de problèmes de compatibilité à l'avenir.

Alex
la source
Bien que cela semble être un commentaire extrême, il contient beaucoup de vérité. Tout d'abord, à cause de ce problème: "cela briserait l'API et tous les appelants de la pile" async / await rend une application plus étroitement couplée. Cela pourrait facilement casser le principe de substitution de Liskov si une sous-classe veut utiliser async / await, par exemple. De plus, il est difficile d'imaginer une architecture de microservices où la plupart des méthodes n'avaient pas besoin de async / await.
ItsAllABadJoke