Reaktori I/O i plotë në C të pastër

Reaktori I/O i plotë në C të pastër

Hyrje

Reaktori I/O (njëthë një cikël ngjarjesh) — është një model për shkruajtur softuer 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, nga një zbatim me më pak se 200 rreshta kod dhe do të bëjmë një server HTTP të thjeshtë që e trajton mbi 40 milion kërkesa/min.

Parathënie

  • Artikulli është shkruar me qëllim për të ndihmuar në kuptimin e funksionimit të reaktorit I/O, dhe ndonjëherë për të kuptuar rreziqet e përdorimit të tij.
  • Për të kuptuar artikullin kërkohet njohuri bazë e gjuhës C dhe një përvojë të vogël në zhvillimin e aplikacioneve rrjetë.
  • Të gjithë kodet janë shkruar në gjuhën C në përputhje me (kujdes: PDF i gjatë) standardin C11 për Linux dhe janë në dispozicion në GitHub.

Pse është e nevojshme?

Me rritjen e popullaritetit të Internetit, serverat web duhet të trajtojnë një numër të madh lidhjesh në të njëjtën kohë, për çfarë janë provuar dy qasje: I/O bllokuese në një numër të madh të proceseve të OS dhe I/O jo bllokuese në kombinim me një sistem njoftimi për ngjarje, gjithashtu të quajtur 'selezionuesi i sistemit' (epoll/kqueue/IOCP/etc).

Qasja e parë nënkupton krijimin e një procesi të ri OS për çdo lidhje hyrëse. Disavantazhi i saj është shkallëzimi i dobët: sistemi operativ do të duhet të bëjë shumë kalime konteksti dhe thirrje sistemike. Këto janë operacione të shtrenjta dhe mund të çojnë në mungesë të RAM-it të lirë në rastin e një numri të konsiderueshëm lidhjesh.

Një version i modifikuar ndan një numër të fiksuar të proceseve (puli i proceseve), duke mos lejuar që sistemi të ndalojë papritmas ekzekutimin, por njëkohësisht sjell një problem të ri: nëse për momentin pulat e proceseve bllokojnë operacione të zgjatura të leximit, atëherë socket-e të tjera, të cilat janë tashmë në gjendje të pranojnë të dhëna, nuk do të jenë në gjendje ta bëjnë këtë.

Qasja e dytë përdor një sistem njoftimi për ngjarje (selezionuesi i sistemit) që ofrohet nga OS. Në këtë artikull shqyrtohet forma më e zakonshme e selektorit të sistemit, e bazuar në njoftime (ngjarje, njoftimet) për gatishmërinë për operacione I/O, përkundrazi nga njoftimet për përfundimin e tyre. Një shembull i përmbledhur i përdorimit të tij mund të paraqitet në këtë diagram të thjeshtë:

Reaktori I/O i plotë në C të pastër

Dallimi midis këtyre qasjeve është si në vijim:

  • Operacioneve I/O bllokuese ndalojnë rrjedhën e përdoruesit Ky mungesë kulture mbështetjeje, së bashku me parimin "le të thyejmë, për ta bërë më të bukur", i largon zhvilluesit nga ata., derisa OS-ja nuk e defragmenton paketave IP në një rrjedhë bajtësh (TCP, marrja e të dhënave) ose nuk lirohet hapësirë e mjaftueshme në buffer-at e brendshëm të shkrimit për dërgesat NIC (dërgimi i të dhënave).
  • Selekstori sistemor pas një kohe njofton programin se OS tashmë ka defragmentuar paketat IP (TCP, marrja e të dhënave) ose ka hapësirë të mjaftueshme në buffer-at e brendshëm të shkrimit tashmë në dispozicion (dërgimi i të dhënave).

Për të përmbledhur, rezervimi i rrjedhës së OS për çdo I/O është një shpenzim i kotë i fuqisë llogaritëse, sepse në të vërtetë, rrjedhat nuk janë të angazhuara në punë të dobishme (këtu përfshihet termi «prezenca programore»). Selekstori sistemor zgjidh këtë problem duke lejuar programin përdorues të shpenzojë burimet e CPU-së në mënyrë më efektive.

Modeli i reaktorit I/O

Reaktori I/O shërben si një ndërmjetës midis selektorit sistemor dhe kodit përdorues. Parimi i tij i funksionimit përshkruhet nga diagrami i mëposhtëm:

Reaktori I/O i plotë në C të pastër

  • Për të kujtuar, një ngjarje është një njoftim se një soket i caktuar është në gjendje për të kryer një operacion I/O jo bllokues.
  • Përgjegjësi i ngjarjeve është një funksion, i cili thirret nga reaktori I/O kur merr një ngjarje, e cila më pas kryen një operacion I/O jo bllokues.

Është e rëndësishme të theksohet se reaktori I/O përkufizohet si një njëzë, por nuk ka asgjë që e ndalon përdorimin e konceptit në një ambient shumëzuar në lidhje me 1 rrjedhë: 1 reaktor, duke shfrytëzuar kështu të gjitha bërthamat e CPU-së.

Implementimi

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

Shfaq shpalljet në reactor.h

typedef struct reactor Reactor;

/*
 * Pika për funksionin që do të thirret nga reaktori I/O kur ndodh një
 * ngjarje nga selektori sistemor.
 */
typedef void (*Callback)(void *arg, int fd, uint32_t events);

/*
 * Kthen `NULL` në rast të gabimit, pointer jo-`NULL` në `Reactor`
 * në rast tjetër.
 */
Reactor *reactor_new(void);

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

/*
 * Nis ciklin e ngjarjeve me kufirin `timeout`.
 *
 * Kjo funksion do të transferojë kontrollin në kodin e thirrjes nëse koha e rezervuar ka skaduar
 * ose/edhe në mungesë të soketeve të regjistruara.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

Struktura e reaktorit I/O përbëhet nga deshifruesi i skedarit selektori epoll dhe tabela hash GHashTable, e cila përputh çdo socket me CallbackData (strukturë nga menaxheri i ngjarjeve dhe argumenti i përdoruesit për të).

Shiko Strukturën e Reaktorit dhe CallbackData

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

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

Vërejtje, kemi përdorur mundësinë e trajtimit të tipit të papërfunduar përmes pointerit. Në reactor.h ne deklarojmë strukturën reactor, ndërsa në reactor.c e cila e përcakton, duke mos lejuar përdoruesin që të ndryshojë qartë fushat e saj. Kjo është një nga modelet e fshehjes së të dhënave, që përputhet me semantikën C.

Funksionet reactor_register, reactor_deregister dhe reactor_reregister përditësojnë listën e soketeve të interesit dhe menaxherëve të ngjarjeve në selektorin sistemor dhe në tabelën hash.

Shiko 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 kapjes së ngjarjes nga reaktori I/O me deshifruesin fd, ai thërret menaxherin përkatës të ngjarjes, duke i kaluar fd, maskën e bitëve të ngjarjeve të gjeneruara dhe pointerin e përdoruesit në void.

Shiko 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) {
        // Ошибка
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Время вышло
        case 0:
            result = 0;
            goto cleanup;
        // Успешная операция
        default:
            // Вызвать обработчиков событий
            for (int i = 0; i table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

Në përfundim, zinxhiri i thirrjeve të funksioneve në kodin e përdoruesit do të marrë formën e mëposhtme:

Reaktori I/O i plotë në C të pastër

Server një-fibrash

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ë.

Informacione të shkurtra mbi protokollin HTTP

HTTP — është një protokoll i nivelit aplikativ, kryesisht i përdorur për komunikimin ndërmjet serverit dhe shfletuesit.

HTTP mund të përdoret lehtë mbi transportin protokollit TCP, duke dërguar dhe pranuar mesazhe në formatin e përcaktuar nga specifikimin.

Formati i kërkesës

CRLF
CRLF
CRLF
CRLF CRLF

  • CRLF — është një sekuencë e dy simboleve: r dhe n, që ndan rreshtin e parë të kërkesës, headers dhe të dhënat.
  • <КОМАНДА> — njëra nga CONNECT, FSHI, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Shfletuesi do t'i dërgojë serverit tonë komandën GET, që do të thotë "Më dërgo përmbajtjen e skedarit".
  • <URI> — identifikuesi i unifikuar i burimit. Për shembull, nëse URI = /index.html, klienti kërkon faqen kryesore të faqes.
  • <ВЕРСИЯ HTTP> — versioni i protokollit HTTP në formatin HTTP/X.Y. Versioni më i përdorur sot është HTTP/1.1.
  • <ЗАГОЛОВОК N> — është një çift çelës-vlerë në formatin :, që dërgohet serverit për analizë të mëtejshme.
  • <ДАННЫЕ> — të dhënat e nevojshme për serverin për të kryer operacionin. Shpesh është thjesht JSON ose ndonjë format tjetër.

Formati i përgjigjes

CRLF CRLF CRLF CRLF CRLF

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

  • <ЗАГОЛОВОК N> — një kokë e po atij formati si në kërkesë. Ne do të kthejmë kokat Content-Length (madhësia e skedarit) dhe Content-Type: text/html (tipi i të dhënave që kthehen).
  • <ДАННЫЕ> — të dhënat e kërkuara nga përdoruesi. Në rastin tonë, kjo është rruga drejt images në HTML.

Skeda http_server.c (server i njëmbledhës) përfshin skedarin common.h, i cili përmban prototipet e funksioneve të mëposhtme:

Shfaq 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 makrosi funksional SAFE_CALL() dhe është e definuar funksioni fail(). Makrosi krahasohet me vlerën e shprehjes me gabimin, dhe nëse kushti është 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 dhëna në terminal (si printf()) dhe ndalon punën e programit 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ë deshifrim skedari për 'socketin' e serverit, të krijuar me thirrjet sistemore socket(), bind() dhe listen() dhe është e aftë të pranojë lidhje të ardhshme në modin jo-bllokues.

Shfaq 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 socketi fillimisht krijohet në modin jo-bllokues me flagun SOCK_NONBLOCK, në mënyrë që në funksionin on_accept() (lexoni më tej) thirrja sistemore accept() nuk ndalon ekzekutimin e thread-it.
  • Nëse reuse_port është true, atëherë ky funksion do ta konfigurojë socketin me opsionin SO_REUSEPORT përmes setsockopt(), për të përdorur të njëjtin port në një mjedis shumë-threadesh (shih seksionin 'Server i shumë-threadit').

Menaxheri i ngjarjeve on_accept() thirret pasi OS të gjenerojë një ngjarje EPOLLIN, në këtë rast që nënkupton se një lidhje e re mund të pranohet. on_accept() pranon një lidhje të re, e kalon në modin jo-bllokues dhe regjistron me menaxherin e ngjarjes on_recv() në reaktorin I/O.

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

Menaxheri i ngjarjeve on_recv() thirret pasi OS të gjenerojë një ngjarje EPOLLIN, që nënkupton se lidhja e regjistruar on_accept(), është gati për të pranuar të dhëna.

on_recv() lexon të dhëna nga lidhja derisa kërkesa HTTP të jetë e plotë, pastaj regjistron trajtuesin on_send() për të dërguar përgjigjen HTTP. Nëse klienti ndërpret lidhjen, socket-i de-regjistrohet dhe mbyllet përmes close().

Trego funksionin on_recv()

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

    // Pranojem të dhënat 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 tjetër përveç gabimit që do ta 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ë trajtuesin
    // 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);
    }
}

Menaxheri i ngjarjeve on_send() thirret pasi OS të gjenerojë një ngjarje EPOLLOUT, që nënkupton se lidhja e regjistruar on_recv(), është gati për të dërguar të dhëna. Ky funksion dërgon përgjigjen HTTP, që përmban HTML me një imazh, klientit, dhe më pas ndryshon përsëri trajtuesin 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 në fund, në skedarin http_server.c, në funksionin main() ne krijojmë një reaktor I/O përmes reactor_new(), krijojmë një socket serveri dhe e regjistrojmë, e fillojmë reaktorin përmes reactor_run() pikërisht për një minutë, dhe më pas lirojmë burimet dhe dalim nga programi.

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

Le t'i kontrollojmë se të gjitha funksionon siç duhet. E kompulojmë (chmod a+x compile.sh && ./compile.sh në rrënjën e projektit) dhe nisëm serverin tonë të shkruar vetë, hapim http://127.0.0.1:18470 në shfletues dhe vëzhgojmë atë që prisnim:

Reaktori I/O i plotë në C të pastër

Matja e performancës

Trego karakteristikat e makinës sime

$ 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 një serveri një-në-një. Do ta hapim dy terminale: në njërin do të ekzekutojmë ./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 ynë një-në-një arriti të përpunojë mbi 11 milion kërkesa në minutë nga 100 lidhje. Një rezultat i këndshëm, por a mund ta përmirësojmë atë?

Serveri shumë-në-një

Siç u tha më lart, reaktori I/O mund të krijohet në procese të veçanta, duke shfrytëzuar të gjithë bërthamat e CPU-së. 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 vet: static Reactor *reactor; #pragma omp threadprivate(reactor)

Vini re se si argumenti i funksionit

. Kjo do të thotë se po i japim socket-it të serverit mundësinë new_server() të ardhmen do të dalin true, për ta përdorur atë në një mjedis shumë-në-një. Mund të lexoni më shumë SO_REUSEPORTTani do të masim performancën e serverit shumë-në-një: këtu.

Qasja e dytë

$ 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

Numri i kërkesave të përpunuara në 1 minutë u rrit në ~3.28 herë! Por na munguan vetëm ~dy milion për një numër të rrethuar, le të përpiqemi ta rregullojmë këtë.

Së pari, le të shohim statistikat e gjeneruara

Së pari, le të shikojmë statistikën e gjeneruar 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 numrave për '. /http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPU-t e shfrytëzuar          
         1 813 074      kalimet e kontekstit          #    0,007 M/sec                  
             4 689      migrimet e CPU-së            #    0,019 K/sec                  
               254      gabimet e faqeve               #    0,001 K/sec                  
   895 324 830 170      ciklet                    #    3,693 GHz                    
   621 378 066 808      instruksyonet              #    0,69  instruksione për cikël         
   119 926 709 370      degët                  #  494,653 M/sec                  
     3 227 095 669      humbjet e degëve             #    2,69% e të gjitha degëve        
           808 664      humbjet e caches                                                

      60,604330670 sekondë kohë e kaluar

Përdorimi i afinitetit të CPU-së, kompilimi me -march=native, PGO, rritja e numrit të goditjeve në cache, rritja MAX_EVENTS dhe përdorimi EPOLLET nuk solli një rritje të konsiderueshme në performancë. Po, çfarë do ndodhte nëse rritnim numrin e lidhjeve të njëkohshme?

Statistika me 352 lidhje të njëkohshme:

$ 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 e realizuar testin për 1m @ http://127.0.0.1:18470
  8 threads dhe 352 lidhje
  Statistikat e Fijeve   Avg      Stdev     Max   +/ Stdev
    Vonesa     2.12ms    3.79ms  68.23ms   87.49%
    Req/Sek    83.78k    12.69k  169.81k    83.59%
  40006142 kërkesa në 1.00m, 5.48GB lexuar
Kërkesa/sec: 665789.26
Transfer/sec:     93.34MB

Rezultati i dëshiruar është arritur, dhe bashkë me të një grafik interesant që tregon varësinë e numrit të kërkesave të procesuar gjatë 1 minute nga numri i lidhjeve:

Reaktori I/O i plotë në C të pastër

E shohim se pas disa qindra lidhjesh, numri i kërkesave të procesuara për të dy serverët bie ndjeshëm (në versionin me shumë fije më duket edhe më e dukshme). A ka të bëjë kjo me implementimin e stack-ut TCP/IP në Linux? Shprehni mendimet tuaja lidhur me këtë sjellje të grafikët dhe optimizimet e versioneve me shumë dhe një fije në komente.

Si theksuan në komente, ky test performancës nuk tregon sjelljen e reaktorit I/O nën ngarkesa reale, pasi pothuajse gjithmonë serveri interakton me DB, shfaq log-et, përdor kriptografi me TLS etj., duke e bërë ngarkesën të pauniformë (dinamike). Testet së bashku me komponentët e jashtëm do të zhvillohen në një artikull mbi reaktorin I/O.

Disavantazhet e reaktorit I/O

Duhet të kuptoni se reaktori I/O nuk është pa disavantazhe, pra:

  • Të përdorësh reaktorin I/O në një mjedis me shumë fije është disi më e komplikuar, pasi do të duhet të menaxhosh manualisht fijet.
  • Praktika tregon se në shumicën e rasteve ngarkesa është e pabarabartë, gjë që mund të çojë në situata ku një rrjedhë do të punojë, ndërkohë që një tjetër do të jetë e ngarkuar me punë.
  • Nëse një trajtues eventi bllokon rrjedhën, atëherë do të bllokohet gjithashtu dhe selektori sistematik, që mund të çojë në defekte të vështira për t'u kapur.

Këto probleme zgjidhen nga I/O proaktor, shpesh me një planifikues që shpërndan ngarkesën në mënyrë të barabartë në grupin e rrjedhave, dhe gjithashtu me një API më të lehtë për t'u përdorur. Për atë do të flasim më vonë, në artikullin tim tjetër.

Përfundim

Kështu, udhëtimi ynë nga teoria drejtpërdrejt në rezultatin e profiler-it përfundoi.

Nuk duhet të ndalemi këtu, sepse ka shumë qasje të tjera po aq interesante për shkrimin e softuerit rrjetor me nivele të ndryshme lehtësie dhe shpejtësie. Linket interesante, sipas mendimit tim, janë të dhëna më poshtë.

Në takim të ardhshëm!

Projekte interesante

Çfarë tjetër të lexoni?

Burimi: habr.com

Blini hosting të besueshëm për faqe interneti me mbrojtje nga DDoS, serverë VPS VDS 🔥 Blini hosting të besueshëm për faqe interneti me mbrojtje nga DDoS, serverë VPS VDS | ProHoster