Reattore I/O completo in C nativo

Reattore I/O completo in C nativo

Introduzione

Reattore I/O (a thread singolo ciclo di eventi) — è un pattern per la scrittura di software ad alta intensità di carico, utilizzato in molte soluzioni popolari:

In questo articolo esamineremo il funzionamento interno del reattore I/O e il suo principio di funzionamento, scrivendo un'implementazione in meno di 200 righe di codice e facendo in modo che un semplice server HTTP gestisca oltre 40 milioni di richieste al minuto.

Prefazione

  • L'articolo è scritto per aiutare a comprendere il funzionamento del reattore I/O e, quindi, riconoscere i rischi del suo utilizzo.
  • Per comprendere l'articolo è necessaria una conoscenza di base del linguaggio C e una piccola esperienza nello sviluppo di applicazioni di rete.
  • Tutto il codice è scritto in linguaggio C rigorosamente conforme allo standardAttenzione: PDF lungo) C11 per Linux ed è disponibile su GitHub.

A cosa serve?

Con l'aumento della popolarità di Internet, i server web hanno iniziato a dover gestire un gran numero di connessioni simultaneamente, e sono stati provati due approcci: I/O bloccante su un gran numero di thread di sistema operativo e I/O non bloccante combinato con un sistema di notifica di eventi, noto anche come «selettore di sistema» (epoll/kqueue/IOCP/etc).

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à effettuare numerosi passaggi di contesto e chiamate di sistema. Esse sono operazioni costose e possono portare a una carenza di RAM libera con un numero elevato di connessioni.

Una versione modificata assegna un numero fisso di thread (thread pool), impedendo così al sistema di terminare bruscamente l'esecuzione, ma porta con sé un nuovo problema: se al momento il pool di thread è occupato in operazioni di lettura prolungate, altri socket che sono già in grado di ricevere dati non potranno farlo.

Il secondo approccio utilizza un sistema di notifica degli eventi (selettore di sistema) fornito dal sistema operativo. In questo articolo viene trattato il tipo di selettore di sistema più comune, basato su notifiche (eventi, avvisi) di prontezza per operazioni I/O, piuttosto che su notifiche di completamento. Un esempio semplificato del suo utilizzo può essere rappresentato dal seguente diagramma di flusso:

Reattore I/O completo in C nativo

La differenza tra questi approcci è la seguente:

  • Operazioni I/O bloccanti mettono in pausa il flusso utente fino a quandoil sistema operativo non deframmenta i pacchetti IP in un flusso di byte (TCP, ricezione dei dati) o non si libera spazio sufficiente nei buffer interni per la successiva trasmissione attraverso NIC (invio dei dati).
  • Il selettore di sistema dopo un po' notifica il programma che il sistema operativo ha già deframmentato i pacchetti IP (TCP, ricezione dei dati) o che c'è spazio sufficiente nei buffer interni ha già disponibile (invio dei dati).

In sintesi, riservare un flusso del sistema operativo per ogni I/O è uno spreco di potenza computazionale, poiché in realtà i flussi non sono occupati in lavori utili (da qui deriva il termine "interruzione del programma"). Il selettore di sistema affronta questo problema, consentendo al programma utente di sfruttare le risorse della CPU in modo molto più efficiente.

Modello I/O reattivo

L'I/O reattivo funge da intermediario tra il selettore di sistema e il codice utente. Il suo funzionamento è descritto dal seguente diagramma di flusso:

Reattore I/O completo in C nativo

  • Ricordo che un evento è una notifica che un determinato socket è in grado di eseguire un'operazione I/O non bloccante.
  • Un gestore di eventi è una funzione chiamata dal reattore I/O al ricevimento di un evento, che poi esegue un'operazione I/O non bloccante.

È importante notare che il reattore I/O è per definizione monothread, ma non c'è nulla che impedisca di utilizzare il concetto in un ambiente multithread con 1 thread: 1 reattore, sfruttando così tutti i core della CPU.

Implementazione

Inseriremo l'interfaccia pubblica nel file reactor.h, mentre l'implementazione sarà in reactor.c. reactor.h sarà composta dalle seguenti dichiarazioni:

Mostra le dichiarazioni in reactor.h

typedef struct reactor Reactor;

/*
 * Puntatore alla funzione che sarà chiamato dal reattore I/O quando
 * si verifica un evento dal selettore di sistema.
 */
typedef void (*Callback)(void *arg, int fd, uint32_t events);

/*
 * Restituisce `NULL` in caso di errore, un puntatore non-`NULL` a `Reactor` in
 * caso contrario.
 */
Reactor *reactor_new(void);

/*
 * Libera il selettore di sistema, tutti i socket registrati in questo
 * momento e il reattore I/O stesso.
 *
 * Le seguenti funzioni restituiscono -1 in caso di errore, 0 in caso di successo.
 */
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);

/*
 * Avvia un ciclo di eventi con un timeout di `timeout`.
 *
 * Questa funzione passerà il controllo al codice chiamante se il tempo
 * scade oppure in assenza di socket registrati.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

La struttura del reattore I/O consiste in descrittore di file selettore epoll e tabella hash GHashTable, che associa ogni socket a CallbackData (struttura del gestore di eventi e argomento utente per esso).

Mostra Reactor e CallbackData

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

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

Si prega di notare che abbiamo implementato la possibilità di gestire un tipo incompleto tramite puntatore. Nella reactor.h dichiarazione della struttura reactor, e nella reactor.c sua definizione, impedendo così all'utente di modificare esplicitamente i suoi campi. Questo è uno dei modelli di incapsulamento dei dati, che si integra in modo conciso nella semantica del C.

Le funzioni reactor_register, reactor_deregister e reactor_reregister aggiornano l'elenco dei socket di interesse e i relativi handler 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 un evento con il descrittore fd, chiama il rispettivo handler di eventi, a cui passa fd, una maschera di bit degli 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) {
        // Errore
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Timeout scaduto
        case 0:
            result = 0;
            goto cleanup;
        // Operazione riuscita
        default:
            // Chiamare i gestori degli eventi
            for (int i = 0; i table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

In sintesi, la catena di chiamate di funzioni nel codice dell'utente sarà la seguente:

Reattore I/O completo in C nativo

Server monothread

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

Breve guida sul protocollo HTTP

HTTP è un protocollo a livello applicativo, principalmente utilizzato per l'interazione tra il server e il browser.

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

Formato della richiesta

CRLF
CRLF
CRLF
CRLF CRLF

  • CRLF — è una sequenza di due caratteri: r e n, che separa la prima riga della richiesta, le intestazioni e i dati.
  • <КОМАНДА> — uno dei CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Il browser invierà al nostro server il comando GET, che significa «Restituisci il contenuto del file».
  • <URI>identificatore uniforme del risorsa. Ad esempio, se URI = /index.html, il client richiede la home page del sito.
  • <ВЕРСИЯ HTTP> — versione del protocollo HTTP nel formato HTTP/X.Y. La versione più comunemente usata è attualmente HTTP/1.1.
  • — è una coppia chiave-valore nel formato :, inviata al server per un'analisi successiva.
  • — dati richiesti al server per eseguire l'operazione. Spesso è semplicemente JSON o qualsiasi altro formato.

Formato della risposta

CRLF
CRLF
CRLF
CRLF CRLF

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

File http_server.c (server monothread) include il file common.h, che contiene i seguenti prototipi di funzioni:

Mostra i prototipi di funzioni in common.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);

È anche descritto il macro funzionale SAFE_CALL() ed è definita la funzione fail(). Il macro confronta il valore dell'espressione con l'errore e, se la condizione è soddisfatta, chiama la funzione fail():

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

Funzione fail() stampa gli argomenti passati nel terminale (come printf()) e termina il programma con 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 un descrittore di file del socket "server" creato con chiamate di sistema socket(), bind() e listen() e capace di accettare connessioni in modo 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;
}

  • Si noti che il socket è inizialmente creato in modalità non bloccante utilizzando il flag SOCK_NONBLOCK, affinché nella funzione on_accept() (continua a leggere) la chiamata di sistema accept() non blocchi 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 multithread (vedi sezione "Server multithread").

Il gestore degli eventi on_accept() viene chiamato dopo che il sistema operativo genera un evento EPOLLIN, che in questo caso significa che una nuova connessione può essere accettata. on_accept() accetta una nuova connessione, la imposta in modalità non bloccante e la registra con un gestore eventi on_recv() nel 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);
}

Il gestore degli eventi on_recv() viene chiamato dopo che il sistema operativo genera un evento EPOLLIN, in questo caso significando che la connessione registrata on_accept(), è pronta a ricevere dati.

on_recv() legge i dati dalla connessione finché la richiesta HTTP non è completamente ricevuta, quindi registra il gestore on_send() per inviare la risposta HTTP. Se il client interrompe la connessione, il socket viene deregistrato e chiuso tramite close().

Mostra la funzione on_recv()

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

    // Riceviamo i dati in ingresso fino a quando recv restituisce 0 o un errore
    ssize_t nread;
    while ((nread = recv(fd, buffer->data + buffer->size,
                         REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
        buffer->size += nread;

    // Il client ha interrotto la connessione
    if (nread == 0) {
        SAFE_CALL(reactor_deregister(reactor, fd), -1);
        SAFE_CALL(close(fd), -1);
        request_buffer_destroy(buffer);
        return;
    }

    // read ha restituito un errore diverso da un errore che bloccherebbe il
    // thread
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        request_buffer_destroy(buffer);
        fail("read");
    }

    // È stata ricevuta una richiesta HTTP completa dal cliente. Ora registriamo il
    // gestore degli eventi per l'invio dei dati
    if (request_buffer_is_complete(buffer)) {
        request_buffer_clear(buffer);
        SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
                  -1);
    }
}

Il gestore degli eventi on_send() viene chiamato dopo che il sistema operativo genera un evento EPOLLOUT, che indica che la connessione registrata on_recv(), è pronta per l'invio dei dati. Questa funzione invia una risposta HTTP contenente HTML con un'immagine al cliente, e poi cambia di nuovo il gestore degli eventi in 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, nella funzione main() creiamo il reattore I/O tramite reactor_new(), creiamo un socket server e lo registriamo, avviamo il reattore usando reactor_run() esattamente un minuto, quindi liberiamo 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);
}

Verifichiamo che tutto funzioni correttamente. Compiliamo (chmod a+x compile.sh && ./compile.sh nella radice del progetto) e avviamo il server personalizzato, apriamo http://127.0.0.1:18470 nel browser e osserviamo ciò che ci aspettavamo:

Reattore I/O completo in C nativo

Misurazione delle prestazioni

Mostra le specifiche della mia macchina

$ 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   Risoluzione: 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   Tema WM: Mint-Y-Dark (Mint-Y)
     NMm  dMM`  ..`   ...   ydm. dMM   Tema GTK: Mint-Y [GTK2/3]
     hMM- +MMd/-------...-:sdds  dMM   Tema icona: 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

Misureremo le prestazioni del server a singolo thread. Apriamo due terminali: in uno lanceremo ./http_server, nell'altro — wrk. Dopo un minuto nel secondo terminale apparirà la seguente statistica:

$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: it-IT,it;q=0.5" -H "Connection: keep-alive"
Esecuzione del test di 1m su http://127.0.0.1:18470
  8 thread e 100 connessioni
  Statistiche Thread   Avg      Stdev     Max   +/− Stdev
    Latency   493.52us   76.70us  17.31ms   89.57%
    Req/Sec    24.37k     1.81k   29.34k    68.13%
  11657769 richieste in 1.00m, 1.60GB letti
Richieste/sec: 193974.70
Trasferimento/sec:     27.19MB

Il nostro server a thread singolo è riuscito a gestire oltre 11 milioni di richieste al minuto provenienti da 100 connessioni. Un risultato notevole, ma possiamo migliorarlo?

Server a più thread

Come detto in precedenza, il reattore I/O può essere creato in thread separati, sfruttando così tutti i core della CPU. Applichiamo questo approccio nella pratica:

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 proprio reattore:

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

Nota che l'argomento della funzione new_server() è true. Questo significa che assegniamo al socket del server l'opzione SO_REUSEPORT, per utilizzarlo in un ambiente multithread. Puoi leggere di più qui.

Seconda battuta

Ora misureremo le prestazioni del server a più thread:

$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: it-IT,it;q=0.5" -H "Connection: keep-alive"
Esecuzione del test di 1 m @ http://127.0.0.1:18470
  8 thread e 100 connessioni
  Statistiche sui thread   Media      Deviazione standard     Max   +/− Deviazione standard
    Latenza     1.14ms    2.53ms  40.73ms   89.98%
    Richieste/Sec    79.98k    18.07k  154.64k    78.65%
  38208400 richieste in 1.00m, 5.23GB letti
Richieste/sec: 635876.41
Trasferimento/sec:     89.14MB

Il numero di richieste elaborate in 1 minuto è aumentato di circa 3.28 volte! Ma ci mancavano solo circa due milioni per raggiungere un numero tondo, proviamo a correggere questo.

Iniziamo a dare 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

 Statistiche del contatore delle prestazioni per './http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPU utilizzate          
         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      cicli                    #    3,693 GHz                    
   621 378 066 808      istruzioni              #    0,69  insn per ciclo         
   119 926 709 370      rami                  #  494,653 M/sec                  
     3 227 095 669      errori su ramificazioni             #    2,69% di tutti i rami        
           808 664      errori di cache                                                

      60,604330670 secondi di tempo trascorso

Utilizzo dell'affinità della CPU, compilazione con -march=native, PGO, aumento del numero di hit in cache, aumento MAX_EVENTS e utilizzo EPOLLET non ha comportato un significativo incremento delle prestazioni. Ma cosa succede se aumentiamo il numero di connessioni simultanee?

Statistiche con 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"
Esecuzione test di 1m @ http://127.0.0.1:18470
  8 thread e 352 connessioni
  Statistiche Thread   Avg      Stdev     Max   +/ - Stdev
    Latency     2.12ms    3.79ms  68.23ms   87.49%
    Req/Sec    83.78k    12.69k  169.81k    83.59%
  40006142 richieste in 1.00m, 5.48GB letti
Richieste/sec: 665789.26
Trasferimento/sec:     93.34MB

Il risultato desiderato è stato ottenuto, insieme a un grafico interessante che mostra la dipendenza del numero di richieste elaborate in 1 minuto dal numero di connessioni:

Reattore I/O completo in C nativo

Possiamo vedere che dopo un paio di centinaia di connessioni, il numero di richieste elaborate da entrambi i server scende drasticamente (questo è più evidente nella versione multithreaded). È correlato all'implementazione dello stack TCP/IP di Linux? Sentitevi liberi di condividere nei commenti le vostre ipotesi riguardo questo comportamento del grafico e le ottimizzazioni delle versioni multi e single-thread.

Come notato nei commenti, questo test delle performance non mostra il comportamento del reattore I/O sotto carichi reali, poiché quasi sempre il server interagisce con il DB, genera log, utilizza la crittografia con TLS ecc., il che rende il carico non omogeneo (dinamico). I test insieme a componenti esterni saranno eseguiti in un articolo sul reattore I/O.

Svantaggi del reattore I/O

È importante capire che il reattore I/O non è privo di svantaggi, precisamente:

  • Utilizzare un reattore I/O in un ambiente multithreading è un po' più complesso, poiché è necessario gestire manualmente i thread.
  • La pratica dimostra che nella maggior parte dei casi il carico non è omogeneo, il che può portare a situazioni in cui un thread elabora mentre un altro è sovraccarico di lavoro.
  • Se un gestore di eventi blocca un thread, anche il selettore di sistema sarà bloccato, il che può portare a bug difficili da individuare.

Questi problemi sono risolti dal reattore I/O, che di solito ha un pianificatore che distribuisce uniformemente il carico nel pool di thread, e ha anche un'API più conveniente. Di questo parlerò più avanti, nel mio altro articolo.

Conclusione

Con questo, il nostro viaggio dalla teoria alla pratica dell'analisi del profiler si conclude.

Non fermiamoci qui, ci sono molte altre interessanti avventure nella scrittura di software di rete, con diversi livelli di comodità e velocità. Di seguito trovi i link che considero interessanti.

A presto!

Progetti interessanti

Cosa leggere ancora?

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster