Yüzlerce hatta binlerce oyuncuya aynı anda hizmet veren bir oyun sunucusu yazıyorsanız, er ya da geç bir c++ multithread server mimarisine ihtiyaç duyarsınız. Tek iş parçacığı (thread) üzerinde çalışan bir sunucu, modern çok çekirdekli işlemcilerin yalnızca bir çekirdeğini kullanır; geri kalan çekirdekler boş dururken sunucunuz tıkanır. Bu yazıda, C++ ile ölçeklenir, kararlı ve güvenli bir sunucu mimarisini; thread pool, olay döngüsü ve doğru eşzamanlılık (concurrency) desenleriyle nasıl kuracağınızı adım adım anlatıyorum.
Neden çok iş parçacıklı bir sunucu?
Bir sunucunun işi temelde iki şeydir: ağdan veri okuyup yazmak (I/O) ve oyun mantığını işlemek (CPU). Bu iki yük tipik olarak farklı hızlarda akar. Ağ I/O'su çoğu zaman bekler — bir paketin gelmesini, bir soketin yazılabilir olmasını. Oyun mantığı ise CPU'yu meşgul eder. Tek iş parçacığında bunları sıralı yürütürseniz, bir oyuncunun yavaş bağlantısı tüm sunucuyu yavaşlatır.
Çok iş parçacıklı tasarımın hedefi bu yükleri ayırmaktır:
- I/O iş parçacıkları: soketleri dinler, gelen baytları okur, giden baytları yazar.
- Çalışan (worker) iş parçacıkları: kuyruğa düşen görevleri (oyun olayları, veritabanı sorguları) işler.
- Zamanlayıcı/tick iş parçacığı: sabit aralıklarla oyun dünyasını güncelleyen döngü.
Bu ayrım sayesinde 8 çekirdekli bir VPS, gerçekten 8 çekirdek gibi çalışır.
Olay döngüsü: epoll ile ölçeklenir I/O
Her bağlantı için ayrı bir iş parçacığı açmak (thread-per-connection) basit görünür ama binlerce oyuncuda çöker: her iş parçacığı yığın (stack) belleği tüketir ve bağlam değişimi (context switch) pahalıdır. Bunun yerine Linux'ta epoll, BSD/macOS'ta kqueue kullanarak olay tabanlı bir I/O modeli kurarız. Tek bir iş parçacığı, on binlerce soketi engellemeden (non-blocking) izleyebilir.
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); // soketi EAGAIN olana dek oku
}
}
Edge-triggered (EPOLLET) modda soketi EAGAIN dönene kadar okumayı unutmayın; aksi halde bir uyarı kaçırırsanız o bağlantı kilitlenir. Birden çok I/O iş parçacığı istiyorsanız her birine ayrı bir epoll örneği verip bağlantıları aralarında bölüştürün — bu, kilitsiz bir sharding yaklaşımıdır.
Thread pool: işi çalışanlara dağıtmak
I/O iş parçacığı ağdan bir paket okuduğunda, ağır işi orada yapmaz; bir görevi kuyruğa atar ve hemen okumaya devam eder. Çalışan iş parçacıkları bu kuyruktan görev çeker. Sabit boyutlu bir thread pool, sınırsız iş parçacığı üretmenin yol açtığı bellek ve zamanlama sorunlarını önler.
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;
};
Buradaki üç kritik nokta: kuyruğu bir mutex ile korumak, çalışanları condition_variable ile uyutup uyandırmak (meşgul beklemeden — busy-wait — kaçınmak) ve yıkıcıda (destructor) tüm iş parçacıklarını düzgünce join etmek. Çekirdek sayısı kadar çalışan iyi bir başlangıçtır; std::thread::hardware_concurrency() ile öğrenebilirsiniz.
Paylaşılan durumu güvenle yönetmek
Çok iş parçacıklı kodda hataların büyük çoğunluğu veri yarışından (data race) kaynaklanır: iki iş parçacığı aynı belleğe senkronizasyon olmadan, en az biri yazarak erişir. Sonuç tanımsız davranıştır — bazen çalışır, bazen çöker. Birkaç sağlam ilke:
- Paylaşımı en aza indirin. Veri tek bir iş parçacığına aitse kilit gerekmez. Mümkün olduğunca mesaj geçirme (kuyruk) kullanın.
- Sayaç gibi basit alanlar için
std::atomic<int>kullanın;mutex'ten ucuzdur. - Birden çok kilit alıyorsanız her zaman aynı sırayla alın; aksi halde kilitlenme (deadlock) olur.
- Kilidi yalnızca kritik bölge boyunca tutun; içeride ağ veya disk I/O yapmayın.
Oyuncu listesi gibi paylaşılan bir yapıya çoğunlukla okuma, ara sıra yazma yapıyorsanız std::shared_mutex (okuyucu-yazıcı kilidi) okuma tarafını paralelleştirir.
Tick döngüsü ve sıralı oyun mantığı
Oyun durumu (karakter konumları, savaş, NPC yapay zekâsı) tutarlı kalmalıdır. Bunu en güvenli yöneten desen, oyun dünyasını tek bir iş parçacığında, sabit bir tick hızında (örneğin saniyede 20 kez) güncellemektir. I/O iş parçacıkları gelen komutları kilitli bir kuyruğa yazar; tick iş parçacığı her döngüde kuyruğu boşaltıp işler. Böylece oyun mantığında neredeyse hiç kilit gerekmez ve mantık kolayca akıl yürütülebilir kalır.
using namespace std::chrono;
auto tick = milliseconds(50); // 20 tick/sn
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 ile sabit ritim, sleep_for'a göre kayma (drift) biriktirmez. Bu hibrit model — olay tabanlı I/O + thread pool + tek iş parçacıklı tick — Metin2 gibi MMO sunucularında ve birçok gerçek zamanlı oyunda kanıtlanmış bir yaklaşımdır.
Test, ölçüm ve dağıtım
Eşzamanlılık hataları sinsidir; testi şansa bırakmayın. Derleme ve çalışma zamanında şu araçlar paha biçilmezdir:
- ThreadSanitizer (
-fsanitize=thread): veri yarışlarını çalışırken yakalar. - AddressSanitizer (
-fsanitize=address): bellek bozulmalarını ve kullanım-sonrası-serbest hatalarını bulur. perfvehtop: çekirdek başına yükü ve darboğazları gözlemleyin.
Üretimde sunucuyu bir systemd servisi olarak çalıştırın, çökünce yeniden başlasın; günlükleri yapısal tutun. Gerçek oyuncu trafiğini taklit eden bir yük testi (örneğin sahte istemcilerle eşzamanlı binlerce bağlantı) açmadan, ölçeklenirlik iddialarına güvenmeyin.
Sık Sorulan Sorular
Kaç tane çalışan iş parçacığı açmalıyım?
İyi bir başlangıç noktası çekirdek sayısı kadardır (hardware_concurrency()). CPU-yoğun işler için bu sayıyı aşmak fayda getirmez; bağlam değişimi maliyeti artar. I/O ağırlıklı görevler için biraz fazlası bekleme sürelerini doldurabilir, ama doğrusunu yük testiyle ölçmektir.
Coroutine'ler (C++20) thread pool'un yerini alır mı?
Hayır, tamamlayıcıdır. C++20 coroutine'leri tek iş parçacığında çok sayıda eşzamanlı I/O işini (callback yerine düz akan kod) zarif biçimde ifade etmenizi sağlar; ama işi yine de çekirdeklere dağıtacak bir thread pool veya scheduler gerekir. İkisini birlikte kullanmak güçlü bir kombinasyondur.
Hazır bir ağ kütüphanesi mi kullanmalıyım, sıfırdan mı yazmalıyım?
Çoğu üretim projesi için Boost.Asio gibi olgun bir kütüphane epoll/kqueue ayrıntılarını, zamanlayıcıları ve taşınabilirliği sizin için halleder. Sıfırdan yazmak öğrenmek ve tam denetim için değerlidir; ama hata yüzeyi geniştir.
Ölçeklenir bir oyun sunucusuna mı ihtiyacınız var? C++ ile çok iş parçacıklı sunucu mimarisi, Metin2 ve özel oyun sunucuları üzerine birlikte çalışabiliriz. Projenizi konuşmak için benimle iletişime geçin.