aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Game Server

C++ Multithread Server: A Scalable Architecture Guide

If you are writing a game server that handles hundreds or even thousands of players at once, sooner or later you will need a c++ multithread server architecture. A server running on a single thread uses only one core of a modern multi-core CPU; the remaining cores sit idle while your server chokes. In this article I walk you through how to build a scalable, stable and safe server in C++ using a thread pool, an event loop and the right concurrency patterns.

Why a multithreaded server?

A server fundamentally does two things: read and write data over the network (I/O) and process game logic (CPU). These two workloads typically flow at different speeds. Network I/O mostly waits — for a packet to arrive, for a socket to become writable. Game logic keeps the CPU busy. If you run them sequentially on a single thread, one player's slow connection drags down the whole server.

The goal of a multithreaded design is to separate these workloads:

  • I/O threads: listen on sockets, read incoming bytes, write outgoing bytes.
  • Worker threads: process queued tasks (game events, database queries).
  • Scheduler/tick thread: a loop that updates the game world at fixed intervals.

Thanks to this separation, an 8-core VPS actually behaves like 8 cores.

The event loop: scalable I/O with epoll

Opening a separate thread per connection (thread-per-connection) looks simple but collapses with thousands of players: each thread consumes stack memory and context switching is expensive. Instead, on Linux we build an event-based I/O model with epoll (or kqueue on BSD/macOS). A single thread can watch tens of thousands of sockets without blocking (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);   // read the socket until EAGAIN
    }
}

In edge-triggered (EPOLLET) mode, remember to read the socket until it returns EAGAIN; otherwise if you miss a notification that connection stalls. If you want multiple I/O threads, give each its own epoll instance and shard the connections between them — a lock-free sharding approach.

The thread pool: distributing work to workers

When an I/O thread reads a packet from the network, it does not do the heavy work there; it pushes a task onto a queue and immediately goes back to reading. Worker threads pull tasks from this queue. A fixed-size thread pool avoids the memory and scheduling problems of spawning unbounded 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;
};

The three critical points here: protect the queue with a mutex, put workers to sleep and wake them with a condition_variable (to avoid busy-waiting), and in the destructor cleanly join every thread. As many workers as you have cores is a good start; you can query it with std::thread::hardware_concurrency().

Managing shared state safely

The vast majority of bugs in multithreaded code stem from data races: two threads accessing the same memory without synchronization, with at least one writing. The result is undefined behavior — sometimes it works, sometimes it crashes. A few solid principles:

  • Minimize sharing. If data belongs to a single thread, no lock is needed. Prefer message passing (queues) wherever you can.
  • For simple fields like counters, use std::atomic<int>; it is cheaper than a mutex.
  • If you acquire multiple locks, always acquire them in the same order; otherwise you get a deadlock.
  • Hold a lock only for the critical section; never do network or disk I/O while holding it.

For a shared structure like a player list that is mostly read and occasionally written, a std::shared_mutex (reader-writer lock) parallelizes the read side.

The tick loop and serialized game logic

Game state (character positions, combat, NPC AI) must stay consistent. The safest pattern to manage this is to update the game world on a single thread at a fixed tick rate (for example 20 times per second). I/O threads push incoming commands into a locked queue; on each iteration the tick thread drains and processes the queue. This way the game logic needs almost no locks and stays easy to reason about.

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);
}

Using sleep_until for a steady rhythm avoids the drift that sleep_for accumulates. This hybrid model — event-based I/O + thread pool + single-threaded tick — is a proven approach in MMO servers like Metin2 and in many real-time games.

Testing, measuring and deployment

Concurrency bugs are sneaky; don't leave testing to chance. At compile and run time the following tools are invaluable:

  • ThreadSanitizer (-fsanitize=thread): catches data races at runtime.
  • AddressSanitizer (-fsanitize=address): finds memory corruption and use-after-free bugs.
  • perf and htop: observe per-core load and bottlenecks.

In production, run the server as a systemd service so it restarts on crash; keep logs structured. Don't trust scalability claims until you run a load test that simulates real player traffic (for example thousands of concurrent connections with fake clients).

Frequently Asked Questions

How many worker threads should I spawn?

A good starting point is the number of cores (hardware_concurrency()). For CPU-bound work, exceeding that brings no benefit; context-switch cost rises. For I/O-heavy tasks a few extra can fill wait times, but the right answer is to measure with a load test.

Do C++20 coroutines replace the thread pool?

No, they complement it. C++20 coroutines let you express many concurrent I/O operations on a single thread elegantly (straight-line code instead of callbacks); but you still need a thread pool or scheduler to spread the work across cores. Using both together is a powerful combination.

Should I use a ready-made networking library or write it from scratch?

For most production projects a mature library like Boost.Asio handles the epoll/kqueue details, timers and portability for you. Writing from scratch is valuable for learning and full control, but the surface for bugs is large.

Need a scalable game server? I work on multithreaded server architecture in C++ for Metin2 and custom game servers. Get in touch to talk about your project.

Devamı için