Reattore I/O bare-C completo

Reattore I/O bare-C completo

Introduzione

Reattore I/O (filetto singolo ciclo di eventi) è un modello per scrivere software ad alto carico, utilizzato in molte soluzioni popolari:

In questo articolo esamineremo i dettagli di un reattore I/O e come funziona, scriveremo un'implementazione in meno di 200 righe di codice e faremo in modo che un semplice server HTTP elabori oltre 40 milioni di richieste/min.

prefazione

  • L'articolo è stato scritto per aiutare a comprendere il funzionamento del reattore I/O e quindi a comprendere i rischi derivanti dal suo utilizzo.
  • Per comprendere l'articolo è necessaria la conoscenza delle nozioni di base. Linguaggio C e una certa esperienza nello sviluppo di applicazioni di rete.
  • Tutto il codice è scritto in linguaggio C rigorosamente secondo (attenzione: PDF lungo) alla norma C11 per Linux e disponibile su GitHub.

Perché farlo?

Con la crescente popolarità di Internet, i server web hanno cominciato a dover gestire un gran numero di connessioni simultaneamente, e quindi sono stati tentati due approcci: bloccando l'I/O su un gran numero di thread del sistema operativo e non bloccando l'I/O in combinazione con un sistema di notifica degli eventi, detto anche “selettore di sistema” (epol/kcoda/IOCP/eccetera).

Il primo approccio prevedeva la creazione di un nuovo thread del sistema operativo per ogni connessione in entrata. Il suo svantaggio è la scarsa scalabilità: il sistema operativo dovrà implementarne molti transizioni di contesto и chiamate di sistema. Sono operazioni costose e possono portare alla mancanza di RAM libera con un numero impressionante di connessioni.

La versione modificata viene evidenziata numero fisso di thread (pool di thread), impedendo così al sistema di interrompere l'esecuzione, ma allo stesso tempo introducendo un nuovo problema: se un pool di thread è attualmente bloccato da operazioni di lettura lunghe, allora altri socket che sono già in grado di ricevere dati non saranno in grado di farlo fare così.

Il secondo approccio utilizza sistema di notifica degli eventi (selettore di sistema) fornito dal sistema operativo. Questo articolo discute il tipo più comune di selettore di sistema, basato su avvisi (eventi, notifiche) sulla disponibilità per le operazioni di I/O, piuttosto che su notifiche sul loro completamento. Un esempio semplificato del suo utilizzo può essere rappresentato dal seguente schema a blocchi:

Reattore I/O bare-C completo

La differenza tra questi approcci è la seguente:

  • Blocco delle operazioni di I/O sospendere flusso degli utenti fino afino a quando il sistema operativo non è corretto deframmentazioni in arrivo Pacchetti IP al flusso di byte (TCP, ricezione dati) oppure non ci sarà abbastanza spazio disponibile nei buffer di scrittura interni per il successivo invio tramite NIC (invio dati).
  • Selettore di sistema dopo un po ' notifica al programma che il sistema operativo già pacchetti IP deframmentati (TCP, ricezione dati) o spazio sufficiente nei buffer di scrittura interni già disponibile (invio dati).

Per riassumere, riservare un thread del sistema operativo per ciascun I/O è uno spreco di potenza di calcolo, perché in realtà i thread non svolgono un lavoro utile (da qui deriva il termine "interruzione del software"). Il selettore di sistema risolve questo problema, consentendo al programma utente di utilizzare le risorse della CPU in modo molto più economico.

Modello di reattore I/O

Il reattore I/O funge da strato tra il selettore di sistema e il codice utente. Il principio di funzionamento è descritto dal seguente schema a blocchi:

Reattore I/O bare-C completo

  • Permettimi di ricordarti che un evento è una notifica che un determinato socket è in grado di eseguire un'operazione I/O non bloccante.
  • Un gestore eventi è una funzione chiamata dal reattore I/O quando viene ricevuto un evento, che quindi esegue un'operazione I/O non bloccante.

È importante notare che il reattore I/O è per definizione a thread singolo, ma non c'è nulla che impedisca l'utilizzo del concetto in un ambiente multi-thread con un rapporto di 1 thread: 1 reattore, riciclando così tutti i core della CPU.

implementazione

Inseriremo l'interfaccia pubblica in un file reactor.he implementazione - in reactor.c. reactor.h sarà composto dai seguenti annunci:

Mostra le dichiarazioni in reactor.h

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);

La struttura del reattore I/O è composta da descrittore di file selettore epol и tabelle hash GHashTable, che mappa ciascun socket su CallbackData (struttura di un gestore eventi e relativo argomento utente).

Mostra Reactor e CallbackData

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

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

Tieni presente che abbiamo abilitato la possibilità di gestire tipo incompleto secondo l'indice. IN reactor.h dichiariamo la struttura reactor, mentre in reactor.c lo definiamo, impedendo così all'utente di modificare esplicitamente i suoi campi. Questo è uno dei modelli nascondere i dati, che si adatta succintamente alla semantica C.

funzioni reactor_register, reactor_deregister и reactor_reregister aggiornare l'elenco dei socket di interesse e dei corrispondenti gestori di eventi nel selettore di sistema e nella tabella hash.

Mostra le funzioni di registrazione

#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;
}

Dopo che il reattore I/O ha intercettato l'evento con il descrittore fd, chiama il gestore eventi corrispondente, al quale passa fd, maschera di bit eventi generati e un puntatore utente a void.

Mostra la funzione reactor_run()

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;
}

Riassumendo, la catena di chiamate di funzione nel codice utente assumerà la seguente forma:

Reattore I/O bare-C completo

Server a thread singolo

Per testare il reattore I/O sotto carico elevato, scriveremo un semplice server web HTTP che risponde a qualsiasi richiesta con un'immagine.

Un rapido riferimento al protocollo HTTP

HTTP - questo è il protocollo livello di applicazione, utilizzato principalmente per l'interazione server-browser.

HTTP può essere facilmente utilizzato trasporto protocollo TCP, inviando e ricevendo messaggi in un formato specificato specifica.

Richiedi formato

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

  • CRLF è una sequenza di due caratteri: r и n, separando la prima riga della richiesta, intestazioni e dati.
  • <КОМАНДА> - uno di CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Il browser invierà un comando al nostro server GET, che significa "Inviami il contenuto del file".
  • <URI> - identificatore uniforme della risorsa. Ad esempio, se URI = /index.html, quindi il client richiede la pagina principale del sito.
  • <ВЕРСИЯ HTTP> — versione del protocollo HTTP nel formato HTTP/X.Y. La versione più comunemente usata oggi è HTTP/1.1.
  • <ЗАГОЛОВОК N> è una coppia chiave-valore nel formato <КЛЮЧ>: <ЗНАЧЕНИЕ>, inviato al server per ulteriori analisi.
  • <ДАННЫЕ> — dati richiesti dal server per eseguire l'operazione. Spesso è semplice JSON o qualsiasi altro formato.

Formato della risposta

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

  • <КОД СТАТУСА> è un numero che rappresenta il risultato dell'operazione. Il nostro server restituirà sempre lo stato 200 (operazione riuscita).
  • <ОПИСАНИЕ СТАТУСА> — rappresentazione in stringa del codice di stato. Per il codice di stato 200 questo è OK.
  • <ЗАГОЛОВОК N> — intestazione dello stesso formato della richiesta. Restituiremo i titoli Content-Length (dimensione del file) e Content-Type: text/html (tipo di dati restituiti).
  • <ДАННЫЕ> — dati richiesti dall'utente. Nel nostro caso, questo è il percorso dell'immagine HTML.

file http_server.c (server a thread singolo) include file common.h, che contiene i seguenti prototipi di funzioni:

Mostra prototipi di funzioni in comune.h

/*
 * Обработчик событий, который вызовется после того, как сокет будет
 * готов принять новое соединение.
 */
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);

Viene inoltre descritta la macro funzionale SAFE_CALL() e la funzione è definita fail(). La macro confronta il valore dell'espressione con l'errore e, se la condizione è vera, chiama la funzione fail():

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

Funzione fail() stampa gli argomenti passati al terminale (come printf()) e termina il programma con il codice 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);
}

Funzione new_server() restituisce il descrittore di file del socket "server" creato dalle chiamate di sistema socket(), bind() и listen() e in grado di accettare connessioni in entrata in modalità non bloccante.

Mostra la funzione new_server()

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;
}

  • Tieni presente che il socket viene inizialmente creato in modalità non bloccante utilizzando il flag SOCK_NONBLOCKin modo che nella funzione on_accept() (continua a leggere) chiamata di sistema accept() non ha interrotto l'esecuzione del thread.
  • se reuse_port è uguale a true, questa funzione configurerà il socket con l'opzione SO_REUSEPORT attraverso setsockopt()per utilizzare la stessa porta in un ambiente multi-thread (vedere la sezione “Server multi-thread”).

Gestore di eventi on_accept() chiamato dopo che il sistema operativo ha generato un evento EPOLLIN, in questo caso significa che la nuova connessione può essere accettata. on_accept() accetta una nuova connessione, la passa alla modalità non bloccante e si registra con un gestore eventi on_recv() in un reattore I/O.

Mostra la funzione on_accept()

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);
}

Gestore di eventi on_recv() chiamato dopo che il sistema operativo ha generato un evento EPOLLIN, in questo caso significa che la connessione è stata registrata on_accept(), pronto a ricevere dati.

on_recv() legge i dati dalla connessione fino alla ricezione completa della richiesta HTTP, quindi registra un gestore on_send() per inviare una risposta HTTP. Se il client interrompe la connessione, il socket viene annullato e chiuso utilizzando close().

Mostra funzione on_recv()

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);
    }
}

Gestore di eventi on_send() chiamato dopo che il sistema operativo ha generato un evento EPOLLOUT, il che significa che la connessione è stata registrata on_recv(), pronto per inviare dati. Questa funzione invia una risposta HTTP contenente HTML con un'immagine al client e quindi ripristina il gestore eventi on_recv().

Mostra la funzione on_send()

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);
}

E infine, nel file http_server.c, in funzione main() creiamo un reattore I/O utilizzando reactor_new(), crea un socket del server e registralo, avvia il reattore utilizzando reactor_run() per esattamente un minuto, quindi rilasciamo le risorse e usciamo dal programma.

Mostra http_server.c

#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);
}

Controlliamo che tutto funzioni come previsto. Compilazione (chmod a+x compile.sh && ./compile.sh nella root del progetto) e avviare il server autoscritto, open http://127.0.0.1:18470 nel browser e vedere cosa ci aspettavamo:

Reattore I/O bare-C completo

Valutazione della prestazione

Mostra le specifiche della mia auto

$ 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

Misuriamo le prestazioni di un server a thread singolo. Apriamo due terminali: in uno correremo ./http_server, in un modo diverso - lavoro. Dopo un minuto, nel secondo terminale verranno visualizzate le seguenti statistiche:

$ 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

Il nostro server a thread singolo è stato in grado di elaborare oltre 11 milioni di richieste al minuto provenienti da 100 connessioni. Non è un brutto risultato, ma può essere migliorato?

Server multithread

Come accennato in precedenza, il reattore I/O può essere creato in thread separati, utilizzando quindi tutti i core della CPU. Mettiamo in pratica questo approccio:

Mostra http_server_multithreaded.c

#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);
    }
}

Ora ogni thread possiede il suo reattore:

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

Si prega di notare che l'argomento della funzione new_server() atti true. Ciò significa che assegniamo l'opzione al socket del server SO_REUSEPORTper usarlo in un ambiente multi-thread. Puoi leggere maggiori dettagli qui.

Seconda corsa

Ora misuriamo le prestazioni di un server multi-thread:

$ 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

Il numero di richieste elaborate in 1 minuto è aumentato di ~3.28 volte! Ma mancavano solo circa XNUMX milioni al numero tondo, quindi proviamo a risolvere il problema.

Per prima cosa diamo un'occhiata alle statistiche generate 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

Utilizzo dell'affinità della CPU, compilazione con -march=native, PGO, un aumento del numero di risultati denaro contante, aumento MAX_EVENTS e usare EPOLLET non ha dato un aumento significativo delle prestazioni. Ma cosa succede se aumenti il ​​numero di connessioni simultanee?

Statistiche per 352 connessioni simultanee:

$ 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

È stato ottenuto il risultato desiderato e con esso un interessante grafico che mostra la dipendenza del numero di richieste elaborate in 1 minuto dal numero di connessioni:

Reattore I/O bare-C completo

Vediamo che dopo un paio di centinaia di connessioni, il numero di richieste elaborate per entrambi i server diminuisce drasticamente (nella versione multi-thread questo è più evidente). Ciò è correlato all'implementazione dello stack TCP/IP di Linux? Sentiti libero di scrivere le tue ipotesi su questo comportamento del grafico e sulle ottimizzazioni per le opzioni multi-thread e single-thread nei commenti.

Come noto nei commenti, questo test delle prestazioni non mostra il comportamento del reattore I/O sotto carichi reali, perché quasi sempre il server interagisce con il database, genera log, utilizza la crittografia con TLS ecc., per cui il carico diventa non uniforme (dinamico). I test insieme ai componenti di terze parti verranno eseguiti nell'articolo sul proactor I/O.

Svantaggi del reattore I/O

È necessario comprendere che il reattore I/O non è privo di inconvenienti, vale a dire:

  • Usare un reattore I/O in un ambiente multi-thread è un po' più difficile, perché dovrai gestire manualmente i flussi.
  • La pratica dimostra che nella maggior parte dei casi il carico non è uniforme, il che può portare alla registrazione di un thread mentre un altro è impegnato nel lavoro.
  • Se un gestore eventi blocca un thread, anche il selettore di sistema stesso si bloccherà, il che può portare a bug difficili da trovare.

Risolve questi problemi Proattore I/O, che spesso ha uno scheduler che distribuisce uniformemente il carico su un pool di thread e ha anche un'API più conveniente. Ne parleremo più avanti, in un altro mio articolo.

conclusione

Qui è giunto al termine il nostro viaggio dalla teoria direttamente allo scarico del profiler.

Non dovresti soffermarti su questo, perché esistono molti altri approcci altrettanto interessanti per scrivere software di rete con diversi livelli di comodità e velocità. Interessanti, a mio avviso, i link sono riportati di seguito.

Fino a quando ci incontriamo di nuovo!

Progetti interessanti

Cos'altro dovrei leggere?

Fonte: habr.com

Aggiungi un commento