Single Thread, multi threading, synchronous et asynchronous. Cet article explique les différents modes d’exécution du code dans Flutter.

Difficulté: Intermédiaire

Introduction

J’ai récemment reçu quelques questions sur les notions de Future, async, await, Isolate et de traitement parallèle.

Certaines de ces questions parlaient de problèmes rencontrés avec l’ordre dans lequel les lignes de leur code étaient exécutées.

J’ai pensé qu’il serait très utile de saisir l’opportunité d’un article pour couvrir ces sujets et lever toute ambiguïté, principalement autour des notions de traitements asynchrones et parallèles.


Dart est un langage Single Thread

Tout d’abord, tout le monde doit garder à l’esprit que Dart est Single Thread et que Flutter repose sur Dart.

IMPORTANT

Dart exécute une opération à la fois, l’une après l’autre, ce qui signifie que tant qu’une opération est en cours d’exécution, elle ne peut pas être interrompue par un autre code Dart.

En d’autres termes, si vous considérez une méthode purement synchrone, cette dernière sera la seule à être exécutée jusqu’à ce qu’elle soit terminée.

void myBigLoop(){
    for (int i = 0; i < 1000000; i++){
        _doSomethingSynchronously();
    }
}

Dans l’exemple ci-dessus, l’exécution de la méthode myBigLoop() ne sera jamais interrompue tant qu’elle ne sera pas terminée. En conséquence, si cette méthode prend du temps, l’application sera “bloquée” pendant toute l’exécution de la méthode.


Le modèle d’éxecution de Dart

En coulisse, comment Dart gère-t-il réellement la séquence d’opérations à exécuter?

Afin de répondre à cette question, nous devons examiner le séquenceur de code Dart, appelé Event Loop.

Lorsque vous démarrez une application Flutter (ou toute application Dart), un nouveau processus Thread (en language Dart = “Isolate”) est créé et lancé. Ce thread sera le seul dont vous aurez à vous occuper pour l’ensemble de l’application.

Donc, lorsque ce thread est créé, Dart automatiquement

  1. initialise 2 Queues de type FIFO: “MicroTask” et “Event”;
  2. exécute la méthode main() et, lorsque l’exécution est terminée,
  3. démarre le Event Loop

Pendant toute la durée de vie du thread, un processus unique interne et invisible, appelé “Event Loop”, déterminera la façon dont votre code sera exécuté et dans quel ordre de séquence, en fonction du contenu de Queues MicroTask et Event.

L’Event Loop correspond à une sorte de boucle infinie, cadencée par une horloge interne qui, à chaque tick, et si aucun autre code Dart ne tourne à ce moment, effectue quelque chose qui ressemble à ceci:

void eventLoop(){
    while (microTaskQueue.isNotEmpty){
        fetchFirstMicroTaskFromQueue();
        executeThisMicroTask();
        return;
    }

    if (eventQueue.isNotEmpty){
        fetchFirstEventFromQueue();
        executeThisEventRelatedCode();
    }
}

Comme nous pouvons le voir, la queue MicroTask à la précédence sur la queue Event. Mais, à quoi servent ces 2 queues?

MicroTask Queue

La Queue MicroTask est utilisée pour les actions internes très courtes qui doivent être exécutées de manière asynchrone, immédiatement après la fin de la tâche et avant de rendre la main à la Queue Event.

En tant qu’exemple de MicroTask, vous pouvez imaginer devoir disposer d’une ressource juste après sa fermeture. Comme le processus de fermeture peut prendre un certain temps, vous pouvez écrire quelque chose comme ceci:

    MyResource myResource;

    ...

    void closeAndRelease() {
        scheduleMicroTask(_dispose);
        _close();
    }

    void _close(){
        // Le code à exécuter de manière
        // synchrone pour fermer la ressource
        ...
    }

    void _dispose(){
        // Le code à exécuter
        // dès que _close() est terminé
    }

C’est quelque chose avec lequel vous n’aurez pas à travailler la plupart du temps. Pour exemple, le code entier de Flutter ne fait appel à la méthode scheduleMicroTask() que 7 fois.

Il est toujours préférable d’utiliser la queue Event.

Event Queue

La Queue Event est utilisée pour référencer les opérations qui résultent de

  • événements externes tels que
    • I/O;
    • gesture;
    • drawing;
    • timers;
    • streams;
  • futures

En fait, à chaque fois qu’un événement externe est déclenché, le code à exécuter correspondant est référencé dans la Queue Event.

Dès qu’il n’existe plus aucune micro task à exécuter, l’Event Loop prend le premier élément de la file d’attente Event et l’exécute.

Il est très intéressant de noter que les Futures sont également gérées via la file d’attente Event.


Futures

Un (ou une) Future correspond à une tâche qui s’exécute de manière asynchrone et se termine (ou échoue) à un moment donné dans le futur.

Lorsque vous initialisez une nouvelle Future:

  • une instance de cette Future est créée et répertoriée dans un tableau interne, géré par Dart;
  • le code qui doit être exécuté par cette Future est directement injecté dans la Queue Event;
  • l’instance de la future est retournée avec un statut (=incomplet);
  • le cas échéant, le code qui suit est exécuté (PAS le code de la Future)

Le code référencé par la future sera exécuté comme tout autre événement de la Queue Event, dès que l’Event Loop le prendra en compte.

Lorsque ce code sera exécuté et se terminera (ou échouera) les méthodes then() ou catchError() respectives seront directement exécutées.

Pour illustrer cela, prenons l’exemple suivant:

void main(){
    print('Avant Future');
    Future((){
        print('Future active');
    }).then((_){
        print('Future terminée');
    });
    print('Après Future');
}

Si nous exécutons ce code, la sortie sera la suivante:

Avant Future
Après Future
Future active
Future terminée

Ceci est tout à fait normal car le déroulement de l’exécution est le suivant:

  1. print(‘Avant Future’)
  2. ajouter “(){print(‘Future active’);}” à la queue “Event”;
  3. print(‘Après Future’)
  4. l’Event Loop récupère le code (référencé au point 2) et le lance
  5. quand le code se termine, il recherche le then() et l’exécute

Quelque chose de très important à garder à l’esprit:

Une Future N’EST PAS exécutée en parallèle mais selon la séquence normale des événements, gérés par l’Event Loop


Méthodes de type Async

Lorsque vous suffixez la déclaration d’une méthode avec le mot clé async, Dart sait que:

  • le résultat de la méthode est une Future;
  • il exécute le code de cette méthode de manière synchrone jusqu’au tout premier mot-clé await, puis il suspend l’exécution du reste de cette méthode;
  • la prochaine ligne de code sera exécutée dès que la Future, référencée par le mot-clé await, sera terminé.

Il est très important de comprendre ceci car beaucoup de développeurs pensent que l’utilisation de await pause toute exécution jusqu’à que la fonction référencée par le await se termine mais ce n’est absolument pas le cas. Ils oublient le fonctionnement de l’Event Loop.

Pour mieux illustrer cette affirmation, prenons l’exemple suivant et essayons de comprendre le résultat de son exécution.

void main() async {
  methodA();
  await methodB();
  await methodC('main');
  methodD();
}

methodA(){
  print('A');
}

methodB() async {
  print('B start');
  await methodC('B');
  print('B end');
}

methodC(String from) async {
  print('C start from $from');
  
  Future((){                // <== Ce code sera exécuté quelque part dans le futur
    print('C running Future from $from');
  }).then((_){
    print('C end of Future from $from');
  });

  print('C end from $from');  
}

methodD(){
  print('D');
}

La séquence correcte est la suivante:

  1. A
  2. B start
  3. C start from B
  4. C end from B
  5. B end
  6. C start from main
  7. C end from main
  8. D
  9. C running Future from B
  10. C end of Future from B
  11. C running Future from main
  12. C end of Future from main

Maintenant, supposons que la méthode “methodC()” du code ci-dessus, corresponde à un appel vers un serveur dont le temps de réponse est très variable. Il est évident qu’il deviendrait alors très difficile de prédire avec certitude quelle serait alors la séquence d’exécution.

Si, à partir du code ci-dessus, votre désir initial aurait été de voir exécuter la méthode methodD() à la fin de toute autre exécution, vous auriez dû modifier le code comme ceci:

void main() async {
  methodA();
  await methodB();
  await methodC('main');
  methodD();  
}

methodA(){
  print('A');
}

methodB() async {
  print('B start');
  await methodC('B');
  print('B end');
}

methodC(String from) async {
  print('C start from $from');

  await Future((){                  // <== la modification est ici
    print('C running Future from $from');
  }).then((_){
    print('C end of Future from $from');
  });
  print('C end from $from');  
}

methodD(){
  print('D');
}

Cela donne la séquence suivante:

  1. A
  2. B start
  3. C start from B
  4. C running Future from B
  5. C end of Future from B
  6. C end from B
  7. B end
  8. C start from main
  9. C running Future from main
  10. C end of Future from main
  11. C end from main
  12. D

Le fait d’avoir ajouté un simple await au niveau de la Future dans la méthode methodC() a modifié le comportement total.

Il est également très important de garder à l’esprit:

Une méthode async N’EST PAS exécutée en parallèle mais selon la séquence normale des événements, gérés par l’Event Loop

Un dernier exemple que je voulais vous montrer est le suivant. Quel sera le résultat de l’exécution de method1 et celui de method2 ? Seront-ils les mêmes ?

void method1(){
  List<String> myArray = <String>['a','b','c'];
  print('before loop');
  myArray.forEach((String value) async {
    await delayedPrint(value);
  });  
  print('end of loop');
}

void method2() async {
  List<String> myArray = <String>['a','b','c'];
  print('before loop');
  for(int i=0; i<myArray.length; i++) {
    await delayedPrint(myArray[i]);
  }
  print('end of loop');
}

Future<void> delayedPrint(String value) async {
  await Future.delayed(Duration(seconds: 1));
  print('delayedPrint: $value');
}

Réponse:

method1() method2()
  1. before loop
  2. end of loop
  3. delayedPrint: a (après 1 seconde)
  4. delayedPrint: b (directement après)
  5. delayedPrint: c (directement après)
  1. before loop
  2. delayedPrint: a (après 1 seconde)
  3. delayedPrint: b (1 seconde plus tard)
  4. delayedPrint: c (1 seconde plus tard)
  5. end of loop (directement après)

Avez-vous vu la différence et la raison pour laquelle leur comportement n’est pas le même?

La solution réside dans le fait que method1 utilise une fonction forEach() pour itérer le tableau. A chaque itération, il fait appel à un callback qui est de type async (donc une Future). Il exécute cette fonction jusqu’au premier await puis, met le reste de l’exécution de ce callback dans la queue Event. Dès que l’itération du tableau est terminée, il exécute le code suivant “print(‘end of loop’)”. Quand tout est terminé, l’Event Loop processera les 3 callbacks.

En ce qui concerne le code de method2, tout tourne au sein du même “bloc de code” et, dans cet exemple, s’exécute ligne après ligne.

Comme vous pouvez le constater, même dans un code qui a l’air très simple, nous devons toujours garder à l’esprit le fonctionnement de l’Event Loop


Multi-Threading

Par conséquent, comment pourrions-nous exécuter des codes parallèles dans Flutter? Est-ce possible?

Yes, grâce à la notion d’Isolate.


Qu’est-ce qu’un Isolate?

Un Isolate correspond à la version Dart de la notion de Thread, comme expliqué précédemment.

Néanmoins, il existe une différence majeure avec l’implémentation habituelle des “Threads” et c’est pourquoi nous appelons cela “Isolates”.

En Flutter, les “Isolates” ne partagent pas la mémoire. Les interactions entre Isolates s’effectuent au travers de “messages”.


Chaque Isolate à son propre “Event Loop

Chaque “Isolate” possède son propre “Event Loop” et Queues (MicroTask and Event). Cela signifie que le code tourne au sein d’un Isolate de manière tout à fait indépendante des autres Isolates.

Et c’est grâce à cela que nous pouvons obtenir un traitement parallèle.


Comment lancer un Isolate?

Selon les besoins que vous avez à exécuter un Isolate, vous devrez peut-être envisager différentes approches.

1. Solution bas niveau

Cette première solution n’utilise aucun package et s’appuie entièrement sur l’API de bas niveau proposée par Dart.

1.1. Step 1: création et hand-shaking

Comme je l’ai dit précédemment, les Isolates ne partagent aucune mémoire et communiquent via des messages. Nous devons donc trouver un moyen d’établir cette communication entre le “caller” et le nouveau isolate.

Chaque Isolate expose un port qui est utilisé pour transmettre un message à cet Isolate. Ce port s’appelle “SendPort” (je trouve personnellement que le nom est un peu trompeur puisqu’il s’agit d’un port destiné à recevoir / écouter, mais c’est le nom officiel).

Cela signifie que “caller” et “isolate” doivent connaître le port de l’un et de l’autre pour pouvoir communiquer. Ce processus de “hand-shaking” est indiqué ci-dessous:

//
// The port of the new isolate
// this port will be used to further
// send messages to that isolate
//
SendPort newIsolateSendPort;

//
// Instance of the new Isolate
//
Isolate newIsolate;

//
// Method that launches a new isolate
// and proceeds with the initial
// hand-shaking
//
void callerCreateIsolate() async {
    //
    // Local and temporary ReceivePort to retrieve
    // the new isolate's SendPort
    //
    ReceivePort receivePort = ReceivePort();

    //
    // Instantiate the new isolate
    //
    newIsolate = await Isolate.spawn(
        callbackFunction,
        receivePort.sendPort,
    );

    //
    // Retrieve the port to be used for further
    // communication
    //
    newIsolateSendPort = await receivePort.first;
}

//
// The entry point of the new isolate
//
static void callbackFunction(SendPort callerSendPort){
    //
    // Instantiate a SendPort to receive message
    // from the caller
    //
    ReceivePort newIsolateReceivePort = ReceivePort();

    //
    // Provide the caller with the reference of THIS isolate's SendPort
    //
    callerSendPort.send(newIsolateReceivePort.sendPort);

    //
    // Further processing
    //
}

CONTRAINTES

Le “point d’entrée” d’un isolate DOIT être une fonction de haut-niveau (= externe à une classe) ou une méthode de type STATIC.

1.2. Step 2: envoi d’un Message vers l’Isolate

Maintenant que nous avons le port à utiliser pour envoyer un message à l’Isolate, voyons comment le faire:

//
// Method that sends a message to the new isolate
// and receives an answer
// 
// In this example, I consider that the communication
// operates with Strings (sent and received data)
//
Future<String> sendReceive(String messageToBeSent) async {
    //
    // We create a temporary port to receive the answer
    //
    ReceivePort port = ReceivePort();

    //
    // We send the message to the Isolate, and also
    // tell the isolate which port to use to provide
    // any answer
    //
    newIsolateSendPort.send(
        CrossIsolatesMessage<String>(
            sender: port.sendPort,
            message: messageToBeSent,
        )
    );

    //
    // Wait for the answer and return it
    //
    return port.first;
}

//
// Extension of the callback function to process incoming messages
//
static void callbackFunction(SendPort callerSendPort){
    //
    // Instantiate a SendPort to receive message
    // from the caller
    //
    ReceivePort newIsolateReceivePort = ReceivePort();

    //
    // Provide the caller with the reference of THIS isolate's SendPort
    //
    callerSendPort.send(newIsolateReceivePort.sendPort);

    //
    // Isolate main routine that listens to incoming messages,
    // processes it and provides an answer
    //
    newIsolateReceivePort.listen((dynamic message){
        CrossIsolatesMessage incomingMessage = message as CrossIsolatesMessage;

        //
        // Process the message
        //
        String newMessage = "complemented string " + incomingMessage.message;

        //
        // Sends the outcome of the processing
        //
        incomingMessage.sender.send(newMessage);
    });
}

//
// Helper class
//
class CrossIsolatesMessage<T> {
    final SendPort sender;
    final T message;

    CrossIsolatesMessage({
        @required this.sender,
        this.message,
    });
}
1.3. Step 3: destruction de l’Isolate

Lorsque vous n’avez plus besoin de la nouvelle instance Isolate, il est recommandé de la libérer, de la manière suivante:

//
// Routine to dispose an isolate
//
void dispose(){
    newIsolate?.kill(priority: Isolate.immediate);
    newIsolate = null;
}
1.4. Note spéciale - Single-Listener Streams

Vous avez certainement remarqué que nous utilisons des Streams pour communiquer entre le “caller” et le nouveau isolate. Ces Streams sont de type: “Single-Listener”.


2. One-shot computation

Si vous n’avez besoin que d’exécuter du code pour effectuer un travail spécifique et que vous n’avez pas besoin d’interagir avec cet Isolate une fois le travail terminé, il existe un Helper très pratique, appelé compute.

Cette fonction:

  • crée un Isolate,
  • exécute la fonction callback dans cet isolate, en lui passant des données,
  • retourne la valeur, résultant du callback,
  • et supprime l’Isolate à la fin de l’exécution du callback.

CONTRAINTES

La méthode “callback” DOIT ETRE de type top-level (= externe à une classe) et NE PEUT PAS être une méthode d’une classe (static ou pas).


3. LIMITATION IMPORTANTE

Au moment de la rédaction de cet article, il est important de noter que

Les communications “Platform-Channel” NE SONT SUPPORTEES que par le main isolate. Ce main isolate correspond à celui qui est créé au lancement de l’application.

En d’autres mots, la communication Platform-Channel n’est pas possible au-travers d’Isolates qui auraient été créés par l’application…

Il y a cependant une solution de contournement… Veuillez vous référer à ce lien pour une discussion sur ce sujet.


Quand devrais-je utiliser des Futures et/ou des Isolates?

Les utilisateurs évalueront la qualité d’une application en fonction de différents facteurs, tels que:

  • fonctionnalités
  • look
  • convivialité

Votre application peut répondre à tous ces facteurs, mais si l’utilisateur rencontre des retards au cours de certains traitements, il est fort probable que cela vous attirera des griefs.

Voici donc quelques astuces à prendre systématiquement en compte dans vos développements:

  1. Si des morceaux de code NE PEUVENT PAS être interrompus, utilisez un processus synchrone normal (une méthode ou plusieurs méthodes s’appelant)
  2. Si des codes peuvent fonctionner indépendamment SANS altérer la fluidité de l’application, envisagez d’utiliser l’Event Loop via l’utilisation de Futures;
  3. Si des traitements lourds prennent du temps et peuvent impacter la fluidité de l’application, envisagez d’utiliser des Isolates.

En d’autres termes, il est conseillé d’essayer d’utiliser autant que possible la notion de Futures (directement ou indirectement via des méthodes async) car le code de ces Futures sera exécuté dès que l’Event Loop en aura le temps. Cela donnera à l’utilisateur le sentiment que les choses sont traitées en parallèle (alors que nous savons maintenant que ce n’est pas le cas).

Un autre facteur susceptible de vous aider à décider d’utiliser une Future ou un Isolate est le temps moyen requis pour exécuter du code.

  • Si une méthode prend quelques millisecondes => Future
  • Si un traitement peut prendre plusieurs centaines de millisecondes => Isolate

Voici quelques bons candidats pour des Isolates:

  • JSON decoding

    décoder un JSON, résultat d’une requête HttpRequest, peut prendre un certain temps => utilisez un compute

  • encryption

    encryption peut prendre beaucoup de temps => Isolate

  • image processing

    traiter d’une image (rognage, par exemple) prend un certain temps. => Isolate

  • charger une image à partir du Web

    dans ce cas, pourquoi ne pas déléguer ceci à un Isolate qui renverra l’image complète, une fois complètement chargée?


Conclusions

Je pense que comprendre le fonctionnement de l’Event Loop est essentiel.

Il est également important de garder à l’esprit que Flutter (Dart) est Single-Thread, par conséquent, afin de satisfaire les utilisateurs, les développeurs doivent s’assurer que l’application fonctionnera le mieux possible. Futures et Isolates sont des outils très puissants qui peuvent vous aider à atteindre cet objectif.

Restez à l’écoute pour de nouveaux articles. D’ici-là, je vous souhaite un bon codage.