Als je een gameserver schrijft die honderden of zelfs duizenden spelers tegelijk bedient, heb je vroeg of laat een c++ multithread server-architectuur nodig. Een server die op één thread draait, gebruikt slechts één kern van een moderne multicore-processor; de overige kernen staan stil terwijl je server vastloopt. In dit artikel leg ik stap voor stap uit hoe je in C++ een schaalbare, stabiele en veilige server bouwt met een thread pool, een event loop en de juiste concurrency-patronen.
Waarom een multithreaded server?
Een server doet in de kern twee dingen: data lezen en schrijven over het netwerk (I/O) en gamelogica verwerken (CPU). Deze twee werklasten stromen meestal op verschillende snelheden. Netwerk-I/O wacht meestal — tot een pakket aankomt, tot een socket beschrijfbaar wordt. Gamelogica houdt de CPU bezig. Als je ze sequentieel op één thread draait, vertraagt de trage verbinding van één speler de hele server.
Het doel van een multithreaded ontwerp is deze werklasten te scheiden:
- I/O-threads: luisteren op sockets, lezen inkomende bytes, schrijven uitgaande bytes.
- Worker-threads: verwerken taken uit de wachtrij (game-events, databasequery's).
- Scheduler-/tick-thread: een lus die de gamewereld op vaste intervallen bijwerkt.
Dankzij deze scheiding gedraagt een VPS met 8 kernen zich echt als 8 kernen.
De event loop: schaalbare I/O met epoll
Een aparte thread per verbinding openen (thread-per-connection) lijkt eenvoudig, maar stort in bij duizenden spelers: elke thread verbruikt stackgeheugen en context switching is duur. In plaats daarvan bouwen we op Linux een event-gebaseerd I/O-model met epoll (of kqueue op BSD/macOS). Eén thread kan tienduizenden sockets in de gaten houden zonder te blokkeren (non-blocking).
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); // lees de socket tot EAGAIN
}
}
In edge-triggered (EPOLLET) modus moet je de socket lezen tot deze EAGAIN teruggeeft; anders blijft die verbinding hangen als je een melding mist. Wil je meerdere I/O-threads, geef elke thread dan een eigen epoll-instantie en verdeel de verbindingen ertussen — een lock-vrije sharding-aanpak.
De thread pool: werk verdelen over workers
Wanneer een I/O-thread een pakket van het netwerk leest, doet hij daar niet het zware werk; hij zet een taak in een wachtrij en gaat meteen verder met lezen. Worker-threads halen taken uit deze wachtrij. Een thread pool met vaste grootte voorkomt de geheugen- en planningsproblemen van het ongelimiteerd aanmaken van 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;
};
De drie kritieke punten hier: bescherm de wachtrij met een mutex, laat workers slapen en wek ze met een condition_variable (om busy-waiting te vermijden), en doe in de destructor netjes join op elke thread. Evenveel workers als kernen is een goed begin; je kunt dit opvragen met std::thread::hardware_concurrency().
Gedeelde toestand veilig beheren
De overgrote meerderheid van bugs in multithreaded code komt voort uit data races: twee threads benaderen hetzelfde geheugen zonder synchronisatie, waarbij minstens één schrijft. Het resultaat is undefined behavior — soms werkt het, soms crasht het. Een paar solide principes:
- Minimaliseer delen. Hoort data bij één thread, dan is geen lock nodig. Geef de voorkeur aan message passing (wachtrijen) waar je kunt.
- Voor eenvoudige velden zoals tellers gebruik je
std::atomic<int>; dat is goedkoper dan eenmutex. - Neem je meerdere locks, neem ze dan altijd in dezelfde volgorde; anders krijg je een deadlock.
- Houd een lock alleen vast voor de kritieke sectie; doe er nooit netwerk- of schijf-I/O in.
Voor een gedeelde structuur zoals een spelerslijst die vooral gelezen en zelden geschreven wordt, parallelliseert een std::shared_mutex (reader-writer-lock) de leeskant.
De tick-lus en geserialiseerde gamelogica
De gamestaat (personagesposities, gevecht, NPC-AI) moet consistent blijven. Het veiligste patroon hiervoor is de gamewereld bijwerken op één enkele thread, op een vaste tick rate (bijvoorbeeld 20 keer per seconde). I/O-threads zetten inkomende commando's in een vergrendelde wachtrij; bij elke iteratie leegt en verwerkt de tick-thread de wachtrij. Zo heeft de gamelogica vrijwel geen locks nodig en blijft ze makkelijk te doorgronden.
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);
}
sleep_until voor een stabiel ritme vermijdt de drift die sleep_for opbouwt. Dit hybride model — event-gebaseerde I/O + thread pool + single-threaded tick — is een bewezen aanpak in MMO-servers zoals Metin2 en in veel realtime games.
Testen, meten en uitrollen
Concurrency-bugs zijn sluw; laat testen niet aan het toeval over. Bij compileren en uitvoeren zijn de volgende tools onmisbaar:
- ThreadSanitizer (
-fsanitize=thread): vangt data races tijdens runtime. - AddressSanitizer (
-fsanitize=address): vindt geheugencorruptie en use-after-free-bugs. perfenhtop: observeer de belasting per kern en knelpunten.
Draai de server in productie als een systemd-service zodat hij herstart na een crash; houd logs gestructureerd. Vertrouw schaalbaarheidsclaims pas wanneer je een loadtest hebt gedaan die echt spelersverkeer nabootst (bijvoorbeeld duizenden gelijktijdige verbindingen met nepclients).
Veelgestelde vragen
Hoeveel worker-threads moet ik aanmaken?
Een goed startpunt is het aantal kernen (hardware_concurrency()). Voor CPU-gebonden werk levert meer dan dat niets op; de kosten van context switching stijgen. Voor I/O-zware taken kunnen een paar extra de wachttijden opvullen, maar het juiste antwoord meet je met een loadtest.
Vervangen coroutines (C++20) de thread pool?
Nee, ze vullen elkaar aan. C++20-coroutines laten je veel gelijktijdige I/O-operaties op één thread elegant uitdrukken (lineaire code in plaats van callbacks); maar je hebt nog steeds een thread pool of scheduler nodig om het werk over de kernen te verdelen. Beide samen gebruiken is een krachtige combinatie.
Moet ik een kant-en-klare netwerkbibliotheek gebruiken of alles zelf schrijven?
Voor de meeste productieprojecten regelt een volwassen bibliotheek als Boost.Asio de epoll/kqueue-details, timers en portabiliteit voor je. Alles zelf schrijven is waardevol om te leren en voor volledige controle, maar het oppervlak voor bugs is groot.
Een schaalbare gameserver nodig? Ik werk aan multithreaded server-architectuur in C++ voor Metin2 en custom gameservers. Neem contact op om over je project te praten.