web-dev-qa-db-fra.com

Pourquoi les langages de programmation ne gèrent-ils pas automatiquement le problème synchrone / asynchrone?

Je n'ai pas trouvé beaucoup de ressources à ce sujet: je me demandais si c'était possible/une bonne idée de pouvoir écrire du code asynchrone de manière synchrone.

Par exemple, voici du code JavaScript qui récupère le nombre d'utilisateurs stockés dans une base de données (une opération asynchrone):

getNbOfUsers(function (nbOfUsers) { console.log(nbOfUsers) });

Ce serait bien de pouvoir écrire quelque chose comme ça:

const nbOfUsers = getNbOfUsers();
console.log(getNbOfUsers);

Et donc le compilateur se chargera automatiquement d'attendre la réponse et d'exécuter console.log. Il attendra toujours la fin des opérations asynchrones avant que les résultats ne soient utilisés ailleurs. Nous utiliserions tellement moins les promesses de rappel, async/wait ou autre, et n'aurions jamais à nous soucier de savoir si le résultat d'une opération est disponible immédiatement ou non.

Les erreurs seraient toujours gérables (est-ce que nbOfUsers a obtenu un entier ou une erreur?) En utilisant try/catch, ou quelque chose comme des options comme dans la langue Swift .

C'est possible? Ce peut être une idée terrible/une utopie ... Je ne sais pas.

29
Cinn

Async/Wait est exactement la gestion automatisée que vous proposez, bien qu'avec deux mots-clés supplémentaires. Pourquoi sont-ils importants? Mis à part la compatibilité descendante?

  • Sans points explicites où une coroutine peut être suspendue et reprise, nous aurions besoin d'un type system pour détecter où une valeur attendue doit être attendue. De nombreux langages de programmation n'ont pas un tel système de type.

  • En rendant explicite l'attente d'une valeur, nous pouvons également transmettre des valeurs attendues comme des objets de première classe: les promesses. Cela peut être très utile lors de l'écriture de code d'ordre supérieur.

  • Le code asynchrone a des effets très profonds sur le modèle d'exécution d'un langage, similaires à l'absence ou à la présence d'exceptions dans le langage. En particulier, une fonction asynchrone ne peut être attendue que par des fonctions asynchrones. Cela affecte toutes les fonctions d'appel! Mais que se passe-t-il si nous changeons une fonction de non asynchrone en async à la fin de cette chaîne de dépendance? Ce serait un changement incompatible vers l'arrière… sauf si toutes les fonctions sont asynchrones et que chaque appel de fonction est attendu par défaut.

    Et cela est hautement indésirable car il a de très mauvaises implications en termes de performances. Vous ne pourriez pas simplement renvoyer des valeurs bon marché. Chaque appel de fonction deviendrait beaucoup plus cher.

L'async est génial, mais une sorte d'async implicite ne fonctionnera pas en réalité.

Les langages fonctionnels purs comme Haskell ont un peu une trappe d'échappement car l'ordre d'exécution est en grande partie non spécifié et non observable. Ou formulé différemment: tout ordre d'opérations spécifique doit être explicitement codé. Cela peut être assez lourd pour les programmes du monde réel, en particulier pour les programmes lourds d'E/S pour lesquels le code asynchrone convient très bien.

65
amon

Ce qui vous manque, c'est le but des opérations asynchrones: elles vous permettent de profiter de votre temps d'attente!

Si vous transformez une opération asynchrone, comme demander une ressource à un serveur, en une opération synchrone en attendant implicitement et immédiatement la réponse, votre thread ne peut rien faire d'autre avec le temps d'attente . Si le serveur met 10 millisecondes pour répondre, il y a environ 30 millions de cycles CPU à perdre. La latence de la réponse devient le temps d'exécution de la demande.

La seule raison pour laquelle les programmeurs ont inventé les opérations asynchrones, est de cacher la latence des tâches intrinsèquement longues derrière d'autres calculs utiles. Si vous pouvez remplir le temps d'attente avec un travail utile, c'est du temps CPU économisé. Si vous ne pouvez pas, eh bien, rien n'est perdu par l'opération asynchrone.

Donc, je recommande d'embrasser les opérations asynchrones que vos langues vous fournissent. Ils sont là pour vous faire gagner du temps.

Certains le font.

Ils ne sont pas (encore) courants car l'async est une fonctionnalité relativement nouvelle pour laquelle nous venons juste de nous faire une bonne idée si c'est même une bonne fonctionnalité, ou comment la présenter aux programmeurs de manière conviviale/utilisable/expressif/etc. Les fonctionnalités asynchrones existantes sont en grande partie intégrées aux langages existants, ce qui nécessite une approche de conception légèrement différente.

Cela dit, ce n'est pas clairement une bonne idée de faire partout. Un échec courant consiste à effectuer des appels asynchrones en boucle, sérialisant efficacement leur exécution. Le fait d'avoir des appels asynchrones implicites peut masquer ce type d'erreur. En outre, si vous prenez en charge la contrainte implicite à partir d'un Task<T> (ou l'équivalent de votre langue) à T, ce qui peut ajouter un peu de complexité/coût à votre vérificateur de frappe et de rapport d'erreurs lorsqu'il n'est pas clair lequel des deux le programmeur voulait vraiment.

Mais ce ne sont pas des problèmes insurmontables. Si vous vouliez soutenir ce comportement, vous le pourriez presque certainement, mais il y aurait des compromis.

13
Telastyn

Il y a des langues qui font ça. Mais, en réalité, il n'y a pas beaucoup de besoin, car il peut être facilement accompli avec les fonctionnalités de langage existantes.

Tant que vous avez certains moyen d'exprimer l'asynchronie, vous pouvez implémenter Futures ou Promises uniquement comme une fonctionnalité de bibliothèque, vous n'avez pas besoin fonctionnalités linguistiques spéciales. Et tant que vous avez certains d'exprimer proxy transparents, vous pouvez mettre les deux fonctionnalités ensemble et vous avez Futures transparents.

Par exemple, dans Smalltalk et ses descendants, un objet peut changer son identité, il peut littéralement "devenir" un objet différent (et en fait la méthode qui fait cela s'appelle Object>>become:).

Imaginez un calcul de longue durée qui renvoie un Future<Int>. Cette Future<Int> a toutes les mêmes méthodes que Int, sauf avec des implémentations différentes. Future<Int>'s + la méthode n'ajoute pas un autre nombre et renvoie le résultat, elle renvoie un nouveau Future<Int> qui encapsule le calcul. Et ainsi de suite. Méthodes qui ne peuvent raisonnablement pas être implémentées en renvoyant un Future<Int>, va automatiquement à la place await le résultat, puis appelle self become: result., ce qui rendra l'objet en cours d'exécution (self, c'est-à-dire le Future<Int>) devient littéralement l'objet result, c'est-à-dire désormais la référence d'objet qui était un Future<Int> est désormais un Int partout, complètement transparent pour le client.

Aucune fonctionnalité linguistique spéciale liée à l'asynchronie n'est requise.

12
Jörg W Mittag

Ils le font (enfin, la plupart d'entre eux). La fonctionnalité que vous recherchez s'appelle threads.

Les threads ont cependant leurs propres problèmes:

  1. Parce que le code peut être suspendu à n'importe quel point, vous ne pouvez pas jamais supposer que les choses ne changeront pas "par elles-mêmes". Lorsque vous programmez avec des threads, vous perdez beaucoup de temps à réfléchir à la façon dont votre programme doit gérer les choses en évolution.

    Imaginez qu'un serveur de jeu traite l'attaque d'un joueur contre un autre joueur. Quelque chose comme ça:

    if (playerInMeleeRange(attacker, victim)) {
        const damage = calculateAttackDamage(attacker, victim);
        if (victim.health <= damage) {
    
            // attacker gets whatever the victim was carrying as loot
            const loot = victim.getInventoryItems();
            attacker.addInventoryItems(loot);
            victim.removeInventoryItems(loot);
    
            victim.sendMessage("${attacker} hits you with a ${attacker.currentWeapon} and you die!");
            victim.setDead();
        } else {
            victim.health -= damage;
            victim.sendMessage("${attacker} hits you with a ${attacker.currentWeapon}!");
        }
        attacker.markAsKiller();
    }
    

    Trois mois plus tard, un joueur découvre cela en se faisant tuer et en se déconnectant précisément quand attacker.addInventoryItems est en cours d'exécution, puis victim.removeInventoryItems échouera, il peut conserver ses objets et l'attaquant obtient également une copie de ses objets. Il le fait plusieurs fois, créant un million de tonnes d'or à partir de rien et écrasant l'économie du jeu.

    Alternativement, l'attaquant peut se déconnecter pendant que le jeu envoie un message à la victime, et il n'obtiendra pas d'étiquette "meurtrier" au-dessus de sa tête, de sorte que sa prochaine victime ne fuira pas loin de lui.

  2. Parce que le code peut être suspendu à n'importe quel point, vous devez utiliser des verrous partout lors de la manipulation des structures de données. J'ai donné un exemple ci-dessus qui a des conséquences évidentes dans un jeu, mais il peut être plus subtil. Pensez à ajouter un élément au début d'une liste chaînée:

    newItem.nextItem = list.firstItem;
    list.firstItem = newItem;
    

    Ce n'est pas un problème si vous dites que les threads ne peuvent être suspendus que lorsqu'ils font des E/S, et à aucun moment. Mais je suis sûr que vous pouvez imaginer une situation où il y a une opération d'E/S - comme la journalisation:

    for (player = playerList.firstItem; player != null; player = item.nextPlayer) {
        debugLog("${item.name} is online, they get a gold star");
        // Oops! The player might've logged out while the log message was being written to disk, and now this will throw an exception and the remaining players won't get their gold stars.
        // Or the list might've been rearranged and some players might get two and some players might get none.
        player.addInventoryItem(InventoryItems.GoldStar);
    }
    
  3. Parce que le code peut être suspendu à n'importe quel point, il pourrait y avoir beaucoup d'état à enregistrer. Le système gère cela en donnant à chaque thread une pile entièrement distincte. Mais la pile est assez grande, vous ne pouvez donc pas avoir plus de 2000 threads dans un programme 32 bits. Ou vous pourriez réduire la taille de la pile, au risque de la rendre trop petite.

7
user253751

Beaucoup de réponses ici trompeuses, car alors que la question portait littéralement sur la programmation asynchrone et non sur les E/S non bloquantes, je ne pense pas que nous puissions en discuter une sans discuter de l'autre dans ce cas particulier.

Alors que la programmation asynchrone est intrinsèquement, eh bien, asynchrone, la raison d'être de la programmation asynchrone est principalement d'éviter de bloquer les threads du noyau. Node.js utilise l'asynchronosité via des rappels ou Promises pour permettre aux opérations de blocage d'être distribuées à partir d'une boucle d'événement et Netty dans Java utilise l'asynchronisation via des rappels ou CompletableFutures pour faire quelque chose de similaire.

Le code non bloquant ne nécessite cependant pas d'asynchronisme. Cela dépend de ce que votre langage de programmation et votre runtime sont prêts à faire pour vous.

Go, Erlang et Haskell/GHC peuvent gérer cela pour vous. Vous pouvez écrire quelque chose comme var response = http.get('example.com/test') et lui faire libérer un thread noyau dans les coulisses en attendant une réponse. Cela se fait par des goroutines, des processus Erlang ou forkIO abandonnant les threads du noyau en arrière-plan lors du blocage, lui permettant de faire d'autres choses en attendant une réponse.

Il est vrai que le langage ne peut pas vraiment gérer l'asynchronisme pour vous, mais certaines abstractions vous permettent d'aller plus loin que d'autres, par exemple suites non délimitées ou coroutines asymétriques. Cependant, la principale cause du code asynchrone, le blocage des appels système, est absolument peut être éloignée du développeur.

Node.js et Java supporte asynchrone non bloquant code, tandis que Go et Erlang supportent synchrone non bloquant code. Ils sont tous les deux approches valides avec différents compromis.

Mon argument plutôt subjectif est que ceux qui s'opposent à la gestion du non-blocage des runtimes au nom du développeur sont comme ceux qui s'opposent à la collecte des ordures au début des années 2000. Oui, cela entraîne un coût (dans ce cas, principalement plus de mémoire), mais cela facilite le développement et le débogage et rend les bases de code plus robustes.

Je dirais personnellement que non-bloquant asynchrone le code devrait être réservé pour la programmation des systèmes à l'avenir et que les piles technologiques plus modernes devraient migrer vers non-bloquant synchrone les temps d'exécution pour le développement d'applications .

4
Louis Jackman

Si je vous lis bien, vous demandez un modèle de programmation synchrone, mais une implémentation haute performance. Si c'est correct, cela nous est déjà disponible sous la forme de fils verts ou de processus, par exemple Erlang ou Haskell. Alors oui, c'est une excellente idée, mais l'adaptation aux langues existantes ne peut pas toujours être aussi fluide que vous le souhaitez.

3
monocell

J'apprécie la question et je trouve que la majorité des réponses sont simplement défensives du statu quo. Dans le spectre des langues de bas à haut niveau, nous sommes coincés dans une ornière depuis un certain temps. Le niveau supérieur suivant sera clairement un langage moins axé sur la syntaxe (le besoin de mots clés explicites comme wait et async) et beaucoup plus sur l'intention. (Un crédit évident pour Charles Simonyi, mais en pensant à 2019 et à l'avenir.)

Si j'ai dit à un programmeur d'écrire du code qui récupère simplement une valeur dans une base de données, vous pouvez supposer en toute sécurité que je veux dire "et BTW, ne suspendez pas l'interface utilisateur" et "n'introduisez pas d'autres considérations qui masquent les bogues difficiles à trouver ". Les programmeurs du futur, avec une nouvelle génération de langages et d'outils, seront certainement capables d'écrire du code qui récupère simplement une valeur dans une ligne de code et part de là.

La langue de plus haut niveau serait de parler anglais et de compter sur la compétence du responsable de tâche pour savoir ce que vous voulez vraiment faire. (Pensez à l'ordinateur dans Star Trek, ou demandez quelque chose à Alexa.) Nous en sommes loin, mais nous nous rapprochons, et je m'attends à ce que le langage/compilateur soit davantage capable de générer du code robuste et intentionné sans aller jusqu'à besoin d'IA.

D'une part, il existe de nouveaux langages visuels, comme Scratch, qui font cela et ne sont pas embourbés avec toutes les technicités syntaxiques. Certes, il y a beaucoup de travail en coulisse afin que le programmeur n'ait pas à s'en soucier. Cela dit, je n'écris pas de logiciels de classe affaires dans Scratch, donc, comme vous, j'ai la même attente qu'il est temps pour les langages de programmation matures de gérer automatiquement le problème synchrone/asynchrone.

2
Mikey Wetzel

Le problème que vous décrivez est double.

  • Le programme que vous écrivez devrait se comporter de manière asynchrone dans son ensemble vu de l'extérieur.
  • Il devrait pas être visible sur le site d'appel, qu'un appel de fonction abandonne potentiellement le contrôle ou non.

Il y a deux façons d'y parvenir, mais elles se résument essentiellement à

  1. avoir plusieurs threads (à un certain niveau d'abstraction)
  2. ayant plusieurs types de fonction au niveau de la langue, qui sont tous appelés comme ceci foo(4, 7, bar, quux).

Pour (1), je suis en train de regrouper forking et exécuter plusieurs processus, engendrant plusieurs threads du noyau et des implémentations de threads verts qui planifient les threads de niveau d'exécution du langage sur les threads du noyau. Du point de vue du problème, ce sont les mêmes. Dans ce monde, aucune fonction n'abandonne ou ne perd le contrôle du point de vue de son thread. Le thread lui-même n'a parfois pas de contrôle et parfois ne fonctionne pas mais vous n'abandonnez pas le contrôle de votre propre thread dans ce monde. Un système correspondant à ce modèle peut ou non avoir la capacité de générer de nouveaux threads ou de se joindre à des threads existants. Un système correspondant à ce modèle peut ou non avoir la possibilité de dupliquer un thread comme fork d'Unix.

(2) est intéressant. Pour que justice soit faite, nous devons parler de formulaires d'introduction et d'élimination.

Je vais montrer pourquoi le await implicite ne peut pas être ajouté à une langue comme Javascript d'une manière rétrocompatible. L'idée de base est qu'en exposant les promesses à l'utilisateur et en distinguant les contextes synchrone et asynchrone, Javascript a divulgué un détail d'implémentation qui empêche la gestion uniforme des fonctions synchrones et asynchrones. Il y a aussi le fait que vous ne pouvez pas await une promesse en dehors d'un corps de fonction asynchrone. Ces choix de conception sont incompatibles avec "rendre l'asynchronisme invisible pour l'appelant".

Vous pouvez introduire une fonction synchrone à l'aide d'un lambda et l'éliminer avec un appel de fonction.

Introduction de la fonction synchrone:

((x) => {return x + x;})

Élimination de la fonction synchrone:

f(4)

((x) => {return x + x;})(4)

Vous pouvez comparer cela avec l'introduction et l'élimination de la fonction asynchrone.

Introduction à la fonction asynchrone

(async (x) => {return x + x;})

Élimination de la fonction asynchrone (remarque: valable uniquement dans une fonction async)

await (async (x) => {return x + x;})(4)

Le problème fondamental ici est que ne fonction asynchrone est également une fonction synchrone produisant un objet de promesse.

Voici un exemple d'appel d'une fonction asynchrone de manière synchrone dans le repl node.js.

> (async (x) => {return x + x;})(4)
Promise { 8 }

Vous pouvez hypothétiquement avoir un langage, même typé dynamiquement, où la différence entre les appels de fonction asynchrones et synchrones n'est pas visible sur le site d'appel et n'est peut-être pas visible sur le site de définition.

Prendre un langage comme celui-ci et le réduire en Javascript est possible, il vous suffirait de rendre toutes les fonctions asynchrones de manière efficace.

1
Gregory Nisbet

Avec les goroutines de langue Go et le temps d'exécution de la langue Go, vous pouvez écrire tout le code comme s'il s'agissait d'une synchrone. Si une opération se bloque dans un goroutine, l'exécution se poursuit dans d'autres goroutines. Et avec les canaux, vous pouvez communiquer facilement entre les goroutins. C'est souvent plus facile que les rappels comme en Javascript ou async/wait dans d'autres langues. Voir https://tour.golang.org/concurrency/1 pour quelques exemples et une explication.

De plus, je n'ai aucune expérience personnelle avec cela, mais j'entends qu'Erlang a des installations similaires.

Donc, oui, il existe des langages de programmation comme Go et Erlang, qui résolvent le problème syncrone/asynchrone, mais malheureusement ils ne sont pas encore très populaires. À mesure que ces langues gagnent en popularité, les installations qu'elles fournissent seront peut-être également mises en œuvre dans d'autres langues.

1
user332375

Il y a un aspect très important qui n'a pas encore été soulevé: la réentrance. Si vous avez un autre code (c'est-à-dire: boucle d'événements) qui s'exécute pendant l'appel asynchrone (et si vous n'en avez pas alors pourquoi avez-vous même besoin d'async?), Le code peut affecter l'état du programme. Vous ne pouvez pas masquer les appels asynchrones de l'appelant car l'appelant peut dépendre de certaines parties de l'état du programme pour ne pas être affectées pendant la durée de son appel de fonction. Exemple:

function foo( obj ) {
    obj.x = 2;
    bar();
    log( "obj.x equals 2: " + obj.x );
}

Si bar() est une fonction asynchrone, il peut être possible que obj.x Change pendant son exécution. Ce serait plutôt inattendu sans aucune indication que la barre est asynchrone et que cet effet est possible. La seule alternative serait de suspecter chaque fonction/méthode possible d'être asynchrone et de récupérer et de revérifier tout état non local après chaque appel de fonction. Cela est sujet à des bogues subtils et peut même ne pas être possible du tout si une partie de l'état non local est récupérée via des fonctions. Pour cette raison, le programmeur doit savoir quelles fonctions ont le potentiel de modifier l'état du programme de manière inattendue:

async function foo( obj ) {
    obj.x = 2;
    await bar();
    log( "obj.x equals 2: " + obj.x );
}

Maintenant, il est clairement visible que la bar() est une fonction asynchrone, et la bonne façon de la gérer est de revérifier la valeur attendue de obj.x Après et de traiter toutes les modifications qui pourraient avoir avait eu lieu.

Comme déjà noté par d'autres réponses, les langages fonctionnels purs comme Haskell peuvent échapper complètement à cet effet en évitant le besoin d'un état partagé/global. Je n'ai pas beaucoup d'expérience avec les langages fonctionnels, donc je suis probablement partisan de cela, mais je ne pense pas que l'absence d'état global soit un avantage lors de l'écriture d'applications plus volumineuses.

1
j_kubik

Dans le cas de Javascript, que vous avez utilisé dans votre question, il y a un point important à prendre en compte: Javascript est monothread, et l'ordre d'exécution est garanti tant qu'il n'y a pas d'appels asynchrones.

Donc, si vous avez une séquence comme la vôtre:

const nbOfUsers = getNbOfUsers();

Vous avez la garantie que rien d'autre ne sera exécuté dans l'intervalle. Pas besoin de serrures ou quelque chose de similaire.

Cependant, si getNbOfUsers est asynchrone, alors:

const nbOfUsers = await getNbOfUsers();

signifie que pendant que getNbOfUsers s'exécute, les rendements d'exécution et d'autres codes peuvent s'exécuter entre les deux. Cela peut à son tour nécessiter un certain verrouillage, selon ce que vous faites.

Donc, c'est une bonne idée de savoir quand un appel est asynchrone et quand il ne l'est pas, car dans certaines situations, vous devrez prendre des précautions supplémentaires dont vous n'auriez pas besoin si l'appel était synchrone.

0
jcaron