Bir oyun sunucusu, sohbet servisi ya da gerçek zamanlı API yazarken er ya da geç aynı duvara çarparsınız: her bağlantı için bir thread açmak binlerce kullanıcıda çöker. Linux'ta bu sorunun standart çözümü epoll server mimarisidir — tek bir thread'de on binlerce eşzamanlı soketi, sadece gerçekten veri gelen bağlantılarla ilgilenerek yöneten bir olay döngüsü. Bu yazıda epoll'un nasıl çalıştığını, kenar-tetiklemeli (edge-triggered) modu ve C++ ile çalışan bir iskeletin tamamını adım adım göreceksiniz.
Neden select/poll değil de epoll?
Klasik select() ve poll() her çağrıda izlediğiniz tüm dosya tanımlayıcılarını (fd) çekirdeğe yeniden bildirmenizi ister ve dönüşte hepsini tek tek tararsınız. Maliyet bağlantı sayısıyla doğru orantılı (O(n)) büyür; 10.000 bağlantıda her döngü ağır bir tarama olur.
epoll ise ilgilenilen fd kümesini çekirdekte tutar. Siz bir kez kaydedersiniz, sonra yalnızca hazır olan soketlerin listesini alırsınız. Yani işlem maliyeti toplam bağlantıyla değil, o an aktif olan olay sayısıyla orantılıdır. Binlerce uyuyan bağlantı varken bu devasa bir fark yaratır.
epoll_create1()— bir epoll örneği oluşturur, bir fd döndürür.epoll_ctl()— bu örneğe fd ekler, siler veya günceller.epoll_wait()— hazır olayları bloklayarak bekler ve döndürür.
Olay döngüsünün kurulumu
Önce bir dinleyici soket açıp bind/listen yaparsınız, sonra onu epoll örneğine eklersiniz. Dikkat edilmesi gereken kritik nokta: ölçeklenen bir epoll server için bütün soketler bloklamayan (non-blocking) modda olmalıdır.
int make_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int epfd = epoll_create1(0);
struct epoll_event ev{};
ev.events = EPOLLIN; // okumaya hazır olduğunda haber ver
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
ev.data bir union'dır; içine fd koyabilir ya da bağlantıya ait bir nesne işaretçisini (ptr) saklayabilirsiniz. Pratikte her bağlantı için bir bağlam (buffer'lar, durum makinesi) tutan yapıyı ptr ile taşımak çok yaygındır.
Seviye-tetikleme mi, kenar-tetikleme mi?
epoll iki çalışma kipi sunar. Varsayılan seviye-tetikleme (level-triggered): soketde okunacak veri kaldıkça epoll_wait her seferinde haber verir — poll'a benzer, affedicidir. Kenar-tetikleme (EPOLLET) ise yalnızca durum değiştiğinde bir kez haber verir. Daha az sistem çağrısı, daha yüksek performans, ama bir kural getirir:
- Kenar-tetiklemede, bildirimi aldığınızda soketi tamamen boşaltana kadar (yani
read/recvEAGAINya daEWOULDBLOCKdönene kadar) bir döngüde okumalısınız. - Aksi halde geride kalan veriyi bir daha asla haber almazsınız ve bağlantı "asılı" kalır.
Bağlantıları kabul etmek ve okumak
Döngünün kalbi epoll_wait'tir. Dönen her olay için: olay dinleyici soketten geliyorsa yeni bağlantıları accept ederiz; değilse veriyi okuruz. Kenar-tetiklemeli modda accept de bir döngü içinde, EAGAIN görene kadar tekrarlanmalıdır çünkü tek bildirimde birden çok bağlantı birikmiş olabilir.
struct epoll_event events[MAX_EVENTS];
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// Birikmiş tüm yeni bağlantıları boşalt
while (true) {
int conn = accept(listen_fd, nullptr, nullptr);
if (conn == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
break; // başka hata
}
make_nonblocking(conn);
struct epoll_event cev{};
cev.events = EPOLLIN | EPOLLET; // kenar-tetikleme
cev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev);
}
} else if (events[i].events & (EPOLLERR | EPOLLHUP)) {
close(fd); // epoll fd kapanınca otomatik düşer
} else if (events[i].events & EPOLLIN) {
handle_read(epfd, fd);
}
}
}
Okuma fonksiyonunda da aynı disiplin geçerlidir: recv 0 dönerse karşı taraf bağlantıyı kapatmıştır; EAGAIN dönerse o turdaki tüm veri tükenmiştir.
void handle_read(int epfd, int fd) {
char buf[4096];
while (true) {
ssize_t r = recv(fd, buf, sizeof(buf), 0);
if (r > 0) {
// buf içindeki r bayt işlenir / tampona eklenir
} else if (r == 0) {
close(fd); // istemci kapattı
return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
if (errno == EINTR) continue;
close(fd); // gerçek hata
return;
}
}
}
Yazma, geri-basınç ve EPOLLOUT
Okuma kolay kısım. Asıl incelik yazmada: send bloklamayan bir soketde her zaman tüm veriyi yutmaz, EAGAIN dönüp "tampon dolu, sonra dene" diyebilir. Bu durumda kalan baytları bağlantı başına bir çıkış kuyruğunda saklar ve o fd'yi EPOLLOUT ile yeniden kaydedersiniz. Soket tekrar yazılabilir olunca epoll_wait sizi uyarır, kuyruğu boşaltırsınız ve kuyruk bitince EPOLLOUT'u geri kapatırsınız. Bu mekanizma yavaş istemcilerin sunucunuzun belleğini şişirmesini engelleyen geri-basınç (backpressure) kontrolüdür.
- Her zaman
EPOLLOUTdinlemeyin — sürekli tetiklenir ve CPU'yu boşa yakar. Yalnızca yazılacak veri kuyrukta beklerken açın. - Bağlantı başına okuma ve yazma tamponlarını ayrı tutun; durumu bir state machine ile yönetin.
Üretime götürürken
İskelet çalışıyor; ama gerçek bir epoll server için birkaç ek adım gerekir. Çok çekirdekli makinelerde SO_REUSEPORT ile her CPU çekirdeğinde ayrı bir kabul eden döngü çalıştırıp yükü çekirdeğe dağıtabilirsiniz. signal ile SIGPIPE'i yok sayın (ya da send'e MSG_NOSIGNAL verin), yoksa kapanan bir soketе yazınca süreç ölür. Dosya tanımlayıcı limitini (ulimit -n) ve listen backlog'unu yükseltin. Son olarak zaman aşımı yönetimi için timerfd'yi de epoll'a ekleyebilir, böylece her şeyi tek döngüde toplayabilirsiniz.
Sık Sorulan Sorular
epoll yerine io_uring kullanmalı mıyım?
io_uring daha yeni ve disk G/Ç'sinde özellikle güçlü bir arayüzdür, ağ için de olgunlaşmaktadır. Ancak epoll hâlâ en yaygın, en taşınabilir ve en iyi belgelenmiş çözümdür. Çoğu ağ sunucusu için epoll fazlasıyla yeterli; io_uring'e ancak ölçtüğünüz somut bir darboğaz varsa geçin.
Tek thread binlerce bağlantıya yeter mi?
Evet, iş yükü G/Ç-bağımlıysa (çoğu ağ uygulaması böyledir) tek bir epoll döngüsü on binlerce bağlantıyı rahatça taşır. CPU-bağımlı ağır işler varsa, bu işleri bir thread havuzuna devredip döngüyü asla bloklamamak gerekir.
epoll Windows'ta çalışır mı?
Hayır, epoll Linux'a özgüdür. Windows'ta IOCP, macOS/BSD'de kqueue vardır. Taşınabilirlik istiyorsanız bu farkları soyutlayan libuv, libev veya Boost.Asio gibi kütüphaneleri tercih edin.
Yüksek performanslı bir oyun ya da gerçek zamanlı sunucu mu kuruyorsunuz? Linux ağ yığını, C++ ve ölçeklenebilir mimari konusunda birlikte çalışalım — benimle iletişime geçin.