Si vous écrivez un serveur de jeu qui gère des centaines, voire des milliers de joueurs en même temps, vous aurez tôt ou tard besoin d'une architecture de serveur multithread C++ (c++ multithread server). Un serveur qui tourne sur un seul thread n'utilise qu'un cœur d'un processeur multicœur moderne ; les autres cœurs restent inactifs pendant que votre serveur s'étrangle. Dans cet article, je vous explique pas à pas comment construire un serveur scalable, stable et sûr en C++ à l'aide d'un pool de threads, d'une boucle d'événements et des bons patterns de concurrence.
Pourquoi un serveur multithread ?
Un serveur fait fondamentalement deux choses : lire et écrire des données sur le réseau (I/O) et traiter la logique de jeu (CPU). Ces deux charges s'écoulent généralement à des vitesses différentes. Les I/O réseau attendent la plupart du temps — qu'un paquet arrive, qu'une socket devienne accessible en écriture. La logique de jeu, elle, occupe le CPU. Si vous les exécutez séquentiellement sur un seul thread, la connexion lente d'un joueur ralentit tout le serveur.
L'objectif d'une conception multithread est de séparer ces charges :
- Threads d'I/O : écoutent les sockets, lisent les octets entrants, écrivent les octets sortants.
- Threads de travail (workers) : traitent les tâches en file (événements de jeu, requêtes base de données).
- Thread d'ordonnancement/tick : une boucle qui met à jour le monde du jeu à intervalles fixes.
Grâce à cette séparation, un VPS à 8 cœurs se comporte réellement comme 8 cœurs.
La boucle d'événements : I/O scalable avec epoll
Ouvrir un thread par connexion (thread-per-connection) paraît simple mais s'effondre avec des milliers de joueurs : chaque thread consomme de la mémoire de pile et les changements de contexte sont coûteux. À la place, sous Linux on construit un modèle d'I/O orienté événements avec epoll (ou kqueue sous BSD/macOS). Un seul thread peut surveiller des dizaines de milliers de sockets sans bloquer (non-bloquant).
int ep = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN | EPOLLET; // edge-triggered
ev.data.fd = client_fd;
epoll_ctl(ep, EPOLL_CTL_ADD, client_fd, &ev);
std::vector<epoll_event> events(1024);
while (running) {
int n = epoll_wait(ep, events.data(), events.size(), 50);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (events[i].events & EPOLLIN)
read_until_eagain(fd); // lire la socket jusqu'à EAGAIN
}
}
En mode edge-triggered (EPOLLET), pensez à lire la socket jusqu'à ce qu'elle renvoie EAGAIN ; sinon, si vous ratez une notification, cette connexion se bloque. Si vous voulez plusieurs threads d'I/O, donnez à chacun sa propre instance epoll et répartissez les connexions entre eux — une approche de sharding sans verrou.
Le pool de threads : répartir le travail vers les workers
Quand un thread d'I/O lit un paquet sur le réseau, il n'y effectue pas le gros du travail ; il pousse une tâche dans une file et retourne immédiatement lire. Les threads de travail tirent les tâches de cette file. Un pool de threads de taille fixe évite les problèmes de mémoire et d'ordonnancement liés à la création illimitée de threads.
class ThreadPool {
public:
explicit ThreadPool(size_t n) {
for (size_t i = 0; i < n; ++i)
workers_.emplace_back([this]{ run(); });
}
void submit(std::function<void()> task) {
{
std::lock_guard<std::mutex> lock(m_);
queue_.push(std::move(task));
}
cv_.notify_one();
}
~ThreadPool() {
{ std::lock_guard<std::mutex> lock(m_); stop_ = true; }
cv_.notify_all();
for (auto& t : workers_) t.join();
}
private:
void run() {
for (;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [this]{ return stop_ || !queue_.empty(); });
if (stop_ && queue_.empty()) return;
task = std::move(queue_.front());
queue_.pop();
}
task();
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> queue_;
std::mutex m_;
std::condition_variable cv_;
bool stop_ = false;
};
Les trois points critiques ici : protéger la file avec un mutex, endormir et réveiller les workers avec une condition_variable (pour éviter l'attente active — busy-wait), et dans le destructeur faire proprement le join de chaque thread. Autant de workers que de cœurs est un bon départ ; vous pouvez l'obtenir avec std::thread::hardware_concurrency().
Gérer l'état partagé en toute sécurité
La grande majorité des bogues en code multithread vient des data races : deux threads accèdent à la même mémoire sans synchronisation, dont au moins un en écriture. Le résultat est un comportement indéfini — parfois ça marche, parfois ça plante. Quelques principes solides :
- Minimisez le partage. Si une donnée appartient à un seul thread, aucun verrou n'est nécessaire. Préférez le passage de messages (files) autant que possible.
- Pour des champs simples comme des compteurs, utilisez
std::atomic<int>; c'est moins coûteux qu'unmutex. - Si vous prenez plusieurs verrous, prenez-les toujours dans le même ordre ; sinon, c'est l'interblocage (deadlock).
- Ne gardez un verrou que pour la section critique ; n'y faites jamais d'I/O réseau ou disque.
Pour une structure partagée comme une liste de joueurs, surtout lue et rarement modifiée, un std::shared_mutex (verrou lecteurs-écrivain) parallélise le côté lecture.
La boucle de tick et la logique de jeu sérialisée
L'état du jeu (positions des personnages, combat, IA des PNJ) doit rester cohérent. Le pattern le plus sûr consiste à mettre à jour le monde du jeu sur un seul thread, à une fréquence de tick fixe (par exemple 20 fois par seconde). Les threads d'I/O poussent les commandes entrantes dans une file verrouillée ; à chaque itération, le thread de tick vide et traite la file. Ainsi la logique de jeu n'a presque pas besoin de verrous et reste facile à raisonner.
using namespace std::chrono;
auto tick = milliseconds(50); // 20 ticks/s
auto next = steady_clock::now();
while (running) {
process_incoming_commands();
world.update(0.05f); // dt = 50 ms
broadcast_state();
next += tick;
std::this_thread::sleep_until(next);
}
Utiliser sleep_until pour un rythme régulier évite la dérive (drift) que sleep_for accumule. Ce modèle hybride — I/O orienté événements + pool de threads + tick mono-thread — est une approche éprouvée dans les serveurs MMO comme Metin2 et dans de nombreux jeux en temps réel.
Tests, mesures et déploiement
Les bogues de concurrence sont sournois ; ne laissez pas les tests au hasard. À la compilation et à l'exécution, les outils suivants sont précieux :
- ThreadSanitizer (
-fsanitize=thread) : détecte les data races à l'exécution. - AddressSanitizer (
-fsanitize=address) : trouve les corruptions mémoire et les use-after-free. perfethtop: observez la charge par cœur et les goulots d'étranglement.
En production, lancez le serveur comme un service systemd pour qu'il redémarre après un crash ; gardez des journaux structurés. Ne faites pas confiance aux promesses de scalabilité avant d'avoir mené un test de charge simulant un vrai trafic de joueurs (par exemple des milliers de connexions concurrentes avec de faux clients).
Questions fréquentes
Combien de threads de travail faut-il créer ?
Un bon point de départ est le nombre de cœurs (hardware_concurrency()). Pour un travail lié au CPU, dépasser ce nombre n'apporte rien ; le coût des changements de contexte augmente. Pour des tâches gourmandes en I/O, quelques threads de plus peuvent combler les temps d'attente, mais la bonne réponse se mesure par un test de charge.
Les coroutines (C++20) remplacent-elles le pool de threads ?
Non, elles le complètent. Les coroutines C++20 permettent d'exprimer élégamment de nombreuses opérations d'I/O concurrentes sur un seul thread (du code linéaire au lieu de callbacks) ; mais il faut toujours un pool de threads ou un ordonnanceur pour répartir le travail sur les cœurs. Combiner les deux est une combinaison puissante.
Faut-il utiliser une bibliothèque réseau prête à l'emploi ou tout écrire soi-même ?
Pour la plupart des projets en production, une bibliothèque mature comme Boost.Asio gère pour vous les détails d'epoll/kqueue, les timers et la portabilité. Tout écrire de zéro est utile pour apprendre et tout contrôler, mais la surface de bogues est large.
Besoin d'un serveur de jeu scalable ? Je travaille sur l'architecture de serveur multithread en C++ pour Metin2 et les serveurs de jeu personnalisés. Contactez-moi pour parler de votre projet.