Dans le développement d'applications haute performance en C++, le choix du modèle de concurrence est crucial. Le multi-threading traditionnel est idéal pour les calculs intensifs (CPU-bound) mais sature rapidement face à un grand nombre de tâches à cause du coût de création et de commutation des threads système. À l'inverse, les coroutines excellent dans la gestion de tâches concurrentes massives subissant des attentes ou des blocages (I/O-bound), en minimisant le surcoût lié aux changements de contexte.
Cet article analyse comment combiner ces deux approches pour optimiser des tâches bloquantes, en s'appuyant sur la bibliothèque state-threads (ST). Cette dernière implémente des coroutines avec pile (stackful) via les instructions longjmp et epoll.
Utilisation fondamentale de state-threads
L'exemple suivant illustre la création de threads légers gérés en espace utilisateur :
#include <cstdio>
#include <cstdlib>
#include "st.h"
void* execute_task(void* param) {
long delay = (long)param;
while (true) {
printf("Tâche en cours (ID: %ld) - Attente...\n", delay);
st_usleep(delay * 1000); // Cède le CPU pendant 'delay' ms
}
return nullptr;
}
int main(int argc, char** argv) {
if (st_init() < 0) {
perror("Échec st_init");
return 1;
}
int total_tasks = (argc > 1) ? atoi(argv[1]) : 10;
for (long i = 1; i <= total_tasks; i++) {
if (st_thread_create(execute_task, (void*)i, 0, 0) == nullptr) {
fprintf(stderr, "Erreur création coroutine %ld\n", i);
}
}
st_thread_exit(nullptr);
return 0;
}
Comparaison : Multi-threading vs Modèle Hybride
Considérons un calcul de multiplication de matricse segmenté. Pour simuler une opération bloquante (comme un accès disque ou réseau), nous introduisons un délai artificiel de 1 microseconde par itération.
Modèle 1 : Multi-threading Standard
Ici, nous divisons la matrice entre plusieurs threads std::thread. Chaque thread subit un blocage via std::this_thread::sleep_for, ce qui force l'ordonnanceur du système d'exploitation à intervenir.
#include <iostream>
#include <vector>
#include <thread>
#include <chrono>
void process_matrix_block(const std::vector<std::vector<int>>& m1,
const std::vector<std::vector<int>>& m2,
std::vector<std::vector<int>>& res,
int row_start, int row_end) {
int cols = m2[0].size();
int inner = m1[0].size();
for (int i = row_start; i < row_end; ++i) {
for (int j = 0; j < cols; ++j) {
res[i][j] = 0;
for (int k = 0; k < inner; ++k) {
res[i][j] += m1[i][k] * m2[k][j];
}
// Simulation d'un blocage système
std::this_thread::sleep_for(std::chrono::microseconds(1));
}
}
}
// Temps d'exécution observé pour N=1000 avec 4 threads : ~14.9s
Modèle 2 : Hybride Multi-threading + Coroutines
Dans cette configuration, nous créons un nombre restreint de threads système (correspondant souvent au nombre de cœurs CPU). À l'intérieur de chaque thread, nous déployons plusieurs coroutines qui gèrent les segments de calcul. Lorsqu'une coroutine est bloquée, elle passe la main à une autre coroutine du même thread sans solliciter le noyau.
#include <pthread.h>
#include "st.h"
struct WorkerConfig {
const std::vector<std::vector<int>> *matA, *matB;
std::vector<std::vector<int>> *matC;
int r_start, r_end;
volatile int* pending_jobs;
};
void* coroutine_compute(void* arg) {
WorkerConfig* cfg = static_cast<WorkerConfig*>(arg);
int p = cfg->matB->at(0).size();
int n = cfg->matA->at(0).size();
for (int i = cfg->r_start; i < cfg->r_end; ++i) {
for (int j = 0; j < p; ++j) {
int sum = 0;
for (int k = 0; k < n; ++k) {
sum += (*cfg->matA)[i][k] * (*cfg->matB)[k][j];
}
(*cfg->matC)[i][j] = sum;
st_usleep(1); // Rendement coopératif (1µs)
}
}
__sync_fetch_and_sub(cfg->pending_jobs, 1);
delete cfg;
return nullptr;
}
void* thread_launcher(void* arg) {
// Initialisation locale du runtime ST par thread
if (st_init() < 0) return nullptr;
// ... Logique de distribution des lignes aux coroutines ...
// Utilisation de st_thread_create() pour lancer les coroutines
// Attente via st_thread_yield() ou boucles sur pending_jobs
return nullptr;
}
// Temps d'exécution observé pour N=1000 avec 4 threads et 10 coroutines/thread : ~3.3s
Analyse des gains de performance
L'écart de performance (de 14.9s à 3.3s) s'explique par la nature du changement de contexte :
- Threads système : Chaque blocage nécessite un saut dans le mode noyau (kernel mode), la sauvegarde des registres processeur et une décision de l'ordonnanceur OS, ce qui est coûteux.
- Coroutines ST : La commutation se fait entièrement en espace utilisateur.
st_usleeputilise un mécanisme d'I/O multiplexé (epoll) pour réveiller les coroutines prêtes, évitant ainsi la lourdeur des interruptions matérielles.
Architecture des bibliothèques de coroutines
Le choix d'une bibliothèque de coroutines dépend de plusieurs critères architecturaux :
- Stackful vs Stackless : Les corotuines stackful (comme
state-threadsouboost.fiber) possèdent leur propre pile d'exécution. Elles peuvent être suspendues depuis n'importe quel niveau de profondeur d'appel de fonction. Les coroutines stackless (comme celles de C++20) sont plus économes en mémoire mais ne peuvent être suspendues qu'au niveau de la fonction de premier niveau. - Symétrie : Dans un modèle asymétrique, une coroutine rend le contrôle spécifiquement à son appelant. Dans un modèle symétrique, les coroutines peuvent transférer le contrôle à n'importe quelle autre coroutine de même niveau.
- Implémentation du contexte :
ucontext: Standard mais lent car il implique souvent des appels système pour manipuler les masques de signaux.- Assembleur : Les bibliothèques performantes (ST, Cyber RT) écrivent manuellement la sauvegarde des registres en asssembleur pour garantir une commutation ultra-rapide sans passer par le noyau.
En conclusion, l'intégration de coroutines au sein d'un pool de threads système permet de saturer efficacement les ressources CPU tout en gérant gracieusement les phases d'attente inhérentes aux applications complexes.