Asynchronie en C # et F #. Pièges asynchrones en C #

Bonjour, Habr! Je présente à votre attention la traduction de l'article "Async en C # et F # Asynchronous gotchas in C #" de Tomas Petricek.



En février dernier, j'ai assisté au sommet annuel des MVP, un événement organisé par Microsoft pour les MVP. J'ai également profité de cette occasion pour visiter Boston et New York, faire deux conférences sur F # et enregistrer la conférence de Channel 9 sur les fournisseurs de types . Malgré d' autres activités (comme visiter des pubs, discuter avec d'autres personnes à propos de F # et faire de longues siestes le matin), j'ai également pu avoir quelques discussions.



image



Une discussion (pas dans le cadre de la NDA) a été le discours de la clinique Async sur les nouveaux mots-clés de C # 5.0 - async and await. Lucian et Stephen ont parlé des problèmes courants auxquels les développeurs C # sont confrontés lors de l'écriture de programmes asynchrones. Dans cet article, je vais examiner certains des problèmes d'un point de vue F #. La conversation a été assez animée et quelqu'un a décrit la réaction du public F # comme suit:



image

(Quand les MVP écrivant en F # voient des exemples de code C #, ils rient comme des filles)



Pourquoi cela se produit-il? Il s'avère que la plupart des erreurs courantes sont impossibles (ou beaucoup moins probables) lors de l'utilisation du modèle asynchrone F # (qui est apparu dans F # 1.9.2.7, publié en 2007 et livré avec Visual Studio 2008).



Pitfall # 1: Async ne fonctionne pas de manière asynchrone



Passons directement au premier aspect délicat du modèle de programmation asynchrone C #. Jetez un œil à l'exemple suivant et essayez d'imaginer dans quel ordre les lignes seront imprimées (je n'ai pas trouvé le code exact montré dans la conférence, mais je me souviens que Lucian a démontré quelque chose de similaire):



  async Task WorkThenWait()
  {
      Thread.Sleep(1000);
      Console.WriteLine("work");
      await Task.Delay(1000);
  }
 
  void Demo() 
  {
      var child = WorkThenWait();
      Console.WriteLine("started");
      child.Wait();
      Console.WriteLine("completed");
  }


Si vous pensez que «commencé», «travail» et «terminé» seront imprimés, vous vous trompez. Le code imprime "travail", "commencé" et "terminé", essayez-le vous-même! L'auteur voulait commencer à travailler (en appelant WorkThenWait) puis attendre la fin de la tâche. Le problème est que WorkThenWait commence par faire des calculs lourds (ici Thread.Sleep) et seulement après cela, il utilise wait.



En C #, le premier morceau de code d'une méthode asynchrone s'exécute de manière synchrone (sur le thread de l'appelant). Vous pouvez résoudre ce problème, par exemple, en ajoutant await Task.Yield () au début.



Code F # correspondant



En F #, ce n'est pas un problème. Lors de l'écriture de code asynchrone en F #, tout le code à l'intérieur du bloc async {…} est différé et exécuté plus tard (lorsque vous l'exécutez explicitement). Le code C # ci-dessus correspond à ce qui suit en F #:



let workThenWait() = 
    Thread.Sleep(1000)
    printfn "work done"
    async { do! Async.Sleep(1000) }
 
let demo() = 
    let work = workThenWait() |> Async.StartAsTask
    printfn "started"
    work.Wait()
    printfn "completed"
  


De toute évidence, la fonction workThenWait n'effectue pas le travail (Thread.Sleep) dans le cadre du calcul asynchrone, et qu'il sera exécuté lorsque la fonction est appelée (et non lorsque le flux de travail asynchrone démarre). Un modèle courant en F # consiste à envelopper tout le corps d'une fonction en asynchrone. En F #, vous écririez ce qui suit, qui fonctionne comme prévu:



let workThenWait() = async
{ 
    Thread.Sleep(1000)
    printfn "work done"
    do! Async.Sleep(1000) 
}
  


Piège n ° 2: ignorer les résultats



Voici un autre problème dans le modèle de programmation asynchrone C # (cet article est tiré directement des diapositives de Lucian). Devinez ce qui se passe lorsque vous exécutez la méthode asynchrone suivante:



async Task Handler() 
{
   Console.WriteLine("Before");
   Task.Delay(1000);
   Console.WriteLine("After");
}
 


Vous attendez-vous à ce qu'il imprime «Avant», attendez 1 seconde, puis imprime «Après»? Faux! Les deux messages seront imprimés en même temps, sans délai intermédiaire. Le problème est que Task.Delay renvoie une tâche, et nous avons oublié d'attendre qu'elle se termine (en utilisant await).



Code F # correspondant



Encore une fois, vous n'auriez probablement pas rencontré cela en F #. Vous pouvez bien écrire du code qui appelle Async.Sleep et ignore l'Async renvoyé:



let handler() = async
{
    printfn "Before"
    Async.Sleep(1000)
    printfn "After" 
}
 


Si vous collez ce code dans Visual Studio, MonoDevelop ou Try F #, vous recevrez immédiatement un avertissement:



avertissement FS0020: Cette expression doit avoir le type unit, mais le type Async ‹unit›. Utilisez ignore pour ignorer le résultat de l'expression ou laissez pour lier le résultat à un nom.


avertissement FS0020: Cette expression doit être de type unit mais est de type Async ‹unit›. Utilisez ignore pour ignorer le résultat d'une expression ou laissez pour associer le résultat à un nom.




Vous pouvez toujours compiler le code et l'exécuter, mais si vous lisez l'avertissement, vous verrez que l'expression renvoie Async et vous devez attendre son résultat en utilisant do!:



let handler() = async 
{
   printfn "Before"
   do! Async.Sleep(1000)
   printfn "After" 
}
 


Piège n ° 3: méthodes asynchrones qui renvoient le vide



Une grande partie de la conversation a été consacrée aux méthodes de vide asynchrones. Si vous écrivez async void Foo () {…}, le compilateur C # génère une méthode qui retourne void. Mais sous le capot, il crée et exécute une tâche. Cela signifie que vous ne pouvez pas prédire quand le travail sera réellement effectué.



Dans le discours, la recommandation suivante a été faite sur l'utilisation du modèle async void:



image

(Pour l'amour de Dieu, arrêtez d'utiliser async void!)



En toute honnêteté, il convient de noter que les méthodes asynchrones void peuventêtre utile lors de l'écriture de gestionnaires d'événements. Les gestionnaires d'événements doivent renvoyer void et ils commencent souvent un travail qui se poursuit en arrière-plan. Mais je ne pense pas que cela soit vraiment utile dans le monde MVVM (bien que cela fasse certainement de bonnes démos lors de conférences).



Permettez-moi de démontrer le problème avec un extrait de l' article du magazine MSDN sur la programmation asynchrone en C #:



async void ThrowExceptionAsync() 
{
    throw new InvalidOperationException();
}

public void CallThrowExceptionAsync() 
{
    try 
    {
        ThrowExceptionAsync();
    } 
    catch (Exception) 
    {
        Console.WriteLine("Failed");
    }
}
 


Pensez-vous que ce code affichera «Échec»? J'espère que vous comprenez déjà le style de cet article ...

En effet, l'exception ne sera pas gérée, car après le démarrage du travail, ThrowExceptionAsync se fermera immédiatement, et l'exception sera lancée quelque part dans un thread d'arrière-plan.



Code F # correspondant



Donc, si vous n'avez pas besoin d'utiliser les fonctionnalités d'un langage de programmation, il est probablement préférable de ne pas activer cette fonctionnalité en premier lieu. F # ne vous permet pas d'écrire des fonctions async void - si vous enveloppez le corps d'une fonction dans un bloc async {…}, le type de retour sera Async. Si vous utilisez des annotations de type et que vous avez besoin d'une unité, vous obtenez une incompatibilité de type.



Vous pouvez écrire du code qui correspond au code C # ci-dessus en utilisant Async.Start:



let throwExceptionAsync() = async {
    raise <| new InvalidOperationException()  }

let callThrowExceptionAsync() = 
  try
     throwExceptionAsync()
     |> Async.Start
   with e ->
     printfn "Failed"


L'exception ne sera pas traitée ici non plus. Mais ce qui se passe est plus évident, car nous devons écrire Async.Start explicitement. Sinon, nous recevrons un avertissement indiquant que la fonction renvoie Async et nous ignorons le résultat (comme dans la section précédente, Ignorer les résultats).



Piège n ° 4: fonctions lambda asynchrones qui renvoient void



La situation devient encore plus compliquée lorsque vous passez une fonction lambda asynchrone à une méthode en tant que délégué. Dans ce cas, le compilateur C # déduit le type de la méthode à partir du type du délégué. Si vous utilisez un délégué Action (ou similaire), le compilateur crée une fonction void asynchrone qui démarre le travail et renvoie void. Si vous utilisez le délégué Func, le compilateur génère une fonction qui retourne Task.



Voici un échantillon des diapositives de Lucian. Quand le prochain code (parfaitement correct) se terminera-t-il - une seconde (après que toutes les tâches aient fini d'attendre) ou immédiatement?



Parallel.For(0, 10, async i => 
{
    await Task.Delay(1000);
});


Vous ne serez pas en mesure de répondre à cette question si vous ne savez pas qu'il n'y a que des surcharges pour les délégués For that accept Action - et donc le lambda sera toujours compilé comme un vide asynchrone. Cela signifie également que l'ajout d'une charge (éventuellement utile) sera un changement radical.



Code F # correspondant



F # n'a pas de "fonctions lambda asynchrones" spéciales, mais vous pouvez écrire une fonction lambda qui renvoie des calculs asynchrones. Une telle fonction renverra Async, elle ne peut donc pas être transmise en tant qu'argument aux méthodes qui attendent un délégué de retour de vide. Le code suivant ne se compile pas:



Parallel.For(0, 10, fun i -> async {
  do! Async.Sleep(1000) 
})


Le message d'erreur dit simplement que le type de fonction int -> Async n'est pas compatible avec le délégué Action (en F #, il devrait être int -> unit):



erreur FS0041: aucune surcharge ne correspond à la méthode For. Les surcharges disponibles sont indiquées ci-dessous (ou dans la fenêtre Liste des erreurs).


erreur FS0041: aucune surcharge trouvée pour la méthode For. Les surcharges disponibles sont indiquées ci-dessous (ou dans la zone de liste des erreurs).




Pour obtenir le même comportement que dans le code C # ci-dessus, nous devons démarrer explicitement. Si vous souhaitez exécuter une séquence asynchrone en arrière-plan, cela se fait facilement avec Async.Start (qui prend un calcul asynchrone qui renvoie une unité, la planifie et retourne une unité):



Parallel.For(0, 10, fun i -> Async.Start(async {
  do! Async.Sleep(1000) 
}))


Vous pouvez bien sûr écrire ceci, mais il est assez facile de voir ce qui se passe. Il est également facile de voir que nous gaspillons des ressources, comme la particularité de Parallel, car il effectue des calculs intensifs en CPU (qui sont généralement des fonctions synchrones) en parallèle.



Piège n ° 5: tâches d'imbrication



Je pense que Lucian a inclus cette pierre juste pour tester l'intelligence des gens dans le public, mais la voici. La question est de savoir si le code suivant attendra 1 seconde entre les deux broches de la console?



Console.WriteLine("Before");
await Task.Factory.StartNew(
    async () => { await Task.Delay(1000); });
Console.WriteLine("After");


De manière assez inattendue, il n'y a pas de délai entre ces conclusions. Comment est-ce possible? La méthode StartNew prend un délégué et retourne Task où T est le type retourné par le délégué. Dans notre cas, le délégué retourne Task, donc nous obtenons Task en conséquence. wait n'attend que la fin de la tâche externe (qui renvoie immédiatement la tâche interne), tandis que la tâche interne est ignorée.



En C #, cela peut être résolu en utilisant Task.Run au lieu de StartNew (ou en supprimant async / await dans la fonction lambda).



Pouvez-vous écrire quelque chose comme ça en F #? Nous pouvons créer une tâche qui retournera Async en utilisant la fonction Task.Factory.StartNew et une fonction lambda qui renvoie un bloc async. Pour attendre la fin de la tâche, nous devrons la convertir en exécution asynchrone en utilisant Async.AwaitTask. Cela signifie que nous obtiendrons Async <Async>:



async {
  do! Task.Factory.StartNew(fun () -> async { 
    do! Async.Sleep(1000) }) |> Async.AwaitTask }


Encore une fois, ce code ne se compile pas. Le problème est que le do! nécessite Async sur la droite, mais reçoit en fait Async <Async>. En d'autres termes, nous ne pouvons pas simplement ignorer le résultat. Nous devons faire quelque chose à ce sujet explicitement

(vous pouvez utiliser Async.Ignore pour reproduire le comportement C #). Le message d'erreur n'est peut-être pas aussi clair que les précédents, mais il donne une idée générale:



erreur FS0001: cette expression devait avoir le type Async ‹unit› mais ici a le type unit


erreur FS0001: Expression asynchrone 'unité' attendue, type d'unité présent


Piège n ° 6: Async ne fonctionne pas



Voici un autre morceau de code problématique de la diapositive de Lucian. Cette fois, le problème est assez simple. L'extrait de code suivant définit une méthode FooAsync asynchrone et l'appelle à partir du gestionnaire, mais le code ne s'exécute pas de manière asynchrone:



async Task FooAsync() 
{
    await Task.Delay(1000);
}
void Handler() 
{
    FooAsync().Wait();
}


Il est facile de repérer le problème - nous appelons FooAsync (). Wait (). Cela signifie que nous créons une tâche, puis, en utilisant Wait, nous bloquons le programme jusqu'à ce qu'il se termine. Une simple suppression de Wait résout le problème, car nous voulons simplement démarrer la tâche.



Vous pouvez écrire le même code en F #, mais les flux de travail asynchrones n'utilisent pas de tâches .NET (initialement conçues pour le calcul lié au processeur), mais utilisent plutôt le type F # Async, qui n'est pas fourni avec Wait. Cela signifie que vous devez écrire:



let fooAsync() = async {
    do! Async.Sleep(1000) }
let handler() = 
    fooAsync() |> Async.RunSynchronously


Bien sûr, un tel code peut être écrit par accident, mais si vous êtes confronté à un problème d' asynchronie cassée , vous remarquerez facilement que le code appelle RunSynchronously, donc le travail est effectué - comme son nom l'indique - de manière synchrone .



Résumé



Dans cet article, j'ai examiné six cas dans lesquels le modèle de programmation asynchrone en C # se comporte de manière inattendue. La plupart d'entre eux sont basés sur la conversation de Lucian et Stephen lors du MVP Summit, alors merci à tous les deux pour une liste intéressante de pièges courants!



Pour F #, j'ai essayé de trouver les extraits de code pertinents les plus proches à l'aide de flux de travail asynchrones. Dans la plupart des cas, le compilateur F # émettra un avertissement ou une erreur - ou le modèle de programmation n'a aucun moyen (direct) d'exprimer le même code. Je pense que cela confirme une déclaration que j'ai faite dans un précédent article de blog : «Le modèle de programmation F # semble définitivement plus approprié pour les langages de programmation fonctionnels (déclaratifs). Je pense aussi que cela facilite le raisonnement sur ce qui se passe. "



Enfin, cet article ne doit pas être compris comme une critique destructrice de l'asynchronie en C # :-). Je comprends parfaitement pourquoi la conception de C # suit les mêmes principes qu'elle suit - pour C #, il est logique d'utiliser Task (au lieu d'Async séparé), ce qui a un certain nombre de conséquences. Et je peux comprendre les raisons des autres décisions - c'est probablement la meilleure façon d'intégrer la programmation asynchrone en C #. Mais en même temps, je pense que F # fait un meilleur travail - en partie à cause de sa capacité de composition, mais plus important encore à cause des add-ons sympas comme les agents F # . De plus, l'asynchronie en F # a aussi ses problèmes (l'erreur la plus courante est que les fonctions récursives de queue doivent être utilisées return! Au lieu de do!, Pour éviter les fuites), mais c'est un sujet pour un article de blog séparé.



PS Du traducteur. L'article a été écrit en 2013, mais je l'ai trouvé suffisamment intéressant et pertinent pour être traduit en russe. C'est mon premier article sur Habré, alors ne donnez pas de coups de pied.



All Articles