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

epoll server: ein hochperformanter Netzwerkserver

Wenn man einen Game-Server, einen Chat-Dienst oder eine Echtzeit-API schreibt, stößt man früher oder später an dieselbe Wand: ein Thread pro Verbindung bricht bei ein paar tausend Nutzern zusammen. Unter Linux ist die Standardantwort auf dieses Problem die epoll server-Architektur — eine Event-Schleife, die Zehntausende gleichzeitige Sockets auf einem einzigen Thread verarbeitet und sich nur um die Verbindungen kümmert, die tatsächlich Daten haben. In diesem Artikel siehst du, wie epoll funktioniert, was der Edge-Triggered-Modus bedeutet, und ein vollständiges, funktionierendes C++-Gerüst Schritt für Schritt.

Warum nicht select/poll?

Die klassischen select() und poll() zwingen dich, bei jedem Aufruf alle überwachten File-Deskriptoren (fds) erneut an den Kernel zu übergeben und sie beim Rückkehren erneut zu durchlaufen. Die Kosten wachsen linear mit der Zahl der Verbindungen (O(n)); bei 10.000 Verbindungen wird jede Iteration zu einem schweren Scan.

epoll hält die Menge der überwachten fds im Kernel. Du registrierst einmal und erhältst danach nur die Liste der Sockets, die bereit sind. Der Aufwand ist proportional zur Zahl der aktiven Events, nicht zur Gesamtzahl der Verbindungen. Bei Tausenden inaktiver Verbindungen ist das ein gewaltiger Unterschied.

  • epoll_create1() — erzeugt eine epoll-Instanz und gibt einen fd zurück.
  • epoll_ctl() — fügt einen fd zu dieser Instanz hinzu, entfernt oder ändert ihn.
  • epoll_wait() — blockiert und gibt die bereiten Events zurück.

Aufbau der Event-Schleife

Zuerst öffnest du einen Listen-Socket, machst bind/listen und fügst ihn der epoll-Instanz hinzu. Der entscheidende Punkt: für einen skalierbaren epoll server muss jeder Socket im nicht-blockierenden Modus sein.

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;          // melden, wenn lesebereit
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

ev.data ist eine Union; du kannst darin einen fd speichern oder einen Zeiger (ptr) auf ein Objekt pro Verbindung. In der Praxis ist es sehr verbreitet, für jede Verbindung einen Kontext (Puffer, eine Zustandsmaschine) über ptr mitzuführen.

Level-Triggered oder Edge-Triggered?

epoll bietet zwei Modi. Standard ist Level-Triggered: solange ungelesene Daten am Socket anliegen, meldet dir epoll_wait jedes Mal — ähnlich wie poll und nachsichtig. Edge-Triggered (EPOLLET) meldet nur einmal, wenn sich der Zustand ändert. Weniger Systemaufrufe, höhere Leistung, aber es gilt eine Regel:

  • Im Edge-Triggered-Modus musst du in einer Schleife lesen, bis du den Socket vollständig geleert hast (also bis read/recv EAGAIN oder EWOULDBLOCK zurückgibt).
  • Andernfalls wirst du über die übrigen Daten nie wieder benachrichtigt und die Verbindung hängt.

Verbindungen annehmen und lesen

Das Herz der Schleife ist epoll_wait. Für jedes zurückgegebene Event: kommt es vom Listen-Socket, accepten wir neue Verbindungen; sonst lesen wir Daten. Im Edge-Triggered-Modus muss auch accept in einer Schleife bis EAGAIN laufen, da sich hinter einer einzigen Benachrichtigung mehrere Verbindungen angestaut haben können.

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) {
            // Alle anstehenden neuen Verbindungen leeren
            while (true) {
                int conn = accept(listen_fd, nullptr, nullptr);
                if (conn == -1) {
                    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                    break; // anderer Fehler
                }
                make_nonblocking(conn);
                struct epoll_event cev{};
                cev.events = EPOLLIN | EPOLLET;   // edge-triggered
                cev.data.fd = conn;
                epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev);
            }
        } else if (events[i].events & (EPOLLERR | EPOLLHUP)) {
            close(fd);   // fällt beim Schließen automatisch aus epoll
        } else if (events[i].events & EPOLLIN) {
            handle_read(epfd, fd);
        }
    }
}

Dieselbe Disziplin gilt beim Lesen: gibt recv 0 zurück, hat die Gegenseite die Verbindung geschlossen; gibt es EAGAIN zurück, sind alle Daten dieser Runde aufgebraucht.

void handle_read(int epfd, int fd) {
    char buf[4096];
    while (true) {
        ssize_t r = recv(fd, buf, sizeof(buf), 0);
        if (r > 0) {
            // die r Bytes in buf verarbeiten / an einen Puffer anhängen
        } else if (r == 0) {
            close(fd);            // Client hat geschlossen
            return;
        } else {
            if (errno == EAGAIN || errno == EWOULDBLOCK) return;
            if (errno == EINTR)  continue;
            close(fd);            // echter Fehler
            return;
        }
    }
}

Schreiben, Backpressure und EPOLLOUT

Lesen ist der einfache Teil. Die eigentliche Feinheit liegt im Schreiben: auf einem nicht-blockierenden Socket schluckt send nicht immer alle Daten — es kann EAGAIN zurückgeben, also „der Puffer ist voll, versuch es später erneut“. Dann speicherst du die restlichen Bytes in einer Ausgabewarteschlange pro Verbindung und meldest den fd erneut mit EPOLLOUT an. Wird der Socket wieder beschreibbar, weckt dich epoll_wait, du leerst die Warteschlange und schaltest EPOLLOUT wieder ab, sobald sie leer ist. Dieser Mechanismus ist die Backpressure-Kontrolle, die verhindert, dass langsame Clients den Speicher deines Servers aufblähen.

  • Höre nicht ständig auf EPOLLOUT — es feuert permanent und verbrennt CPU. Aktiviere es nur, solange Daten zum Schreiben in der Warteschlange liegen.
  • Halte getrennte Lese- und Schreibpuffer pro Verbindung; verwalte den Zustand mit einer Zustandsmaschine.

Der Weg in die Produktion

Das Gerüst läuft; aber ein echter epoll server braucht ein paar zusätzliche Schritte. Auf Mehrkern-Maschinen kannst du mit SO_REUSEPORT pro CPU-Kern eine eigene Accept-Schleife laufen lassen und die Last vom Kernel verteilen lassen. Ignoriere SIGPIPE (oder übergib MSG_NOSIGNAL an send), sonst tötet das Schreiben auf einen geschlossenen Socket den Prozess. Erhöhe das File-Deskriptor-Limit (ulimit -n) und das listen-Backlog. Schließlich kannst du für die Timeout-Verwaltung auch einen timerfd zu epoll hinzufügen, sodass alles in einer einzigen Schleife lebt.

Häufige Fragen

Sollte ich io_uring statt epoll verwenden?

io_uring ist eine neuere Schnittstelle, besonders stark bei Disk-I/O, und reift auch fürs Netzwerk heran. Aber epoll ist nach wie vor die verbreitetste, portabelste und am besten dokumentierte Lösung. Für die meisten Netzwerkserver reicht epoll mehr als aus; wechsle nur zu io_uring, wenn du einen konkreten, gemessenen Engpass hast.

Reicht ein einzelner Thread für Tausende Verbindungen?

Ja — ist die Last I/O-gebunden (die meisten Netzwerkanwendungen sind das), trägt eine einzelne epoll-Schleife mühelos Zehntausende Verbindungen. Bei schwerer CPU-gebundener Arbeit lagere diese in einen Thread-Pool aus und blockiere die Schleife niemals.

Funktioniert epoll unter Windows?

Nein, epoll ist Linux-spezifisch. Windows hat IOCP und macOS/BSD hat kqueue. Brauchst du Portabilität, bevorzuge Bibliotheken wie libuv, libev oder Boost.Asio, die diese Unterschiede abstrahieren.

Baust du einen hochperformanten Game- oder Echtzeit-Server? Lass uns gemeinsam am Linux-Netzwerkstack, an C++ und einer skalierbaren Architektur arbeiten — nimm Kontakt auf.

Devamı için