Les directives d'utilisation asynchrone / wait en C # ne sont-elles pas en contradiction avec les concepts de bonne architecture et de superposition d'abstraction?

103

Cette question concerne le langage C #, mais je m'attends à ce qu'il couvre d'autres langages tels que Java ou TypeScript.

Microsoft recommande les meilleures pratiques sur l' utilisation des appels asynchrones dans .NET. Parmi ces recommandations, prenons deux:

  • modifier la signature des méthodes asynchrones afin qu'elles renvoient la tâche ou la tâche <> (dans TypeScript, ce serait une promesse <>)
  • modifier les noms des méthodes asynchrones pour qu'ils se terminent par xxxAsync ()

Désormais, le remplacement d’un composant synchrone de bas niveau par un composant asynchrone a une incidence sur l’ensemble de la pile de l’application. Comme async / wait n’a un impact positif que s’il est utilisé «à tous les niveaux», cela signifie que les noms de signature et de méthode de chaque couche de l’application doivent être modifiés.

Une bonne architecture implique souvent de placer des abstractions entre chaque couche, de sorte que le remplacement des composants de bas niveau par d'autres ne soit pas vu par les composants de niveau supérieur. En C #, les abstractions se présentent sous la forme d'interfaces. Si nous introduisons un nouveau composant asynchrone de bas niveau, chaque interface de la pile d'appels doit être modifiée ou remplacée par une nouvelle interface. La façon dont un problème est résolu (asynchrone ou sync) dans une classe d'implémentation n'est plus cachée (abstraite) pour les appelants. Les appelants doivent savoir si c'est synchrone ou asynchrone.

Est-ce que les meilleures pratiques ne sont pas asynchrones / en contradiction avec les principes de "bonne architecture"?

Cela signifie-t-il que chaque interface (par exemple IEnumerable, IDataAccessLayer) a besoin de son homologue asynchrone (IAsyncEnumerable, IAsyncDataAccessLayer) pour pouvoir être remplacée dans la pile lors du basculement vers des dépendances asynchrones?

Si nous poussons le problème un peu plus loin, ne serait-il pas plus simple de supposer que chaque méthode est asynchrone (pour renvoyer une tâche <> ou une promesse <>), et que les méthodes synchronisent les appels asynchrones lorsqu'ils ne sont pas réellement Async? Est-ce quelque chose à attendre des futurs langages de programmation?

corentinaltepe
la source
5
Bien que cela semble être une excellente question de discussion, je pense que cette question est trop basée sur l'opinion pour qu'on puisse y répondre ici.
Euphoric
22
@Euphoric: Je pense que le problème abordé ici va plus loin que les directives C #, ce qui n'est qu'un symptôme du fait que le fait de modifier certaines parties d'une application en adoptant un comportement asynchrone peut avoir des effets non locaux sur l'ensemble du système. Donc, mon instinct me dit qu'il doit y avoir une réponse sans opinion pour cela, basée sur des faits techniques. Par conséquent, j'encourage tout le monde ici à ne pas clore cette question trop tôt, mais attendons plutôt quelles réponses nous parviendront (et s'ils sont trop avisés, nous pouvons toujours voter pour la clôture).
Doc Brown
25
@DocBrown Je pense que la question la plus profonde ici est la suivante: "Une partie du système peut-elle être changée de synchrone à asynchrone sans que des parties qui dépendent d'elle aient aussi à changer?" Je pense que la réponse à cette question est clairement "non". Dans ce cas, je ne vois pas comment les "concepts de bonne architecture et de superposition" s’appliquent ici.
Euphoric
6
@Euphoric: ça a l'air d'être une bonne base pour une réponse sans opinion ;-)
Doc Brown
5
@Gherman: parce que C #, comme beaucoup de langages, ne peut pas surcharger en se basant uniquement sur le type de retour. Vous vous retrouveriez avec des méthodes asynchrones qui ont la même signature que leurs homologues de synchronisation (toutes ne peuvent pas prendre CancellationToken, et celles qui le font peuvent souhaiter fournir une valeur par défaut). Supprimer les méthodes de synchronisation existantes (et briser de manière proactive tout le code) est une évidence.
Jeroen Mostert

Réponses:

111

De quelle couleur est votre fonction?

Vous pouvez être intéressé par Quelle couleur est votre fonction de Bob Nystrom 1 .

Dans cet article, il décrit une langue de fiction dans laquelle:

  • Chaque fonction a une couleur: bleu ou rouge.
  • Une fonction rouge peut appeler des fonctions bleues ou rouges, pas de problème.
  • Une fonction bleue ne peut appeler que des fonctions bleues.

Bien que fictif, cela se produit assez régulièrement dans les langages de programmation:

  • En C ++, une méthode "const" peut uniquement appeler d'autres méthodes "const" this.
  • En Haskell, une fonction non-IO ne peut appeler que des fonctions non-IO.
  • En C #, une fonction de synchronisation ne peut appeler que des fonctions de synchronisation 2 .

Comme vous l'avez compris, à cause de ces règles, les fonctions rouges ont tendance à se répandre dans la base de code. Vous en insérez un et petit à petit, il colonise la base de code entière.

1 Bob Nystrom, hormis les blogs, fait également partie de l'équipe Dart et a écrit cette petite série de Crafting Interpreters; hautement recommandé pour tout amateur de langage de programmation / compilateur.

2 Ce n'est pas tout à fait vrai, car vous pouvez appeler une fonction asynchrone et la bloquer jusqu'à ce qu'elle revienne, mais ...

Limite de langue

Il s’agit essentiellement d’une limitation langue / exécution.

Les langues avec un filetage M: N, par exemple Erlang et Go, n'ont pas de asyncfonctions: chaque fonction est potentiellement asynchrone et sa "fibre" est simplement suspendue, remplacée et replacée lorsqu'elle est prête à nouveau.

C # a opté pour un modèle de thread 1: 1 et a donc décidé de faire apparaître la synchronicité dans le langage afin d’éviter tout blocage accidentel des threads.

En présence de limitations linguistiques, les directives de codage doivent être adaptées.

Matthieu M.
la source
4
Les fonctions d'E / S ont tendance à se répandre, mais avec diligence, vous pouvez généralement les isoler des fonctions situées à proximité (dans la pile lors de l'appel) de points d'entrée de votre code. Vous pouvez le faire en appelant les fonctions IO puis en faisant en sorte que d’autres fonctions en traitent la sortie et renvoient tous les résultats nécessaires pour des E / S ultérieures. Je trouve que ce style rend la base de code beaucoup plus facile à gérer et à utiliser. Je me demande s'il y a un corollaire avec la synchronicité.
jpmc26
16
Qu'entendez-vous par "M: N" et "1: 1"?
Captain Man
14
@CaptainMan: 1: 1 thread signifie de mapper un thread d'application sur un thread d'OS, c'est le cas dans des langages tels que C, C ++, Java ou C #. En revanche, M: N threading signifie mapper les threads d'applications M à N threads OS; dans le cas de Go, un thread d'application s'appelle un "goroutine", dans le cas d'Erlang, il s'appelle un "acteur", et vous avez peut-être entendu parler d'eux en tant que "fils verts" ou "fibres". Ils fournissent une concurrence sans nécessiter de parallélisme. Malheureusement, l'article de Wikipedia sur le sujet est plutôt maigre.
Matthieu M.
2
Ceci est quelque peu lié, mais je pense aussi que cette idée de "couleur de fonction" s’applique également aux fonctions qui bloquent les entrées de l’utilisateur, par exemple les boîtes de dialogue modales, les boîtes de message, certaines formes d’E / S de console, etc. cadre a depuis le début.
jrh
2
@MatthieuM. C # n'a pas un thread d'application par thread d'OS et ne l'a jamais fait. Ceci est très évident lorsque vous interagissez avec du code natif, et particulièrement lorsque vous utilisez MS SQL. Et bien sûr, les routines de coopération étaient toujours possibles (et sont encore plus simples avec async); En effet, il s'agissait d'un modèle assez courant pour la création d'une interface utilisateur réactive. Était-ce aussi joli qu'Erlang? Nan. Mais c'est encore loin de C :)
Luaan
82

Vous avez raison, il y a une contradiction ici, mais ce ne sont pas les "meilleures pratiques" qui sont mauvaises. C'est parce que la fonction asynchrone fait essentiellement autre chose qu'une fonction synchrone. Au lieu d'attendre le résultat de ses dépendances (généralement quelques E / S), il crée une tâche à gérer par la boucle d'événements principale. Ce n'est pas une différence qui peut être bien cachée sous abstraction.

max630
la source
27
La réponse est aussi simple que cette OMI. La différence entre un processus synchrone et asynchrone n'est pas un détail d'implémentation, c'est un contrat sémantiquement différent.
Ant P
11
@AntP: Je ne suis pas d'accord pour dire que c'est aussi simple que cela; il apparaît dans le langage C #, mais pas dans le langage Go par exemple. Il ne s'agit donc pas d'une propriété inhérente aux processus asynchrones, mais de la manière dont les processus asynchrones sont modélisés dans le langage donné.
Matthieu M.
1
@MatthieuM. Oui, mais vous pouvez également utiliser des asyncméthodes en C # pour fournir des contrats synchrones, si vous le souhaitez. La seule différence est que Go est asynchrone par défaut alors que C # est synchrone par défaut. asyncvous donne le deuxième modèle de programmation - async est l'abstraction (ce qu'il fait réellement dépend de l'exécution, du planificateur de tâches, du contexte de synchronisation, de la mise en œuvre de l'attente ...).
Luaan
6

Une méthode asynchrone se comporte différemment d'une méthode synchrone, comme vous le savez sûrement. Au moment de l'exécution, convertir un appel asynchrone en un appel synchrone est simple, mais l'inverse ne peut pas être dit. La logique devient alors logique. Pourquoi ne faisons-nous pas des méthodes asynchrones de toutes les méthodes qui peuvent en avoir besoin et laissons-nous "convertir" au besoin en méthode synchrone?

En un sens, c'est comme avoir une méthode qui jette des exceptions et une autre qui est "sûre" et qui ne le sera pas, même en cas d'erreur. À quel moment le codeur est-il excessif pour fournir ces méthodes qui peuvent sinon être converties les unes aux autres?

Il existe deux écoles de pensée dans ce domaine. L’une consiste à créer plusieurs méthodes, chacune appelant une autre méthode, éventuellement privée, offrant la possibilité de fournir des paramètres facultatifs ou des modifications mineures au comportement, telles que l’asynchrone. L’autre consiste à minimiser les méthodes d’interface au strict minimum, en laissant à l’appelant le soin d’apporter lui-même les modifications nécessaires.

Si vous êtes de la première école, il y a une certaine logique à consacrer une classe aux appels synchrones et asynchrones afin d'éviter de doubler chaque appel. Microsoft a tendance à privilégier cette école de pensée et, par convention, à rester cohérent avec le style préconisé par Microsoft, vous aussi devez disposer d'une version asynchrone, de la même manière que les interfaces commencent presque toujours par un "I". Permettez-moi de souligner que ce n'est pas faux en soi, car il est préférable de conserver un style cohérent dans un projet plutôt que de le faire "de la bonne manière" et de changer radicalement de style pour le développement que vous ajoutez à un projet.

Cela dit, j'ai tendance à privilégier la deuxième école, qui consiste à minimiser les méthodes d'interface. Si je pense qu'une méthode peut être appelée de manière asynchrone, la méthode pour moi est asynchrone. L'appelant peut décider d'attendre ou non la fin de la tâche avant de poursuivre. Si cette interface est une interface pour une bibliothèque, il est plus raisonnable de le faire de cette manière afin de réduire le nombre de méthodes à déprécier ou à ajuster. Si l'interface est à usage interne dans mon projet, j'ajouterai une méthode pour chaque appel nécessaire tout au long de mon projet pour les paramètres fournis, sans aucune méthode "extra", et ce, même si le comportement de la méthode n'est pas déjà couvert. par une méthode existante.

Cependant, comme beaucoup de choses dans ce domaine, c'est en grande partie subjectif. Les deux approches ont leurs avantages et leurs inconvénients. Microsoft a également lancé la convention consistant à ajouter des lettres indiquant le type au début du nom de la variable, ainsi que "m_" pour indiquer qu'il s'agit d'un membre menant à des noms de variable tels que m_pUser. Mon argument étant que même Microsoft n'est pas infaillible et peut aussi commettre des erreurs.

Cela dit, si votre projet suit cette convention asynchrone, je vous conseillerais de la respecter et de continuer le style. Et seulement une fois que vous avez un projet à vous, vous pouvez l'écrire de la manière qui vous convient le mieux.

Neil
la source
6
"Au moment de l'exécution, convertir un appel asynchrone en un appel synchrone est simple" Je ne suis pas sûr qu'il en soit ainsi. En .NET, utiliser .Wait()method et similaires peut avoir des conséquences négatives, et en js, autant que je sache, ce n’est pas du tout possible.
max630
2
@ max630 Je n'ai pas dit qu'il n'y avait pas de problèmes simultanés à prendre en compte, mais s'il s'agissait initialement d'une tâche synchrone , il y a de fortes chances que cela ne crée pas de blocages. Cela dit, trivial ne signifie pas "double-cliquer ici pour convertir en synchrone". En js, vous retournez une instance Promise et appelez resol sur elle.
Neil
2
ouais c'est totalement pénible de reconvertir asynchrone asynchrone
Ewan
4
@Neil En javascript, même si vous appelez Promise.resolve(x)puis ajoutez des rappels, ces rappels ne seront pas exécutés immédiatement.
NickL
1
@Neil si une interface expose une méthode asynchrone, s'attendre à ce que l'attente de la tâche ne crée pas d'interblocage n'est pas une bonne hypothèse. Il est bien mieux que l'interface montre qu'elle est réellement synchrone dans la signature de la méthode, plutôt qu'une promesse dans la documentation qui pourrait changer dans une version ultérieure.
Carl Walsh
2

Imaginons qu'il existe un moyen de vous permettre d'appeler des fonctions de manière asynchrone sans changer leur signature.

Ce serait vraiment cool et personne ne vous recommanderait de changer leurs noms.

Cependant, les fonctions asynchrones réelles, pas seulement celles qui attendent une autre fonction asynchrone, mais le niveau le plus bas ont une structure spécifique à leur nature asynchrone. par exemple

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Il est ces deux bits d'information que le code d'appel a besoin pour exécuter le code de manière async que des choses comme Tasket asyncencapsuler.

Il y a plus d'une façon de faire des fonctions asynchrones, async Taskc'est juste un modèle intégré dans le compilateur via des types de retour afin que vous n'ayez pas à lier manuellement les événements

Ewan
la source
0

J'aborderai le point principal d'une manière moins générique et plus générique:

Est-ce que les meilleures pratiques ne sont pas asynchrones / en contradiction avec les principes de "bonne architecture"?

Je dirais que cela dépend du choix que vous faites dans la conception de votre API et de ce que vous laissez à l'utilisateur.

Si vous souhaitez qu'une fonction de votre API soit uniquement asynchrone, il est peu intéressant de suivre la convention de dénomination. Il suffit simplement de toujours renvoyer la tâche <> / Promise <> / Future <> / ... en tant que type de retour, elle est auto-documentée. S'il veut une réponse synchronisée, il pourra toujours le faire en attendant, mais s'il le fait toujours, cela fera un peu casse-tête.

Toutefois, si vous ne synchronisez que votre API, cela signifie que si un utilisateur souhaite le rendre asynchrone, il devra gérer lui-même la partie asynchrone.

Cela peut nécessiter beaucoup de travail supplémentaire, mais cela peut également donner plus de contrôle à l'utilisateur sur le nombre d'appels simultanés qu'il autorise, la mise en attente, les tentatives de répétition, etc.

Dans un grand système avec une API volumineuse, implémenter la plupart d’entre elles pour être synchronisées par défaut pourrait être plus simple et plus efficace que de gérer indépendamment chaque partie de votre API, en particulier si elles partagent des ressources (système de fichiers, CPU, base de données, ...).

En fait, pour les parties les plus complexes, vous pouvez parfaitement avoir deux implémentations de la même partie de votre API: une tâche synchrone, une tâche asynchrone, une version asynchrone reposant sur la tâche synchrone qui gère les tâches et ne gère que les accès simultanés, les chargements, les délais impartis et les nouvelles tentatives. .

Peut-être que quelqu'un d'autre peut partager son expérience avec cela parce que je manque d'expérience avec de tels systèmes.

Walfrat
la source
2
@Miral Vous avez utilisé "appeler une méthode asynchrone à partir d'une méthode de synchronisation" dans les deux cas.
Adrian Wragg
@AdrianWragg C'est ce que j'ai fait; mon cerveau devait avoir une condition de race. Je le réparerai.
Miral
C'est l'inverse; Il est trivial d'appeler une méthode asynchrone à partir d'une méthode sync, mais il est impossible d'appeler une méthode sync depuis une méthode async. (Et là où tout s'écroule, c'est quand quelqu'un essaie de faire la dernière chose, ce qui peut conduire à des blocages.) Donc, si vous deviez en choisir un, l'async par défaut est le meilleur choix. Malheureusement, c'est aussi le choix le plus difficile, car une implémentation async ne peut appeler que des méthodes async.
Miral
(Et par là, j'entends bien sûr une méthode de synchronisation bloquante . Vous pouvez appeler quelque chose qui effectue un calcul pur lié au CPU de manière synchrone à partir d'une méthode async - bien que vous devriez essayer de l'éviter à moins de savoir que vous êtes dans un contexte de travail plutôt que dans un contexte d'interface utilisateur - mais les appels bloquants qui attendent inactifs sur un verrou, une E / S ou une autre opération sont une mauvaise idée.)
Miral