Coroutines

Notez que ce document est un squelette, destiné à être enrichi. 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 ou de CppCon 2016.

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 (il est probable qu'il le soit par voie de spécification technique, donc il y a de l'espoir en vue de C++ 20). 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.

Un exemple naïf d'utilisation de coroutines serait (syntaxe exacte à déterminer; on pourrait utiliser co_yield et co_await par exemple) :

Producteur Consommateur
#include <chrono>
#include <thread>
int tic() {
   using std::this_thread;
   using namespace std::chrono;
   int ntics = 0;
   for(;;) {
      this_thread::sleep_for(1s);
      yield return ntics++;
   }
}
// ...
#include <iostream>
using namespace std;
int main() {
   for(;;) {
      auto n = await tic();
      cout << "Tic #" << n << endl;
   }
}

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.

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 :

#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, 20)) {
      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;
   }
}

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 !