Travailler avec des files d'attente de tâches en C++ : du simple au complexe
Dans le développement moderne en C++, on ne peut guère se passer du traitement asynchrone des données. Les files d'attente de tâches (task queues) sont l'un des outils clés pour organiser l'exécution parallèle du code. Elles permettent de répartir le travail entre les threads, d'éviter les blocages et d'utiliser efficacement les ressources du processeur. Dans cet article, nous allons voir comment implémenter une file d'attente de tâches en C++, examiner le pattern producer-consumer et apprendre à construire un thread pool simple.
1. File d'attente de base basée sur std::queue
La file d'attente de tâches la plus simple est un conteneur dans lequel on peut ajouter des éléments (tâches) et les extraire dans l'ordre FIFO. En C++, std::queue de la bibliothèque standard est parfaitement adaptée à cela. Cependant, lors du travail avec le multithreading, une file d'attente ordinaire n'est pas sûre : un accès simultané depuis différents threads entraîne des data races.
Examinons un exemple de base d'une file d'attente synchronisée utilisant un mutex et une variable conditionnelle :
#include <queue>#include <mutex>#include <condition_variable>#include <functional>
class TaskQueue {public: using Task = std::function<void()>;
void push(Task task) { std::lock_guard<std::mutex> lock(mutex_); queue_.push(std::move(task)); cond_var_.notify_one(); }
Task pop() { std::unique_lock<std::mutex> lock(mutex_); cond_var_.wait(lock, [this]() { return !queue_.empty(); }); Task task = std::move(queue_.front()); queue_.pop(); return task; }
bool empty() const { std::lock_guard<std::mutex> lock(mutex_); return queue_.empty(); }
private: std::queue<Task> queue_; mutable std::mutex mutex_; std::condition_variable cond_var_;};Dans ce code, push() ajoute une tâche et notifie un thread en attente. pop() bloque le thread jusqu'à ce que la file d'attente devienne non vide. Cette approche résout le problème de synchronisation, mais ne passe pas à l'échelle pour de nombreux consommateurs sans modifications.
2. Pattern Producer-Consumer
Le problème classique du producer-consumer (producteur-consommateur) s'adapte parfaitement à la file d'attente de tâches. Un ou plusieurs threads producteurs génèrent des tâches, et les threads consommateurs les exécutent. Il est important de gérer correctement la taille de la file d'attente pour éviter un débordement ou un temps d'inactivité.
Exemple avec plusieurs consommateurs :
#include <thread>#include <vector>#include <iostream>
void worker(TaskQueue& queue, int id) { while (true) { auto task = queue.pop(); if (!task) break; // signal d'arrêt std::cout << "Worker " << id << " executing task"; task(); }}
int main() { TaskQueue queue; std::vector<std::thread> workers;
// Lancement de 4 threads consommateurs for (int i = 0; i < 4; ++i) { workers.emplace_back(worker, std::ref(queue), i); }
// Le producteur ajoute des tâches for (int i = 0; i < 10; ++i) { queue.push([i]() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::cout << "Task " << i << " done"; }); }
// Signal d'arrêt (tâche nullptr) for (auto& w : workers) { queue.push(nullptr); }
for (auto& w : workers) { w.join(); } return 0;}Ici, chaque consommateur reçoit une tâche, l'exécute et attend la suivante. Le signal d'arrêt (nullptr) arrête la boucle. Ce pattern est souvent utilisé dans les applications serveur, les gestionnaires d'événements et les moteurs de jeux.
3. Thread Pool — pool de threads pour une file d'attente de tâches
Un thread pool (pool de threads) est une surcouche de la file d'attente de tâches qui gère