Reactor I/O funcțional complet pe C gol

Reactor I/O funcțional complet pe C gol

Introducere

Reactor I/O (single-threaded event loop) — este un model pentru scrierea de software de înaltă performanță, utilizat în multe soluții populare:

În acest articol, vom explora fundamentele reactorului I/O și principiul său de funcționare, vom scrie o implementare în mai puțin de 200 de linii de cod și vom face un server HTTP simplu să proceseze peste 40 de milioane de cereri pe minut.

Prefață

  • Articolul este scris cu scopul de a ajuta la înțelegerea funcționării reactorului I/O, ceea ce înseamnă și conștientizarea riscurilor asociate utilizării acestuia.
  • Pentru a înțelege articolul, este necesar să aveți cunoștințe de bază în limbajul C și o mică experiență în dezvoltarea aplicațiilor de rețea.
  • Întregul cod este scris în limbajul C strict conformatenție: PDF lung) standardului C11 pentru Linux și este disponibil pe GitHub.

De ce este necesar?

Odată cu creșterea popularității internetului, serverele web au început să fie nevoite să proceseze un număr mare de conexiuni simultan, motiv pentru care au fost testate două abordări: I/O blocant pe un număr mare de fire de execuție OS și I/O non-blocant în combinație cu un sistem de notificare a evenimentelor, cunoscut și sub numele de 'selector de sistem' (epoll/kqueue/IOCP/etc).

Prima abordare presupunea crearea unui nou fir de execuție OS pentru fiecare conexiune de intrare. Dezavantajul său este scalabilitatea slabă: sistemul de operare va trebui să efectueze numeroase schimbări de context și apeluri de sistem. Acestea sunt operații costisitoare și pot conduce la epuizarea memoriei RAM disponibile în cazul unui număr mare de conexiuni.

Versiunea modificată alocă un număr fix de fire (pool de fire), astfel nepermitând sistemului să întrerupă execuția, dar în același timp aducând o nouă problemă: dacă în acel moment pool-ul de fire blochează operațiuni de citire ce durează, celelalte socket-uri care sunt deja pregătite să primească date nu vor putea să facă acest lucru.

A doua abordare utilizează sistemul de notificare a evenimentelor (selector de sistem) pe care îl furnizează OS. În acest articol, vom analiza cel mai frecvent tip de selector de sistem, bazat pe notificări (evenimente, alerte) privind disponibilitatea operațiunilor I/O, mai degrabă decât pe notificările privind finalizarea acestora. Un exemplu simplificat al utilizării sale poate fi reprezentat prin următoarea diagramă de flux:

Reactor I/O funcțional complet pe C gol

Diferența dintre cele două abordări este următoarea:

  • Operațiile I/O blocante suspendă firul de utilizator până când, până când OS-ul nu defragmentează pachetele IP într-un flux de octeți ( , primire de date) sau nu se va elibera un spațiu suficient în bufferele interne de scriere pentru a putea trimiteTCP(trimitere de date). NIC Selectorul de sistem
  • după un timp informează programul că OS-ul a defragmentat pachetele IP (TCP, primire de date) sau că există suficient spațiu în bufferele interne de scriere este deja disponibil (trimitere de date). este deja În concluzie, rezervarea unui flux OS pentru fiecare I/O este o risipă de putere de calcul, deoarece, de fapt, fluxurile nu sunt ocupate cu muncă utilă (de aici provine termenul

„interupere programatică” ). Selectorul de sistem rezolvă această problemă, permițând programului utilizator să consume resursele CPU mult mai eficient.Modelul I/O reactor

I/O reactorul acționează ca un strat între selectorul de sistem și codul utilizator. Principiul său de funcționare este descris în următoarea diagrama de flux:

Amintesc că un eveniment este o notificare că un anumit socket este pregătit să execute o operație I/O non-blocantă.

Reactor I/O funcțional complet pe C gol

  • Handler-ul de evenimente este o funcție apelată de I/O reactor la primirea unui eveniment, care ulterior efectuează o operație I/O non-blocantă.
  • Este important de menționat că I/O reactorul este, prin definiție, unitar, dar nimic nu împiedică utilizarea conceptului într-un mediu multi-threaded în raport cu 1 fir: 1 reactor, astfel utilizând toate nucleele CPU.

Interfața publică o vom plasa în fișierul

Implementarea

reactor.h , iar implementarea înreactor.c va consta din următoarele declarații:. , iar implementarea în Afișați declarațiile în reactor.h

Afișează anunțurile în reactor.h

typedef struct reactor Reactor;

/*
 * Pointeur vers la fonction qui sera appelée par le réacteur I/O lorsqu'un
 * événement est reçu du sélecteur système.
 */
typedef void (*Callback)(void *arg, int fd, uint32_t events);

/*
 * Retourne `NULL` en cas d'erreur, un pointeur non `NULL` vers `Reactor` autrement.
 */
Reactor *reactor_new(void);

/*
 * Libère le sélecteur système, tous les sockets enregistrés à ce moment
 * et le réacteur I/O lui-même.
 *
 * Les fonctions suivantes retournent -1 en cas d'erreur, 0 en cas de succès.
 */
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);

/*
 * Démarre la boucle d'événements avec un délai `timeout`.
 *
 * Cette fonction passera le contrôle au code appelant si le temps imparti est écoulé
 * ou/et en l'absence de sockets enregistrés.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

La structure du réacteur I/O se compose de descripteur de fichier sélecteur epoll și table de hachage GHashTable, qui associe chaque socket à CallbackData (structure contenant le gestionnaire d'événements et l'argument utilisateur pour celui-ci).

Afficher Reactor et CallbackData

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

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

Veuillez noter que nous avons utilisé la possibilité de gérer un type incomplet par pointeur. Dans , iar implementarea în nous déclarons la structure reactor, iar în va consta din următoarele declarații: nous la définissons, empêchant ainsi l'utilisateur de modifier explicitement ses champs. C'est l'un des modèles d'encapsulation des données, s'intégrant élégamment dans la sémantique du C.

Functions reactor_register, reactor_deregister și reactor_reregister mettent à jour la liste des sockets d'intérêt et des gestionnaires d'événements correspondants dans le sélecteur système et dans la table de hachage.

Afficher les fonctions d'enregistrement

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

Après que le réacteur I/O ait intercepté un événement avec le descripteur fd, il appelle le gestionnaire d'événements correspondant, lui transmettant fd, un masque de bits des événements générés et un pointeur utilisateur vers void.

Afficher la fonction 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) {
        // Eroare
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Timp expirat
        case 0:
            result = 0;
            goto cleanup;
        // Operație reușită
        default:
            // Apelare handleri de evenimente
            for (int i = 0; i table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

În concluzie, lanțul de apeluri de funcții din codul utilizatorului va arăta astfel:

Reactor I/O funcțional complet pe C gol

Server unitar

Pentru a testa reactorul I/O sub o sarcină mare, vom scrie un server HTTP simplu care va răspunde cu o imagine la orice cerere.

Scurt rezumat privind protocolul HTTP

HTTP — este un protocol de aplicatie, folosit în principal pentru interacțiunea între server și browser.

HTTP poate fi folosit cu ușurință deasupra transportului protocolului TCP, trimițând și primind mesaje în formatul definit specificația.

Formatul cererii

CRLF
CRLF
CRLF
CRLF CRLF

  • CRLF — este o secvență de două caractere: r și n, care separă prima linie a cererii, capetele de titlu și datele.
  • <КОМАНДА> — este una dintre CONNECT, DELETE, metoda GET., HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Browserul va trimite serverului nostru comanda metoda GET., care înseamnă „Trimite-mi conținutul fișierului”.
  • <URI> — identificator uniform de resursă. De exemplu, dacă URI = /index.html, atunci clientul cere pagina principală a site-ului.
  • <ВЕРСИЯ HTTP> — versiunea protocolului HTTP în formatul HTTP/X.Y. Cea mai utilizată versiune la ora actuală este HTTP/1.1.
  • <ЗАГОЛОВОК N> — este o pereche cheie-valoare în formatul :, trimisă serverului pentru o analiză ulterioară.
  • <ДАННЫЕ> — datele necesare serverului pentru a executa operația. De obicei, aceasta este doar JSON sau orice alt format.

Formatul răspunsului

CRLF
CRLF
CRLF
CRLF CRLF

  • <КОД СТАТУСА> — este un număr care reprezintă rezultatul unei operațiuni. Serverul nostru va returna întotdeauna un status 200 (operațiune reușită).
  • <ОПИСАНИЕ СТАТУСА> — o reprezentare sub formă de șir a codului de status. Pentru codul de status 200 — aceasta este OK.
  • <ЗАГОЛОВОК N> — un antet de același format ca și în cerere. Vom returna antetele Content-Length (dimensiunea fișierului) și Content-Type: text/html (tipul datelor returnate).
  • <ДАННЫЕ> — datele solicitate de utilizator. În cazul nostru, aceasta este calea către imagine în HTML.

Fișier http_server.c (server unitar) include fișierul common.h, care conține următoarele prototipuri de funcții:

Afișați prototipurile de funcții din 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);

De asemenea, este descris un macro funcțional SAFE_CALL() și este definită funcția fail(). Macro-ul compară valoarea expresiei cu o eroare, iar dacă condiția s-a îndeplinit, apelează funcția fail():

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

Funcția fail() tipărește argumentele transmise în terminal (ca și printf()) și finalizează programul cu codul 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);
}

Funcția new_server() returnează un descriptor de fișier pentru socket-ul „server”, creat prin apelurile de sistem socket(), bind() și listen() și capabil să accepte conexiuni incoming în mod non-blocant.

Afișați funcția 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;
}

  • Rețineți că socketul este creat inițial în mod non-blocant folosind flag-ul SOCK_NONBLOCK, astfel încât în funcția on_accept() (citiți mai departe) apelul de sistem accept() nu oprește execuția firului.
  • Dacă reuse_port este true, atunci această funcție va configura socketul cu opțiunea SO_REUSEPORT prin setsockopt(), pentru a folosi aceeași port într-un mediu multi-fir (vezi secțiunea „Server multi-fir”).

Handler-ul de evenimente on_accept() este apelat după ce sistemul de operare generează un eveniment EPOLLIN, care în acest caz înseamnă că o nouă conexiune poate fi acceptată. on_accept() acceptă o nouă conexiune, o comută în mod non-blocant și o înregistrează cu handler-ul de evenimente on_recv() în reactorul I/O.

Afișați funcția 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);
}

Handler-ul de evenimente on_recv() este apelat după ce sistemul de operare generează un eveniment EPOLLIN, în acest caz înseamnă că conexiunea înregistrată on_accept(), este pregătită pentru a primi date.

on_recv() citește date din conexiune până când cererea HTTP este complet primită, apoi înregistrează handler-ul on_send() pentru a trimite răspunsul HTTP. Dacă clientul a încheiat conexiunea, socket-ul este deregistrat și închis prin close().

Afișează funcția on_recv()

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

    \/\/ Primim datele de intrare până când recv returnează 0 sau o eroare
    ssize_t nread;
    while ((nread = recv(fd, buffer->data + buffer->size,
                         REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
        buffer->size += nread;

    \/\/ Clientul a încheiat conexiunea
    if (nread == 0) {
        SAFE_CALL(reactor_deregister(reactor, fd), -1);
        SAFE_CALL(close(fd), -1);
        request_buffer_destroy(buffer);
        return;
    }

    \/\/ read a returnat o eroare, diferită de eroarea care ar bloca
    \/\/ firul
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        request_buffer_destroy(buffer);
        fail("read");
    }

    \/\/ A fost primit un request HTTP complet de la client. Acum înregistrăm handler-ul
    \/\/ evenimentelor pentru a trimite datele
    if (request_buffer_is_complete(buffer)) {
        request_buffer_clear(buffer);
        SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
                  -1);
    }
}

Handler-ul de evenimente on_send() este apelat după ce sistemul de operare generează un eveniment EPOLLOUT, înseamnă că conexiunea înregistrată on_recv(), este pregătită pentru a trimite date. Această funcție trimite răspunsul HTTP, conținând HTML cu o imagine, clientului și apoi schimbă din nou handler-ul evenimentului la on_recv().

Afișează funcția 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);
}

Și, în cele din urmă, în fișierul http_server.c, în funcția main() creăm un reactor I/O prin reactor_new(), creăm un socket de server și îl înregistrăm, apoi pornim reactorul cu ajutorul reactor_run() timp de exact un minut, după care eliberăm resursele și ieșim din program.

Afișează 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);
}

Să verificăm că totul funcționează corect. Compilăm (chmod a+x compile.sh && .\/compile.sh în rădăcina proiectului) și lansăm server-ul personalizat, deschidem http://127.0.0.1:18470 în browser și observăm ceea ce ne așteptam:

Reactor I/O funcțional complet pe C gol

Măsurarea performanței

Afișează specificațiile mașinii mele

$ 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

Măsurăm performanța unui server pe un singur fir. Vom deschide două terminale: în unul vom rula ./http_server, în celălalt — wrk. După un minut, în cel de-al doilea terminal va apărea următoarea statistică:

$ 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

Serverul nostru pe un singur fir a procesat peste 11 milioane de cereri pe minut, provenind din 100 de conexiuni. Rezultat decent, dar se poate îmbunătăți?

Server pe mai multe fire

Așa cum s-a menționat anterior, reactorul I/O poate fi creat în fire separate, astfel utilizând toate nuclee CPU-ului. Să aplicăm această abordare în practică:

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

Acum fiecare fir deține propriul reactor:

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

Observați că argumentul funcției new_server() sunt susținute de true. Asta înseamnă că atribuim socket-ului serverului opțiunea SO_REUSEPORT, pentru a-l folosi într-un mediu multi-thread. Puteți citi mai multe detalii aici.

Al doilea încercare

Acum să măsurăm performanța serverului pe mai multe fire:

$ 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

Numărul de cereri procesate pe minut a crescut de aproximativ 3.28 ori! Dar pentru a ajunge la un număr rotund ne-au lipsit doar aproximativ două milioane, să încercăm să corectăm asta.

Mai întâi să ne uităm la statistica generată perf:

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

 Statistici ale contracontorului pentru '.\/http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPU-uri utilizate          
         1 813 074      schimbări de context        #    0,007 M/sec                  
             4 689      migrații CPU                #    0,019 K/sec                  
               254      erori de pagină             #    0,001 K/sec                  
   895 324 830 170      cicluri                    #    3,693 GHz                    
   621 378 066 808      instrucțiuni                #    0,69  insn per ciclu         
   119 926 709 370      ramuri                     #  494,653 M/sec                  
     3 227 095 669      pierderi de ramuri         #    2,69% din toate ramurile        
           808 664      pierderi de cache                                              

      60,604330670 secunde timp scurs

Utilizarea afinității CPU, compilare cu -march=native, PGO, creșterea numărului de lovituri în cache, creșterea MAX_EVENTS și utilizarea EPOLLET nu a adus un câștig semnificativ în performanță. Dar ce se întâmplă dacă creștem numărul de conexiuni simultane?

Statistici cu 352 de conexiuni simultane:

$ 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"
Se execută un test de 1m @ http:\/\/127.0.0.1:18470
  8 fire de execuție și 352 de conexiuni
  Statistici fir   Avg      Stdev     Max   +\/ - Stdev
    Viteză de răspuns  2.12ms    3.79ms  68.23ms   87.49%
    Req\/Sec    83.78k    12.69k  169.81k    83.59%
  40006142 cereri în 1.00m, 5.48GB citite
Cereri\/sec: 665789.26
Transfer\/sec:     93.34MB

Rezultatul dorit a fost obținut, iar împreună cu el și un grafic interesant care arată dependența numărului de cereri procesate într-un minut de numărul de conexiuni:

Reactor I/O funcțional complet pe C gol

Vedem că după câteva sute de conexiuni, numărul cererilor procesate scade brusc pentru ambele servere (pentru varianta multithreaded este mai evident). Este aceasta legată de implementarea stivei TCP\/IP în Linux? Împărtășiți-vă părerile despre acest comportament al graficului și optimizările variantelor multithreaded și singlethreaded în comentarii.

Cum au menționat în comentarii, acest test de performanță nu arată comportamentul reactorului I\/O în condiții de stres reale, deoarece serverul interacționează aproape întotdeauna cu baza de date, scrie în loguri, folosește criptografie cu TLS etc., ceea ce face ca sarcina să devină heterogenă (dinamică). Testele împreună cu componente terțe vor fi efectuate în articolul despre reactorul I\/O.

Dezavantajele reactorului I\/O

Trebuie înțeles că reactorul I\/O nu este lipsit de dezavantaje, și anume:

  • Folosirea reactorului I\/O într-un mediu multithreaded este ceva mai complicată, deoarece va trebui să gestionați manual firele.
  • Practică demonstrează că, în majoritatea cazurilor, încărcătura este inegală, ceea ce poate duce la faptul că un fir va fi ocupat în timp ce altul va fi încărcat cu muncă.
  • Dacă un handler de eveniment blochează firul, atunci și selectorul sistemului va fi blocat, ceea ce poate duce la bug-uri greu de depistat.

Aceste probleme sunt rezolvate de I/O proactor, adesea cu un planner care distribuie uniform încărcătura în pool-ul de fire și care oferă, de asemenea, o API mai prietenoasă. Despre aceasta voi discuta mai târziu, în alt articol.

Concluzie

Aici s-a încheiat călătoria noastră din teorie direct către ieșirea profiler-ului.

Nu ar trebui să ne oprim aici, deoarece există o mulțime de alte abordări interesante pentru dezvoltarea software-ului de rețea, cu diverse niveluri de confort și viteză. Linkuri interesante, în opinia mea, sunt prezentate mai jos.

Ne vedem data viitoare!

Proiecte interesante

Ce altceva să citim?

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster