Voll ausgestatteter Bare-C-I/O-Reaktor

Voll ausgestatteter Bare-C-I/O-Reaktor

Einführung

I/O-Reaktor (Einzelgewinde Ereignisschleife) ist ein Muster zum Schreiben von Hochlastsoftware, das in vielen gängigen Lösungen verwendet wird:

In diesem Artikel werden wir uns mit den Besonderheiten eines I/O-Reaktors und seiner Funktionsweise befassen, eine Implementierung in weniger als 200 Codezeilen schreiben und einen einfachen HTTP-Server dazu bringen, über 40 Millionen Anfragen/Minute zu verarbeiten.

Vorwort

  • Der Artikel wurde geschrieben, um zu helfen, die Funktionsweise des I/O-Reaktors zu verstehen und damit die Risiken bei seiner Verwendung zu verstehen.
  • Zum Verständnis des Artikels sind Grundkenntnisse erforderlich. C Sprache und etwas Erfahrung in der Entwicklung von Netzwerkanwendungen.
  • Der gesamte Code ist in C-Sprache geschrieben, streng nach (Achtung: langes PDF) nach C11-Standard für Linux und verfügbar auf GitHub.

Warum das?

Mit der wachsenden Beliebtheit des Internets mussten Webserver zunehmend eine große Anzahl von Verbindungen gleichzeitig verarbeiten, weshalb zwei Ansätze ausprobiert wurden: das Blockieren von E/A auf einer großen Anzahl von Betriebssystem-Threads und das nicht-blockierende E/A in Kombination mit ein Ereignisbenachrichtigungssystem, auch „Systemselektor“ genannt (Epoll/warteschlange/IOCP/usw).

Der erste Ansatz bestand darin, für jede eingehende Verbindung einen neuen Betriebssystem-Thread zu erstellen. Sein Nachteil ist die schlechte Skalierbarkeit: Das Betriebssystem muss viele implementieren Kontextübergänge и Systemaufrufe. Dies sind teure Vorgänge und können bei einer beeindruckenden Anzahl von Verbindungen zu einem Mangel an freiem RAM führen.

Die modifizierte Version hebt hervor feste Anzahl von Threads (Thread-Pool), wodurch verhindert wird, dass das System die Ausführung abbricht, aber gleichzeitig ein neues Problem entsteht: Wenn ein Thread-Pool derzeit durch lange Lesevorgänge blockiert ist, können andere Sockets, die bereits Daten empfangen können, dies nicht tun Sie dies.

Der zweite Ansatz verwendet Ereignisbenachrichtigungssystem (Systemselektor), der vom Betriebssystem bereitgestellt wird. In diesem Artikel wird der gebräuchlichste Typ von Systemselektoren erläutert, der auf Warnungen (Ereignissen, Benachrichtigungen) über die Bereitschaft für E/A-Vorgänge basiert und nicht auf Benachrichtigungen über deren Fertigstellung. Ein vereinfachtes Anwendungsbeispiel kann durch das folgende Blockdiagramm dargestellt werden:

Voll ausgestatteter Bare-C-I/O-Reaktor

Der Unterschied zwischen diesen Ansätzen ist wie folgt:

  • Blockieren von E/A-Vorgängen aussetzen Benutzerfluss bis dahinbis das Betriebssystem ordnungsgemäß funktioniert Defragmentierungen eingehend IP-Pakete zum Byte-Stream (TCP, Empfangen von Daten) oder es ist nicht genügend Platz in den internen Schreibpuffern für das anschließende Senden über verfügbar NIC (Senden von Daten).
  • Systemauswahl im Laufe der Zeit Benachrichtigt das Programm, dass das Betriebssystem bereits defragmentierte IP-Pakete (TCP, Datenempfang) oder genügend Platz in internen Schreibpuffern bereits verfügbar (Senden von Daten).

Zusammenfassend lässt sich sagen, dass das Reservieren eines Betriebssystem-Threads für jede E/A eine Verschwendung von Rechenleistung ist, da die Threads in Wirklichkeit keine nützliche Arbeit leisten (daher kommt der Begriff). „Software-Interrupt“). Der Systemselektor löst dieses Problem und ermöglicht es dem Benutzerprogramm, CPU-Ressourcen wesentlich sparsamer zu nutzen.

I/O-Reaktormodell

Der I/O-Reaktor fungiert als Schicht zwischen dem Systemselektor und dem Benutzercode. Das Funktionsprinzip wird durch das folgende Blockdiagramm beschrieben:

Voll ausgestatteter Bare-C-I/O-Reaktor

  • Ich möchte Sie daran erinnern, dass ein Ereignis eine Benachrichtigung ist, dass ein bestimmter Socket einen nicht blockierenden E/A-Vorgang ausführen kann.
  • Ein Event-Handler ist eine Funktion, die vom I/O-Reaktor aufgerufen wird, wenn ein Ereignis empfangen wird, die dann einen nicht blockierenden I/O-Vorgang ausführt.

Es ist wichtig zu beachten, dass ein I/O-Reaktor per Definition ein Single-Thread-Reaktor ist, aber nichts hindert das Konzept daran, das Konzept in einer Multi-Thread-Umgebung mit einem Verhältnis von 1 Thread: 1 Reaktor zu verwenden, wodurch alle CPU-Kerne recycelt werden.

Implementierung

Wir werden die öffentliche Schnittstelle in einer Datei ablegen reactor.h, und Umsetzung - in reactor.c. reactor.h wird aus folgenden Ankündigungen bestehen:

Deklarationen in „reactor.h“ anzeigen

typedef struct reactor Reactor;

/*
 * Указатель на функцию, которая будет вызываться I/O реактором при поступлении
 * события от системного селектора.
 */
typedef void (*Callback)(void *arg, int fd, uint32_t events);

/*
 * Возвращает `NULL` в случае ошибки, не-`NULL` указатель на `Reactor` в
 * противном случае.
 */
Reactor *reactor_new(void);

/*
 * Освобождает системный селектор, все зарегистрированные сокеты в данный момент
 * времени и сам I/O реактор.
 *
 * Следующие функции возвращают -1 в случае ошибки, 0 в случае успеха.
 */
int reactor_destroy(Reactor *reactor);

int reactor_register(const Reactor *reactor, int fd, uint32_t interest,
                     Callback callback, void *callback_arg);
int reactor_deregister(const Reactor *reactor, int fd);
int reactor_reregister(const Reactor *reactor, int fd, uint32_t interest,
                       Callback callback, void *callback_arg);

/*
 * Запускает цикл событий с тайм-аутом `timeout`.
 *
 * Эта функция передаст управление вызывающему коду если отведённое время вышло
 * или/и при отсутствии зарегистрированных сокетов.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

Die I/O-Reaktorstruktur besteht aus Dateideskriptor Wähler Epoll и Hash-Tabellen GHashTable, der jeden Socket zuordnet CallbackData (Struktur eines Event-Handlers und eines Benutzerarguments dafür).

Reaktor- und CallbackData anzeigen

struct reactor {
    int epoll_fd;
    GHashTable *table; // (int, CallbackData)
};

typedef struct {
    Callback callback;
    void *arg;
} CallbackData;

Bitte beachten Sie, dass wir die Möglichkeit zur Bearbeitung aktiviert haben unvollständiger Typ laut Index. IN reactor.h Wir deklarieren die Struktur reactorUnd in reactor.c Wir definieren es und verhindern so, dass der Benutzer seine Felder explizit ändert. Dies ist eines der Muster Daten verstecken, was prägnant in die C-Semantik passt.

Funktionen reactor_register, reactor_deregister и reactor_reregister Aktualisieren Sie die Liste der relevanten Sockets und der entsprechenden Ereignishandler im Systemselektor und in der Hash-Tabelle.

Registrierungsfunktionen anzeigen

#define REACTOR_CTL(reactor, op, fd, interest)                                 
    if (epoll_ctl(reactor->epoll_fd, op, fd,                                   
                  &(struct epoll_event){.events = interest,                    
                                        .data = {.fd = fd}}) == -1) {          
        perror("epoll_ctl");                                                   
        return -1;                                                             
    }

int reactor_register(const Reactor *reactor, int fd, uint32_t interest,
                     Callback callback, void *callback_arg) {
    REACTOR_CTL(reactor, EPOLL_CTL_ADD, fd, interest)
    g_hash_table_insert(reactor->table, int_in_heap(fd),
                        callback_data_new(callback, callback_arg));
    return 0;
}

int reactor_deregister(const Reactor *reactor, int fd) {
    REACTOR_CTL(reactor, EPOLL_CTL_DEL, fd, 0)
    g_hash_table_remove(reactor->table, &fd);
    return 0;
}

int reactor_reregister(const Reactor *reactor, int fd, uint32_t interest,
                       Callback callback, void *callback_arg) {
    REACTOR_CTL(reactor, EPOLL_CTL_MOD, fd, interest)
    g_hash_table_insert(reactor->table, int_in_heap(fd),
                        callback_data_new(callback, callback_arg));
    return 0;
}

Nachdem der I/O-Reaktor das Ereignis mit dem Deskriptor abgefangen hat fd, ruft es den entsprechenden Event-Handler auf, an den es übergeben wird fd, Bitmaske generierte Ereignisse und einen Benutzerzeiger auf void.

Funktion „reaktor_run()“ anzeigen

int reactor_run(const Reactor *reactor, time_t timeout) {
    int result;
    struct epoll_event *events;
    if ((events = calloc(MAX_EVENTS, sizeof(*events))) == NULL)
        abort();

    time_t start = time(NULL);

    while (true) {
        time_t passed = time(NULL) - start;
        int nfds =
            epoll_wait(reactor->epoll_fd, events, MAX_EVENTS, timeout - passed);

        switch (nfds) {
        // Ошибка
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Время вышло
        case 0:
            result = 0;
            goto cleanup;
        // Успешная операция
        default:
            // Вызвать обработчиков событий
            for (int i = 0; i < nfds; i++) {
                int fd = events[i].data.fd;

                CallbackData *callback =
                    g_hash_table_lookup(reactor->table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

Zusammenfassend sieht die Kette von Funktionsaufrufen im Benutzercode wie folgt aus:

Voll ausgestatteter Bare-C-I/O-Reaktor

Single-Threaded-Server

Um den I/O-Reaktor unter hoher Last zu testen, schreiben wir einen einfachen HTTP-Webserver, der auf jede Anfrage mit einem Bild antwortet.

Eine kurze Referenz zum HTTP-Protokoll

HTTP - Das ist das Protokoll Anwendungsebene, wird hauptsächlich für die Server-Browser-Interaktion verwendet.

HTTP kann problemlos verwendet werden Transport Protokoll TCP, Senden und Empfangen von Nachrichten in einem angegebenen Format Spezifikation.

Anfrageformat

<КОМАНДА> <URI> <ВЕРСИЯ HTTP>CRLF
<ЗАГОЛОВОК 1>CRLF
<ЗАГОЛОВОК 2>CRLF
<ЗАГОЛОВОК N>CRLF CRLF
<ДАННЫЕ>

  • CRLF ist eine Folge von zwei Zeichen: r и n, wobei die erste Zeile der Anfrage, Header und Daten getrennt werden.
  • <КОМАНДА> - einer von CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Der Browser sendet einen Befehl an unseren Server GET, was „Senden Sie mir den Inhalt der Datei“ bedeutet.
  • <URI> - einheitlicher Ressourcenbezeichner. Wenn beispielsweise URI = /index.html, dann fordert der Client die Hauptseite der Site an.
  • <ВЕРСИЯ HTTP> — Version des HTTP-Protokolls im Format HTTP/X.Y. Die heute am häufigsten verwendete Version ist HTTP/1.1.
  • <ЗАГОЛОВОК N> ist ein Schlüssel-Wert-Paar im Format <КЛЮЧ>: <ЗНАЧЕНИЕ>, zur weiteren Analyse an den Server gesendet.
  • <ДАННЫЕ> – Daten, die der Server zur Ausführung des Vorgangs benötigt. Oft ist es einfach JSON oder ein anderes Format.

Antwortformat

<ВЕРСИЯ HTTP> <КОД СТАТУСА> <ОПИСАНИЕ СТАТУСА>CRLF
<ЗАГОЛОВОК 1>CRLF
<ЗАГОЛОВОК 2>CRLF
<ЗАГОЛОВОК N>CRLF CRLF
<ДАННЫЕ>

  • <КОД СТАТУСА> ist eine Zahl, die das Ergebnis der Operation darstellt. Unser Server wird immer den Status 200 (erfolgreicher Vorgang) zurückgeben.
  • <ОПИСАНИЕ СТАТУСА> — String-Darstellung des Statuscodes. Für den Statuscode 200 ist dies der Fall OK.
  • <ЗАГОЛОВОК N> — Header im gleichen Format wie in der Anfrage. Wir werden die Titel zurückgeben Content-Length (Dateigröße) und Content-Type: text/html (Rückgabedatentyp).
  • <ДАННЫЕ> — Vom Benutzer angeforderte Daten. In unserem Fall ist dies der Pfad zum Bild in HTML.

Datei http_server.c (Single-Threaded-Server) enthält Datei common.h, das die folgenden Funktionsprototypen enthält:

Funktionsprototypen in common.h anzeigen

/*
 * Обработчик событий, который вызовется после того, как сокет будет
 * готов принять новое соединение.
 */
static void on_accept(void *arg, int fd, uint32_t events);

/*
 * Обработчик событий, который вызовется после того, как сокет будет
 * готов отправить HTTP ответ.
 */
static void on_send(void *arg, int fd, uint32_t events);

/*
 * Обработчик событий, который вызовется после того, как сокет будет
 * готов принять часть HTTP запроса.
 */
static void on_recv(void *arg, int fd, uint32_t events);

/*
 * Переводит входящее соединение в неблокирующий режим.
 */
static void set_nonblocking(int fd);

/*
 * Печатает переданные аргументы в stderr и выходит из процесса с
 * кодом `EXIT_FAILURE`.
 */
static noreturn void fail(const char *format, ...);

/*
 * Возвращает файловый дескриптор сокета, способного принимать новые
 * TCP соединения.
 */
static int new_server(bool reuse_port);

Das Funktionsmakro wird ebenfalls beschrieben SAFE_CALL() und die Funktion ist definiert fail(). Das Makro vergleicht den Wert des Ausdrucks mit dem Fehler und ruft die Funktion auf, wenn die Bedingung wahr ist fail():

#define SAFE_CALL(call, error)                                                 
    do {                                                                       
        if ((call) == error) {                                                   
            fail("%s", #call);                                                 
        }                                                                      
    } while (false)

Funktion fail() gibt die übergebenen Argumente an das Terminal aus (wie printf()) und beendet das Programm mit dem Code EXIT_FAILURE:

static noreturn void fail(const char *format, ...) {
    va_list args;
    va_start(args, format);
    vfprintf(stderr, format, args);
    va_end(args);
    fprintf(stderr, ": %sn", strerror(errno));
    exit(EXIT_FAILURE);
}

Funktion new_server() gibt den Dateideskriptor des durch Systemaufrufe erstellten „Server“-Sockets zurück socket(), bind() и listen() und in der Lage, eingehende Verbindungen in einem nicht blockierenden Modus zu akzeptieren.

Funktion new_server() anzeigen

static int new_server(bool reuse_port) {
    int fd;
    SAFE_CALL((fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP)),
              -1);

    if (reuse_port) {
        SAFE_CALL(
            setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &(int){1}, sizeof(int)),
            -1);
    }

    struct sockaddr_in addr = {.sin_family = AF_INET,
                               .sin_port = htons(SERVER_PORT),
                               .sin_addr = {.s_addr = inet_addr(SERVER_IPV4)},
                               .sin_zero = {0}};

    SAFE_CALL(bind(fd, (struct sockaddr *)&addr, sizeof(addr)), -1);
    SAFE_CALL(listen(fd, SERVER_BACKLOG), -1);
    return fd;
}

  • Beachten Sie, dass der Socket zunächst im nicht blockierenden Modus mithilfe des Flags erstellt wird SOCK_NONBLOCKdamit in der Funktion on_accept() (weiterlesen) Systemaufruf accept() hat die Thread-Ausführung nicht gestoppt.
  • wenn reuse_port ist true, dann konfiguriert diese Funktion den Socket mit der Option SO_REUSEPORT mittels setsockopt()um denselben Port in einer Multithread-Umgebung zu verwenden (siehe Abschnitt „Multithread-Server“).

Ereignishandler on_accept() Wird aufgerufen, nachdem das Betriebssystem ein Ereignis generiert hat EPOLLIN, was in diesem Fall bedeutet, dass die neue Verbindung angenommen werden kann. on_accept() akzeptiert eine neue Verbindung, schaltet sie in den nicht blockierenden Modus und registriert sich bei einem Event-Handler on_recv() in einem I/O-Reaktor.

Funktion on_accept() anzeigen

static void on_accept(void *arg, int fd, uint32_t events) {
    int incoming_conn;
    SAFE_CALL((incoming_conn = accept(fd, NULL, NULL)), -1);
    set_nonblocking(incoming_conn);
    SAFE_CALL(reactor_register(reactor, incoming_conn, EPOLLIN, on_recv,
                               request_buffer_new()),
              -1);
}

Ereignishandler on_recv() Wird aufgerufen, nachdem das Betriebssystem ein Ereignis generiert hat EPOLLIN, was in diesem Fall bedeutet, dass die Verbindung registriert wurde on_accept(), bereit zum Datenempfang.

on_recv() liest Daten aus der Verbindung, bis die HTTP-Anfrage vollständig empfangen wurde, und registriert dann einen Handler on_send() um eine HTTP-Antwort zu senden. Wenn der Client die Verbindung unterbricht, wird der Socket deregistriert und mit geschlossen close().

Funktion on_recv() anzeigen

static void on_recv(void *arg, int fd, uint32_t events) {
    RequestBuffer *buffer = arg;

    // Принимаем входные данные до тех пор, что recv возвратит 0 или ошибку
    ssize_t nread;
    while ((nread = recv(fd, buffer->data + buffer->size,
                         REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
        buffer->size += nread;

    // Клиент оборвал соединение
    if (nread == 0) {
        SAFE_CALL(reactor_deregister(reactor, fd), -1);
        SAFE_CALL(close(fd), -1);
        request_buffer_destroy(buffer);
        return;
    }

    // read вернул ошибку, отличную от ошибки, при которой вызов заблокирует
    // поток
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        request_buffer_destroy(buffer);
        fail("read");
    }

    // Получен полный HTTP запрос от клиента. Теперь регистрируем обработчика
    // событий для отправки данных
    if (request_buffer_is_complete(buffer)) {
        request_buffer_clear(buffer);
        SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
                  -1);
    }
}

Ereignishandler on_send() Wird aufgerufen, nachdem das Betriebssystem ein Ereignis generiert hat EPOLLOUT, was bedeutet, dass die Verbindung registriert wurde on_recv(), bereit zum Senden von Daten. Diese Funktion sendet eine HTTP-Antwort, die HTML mit einem Bild enthält, an den Client und ändert dann den Ereignishandler wieder in on_recv().

Funktion on_send() anzeigen

static void on_send(void *arg, int fd, uint32_t events) {
    const char *content = "<img "
                          "src="https://habrastorage.org/webt/oh/wl/23/"
                          "ohwl23va3b-dioerobq_mbx4xaw.jpeg">";
    char response[1024];
    sprintf(response,
            "HTTP/1.1 200 OK" CRLF "Content-Length: %zd" CRLF "Content-Type: "
            "text/html" DOUBLE_CRLF "%s",
            strlen(content), content);

    SAFE_CALL(send(fd, response, strlen(response), 0), -1);
    SAFE_CALL(reactor_reregister(reactor, fd, EPOLLIN, on_recv, arg), -1);
}

Und schließlich in der Akte http_server.c, in Funktion main() Wir erstellen einen I/O-Reaktor mit reactor_new(), erstellen Sie einen Server-Socket, registrieren Sie ihn und starten Sie den Reaktor mit reactor_run() für genau eine Minute, dann geben wir Ressourcen frei und beenden das Programm.

http_server.c anzeigen

#include "reactor.h"

static Reactor *reactor;

#include "common.h"

int main(void) {
    SAFE_CALL((reactor = reactor_new()), NULL);
    SAFE_CALL(
        reactor_register(reactor, new_server(false), EPOLLIN, on_accept, NULL),
        -1);
    SAFE_CALL(reactor_run(reactor, SERVER_TIMEOUT_MILLIS), -1);
    SAFE_CALL(reactor_destroy(reactor), -1);
}

Überprüfen wir, ob alles wie erwartet funktioniert. Kompilieren (chmod a+x compile.sh && ./compile.sh im Projektstamm) und starten Sie den selbstgeschriebenen Server, öffnen Sie ihn http://127.0.0.1:18470 im Browser und sehen Sie, was wir erwartet haben:

Voll ausgestatteter Bare-C-I/O-Reaktor

Leistungsmessung

Zeigen Sie meine Fahrzeugspezifikationen an

$ screenfetch
 MMMMMMMMMMMMMMMMMMMMMMMMMmds+.        OS: Mint 19.1 tessa
 MMm----::-://////////////oymNMd+`     Kernel: x86_64 Linux 4.15.0-20-generic
 MMd      /++                -sNMd:    Uptime: 2h 34m
 MMNso/`  dMM    `.::-. .-::.` .hMN:   Packages: 2217
 ddddMMh  dMM   :hNMNMNhNMNMNh: `NMm   Shell: bash 4.4.20
     NMm  dMM  .NMN/-+MMM+-/NMN` dMM   Resolution: 1920x1080
     NMm  dMM  -MMm  `MMM   dMM. dMM   DE: Cinnamon 4.0.10
     NMm  dMM  -MMm  `MMM   dMM. dMM   WM: Muffin
     NMm  dMM  .mmd  `mmm   yMM. dMM   WM Theme: Mint-Y-Dark (Mint-Y)
     NMm  dMM`  ..`   ...   ydm. dMM   GTK Theme: Mint-Y [GTK2/3]
     hMM- +MMd/-------...-:sdds  dMM   Icon Theme: Mint-Y
     -NMm- :hNMNNNmdddddddddy/`  dMM   Font: Noto Sans 9
      -dMNs-``-::::-------.``    dMM   CPU: Intel Core i7-6700 @ 8x 4GHz [52.0°C]
       `/dMNmy+/:-------------:/yMMM   GPU: NV136
          ./ydNMMMMMMMMMMMMMMMMMMMMM   RAM: 2544MiB / 7926MiB
             .MMMMMMMMMMMMMMMMMMM

Lassen Sie uns die Leistung eines Single-Threaded-Servers messen. Lassen Sie uns zwei Terminals öffnen: In einem werden wir laufen ./http_server, in einem anderen - WRK. Nach einer Minute werden im zweiten Terminal folgende Statistiken angezeigt:

$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
  8 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   493.52us   76.70us  17.31ms   89.57%
    Req/Sec    24.37k     1.81k   29.34k    68.13%
  11657769 requests in 1.00m, 1.60GB read
Requests/sec: 193974.70
Transfer/sec:     27.19MB

Unser Single-Threaded-Server konnte über 11 Millionen Anfragen pro Minute verarbeiten, die von 100 Verbindungen stammten. Kein schlechtes Ergebnis, aber kann es verbessert werden?

Multithread-Server

Wie oben erwähnt, kann der I/O-Reaktor in separaten Threads erstellt werden, wodurch alle CPU-Kerne genutzt werden. Lassen Sie uns diesen Ansatz in die Praxis umsetzen:

http_server_multithreaded.c anzeigen

#include "reactor.h"

static Reactor *reactor;
#pragma omp threadprivate(reactor)

#include "common.h"

int main(void) {
#pragma omp parallel
    {
        SAFE_CALL((reactor = reactor_new()), NULL);
        SAFE_CALL(reactor_register(reactor, new_server(true), EPOLLIN,
                                   on_accept, NULL),
                  -1);
        SAFE_CALL(reactor_run(reactor, SERVER_TIMEOUT_MILLIS), -1);
        SAFE_CALL(reactor_destroy(reactor), -1);
    }
}

Jetzt jeder Thread besitzt sein eigenes Reaktor:

static Reactor *reactor;
#pragma omp threadprivate(reactor)

Bitte beachten Sie, dass das Funktionsargument new_server() Fürsprecher true. Das bedeutet, dass wir die Option dem Server-Socket zuweisen SO_REUSEPORTum es in einer Multithread-Umgebung zu verwenden. Weitere Einzelheiten können Sie nachlesen hier.

Zweiter Lauf

Lassen Sie uns nun die Leistung eines Multithread-Servers messen:

$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
  8 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.14ms    2.53ms  40.73ms   89.98%
    Req/Sec    79.98k    18.07k  154.64k    78.65%
  38208400 requests in 1.00m, 5.23GB read
Requests/sec: 635876.41
Transfer/sec:     89.14MB

Die Anzahl der in einer Minute verarbeiteten Anfragen stieg um das ~1-fache! Aber wir lagen nur etwa 3.28 Millionen unter der runden Zahl, also versuchen wir, das zu beheben.

Schauen wir uns zunächst die generierten Statistiken an perf:

$ sudo perf stat -B -e task-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,branches,branch-misses,cache-misses ./http_server_multithreaded

 Performance counter stats for './http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPUs utilized          
         1 813 074      context-switches          #    0,007 M/sec                  
             4 689      cpu-migrations            #    0,019 K/sec                  
               254      page-faults               #    0,001 K/sec                  
   895 324 830 170      cycles                    #    3,693 GHz                    
   621 378 066 808      instructions              #    0,69  insn per cycle         
   119 926 709 370      branches                  #  494,653 M/sec                  
     3 227 095 669      branch-misses             #    2,69% of all branches        
           808 664      cache-misses                                                

      60,604330670 seconds time elapsed

Verwendung von CPU-Affinität, Zusammenstellung mit -march=native, PGO, eine Erhöhung der Anzahl der Treffer spät, Zunahme MAX_EVENTS und verwenden EPOLLET brachte keine nennenswerte Leistungssteigerung. Aber was passiert, wenn Sie die Anzahl gleichzeitiger Verbindungen erhöhen?

Statistik für 352 gleichzeitige Verbindungen:

$ wrk -c352 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
  8 threads and 352 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     2.12ms    3.79ms  68.23ms   87.49%
    Req/Sec    83.78k    12.69k  169.81k    83.59%
  40006142 requests in 1.00m, 5.48GB read
Requests/sec: 665789.26
Transfer/sec:     93.34MB

Das gewünschte Ergebnis wurde erzielt und damit ein interessantes Diagramm, das die Abhängigkeit der Anzahl der verarbeiteten Anfragen in 1 Minute von der Anzahl der Verbindungen zeigt:

Voll ausgestatteter Bare-C-I/O-Reaktor

Wir sehen, dass nach ein paar hundert Verbindungen die Anzahl der verarbeiteten Anfragen für beide Server stark abnimmt (in der Multithread-Version ist dies deutlicher spürbar). Hängt das mit der Linux TCP/IP-Stack-Implementierung zusammen? Schreiben Sie Ihre Annahmen zu diesem Verhalten des Diagramms und zu Optimierungen für Multithread- und Single-Thread-Optionen gerne in die Kommentare.

Как bekannt In den Kommentaren zeigt dieser Leistungstest nicht das Verhalten des I/O-Reaktors unter realer Belastung, da der Server fast immer mit der Datenbank interagiert, Protokolle ausgibt und Kryptographie verwendet TLS usw., wodurch die Belastung ungleichmäßig (dynamisch) wird. Tests zusammen mit Komponenten von Drittanbietern werden im Artikel zum I/O-Proaktor durchgeführt.

Nachteile des I/O-Reaktors

Sie müssen verstehen, dass der I/O-Reaktor nicht ohne Nachteile ist, nämlich:

  • Die Verwendung eines I/O-Reaktors in einer Multithread-Umgebung ist etwas schwieriger, weil Sie müssen die Flüsse manuell verwalten.
  • Die Praxis zeigt, dass die Auslastung in den meisten Fällen ungleichmäßig ist, was dazu führen kann, dass ein Thread protokolliert, während ein anderer mit der Arbeit beschäftigt ist.
  • Wenn ein Event-Handler einen Thread blockiert, blockiert auch der Systemselektor selbst, was zu schwer zu findenden Fehlern führen kann.

Löst diese Probleme I/O-Proaktor, das oft über einen Scheduler verfügt, der die Last gleichmäßig auf einen Thread-Pool verteilt, und außerdem über eine praktischere API verfügt. Wir werden später in meinem anderen Artikel darüber sprechen.

Abschluss

Hier endet unsere Reise von der Theorie direkt in den Profiler-Auspuff.

Darüber sollten Sie sich nicht aufhalten, denn es gibt viele andere ebenso interessante Ansätze zum Schreiben von Netzwerksoftware mit unterschiedlichem Komfort und Geschwindigkeit. Interessante Links sind meiner Meinung nach unten aufgeführt.

Bis wir uns wieder treffen!

Interessante Projekte

Was soll ich sonst noch lesen?

Source: habr.com

Kommentar hinzufügen