<Server> Understanding Game Servers(C++)

Monday, Apr 14, 2025 | 5 minute read | Updated at Monday, Apr 14, 2025

Jun Yeop(Johnny) Na

Web Server vs Game Server

Web Server:

  • small # of requests per user
  • don’t need real-time interaction
  • Server doesn’t approach client first
  • Stateless - forgets user after request is done
  • Usually uses web frameworks
  • ex) drivethrough

Game Server:

  • a lot of request/response
  • real-time interaction
  • stateful
  • cannot use web framework (different needs for each game)
  • ex) restaurant - constant service to the user

C++ Thread

#include <thread>

#include <thread>

int main()
{
	std::thread t;

	// CPU core number
	int t.hardware_concurrency();

	// thread id
	auto id = t.get_id();

	// detach the actual thread from std::thread, make it run independently as background thread
	t.detach();

	// check if the thread is valid and can join
	if (t.joinable()) {
		t.join();
	}
}

Atomic Type

all-or-nothing

  • lock behavior for simple types

#include <atomic>

atomic<int> sum = 0;
  • stl containers are probably not multi-thread safe.

Mutex

Basic C++ lock

#include <vector>
#include <atomic>
#include <mutex>

mutex m;
vector<int32> v;

void Push()
{
    for (int32 i = 0; i < 10'000; i++) {
        m.lock();
        v.push_back(i);

        m.unlock();
    }
}

int main()
{
    auto t1 = std::thread(Push);
    auto t2 = std::thread(Push);

    t1.join();
    t2.join();
    cout << v.size() << endl;
}

if there’s unusual jump before m.unlock(), deadlock happens

  • we can’t be checking every branch and unlocking; too error-prone.

Types of Lock

  • Spin Lock(Infinite wait)
  • Sleep
  • Event based lock

Lock Guard - RAII Pattern


// Wrapper class to automate lock acquisition/release
template<typename T>
class LockGuard
{
public:
	LockGuard(T& m)
	{
		_mutex = m;
		_mutex->lock();
	}

	~LockGuard()
	{
		_mutex->unlock();
	}

private:
	T* _mutex;
};


#include <vector>
#include <atomic>
#include <mutex>

mutex m;
vector<int32> v;

void Push()
{
    for (int32 i = 0; i < 10'000; i++) {
		// acquires lock on initialization
		auto lock_guard = LockGuard(m);

		// std C++ version. Same function as above.
		std::lock_guard<std::mutex> lock_guard(m);

		// lock_guard + doesn't lock instantly
		std::unique_lock<std::mutex> lock_guard_unique(m, std::defer_lock);
		lock_guard_unique.lock();


        v.push_back(i);

		// lock_guard is destroyed, automatically releasing lock(even if there's goto)
    }
}

SpinLock

  • voilatile: don’t do compile optimization.
// in release, runs int a = 4 directly!
int a = 0;
a = 1;
a = 2;
a = 3;
a = 4;

// doesn't cut optimizations and run every line
volatile int a = 0;
a = 1;
a = 2;
a = 3;
a = 4;

Needs to be used for lock

class SpinLock {
public:
    void lock()
    {
        // Compare and swap
        bool expected = false;
        bool desired = true;

        while (_locked.compare_exchange_strong(expected, desired) == false) {
            expected = false;
        }
    }

    void unlock()
    {
        _locked.store(false);
    }

private:
    atomic<bool> _locked = false;
};

SleepLock

class SleepLock {
public:
    void lock()
    {
        // Compare and swap
        bool expected = false;
        bool desired = true;

        while (_locked.compare_exchange_strong(expected, desired) == false) {
            expected = false;

			this_thread::sleep_for(0ms);
        }
    }

    void unlock()
    {
        _locked.store(false);
    }

private:
    atomic<bool> _locked = false;
};

EventLock

Sleep until event occurs. Use window/linux system call

// GameServer.cpp : This file contains the 'main' function. Program execution begins and ends there.
//

#include "pch.h"
#include "AccountManager.h"
#include "UserManager.h"

#include <iostream>
#include <thread>
#include <atomic>
#include <mutex>
#include <queue>
#include <windows.h>

mutex m;
queue<int32> q;
HANDLE handle;

void Producer()
{
    while (true)
    {
        unique_lock<mutex> lock(m);
        q.push(100);
    }

    // turns signal on
    ::SetEvent(handle);

    this_thread::sleep_for(100ms);
}

void Consumer()
{
    while (true)
    {
        ::WaitForSingleObject(handle, INFINITE);

        unique_lock<mutex> lock(m);

        if (q.empty() == false)
        {
            int32 data = q.front();
            q.pop();
            cout << data << endl;
        }
    }

}

int main()
{
    // Kernel Object
    // Usage Count, Signal on/off(bool), Auto/Manual(bool)
    handle = ::CreateEvent(NULL, FALSE, FALSE, NULL);
    std::thread t1(Producer);
    std::thread t2(Consumer);

    t1.join();
    t2.join();

    ::CloseHandle(handle);
}

Condition Variable

The event above doesn’t actually give 1 to 1 mapping of Producer and Consumer:

  • Another Producer thread can be called right after SetEvent before any Consumer is called

To guarantee an ordered approach, we need to use condition variable

  • wakes up waiting thread on condition
  • acquires lock if condition is met
// GameServer.cpp : This file contains the 'main' function. Program execution begins and ends there.
//

#include "pch.h"
#include "AccountManager.h"
#include "UserManager.h"

#include <iostream>
#include <thread>
#include <atomic>
#include <mutex>
#include <queue>
#include <windows.h>

mutex m;
queue<int32> q;

condition_variable cv;

void Producer()
{
    while (true)
    {
        {
			unique_lock<mutex> lock(m);
			q.push(100);
        }
    }

    // wake only one waiting thread
    cv.notify_one();

    this_thread::sleep_for(100ms);
}

void Consumer()
{
    while (true)
    {
        unique_lock<mutex> lock(m);

        // why check empty()
        // Spurious Wakeup
        // We don't have the lock when we're waiting, so another consumer could use the queue simultaneously with notify_one.
        cv.wait(lock, []() { return q.empty() == false; });

        if (q.empty() == false)
        {
            int32 data = q.front();
            q.pop();
            cout << data << endl;
        }
    }

}

int main()
{
    // Kernel Object
    // Usage Count, Signal on/off(bool), Auto/Manual(bool)
    std::thread t1(Producer);
    std::thread t2(Consumer);

    t1.join();
    t2.join();

}

One-time only tasks

We can use future/promise/packaged_task

  • future async: simplest
  • promise: more control over start timing, set_exception
  • packaged_task: used for managing big tasks that require multiple thread or thread pool.
// GameServer.cpp : This file contains the 'main' function. Program execution begins and ends there.
//

#include "pch.h"
#include "AccountManager.h"
#include "UserManager.h"

#include <iostream>
#include <future>


int64 Calculate()
{
	int64 sum = 0;

	for (int32 i = 0; i < 100'00; i++)
		sum += i;

	return sum;
}

void PromiseWorker(std::promise<string>&& promise)
{
	promise.set_value("Secret message");
}


void TaskWorker(std::packaged_task < int64(void)> && task)
{
	task();
}

int main()
{
	// std::future: midpoint btw thread and synchronous
	{
		// 1) deferred -> lazy evaluation in current thread
		// 2) async -> create separate thread and run
		// 3) deferred | async: choose any of the two

		std::future<int64> future = std::async(std::launch::async, Calculate);

		future.wait_for(0ms);
		int64 result = future.get();
	}

	// std::promise
	// more control over exactly when to start thread, can deliver exceptions
	{
		std::promise<string> promise;
		std::future<string> future = promise.get_future();

		thread t(PromiseWorker, std::move(promise));

		string message = future.get();
		cout << message << endl;

		t.join();
	}

	// std::packaged_task
	{
		std::packaged_task<int64(void)> task(Calculate);
		std::future<int64> future = task.get_future();

		std::thread t(TaskWorker, std::move(task));
		int64 sum = future.get();
		cout << sum << endl;

		t.join();
	}
}

© 2024 - 2025 Junyeop Na Dev

🌱 Powered by Hugo with theme Dream.