Modèles de programmation asynchrone (async/await)

Cet article fait partie d’une série d’articles sur async/await.

Quelque soit le type d’application, il peut être nécessaire de vouloir exécuter des traitements de façon asynchrone, en particulier pour permettre l’exécution de traitements longs sans bloquer l’interface graphique; ou de pouvoir lancer plusieurs traitements simultanément. La programmation asynchrone implique de pouvoir lancer un ou plusieurs traitements sans bloquer le thread appelant et de pouvoir récupérer les résultats éventuels quand les traitements sont terminés.

En .NET, une implémentation de ce type de programmation a abouti à l’utilisation des mot-clés async/await. Le but de cet article est d’expliquer l’utilisation de async/await et ce qu’implique l’utilisation de ces mot-clés. Dans un premier temps, on va expliquer les différentes approches en .NET pour implémenter des traitements asynchrones. Ensuite, on va expliquer le fonctionnement d’ async/await et enfin, indiquer la façon dont ce pattern est implémenté.

Programmer une exécution de façon asynchrone impose quelques mécanismes qui ne sont pas triviaux:

  • Il faut lancer l’exécution éventuellement dans un thread séparé pour que l’exécution dans le thread appelant puisse se poursuivre.
  • Récupérer le résultat de l’exécution effectuée dans un thread séparé du thread appelant.
  • Le cas échéant attendre la fin d’un traitement en cours d’exécution dans un thread séparé.

Avant d’aborder async/await, plusieurs approches ont été implémentées en .NET pour permettre une programmation asynchrone:

Modèle de programmation asynchrone (Asynchronous Programming Model)

Ce modèle est basé sur l’utilisation de 2 méthodes pour lancer le traitement asynchrone:

  • BeginXXX pour commencer à effectuer le traitement,
  • EndXXX pour éventuellement attendre la fin du traitement asynchrone et récupérer le résultat.

Dans sa version la plus simple, ce modèle permet d’implémenter un algorithme qui lance le traitement asynchrone, permet d’effectuer un autre traitement et attend le résultat de façon bloquante. Un exemple de ce modèle est la lecture d’un fichier avec FileStream.BeginRead() et FileStream.EndRead():

FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
  FileAccess.Read, FileShare.Read, 1024,
  FileOptions.Asynchronous);

Byte[] buffer = new Byte[100];

// Lecture asynchrone d'une quantité de données dans le fichier
IAsyncResult result = fs.BeginRead(buffer, 0, buffer.Length, null, null);

// On peut effectuer un autre traitement 
// ...

// On attend le résultat du traitement asynchrone. 
Int32 bytesRead = fs.EndRead(result);

// Fermeture du flux de lecture
fs.Close();

Un modèle plus complexe comprend une fonctionnalité de “polling” pour vérifier périodiquement si le traitement est terminé. Ce modèle permet d’exécuter un autre traitement tant que le traitement asynchrone n’est pas terminé.

Par exemple si la fonction effectuant un traitement synchrone s’appelle MakeProcess(string arg) avec un argument en entrée alors les fonctions permettant de lancer le traitement asynchrone seront:

public IAsyncResult BeginMakeProcess(string arg, AsyncCallback? callback, object? state);
public int EndMakeProcess(IAsyncResult asyncResult);

La fonction BeginMakeProcess() a pour argument:

  • arg qui est l’argument fonctionnel de la fonction originale
  • AsyncCallback qui est un delegate:
    public delegate void AsyncCallback(IAsyncResult ar);
    
  • IAsyncResult permet d’indiquer le statut de l’opération asynchrone avec les propriétés IsCompleted et CompletedSynchronously.

  • state permet de transmettre la référence d’un objet qui sera transmis dans le résultat de la fonction BeginMakeProcess().

La fonction EndMakeProcess() prend pour argument l’objet de type IAsyncResult renvoyé par BeginMakeProcess().

Avec FileStream, pour vérifier le statut on peut, par exemple, utiliser IAsyncResult.IsCompleted():

FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
    FileAccess.Read, FileShare.Read, 1024,
    FileOptions.Asynchronous);

Byte[] buffer = new Byte[100];

// Lecture asynchrone d'une quantité de données dans le fichier
IAsyncResult result = fs.BeginRead(buffer, 0, buffer.Length, null, null);

while (!result.IsCompleted)
{
  // On peut effectuer un autre traitement 
  // ...

  // On attend avant une nouvelle tentative
  Thread.Sleep(100);
}

// On attend le résultat du traitement asynchrone. 
Int32 bytesRead = fs.EndRead(result);

// Fermeture du flux de lecture
fs.Close();

Ce modèle était valable à un époque où il n’y avait pas les Tasks (Framework .NET 1.0), il est maintenant obsolète.

Modèle asynchrone basé sur des événements (Event-based asynchronous pattern)

A partir du Framework .NET 2.0, est apparu un modèle basé sur des évènements avec des callbacks qui sont exécutées lorsque le traitement est terminé. Ce modèle de programmation introduit la notion de continuation comme dans le cas des Tasks. L’exécution de la continuation est lancée lorsque le tâche asynchrone est terminée. L’implémentation de la callback est fournie au moment du lancement de la tâche asynchrone sous la forme d’une méthode. L’exécution de cette implémentation doit se faire dans un contexte d’exécution précis. Ce contexte va permettre d’organiser l’exécution de la callback.

L’objet SynchronizationContext est apparu aussi avec le framework .NET 2.0. Suivant la technologie utilisée, SynchronizationContext se décline différemment, par exemple: WindowsFormsSynchronizationContext pour les Windows Forms; DispatcherSynchronizationContext pour WPF.

Par exemple, pour lancer la lecture d’un fichier de façon asynchrone, on peut utiliser la surcharge de FileStream:

IAsyncResult BeginRead(Byte[] array, Int32 offset, Int32 numBytes, AsyncCallback userCallback, Object stateObject)

Comme pour le modèle Asynchronous Programming Model:

  • AsyncCallback qui est un delegate:
    public delegate void AsyncCallback(IAsyncResult ar);
    
  • IAsyncResult permet d’indiquer le statut de l’opération asynchrone avec les propriétés IsCompleted et CompletedSynchronously.

En déclinant la lecture d’un fichier avec la modèle Event-based asynchronous pattern, on obtient:

private static Byte[] buffer = new Byte[100];

public static void ReadAsynchronouslyWithEap() 
{
  // On affiche l'ID du thread avec lequel la méthode est exécutée
  Console.WriteLine("Main thread ID={0}", Thread.CurrentThread.ManagedThreadId);

  FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
  FileAccess.Read, FileShare.Read, 1024, FileOptions.Asynchronous);

  // Lecture asynchrone d'une quantité de données dans le fichier
  // On passe le paramètre fs (FileStream) à la callback (méthode ReadIsDone)
  fs.BeginRead(buffer, 0, buffer.Length, WhenReadIsCompleted, fs);

  // On peut effectuer un autre traitement 
  // ...

  // On stoppe le thread principal pour éviter de sortir de la méthode
  Console.ReadLine();
}

private static void WhenReadIsCompleted(IAsyncResult result) 
{
  // On affiche l'ID du thread avec lequel la méthode est exécutée
  Console.WriteLine("ReadIsDone thread ID={0}", Thread.CurrentThread.ManagedThreadId);

  // On récupère l'objet FileStream (correspondant à l'état) fournit en argument
  FileStream fs = (FileStream) result.AsyncState;

  // On récupère le résultat
  Int32 bytesRead = fs.EndRead(result);

  // Fermeture du fichier
  fs.Close();
}

L’intérêt de ce modèle par rapport à Asynchronous Programming Model est la possibilité d’utiliser le contexte de synchronisation SynchronizationContext plus spécifique au contexte dans lequel l’exécution asynchrone est effectuée.
On peut obtenir une instance de SynchronizationContext en utilisant la propriété statique SynchronizationContext.Current. L’ajout de tâches à exécuter dans le scheduler peut se faire avec la méthode SynchronizationContext.Post(), par exemple:

static void ExecuteAction(Action<string> actionToBeExecuted)
{
  SynchronizationContext? sc = SynchronizationContext.Current;
  ThreadPool.QueueUserWorkItem(_ =>
  {
    string message = "Message exemple";
    if (sc is not null)
    {
	    // Exécution par le "scheduler"
      sc.Post(_ => actionToBeExecuted(message), null);
    }
    else
    {
      // Exécution directe
      update(message);
    }
  });
}

Modèle asynchrone basé sur les Tasks (Task-based asynchronous pattern)

Les tasks sont apparues avec le framework .NET 4.0, permettant ainsi d’ajouter une abstraction au dessus des threads. Le gros intérêt des tasks est qu’elles facilitent considérablement l’implémentation d’algorithmes asynchrones. Un objet de type Task contient, en particulier, plusieurs propriétés:

Ces propriétés sont utiles pour savoir si la Task a terminé son exécution, gérer les erreurs, implémenter un mécanisme de continuation (correspondant à une autre Task qui s’exécutera quand la Task précédente sera terminée).

Contexte d’exécution

Le contexte d’exécution permet de capturer des données qui pourront être restituées d’un thread à l’autre. Ainsi dans le cadre de l’exécution d’une continuation, le contexte sera transmis d’une Task à l’autre. De même quand on appelle des méthodes asynchrones, ce contexte est transmis implicitement. Des méthodes comme Task.Run() et ThreadPool.QueueUserWorkItem() transmettent le contexte d’exécution automatiquement. Le contexte est capturé à partir du thread appelant et il est stocké dans l’instance de Task. Si le TaskScheduler exécute un delegate, il le fait avec ExecutionContext.Run() en utilisant le contexte stocké.

Par exemple si on crée une continuation en utilisant Task.ContinueWith() le contexte d’exécution est transmis d’une Task à l’autre. Avec TaskAwaiter.GetAwaiter().UnsafeOnComplete() le contexte n’est pas transmis.

Si on rapproche le modèle Task-based asynchronous pattern aux modèles précédents, la continuation s’apparente à la callback exécutée quand le code asynchrone a terminé son exécution. De plus tout le code implémenté explicitement dans le cadre des autres modèles pour la gestion d’erreurs se trouve dans l’objet Task.

Dans le cadre de l’exécution d’une série de traitements asynchrones, on peut imaginer une série de continuations ce qui amène à l’implémentation d’ async/await.

Dans l’article suivant, on rentrera davantage dans les détails d’async/await.

Leave a Reply