Reaktori I/O funksional në C të pastër

Reaktori I/O funksional në C të pastër

Hyrje

Reaktori I/O (me një reçipent) cikli i ngjarjeve) — është një model për zhvillimin e softuerit me ngarkesë të lartë, i përdorur në shumë zgjidhje të njohura:

Në këtë artikull ne do të shqyrtojmë thelbin e reaktorit I/O dhe parimin e funksionimit të tij, do të shkruajmë një implementim në më pak se 200 linja kodi dhe do të bëjmë që një server HTTP i thjeshtë të përpunojë mbi 40 milion kërkesa/në minutë.

Parathënie

  • Ky artikull është shkruar me qëllimin për t'ju ndihmuar të kuptoni funksionimin e reaktorit I/O, dhe kështu të kuptoni rrezikun e përdorimit të tij.
  • Për të kuptuar artikullin kërkohet njohuri themelore e gjuhës C dhe një përvojë të vogël në zhvillimin e aplikacioneve rrjet.
  • I gjithë kodi është shkruar në gjuhën C në përputhje me (kujdes: PDF i gjatë) standardin C11 për Linux dhe është në dispozicion në GitHub.

Përse është kjo e nevojshme?

Me rritjen e popullaritetit të Internetit, serverët web filluan të kishin nevojë për të përpunuar një numër të madh lidhjesh në të njëjtën kohë, për këtë arsye u provuan dy qasje: I/O i bllokuar me numër të madh të proceseve dhe I/O jo-bllokues në kombinim me një sistem njoftimi për ngjarjet, i quajtur gjithashtu «selektores sistemi» (epoll/kqueue/IOCP/etc).

Qasja e parë përfshinte krijimin e një rrjedhe të re të OS për çdo lidhje që hyn. Disavantazhi i tij është se ka një shkallë të dobët të shkallëzimit: sistemi operacional do të duhet të kryejë shumë kalime konteksti dhe thirrje sistemore. Ato janë operacione të shtrenjta dhe mund të shkaktojnë mungesë të RAM-it të lirë kur numri i lidhjeve është i konsiderueshëm.

Versioni i modifikuar shpërndan një numër të fikstë rrjedhash (baza e rrjedhave), duke e bërë kështu të pamundur që sistemi të ndalojë ekzekutimin papritur, por gjithashtu sjell një problem të ri: nëse në atë moment bazën e rrjedhave e bllokojnë operacionet e shëndoshë të leximit, atëherë soketat e tjera, që janë tashmë në gjendje të pranojnë të dhëna, nuk do të mund ta bëjnë këtë.

Qasja e dytë përdor një sistem njoftimesh për ngjarje (selektoni sistemor), të cilin e ofron OS. Në këtë artikull do të trajtohet lloji më i zakonshëm i selektorit sistemor, i bazuar në njoftime (ngjarje, njoftime) për gatishmërinë për operacione I/O, sesa mbi njoftimet për përfundimin e tyre. Një shembull i përmbledhur i përdorimit të tij mund të paraqitet me diagramin e mëposhtëm:

Reaktori I/O funksional në C të pastër

Dallimi ndërmjet këtyre qasjeve qëndron në si vijon:

  • Operacionet I/O bllokuese ndalojnë rrjedhën e përdoruesit deri sasistemi operativ të defragmentojë paketat IP në një rrjedhë bajtash (TCP, marrja e të dhënave) ose të lirohet mjaftueshëm hapësirë në tamponët e brendshëm të shkrimit për dërgimin e mëtejshëm përmes NIC (dërgimi i të dhënave).
  • Selektoni sistemin pas një kohe njofton programin se sistemi operativ janë ka defragmentuar paketat IP (TCP, marrja e të dhënave) ose është e mjaftueshme hapësira në tamponët e brendshëm të shkrimit janë disponohet (dërgimi i të dhënave).

Duke përmbledhur, rezervimi i rrjedhës së sistemit operativ për çdo I/O është një shpërdorim i fuqive llogaritëse, pasi në të vërtetë, rrjedhat nuk janë të angazhuara në punë të dobishme (këtu fillon termi "prerja programore"). Selektoni sistemin zgjidh këtë problem, duke lejuar programin e përdoruesit të shpenzojë burimet e CPU-së shumë më ekonomike.

Modeli I/O reaktori

I/O reaktori vepron si një shtresë midis selektorit sistemik dhe kodit të përdoruesit. Parimi i funksionimit të tij përshkruhet nga skema e mëposhtme:

Reaktori I/O funksional në C të pastër

  • Të kujtoj se eventi është një njoftim që një socket i caktuar është në gjendje të kryejë një operacion I/O jo bllokues.
  • Punonjësi i ngjarjeve është një funksion, i cili thirret nga reaktori I/O kur merr një ngjarje dhe më pas kryen një operacion I/O jo bllokues.

Është e rëndësishme të theksohet se reaktori I/O është në mënyrë definicionale një线程, por asgjë nuk e pengon përdorimin e konceptit në një mjedis multithread në lidhje me 1线程: 1 reaktor, duke shfrytëzuar kështu të gjitha thellësitë e CPU-së.

Realizimi

Ne do ta vendosim ndërfaqen publike në skedarin reactor.h, dhe realizimin në reactor.c. reactor.h do të përbëhet nga këto shpallje:

Trego shpalljet në reactor.h

typedef struct reactor Reactor;

/*
 * Pika e funksionit që do të thirret nga I/O reaktori kur ndodh
 * një ngjarje nga selektori i sistemit.
 */
typedef void (*Callback)(void *arg, int fd, uint32_t events);

/*
 * Ktheni `NULL` në rast gabimi, një tregues i pa-`NULL` në `Reactor` në
 * përndryshe.
 */
Reactor *reactor_new(void);

/*
 * Çliron selektorin e sistemit, të gjitha soketat e regjistruara në këtë moment
 * dhe vetë I/O reaktorin.
 *
 * Funksionet e mëposhtme kthejnë -1 në rast gabimi, 0 në rast suksesi.
 */
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);

/*
 * Nisin ciklin e ngjarjeve me një kohë-marrje `timeout`.
 *
 * Kjo funksion do të kalojë kontrollin tek kodi që thirret nëse koha e caktuar ka kaluar
 * ose/në mungesë të soketeve të regjistruara.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

Struktura e I/O reaktorit përbëhet nga deshifrikuesi i skedarëve selektori epoll dhe tabela e hashit GHashTable, e cila lidh çdo soket me CallbackData (strukturë e menaxherit të ngjarjeve dhe argumentit të përdoruesit për të).

Shfaq Reactor dhe CallbackData

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

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

Kujdesi, ne kemi përdorur mundësinë e adresimit me tip të papërfunduar përmes treguesit. Në reactor.h ne shpallim strukturën reactor, ndërsa në reactor.c e definojmë, duke e bërë që përdoruesi të mos ketë mundësi ta përmirësojë drejtpërdrejt fushat e saj. Kjo është një nga modelet e fshehjes së të dhënave, e cila përshtatet bukur në semantikën e C.

Funksionet reactor_register, reactor_deregister dhe reactor_reregister përditësojnë listën e sockets që interesojnë dhe përkatësit e ngjarjeve në selektorin sistemik dhe në tabelën e hashes.

Trego funksionet e regjistrimit

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

Pas që I/O reaktori ka kapur një ngjarje me descriptorin fd, ai thërret përkatësin e ngjarjes, në të cilin kalon fd, maskën prej bitësh të ngjarjeve të gjeneruara dhe treguesin personal në void.

Trego funksionin 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) {
        // Gabim
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Koha ka skaduar
        case 0:
            result = 0;
            goto cleanup;
        // Operacioni i suksesshëm
        default:
            // Thirrja e trajtuesve të ngjarjeve
            for (int i = 0; i table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

Dhe në përfundim, zinxhiri i thirrjeve të funksioneve në kodin e përdoruesit do të marrë këtë formë:

Reaktori I/O funksional në C të pastër

Server një-fije

Për të testuar reaktorin I/O nën ngarkesë të lartë, do të shkruajmë një server HTTP të thjeshtë që përgjigjet me një imazh për çdo kërkesë.

Kërkesë e shkurtër për protokollin HTTP

HTTP — ky është protokolli i nivelit aplikativ, kryesisht që përdoret për ndërveprimin e serverit me shfletuesin.

HTTP mund të përdoret lehtësisht mbi transportin protokolli TCP, duke dërgon dhe merr mesazhe formati, i caktuar specifikimi.

Formati i kërkesës

CRLF
CRLF
CRLF
CRLF CRLF

  • CRLF — është një shkronjë e dy simboleve: r dhe n, e cila ndan rreshtin e parë të kërkesës, titujt dhe të dhënat.
  • <КОМАНДА> — një nga CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Shfletuesi do t'i dërgojë serverit tonë komanden GET, që do të thotë "Më dërgo përmbajtjen e skedarit."
  • <URI>identifikuesi i normalizuar i burimit. Për shembull, nëse URI = /index.html, klienti kërkon faqen kryesore të uebit.
  • <ВЕРСИЯ HTTP> — versioni i protokollit HTTP në formatin HTTP/X.Y. Versioni më i përdorur sot është HTTP/1.1.
  • — është një çift çelës-vlerë në formatin :, i dërguar serverit për analizë të mëtejshme.
  • <ДАННЫЕ> — të dhënat e kërkuara nga serveri për të realizuar operacionin. Shpesh, ato janë thjesht JSON ose çdo format tjetër.

Formati i përgjigjes

CRLF

CRLF
CRLF
CRLF CRLF

  • <КОД СТАТУСА> — është një numër që paraqet rezultatin e operacionit. Serveri ynë gjithmonë do të kthejë status 200 (operacion i suksesshëm).
  • — një paraqitje string e kodit të statusit. Për kodin e statusit 200 — kjo është OK.

  • — një krye në të njëjtin format si në kërkesë. Ne do të kthejmë krye Content-Length (madhësia e skedarit) dhe Content-Type: text/html (lloji i të dhënave të kthyer).
  • <ДАННЫЕ> — të dhënat e kërkuara nga përdoruesi. Në rastin tonë, kjo është rruga e imazhit në HTML.

Skedari http_server.c (server një-rrjedhësor) përfshin skedarin common.h, i cili përmban prototipet e funksioneve të mëposhtme:

Trego prototipet e funksioneve në 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);

Gjithashtu është përshkruar makro funksionale SAFE_CALL() dhe është përcaktuar funksioni fail(). Makro krahason vlerën e shprehjes me gabimin, dhe nëse kushti është i plotësuar, thërret funksionin fail():

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

Funksioni fail() printon argumentet e kaluara në terminal (si printf()) dhe e ndalon programin me kodin 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);
}

Funksioni new_server() kthen një deshifrant skedar 'socket' të krijuar me thirrjet sistemore socket(), bind() dhe listen() dhe në gjendje të pranojë lidhje të ardhshme në modin jo bllokues.

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

  • Vini re se soketi krijohet fillimisht në modin jo bllokues me flamurin SOCK_NONBLOCK, për të siguruar që thirrja sistemike on_accept() (lexoni më tej) nuk ndalon ekzekutimin e thread-it. accept() reuse_port
  • Nëse reuse_port është e vërtetë, atëherë ky funksion do ta konfigurojë soketin me opsionin SO_REUSEPORT përmes setsockopt(), për të përdorur të njëjtin port në një mjedis me shumë threade (shih seksionin 'Server me shumë threade').

Trajtuesi i ngjarjeve on_accept() thirret pasi OPS gjeneron një ngjarje EPOLLIN, në këtë rast do të thotë se një lidhje e re mund të pranohet. on_accept() pranon një lidhje të re, e kalon atë në modalitetin jo-bllokues dhe e regjistron me një trajtues ngjarjesh on_recv() në reaktorin I/O.

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

Trajtuesi i ngjarjeve on_recv() thirret pasi OPS gjeneron një ngjarje EPOLLIN, në këtë rast do të thotë se lidhja, e regjistruar on_accept(), është gati për të pranuar të dhëna.

on_recv() lexon të dhënat nga lidhja deri sa të plotësohet kërkesa HTTP, pastaj regjistron trajtuesin on_send() për dërgimin e përgjigjes HTTP. Nëse klienti ka ndërprerë lidhjen, atëherë soketi hiqet nga regjistrimi dhe mbyllet përmes close().

Trego funksionin on_recv()

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

    // Pranojmë të dhënat e hyrjes derisa recv të kthejë 0 ose një gabim
    ssize_t nread;
    while ((nread = recv(fd, buffer->data + buffer->size,
                         REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
        buffer->size += nread;

    // Klienti ndërpreu lidhjen
    if (nread == 0) {
        SAFE_CALL(reactor_deregister(reactor, fd), -1);
        SAFE_CALL(close(fd), -1);
        request_buffer_destroy(buffer);
        return;
    }

    // read ktheu një gabim, ndryshe nga gabimi që do të bllokonte
    // rrjedhën
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        request_buffer_destroy(buffer);
        fail("read");
    }

    // Kemi marrë një kërkesë të plotë HTTP nga klienti. Tani regjistrojmë menaxherin
    // e ngjarjeve për dërgimin e të dhënave
    if (request_buffer_is_complete(buffer)) {
        request_buffer_clear(buffer);
        SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
                  -1);
    }
}

Trajtuesi i ngjarjeve on_send() thirret pasi OPS gjeneron një ngjarje EPOLLOUT, që do të thotë se lidhja e regjistruar on_recv(), është e gatshme për të dërguar të dhëna. Ky funksion dërgon një përgjigje HTTP, që përmban HTML me një imazh, klientit, dhe pastaj ndryshon përsëri menaxherin e ngjarjeve në on_recv().

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

Dhe përfundimisht, në skedarin http_server.c, në funksionin main() krijojmë një reaktor I/O përmes reactor_new(), krijojmë një socket serveri dhe e regjistrojmë atë, duke nisur reaktorin me reactor_run() për një minutë të plotë, pastaj çlironi burimet dhe dilni nga programa.

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

Të kontrollojmë se gjithçka funksionon siç duhet. Kompilojmë (chmod a+x compile.sh && ./compile.sh në rrënjën e projektit) dhe nisëm serverin e shkruar nga vetë, hapim http://127.0.0.1:18470 në shfletues dhe vëzhgojmë atë që pritej:

Reaktori I/O funksional në C të pastër

Masa e performancës

Trego karakteristikat e makinës time

$ 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

Do të masim performancën e serverit me një farë. Do të hapim dy terminale: në një do të nisnim ./http_server, në tjetrin — wrk. Pas një minute në terminalin e dytë do të shfaqet statistika e mëposhtme:

$ 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

Serveri ynjëkanalësh arriti të përpunojë mbi 11 milion kërkesa në minutë nga 100 lidhje. Një rezultat i mirë, por a është e mundur ta përmirësojmë atë?

Server multiniveli

Siç u përmend më lart, reaktori I/O mund të krijohet në procese të veçanta, duke shfrytëzuar kështu të gjitha bërthamat e CPU-së. Le të aplikojmë këtë qasje në praktikë:

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

Tani secili proces ka reaguesin e tij :

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

Vini re se argumenti i funksionit new_server() në këtë rast e vërtetë. Kjo do të thotë se po i japim socketit të serverit opsionin SO_REUSEPORT, për ta përdorur atë në një ambient multiniveli. Më shumë mund të lexoni këtu.

Të dyta

Tani do të matim performancën e serverit multiniveli:

$ 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"
Duke test 1m në http://127.0.0.1:18470
  8 threads dhe 100 connections
  Statistikat e Thread     Avg      Stdev     Max   +/- Stdev
    Latenca     1.14ms    2.53ms  40.73ms   89.98%
    Kërkesa/Sek    79.98k    18.07k  154.64k    78.65%
  38208400 kërkesa në 1.00m, 5.23GB të lexuara
Kërkesa/sek: 635876.41
Transferim/sek:     89.14MB

Numri i kërkesave të përpunuara për 1 minutë u rrit në ~3.28 herë! Por nuk arriti numrin e plotë për afërsisht ~dy milion, le të përpiqemi ta rregullojmë këtë.

Së pari, le të shohim statistikat e gjeneruara perf:

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

Statistikat e numëruesve të performancës për './http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPU të përdorur          
         1 813 074      kalime kontekstesh           #    0,007 M/sec                  
             4 689      migrime CPU            #    0,019 K/sec                  
               254      gabime faqeje               #    0,001 K/sec                  
   895 324 830 170      cikle                    #    3,693 GHz                    
   621 378 066 808      instruksione              #    0,69  instruksion për cikël         
   119 926 709 370      degë                  #  494,653 M/sec                  
     3 227 095 669      humbje të degëve             #    2,69% e të gjitha degëve        
           808 664      humbje cache                                                

      60,604330670 sekonda koha e kaluar

Përdorimi i afinitetit të CPU, kompaktim me -march=native, PGO, rritja e numrit të goditjeve në cache, rritje MAX_EVENTS dhe përdorimi EPOLLET nuk solli një rritje të konsiderueshme në performancë. Por çfarë do ndodhë nëse rritet numri i lidhjeve në të njëjtën kohë?

Statistikat në 352 lidhje ndihmëse:

$ 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"
Duke ekzekutuar testin për 1m @ http://127.0.0.1:18470
  8 threads dhe 352 lidhje
  Statistikët e Threadit   Mesatar    Stdev     Maks   +/- Stdev
    Vonesa     2.12ms    3.79ms  68.23ms   87.49%
    Req/Sec    83.78k    12.69k  169.81k    83.59%
  40006142 kërkesa në 1.00m, 5.48GB të lexuara
Kërkesa/sekond: 665789.26
Transferim/sekond:     93.34MB

Rezultati i dëshiruar është arritur, së bashku me një grafik interesant që tregon varësinë e numrit të kërkesave të përpunuara për 1 minutë nga numri i lidhjeve:

Reaktori I/O funksional në C të pastër

Shohim se pas disa qindra lidhjeve numri i kërkesave të përpunuara bie ndjeshëm për të dy serverët (në variantin me shumë procese kjo është më e dukshme). A është kjo e lidhur me realizimin e TCP/IP stack të Linux? Nëse keni supozime rreth këtij sjellje të grafikës dhe optimizimeve të varianteve me shumë procese dhe një procesi, mos hezitoni të shkruani në komentet.

Si ekspertët e pavarur n komentet, testi i këtij performancë nuk tregon sjelljen e reaktorit I/O nën ngarkesa reale, sepse pothuajse gjithmonë serveri ndërvepron me DB, nxjerr logje, përdor kriptografi me TLS etj., për shkak të së cilës ngarkesa bëhet e ndryshueshme (dinamike). Testet së bashku me komponentët e jashtëm do të kryhen në artikullin për reaktorin I/O.

Disvantazhet e reaktorit I/O

Duhet të kuptohet se reaktori I/O nuk është pa disavantazhe, sidomos:

  • Përdorimi i reaktorit I/O në një mjedis me shumëThreads është disi më i komplikuar, sepse do të duhet të menaxhohen manualisht rrjedhat.
  • Praktika tregon se në shumicën e rasteve ngarkesa është e ndryshueshme, gjë që mund të çojë në situatën ku një rrjedhë do të presë, ndërsa tjetra është e ngarkuar me punë.
  • Nëse një trajtues ngjarjeje bllokon një rrjedhë, atëherë do të bllokohet edhe selektori sistemor, gjë që mund të çojë në defekte të vështira për t'u kapur.

Këto probleme zgjidhen nga reaktori I/O, shpesh duke pasur një planifikues që shpërndan ngarkesën në mënyrë të barabartë në grupin e rrjedhave dhe gjithashtu ka një API më të përshtatshëm. Rreth tij do të flas më vonë, në artikullin tim tjetër.

Përfundimi

Këtu përfundon udhëtimi ynë nga teoria në profilin e daljes.

Nuk duhet të ndaleni këtu, pasi ekzistojnë shumë qasje të tjera po aq interesante për të shkruar software rrjetesh me nivele të ndryshme komoditeti dhe shpejtësie. Linket interesante, sipas mendimit tim, janë të listuara më poshtë.

Hasta la vista!

Projekte interesante

Çfarë tjetër të lexoni?

Burimi: habr.com

Bleni hostim të besueshëm për faqe me mbrojtje nga DDoS, serverë VPS VDS 🔥 Bleni hostim të besueshëm për faqe me mbrojtje nga DDoS, serverë VPS VDS | ProHoster