Coroutines

Notez que ce document est un squelette, destiné à être enrichi et être mis à jour. Pour en savoir plus, examinez les présentations très pertinentes par Gor Nishanov ou James McNellis sur le sujet, offertes lors par exemple lors de CppCon 2015, CppCon 2016, CppCon 2017 ou encore CppCon 2018.

Plusieurs propositions de support aux coroutines ont été mises de l'avant pour C++ 17, mais ce mécanisme n'a pas été intégré à cette version du langage. En fait, les coroutines ont été adoptées pour C++ 20 lors de la rencontre de Kona en 2019.

Par support aux coroutines, on entendra un ensemble de mécanismes permettant :

Par des coroutines, il est possible d'atteindre un parallélisme implicite supporté à même le langage. Le compilateur peut en arriver à soutenir le parallélisme avec une très faible quantité d'états, et peut même parfois escamoter complètement le recours à une pile d'exécution comme dans le cas des threads.

Exemples simples mais charmants

Ces exemples ont été travaillés en collaboration avec Gor Nishanov, que je remercie sincèrement, et qui a eu la gentillesse de me guider en période de documentation instable.

Que peut-on faire avec des coroutines? Voici quelques exemples simples et qui compilent avec Visual Studio 2015 Release 3; les coroutines sont, au moment d'écrire ceci, un outil expérimental, pas une partie intégrante du langage C++, alors il se peut que des parties de ce qui suit évoluent ou changent d'ici leur intégration formelle.

Puisqu'il s'agit d'un mécanisme expérimental, il faut l'activer avec des options de compilation particulières (ici : /await).

Générateurs

Un générateur est une fonction capable de produire, de manière paresseuse (Lazy), une séquence potentiellement infinie de valeurs d'un type donné. Il est possible depuis longtemps de modéliser un générateur par un foncteur, ou en créant ses propres itérateurs / ses propres énumérateurs, mais cela peut être laborieux dans certains cas.

Supposons que l'on souhaite modéliser la génération sur demande d'entiers pairs. Une technique basée sur des foncteurs serait :

#include <iostream>
class Pairs {
   int cur;
public:
   Pairs(int init = {}) : cur{ init } {
      if (cur % 2 != 0) ++cur;
   }
   int operator()() {
      int n = cur;
      cur += 2;
      return n;
   }
};
int main() {
   using namespace std;
   Pairs pairs;
   int jusqu_a = 20;
   for(;;) {
      auto n = pairs();
      cout << n << ' ' << flush;
      if (n >= jusqu_a) break;
   }
   cout << endl;
}

Sans que ce ne soit terriblement ardu, on remarque quelques inélégances : le recours a une variable temporaire intermédiaire dans Pairs::operator(), par exemple, ou encore le fait que l'on ait écrit une boucle « autour » de l'utilisation de pairs plutôt que reposant sur cette utilisation.

Un générateur rend le tout plus élégant. Un exemple naïf d'utilisation de coroutines avec générateurs serait (syntaxe exacte à déterminer) :

Producteur Consommateur
generateur<int> tic(int debut, int fin) {
   using namespace std;
   using namespace std::chrono;
   int n = debut % 2 == 0? debut : debut + 1;
   while(n < fin) {
      this_thread::sleep_for(1s);
      co_yield n++;
   }
}
#include <iostream>
using namespace std;
int main() {
   for (auto n : tic(1, 100)) {
      cout << "Tic #" << n << endl;
   }
}

Ceci implique l'existence d'un type generateur<T>, qui soit itérable et ait un certain nombre de caractéristiques (notez que dans l'état actuel des choses, il y a une quantité importante de code un peu redondant – du Boilerplate Code – à écrire). Si nous l'écrivons manuellement, un programme complet (compilez avec l'option /await dans Visual Studio, du fait que les coroutines ne sont pas encore standards) serait :

#include <chrono>
#include <thread>
#include <future>
#include <iterator>
#include <experimental/coroutine>

//
// Generateur (sera plus simple éventuellement; on est en phase expérimentale)
//
template <typename T>
struct generateur {
   struct promise_type {
      int cur;
      std::experimental::suspend_always yield_value(int value) {
         cur = value;
         return {};
      }
      std::experimental::suspend_always initial_suspend() {
         return {};
      }
      generateur get_return_object() {
         return {
            std::experimental::coroutine_handle<promise_type>::from_promise(*this)
         };
      }
      std::experimental::suspend_always final_suspend() {
         return {};
      }
   };

   std::experimental::coroutine_handle<promise_type> coro;
   generateur(std::experimental::coroutine_handle<promise_type> coro)
      : coro{ coro } {
   }
   ~generateur() {
      if (coro) coro.destroy();
   }

   struct iterator : public std::iterator<std::input_iterator_tag, int> {
      std::experimental::coroutine_handle<promise_type> coro;
      iterator() = default;
      iterator(std::experimental::coroutine_handle<promise_type> coro) : coro{ coro } {
      }

      iterator& operator++() {
         coro.resume();
         if (coro.done()) coro = nullptr;
         return *this;
      }

      int operator*() {
         return coro.promise().cur;
      }

      bool operator!=(const iterator& autre) const {
         return coro != autre.coro;
      }
   };

   iterator begin() {
      if (coro) {
         coro.resume();
         if (coro.done()) return end();
      }
      return { coro };
   }
   iterator end() {
      return {};
   }
};

//
// Producteur
//
generateur<int> tic(int debut, int fin) {
   using namespace std;
   using namespace std::chrono;
   int n = debut % 2 == 0? debut : debut + 1;
   while(n < fin) {
      this_thread::sleep_for(1s);
      co_yield n++;
   }
}
//
// Consommateur
//
#include <iostream>
using namespace std;
int main() {
   for (auto n : tic(1, 100)) {
      cout << "Tic #" << n << endl;
   }
}

Un exemple plus simple, utilisant un generator<T> presque standard, serait :

#include <experimental/generator>
#include <iostream>
using namespace std;
using experimental::generator;
generator<int> pairs(int debut, int fin) {
   if (debut % 2 != 0) ++debut;
   for (; debut <= fin;) {
      co_yield debut;
      debut += 2;
   }
}
int main() {
   for (auto n : pairs(1, 100)) {
      cout << n << ' ' << flush;
   }
}

Ici, le generator<int> retourné par pairs() simule en quelque sorte le comportement d'un itérateur, offrant ses versions de begin() et de end(), ce qui permet d'écrire une boucle for sur un intervalle à partir du générateur directement. Notez qu'adapter le programme pour générer un nombre arbitrairement grand de valeurs est plus simple encore (alors que c'est quelque peu laborieux avec un foncteur; essayez-le!) :

#include <experimental/generator>
#include <iostream>
using namespace std;
using experimental::generator;
generator<int> pairs() {
   int n = 0;
   for (;;) {
      co_yield n;
      n += 2;
   }
}
int main() {
   for (auto n : pairs()) {
      cout << n << ' ' << flush;
   }
}

Un exemple moins naïf d'utilisation de coroutines serait (merci à Gor Nishanov pour celui-ci) :

// ... inclusions pour Tcp:: ... (vous pouvez imaginer le code – synchrone au sens faible – sans peine
#include <future>
using namespace std;
future<void> tcp_reader(int total) {
   char buf[64 * 1024];
   auto conn = await Tcp::Connect("127.0.0.1", 1337);
   do {
      auto bytesRead = await conn.read(buf, sizeof(buf));
      total -= bytesRead;
   } while(total > 0);
}
int main() {
   tcp_reader(1000 * 1000 * 1000).get();
}

Notez qu'une fonction suspendue ne consomme pas de temps d'exécution. Le parallélisme potentiel est implicite, et le support de grandes quantités de coroutines est une conséquence directe du modèle.

Dissociation du thread sous-jacent

Autre exemple suggéré (et écrit!) par Gor Nishanov lui-même, ce qui suit montre qu'une coroutine est dissociée du concept de thread, et représente une abstraction de plus haut niveau. Examinez le code avec attention :

#include <windows.h>
#include <future>
#include <iostream>

auto operator await(std::chrono::system_clock::duration duration) {
   class awaiter {
      static void CALLBACK TimerCallback(PTP_CALLBACK_INSTANCE, void *Context, PTP_TIMER) {
         std::experimental::coroutine_handle<>::from_address(Context)();
      }
      PTP_TIMER timer = nullptr;
      std::chrono::system_clock::duration duration;
   public:
      explicit awaiter(std::chrono::system_clock::duration d) : duration(d) {
      }
      bool await_ready() const {
         return duration.count() <= 0;
      }
      bool await_suspend(std::experimental::coroutine_handle<> resume_cb) {
         int64_t relative_count = -duration.count();
         timer = CreateThreadpoolTimer(TimerCallback, resume_cb.address(), nullptr);
         SetThreadpoolTimer(timer, (PFILETIME)&relative_count, 0, 0);
         return timer != 0;
      }
      void await_resume() {
      }
      ~awaiter() {
         if (timer) CloseThreadpoolTimer(timer);
      }
   };
   return awaiter{ duration };
}

using namespace std;
using namespace std::chrono;

future<void> test() {
   cout << this_thread::get_id() << ": dodo...\n";
   await 1ms;
   cout << this_thread::get_id() << ": reveil\n";
}

int main() {
   test().get();
   cout << this_thread::get_id() << ": de retour dans main()\n";
}

À remarquer que l'on parle de code non-portable et associé à la plateforme Microsoft Windows, mais que cela n'importe que peu pour l'instant car le mécanisme est en évolution et n'est pas pleinement standardisé. Quelques détails :

Ce qui est charmant, c'est l'affichage produit par l'exécution de ce programme. Cet affichage variera, bien sûr, mais pourrait être :

10248: dodo...
11080: reveil
10248: de retour dans main()

Vous constaterez que l'appel de test() et main() sont sur le même thread, mais que le réveil de test() se fait sur un thread distinct de celui qui l'a lancé. Cette caractéristique fait en sorte qu'il soit possible de lancer beaucoup plus de coroutines, en pratique, que le nombre de threads possibles sur un système d'exploitation donné ne pourrait le suggérer.

Exécution parallèle

Les coroutines facilitent la rédaction de code réalisant des tâches parallèles. Par exemple, ci-dessous, l'exemple à gauche s'exécutera en environ cinq minutes (somme des temps) alors que l'exemple de droite s'exécutera en environ trois minutes (maximum des temps) :

Exécution séquentielleExécution parallèle
#include <iostream>
#include <thread>
#include <chrono>
using namespace std;
using namespace std::chrono;
int f(int n) {
   this_thread::sleep_for(2s);
   return n + 1;
}
int g(int n) {
   this_thread::sleep_for(3s);
   return n + 2;
}
int test() {
   return f(1) + g(1);
}
int main() {
   cout << test() << endl;
}
#include <iostream>
#include <experimental/coroutine>
#include <future>
#include <thread>
#include <chrono>
using namespace std;
using namespace std::chrono;
using namespace experimental;
future<int> f(int n) {
   co_await async([] { this_thread::sleep_for(2s); });
   co_return n + 1;
}
future<int> g(int n) {
   co_await async([] { this_thread::sleep_for(3s); });
   co_return n + 2;
}
future<int> test() {
   auto f_ = f(1);
   auto g_ = g(1);
   co_return co_await f_ + co_await g_;
}
int main() {
   cout << test().get() << endl;
}

Il est important d'obtenir les futures d'abord (auto f_ = f(1); par exemple) puis de faire un co_await par la suite; se limiter à écrire test() comme suit :

future<int> test() {
   co_return co_await f(1) + co_await g(1); // <-- non, ne faites pas ça
}

... mènera à un comportement à l'exécution analogue à celui d'appels séquentiels.

Petit Map/ Reduce

Un exemple simple de Map/ Reduce par coroutines serait le suivant :

#include <experimental/coroutine>
#include <future>
#include <iostream>
#include <algorithm>
#include <numeric>
using namespace std;
using namespace std::chrono;
using namespace experimental;
template <class T>
   using val_t = typename iterator_traits<T>::value_type;
template <class It>
   future<val_t<It>> test(It debut, It fin) {
      auto n = distance(debut, fin);
      if (n <= 25) {
         co_return accumulate(debut, fin, val_t<It>{});
      }
      auto somme_a = test(debut, next(debut, n / 2));
      auto somme_b = test(next(debut, n / 2), fin);
      co_return co_await somme_a + co_await somme_b; // mieux que co_return somme_a.get() + somme_b.get();
   }
int main() {
   enum { N = 100 };
   int vals[N];
   iota(begin(vals), end(vals), 1);
   auto somme = test(begin(vals), end(vals));
   cout << somme.get() << endl;
}

Difficile de faire plus charmant.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !