Plne vybavený I/O reaktor s bare-C

Plne vybavený I/O reaktor s bare-C

Úvod

I/O reaktor (s jedným závitom slučka udalostí) je vzor na písanie vysoko zaťaženého softvéru, ktorý sa používa v mnohých populárnych riešeniach:

V tomto článku sa pozrieme na výhody a nevýhody I/O reaktora a na to, ako funguje, napíšeme implementáciu v menej ako 200 riadkoch kódu a vytvoríme jednoduchý proces HTTP servera cez 40 miliónov požiadaviek/min.

Predslov

  • Článok bol napísaný s cieľom pomôcť pochopiť fungovanie I/O reaktora, a teda pochopiť riziká pri jeho používaní.
  • Na pochopenie článku je potrebná znalosť základov. jazyk C a určité skúsenosti s vývojom sieťových aplikácií.
  • Celý kód je napísaný v jazyku C presne podľa (pozor: dlhé PDF) podľa normy C11 pre Linux a dostupné na GitHub.

Prečo to urobil?

S rastúcou popularitou internetu začali webové servery potrebovať zvládnuť veľké množstvo pripojení súčasne, a preto sa vyskúšali dva prístupy: blokovanie I/O na veľkom počte vlákien OS a neblokovanie I/O v kombinácii s systém oznamovania udalostí, nazývaný aj „výber systému“ (epoll/kqueue/IOCP/atď).

Prvý prístup zahŕňal vytvorenie nového vlákna operačného systému pre každé prichádzajúce pripojenie. Jeho nevýhodou je slabá škálovateľnosť: operačný systém ich bude musieť implementovať veľa kontextové prechody и systémové volania. Sú to drahé operácie a môžu viesť k nedostatku voľnej pamäte RAM s pôsobivým počtom pripojení.

Upravená verzia zdôrazňuje pevný počet vlákien (pool vlákien), čím sa bráni systému v prerušení vykonávania, ale zároveň sa zavádza nový problém: ak je fond vlákien momentálne zablokovaný dlhými operáciami čítania, potom ostatné sokety, ktoré sú už schopné prijímať údaje, nebudú môcť urob tak.

Druhý prístup využíva systém oznamovania udalostí (systémový selektor), ktorý poskytuje OS. Tento článok pojednáva o najbežnejšom type selektora systému na základe upozornení (udalosti, upozornenia) o pripravenosti na vstupno-výstupné operácie, a nie na oznámenia o ich ukončení. Zjednodušený príklad jeho použitia môže predstavovať nasledujúca bloková schéma:

Plne vybavený I/O reaktor s bare-C

Rozdiel medzi týmito prístupmi je nasledovný:

  • Blokovanie I/O operácií pozastaviť používateľský tok kýmkým nie je OS správne defragmentuje prichádzajúce IP pakety do byte streamu (TCP, prijímanie dát) alebo v interných zapisovacích vyrovnávacích pamätiach nebude dostatok miesta na následné odoslanie cez NIC (odosielanie údajov).
  • Systémový volič časom upozorní program, že OS defragmentované IP pakety (TCP, príjem dát) alebo dostatok miesta v interných zapisovacích bufferoch dostupné (odosielanie údajov).

Aby som to zhrnul, rezervovanie vlákna OS pre každý I/O je plytvanie výpočtovým výkonom, pretože v skutočnosti vlákna nerobia užitočnú prácu (odtiaľ pojem "softvérové ​​prerušenie"). Tento problém rieši systémový selektor, ktorý umožňuje užívateľskému programu využívať zdroje CPU oveľa hospodárnejšie.

Model I/O reaktora

I/O reaktor funguje ako vrstva medzi selektorom systému a užívateľským kódom. Princíp jeho činnosti je opísaný nasledujúcim blokovým diagramom:

Plne vybavený I/O reaktor s bare-C

  • Dovoľte mi pripomenúť, že udalosť je upozornenie, že určitý soket je schopný vykonať neblokujúcu I/O operáciu.
  • Obsluha udalosti je funkcia volaná I/O reaktorom pri prijatí udalosti, ktorá potom vykoná neblokujúcu I/O operáciu.

Je dôležité poznamenať, že I/O reaktor je z definície jednovláknový, ale nič nebráni tomu, aby sa tento koncept použil vo viacvláknovom prostredí v pomere 1 vlákno: 1 reaktor, čím sa recyklujú všetky jadrá CPU.

Реализация

Verejné rozhranie umiestnime do súboru reactor.h, a implementácia - v reactor.c. reactor.h bude pozostávať z nasledujúcich oznámení:

Zobraziť deklarácie v reaktore.h

typedef struct reactor Reactor;

/*
 * Указатель на функцию, которая будет вызываться I/O реактором при поступлении
 * события от системного селектора.
 */
typedef void (*Callback)(void *arg, int fd, uint32_t events);

/*
 * Возвращает `NULL` в случае ошибки, не-`NULL` указатель на `Reactor` в
 * противном случае.
 */
Reactor *reactor_new(void);

/*
 * Освобождает системный селектор, все зарегистрированные сокеты в данный момент
 * времени и сам I/O реактор.
 *
 * Следующие функции возвращают -1 в случае ошибки, 0 в случае успеха.
 */
int reactor_destroy(Reactor *reactor);

int reactor_register(const Reactor *reactor, int fd, uint32_t interest,
                     Callback callback, void *callback_arg);
int reactor_deregister(const Reactor *reactor, int fd);
int reactor_reregister(const Reactor *reactor, int fd, uint32_t interest,
                       Callback callback, void *callback_arg);

/*
 * Запускает цикл событий с тайм-аутом `timeout`.
 *
 * Эта функция передаст управление вызывающему коду если отведённое время вышло
 * или/и при отсутствии зарегистрированных сокетов.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

Štruktúra I/O reaktora pozostáva z deskriptor súboru selektor epoll и hashovacie tabuľky GHashTable, ktorý mapuje každú zásuvku CallbackData (štruktúra obsluhy udalosti a jej užívateľský argument).

Zobraziť údaje reaktora a spätného volania

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

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

Upozorňujeme, že sme povolili možnosť spracovania neúplný typ podľa indexu. IN reactor.h deklarujeme štruktúru reactorA reactor.c definujeme ho, čím neumožňujeme používateľovi explicitne meniť jeho polia. Toto je jeden zo vzorov skrývanie údajov, čo stručne zapadá do sémantiky C.

Funkcia reactor_register, reactor_deregister и reactor_reregister aktualizujte zoznam záujmových soketov a zodpovedajúcich obslužných programov udalostí v systémovom selektore a hašovacej tabuľke.

Zobraziť funkcie registrácie

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

Potom, čo I/O reaktor zachytil udalosť pomocou deskriptora fd, zavolá zodpovedajúcu obsluhu udalosti, ktorej prejde fd, bitová maska generované udalosti a používateľský ukazovateľ void.

Zobraziť funkciu reaktor_run().

int reactor_run(const Reactor *reactor, time_t timeout) {
    int result;
    struct epoll_event *events;
    if ((events = calloc(MAX_EVENTS, sizeof(*events))) == NULL)
        abort();

    time_t start = time(NULL);

    while (true) {
        time_t passed = time(NULL) - start;
        int nfds =
            epoll_wait(reactor->epoll_fd, events, MAX_EVENTS, timeout - passed);

        switch (nfds) {
        // Ошибка
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Время вышло
        case 0:
            result = 0;
            goto cleanup;
        // Успешная операция
        default:
            // Вызвать обработчиков событий
            for (int i = 0; i < nfds; i++) {
                int fd = events[i].data.fd;

                CallbackData *callback =
                    g_hash_table_lookup(reactor->table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

Aby sme to zhrnuli, reťaz volaní funkcií v používateľskom kóde bude mať nasledujúcu formu:

Plne vybavený I/O reaktor s bare-C

Jednovláknový server

Aby sme otestovali I/O reaktor pri vysokej záťaži, napíšeme jednoduchý HTTP web server, ktorý na akúkoľvek požiadavku odpovie obrázkom.

Stručný odkaz na protokol HTTP

HTTP - toto je protokol úroveň aplikácie, ktorý sa primárne používa na interakciu server-prehliadač.

HTTP sa dá ľahko použiť cez dopravy protokol TCPodosielanie a prijímanie správ v určenom formáte špecifikácia.

Formát žiadosti

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

  • CRLF je postupnosť dvoch znakov: r и n, pričom sa oddeľuje prvý riadok požiadavky, hlavičky a údaje.
  • <КОМАНДА> - jeden z CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Prehliadač odošle príkaz na náš server GET, čo znamená "Pošlite mi obsah súboru."
  • <URI> - jednotný identifikátor zdroja. Napríklad, ak URI = /index.html, potom klient požiada o hlavnú stránku webu.
  • <ВЕРСИЯ HTTP> — verzia protokolu HTTP vo formáte HTTP/X.Y. Dnes je najpoužívanejšia verzia HTTP/1.1.
  • <ЗАГОЛОВОК N> je pár kľúč – hodnota vo formáte <КЛЮЧ>: <ЗНАЧЕНИЕ>, odoslaný na server na ďalšiu analýzu.
  • <ДАННЫЕ> — údaje požadované serverom na vykonanie operácie. Často je to jednoduché JSON alebo v akomkoľvek inom formáte.

Formát odpovede

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

  • <КОД СТАТУСА> je číslo predstavujúce výsledok operácie. Náš server vždy vráti stav 200 (úspešná operácia).
  • <ОПИСАНИЕ СТАТУСА> — reťazcová reprezentácia stavového kódu. Pre stavový kód 200 to je OK.
  • <ЗАГОЛОВОК N> — hlavička rovnakého formátu ako v žiadosti. Titulky vrátime Content-Length (veľkosť súboru) a Content-Type: text/html (typ návratových údajov).
  • <ДАННЫЕ> — údaje požadované používateľom. V našom prípade je to cesta k obrazu in HTML.

súbor http_server.c (server s jedným vláknom) obsahuje súbor common.h, ktorý obsahuje nasledujúce prototypy funkcií:

Zobraziť prototypy funkcií spoločné.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);

Opísané je aj funkčné makro SAFE_CALL() a funkcia je definovaná fail(). Makro porovná hodnotu výrazu s chybou a ak je podmienka pravdivá, zavolá funkciu fail():

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

Funkcia fail() vypíše odovzdané argumenty do terminálu (napr printf()) a ukončí program s kódom 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);
}

Funkcia new_server() vráti deskriptor súboru soketu "server" vytvorený systémovými volaniami socket(), bind() и listen() a schopný prijímať prichádzajúce spojenia v neblokovanom režime.

Zobraziť funkciu 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;
}

  • Všimnite si, že soket je pôvodne vytvorený v neblokujúcom režime pomocou príznaku SOCK_NONBLOCKtakže vo funkcii on_accept() (čítaj viac) systémové volanie accept() nezastavil spustenie vlákna.
  • Ak reuse_port je true, potom táto funkcia nakonfiguruje zásuvku s možnosťou SO_REUSEPORT skrz setsockopt()používať rovnaký port vo viacvláknovom prostredí (pozrite si časť „Viacvláknový server“).

Obslužný program udalostí on_accept() volaný po tom, čo operačný systém vygeneruje udalosť EPOLLIN, čo v tomto prípade znamená, že nové pripojenie môže byť prijaté. on_accept() prijme nové pripojenie, prepne ho do neblokovacieho režimu a zaregistruje sa pomocou obsluhy udalosti on_recv() v I/O reaktore.

Zobraziť funkciu 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);
}

Obslužný program udalostí on_recv() volaný po tom, čo operačný systém vygeneruje udalosť EPOLLIN, čo v tomto prípade znamená, že spojenie zaregistrované on_accept(), pripravený na príjem údajov.

on_recv() číta údaje z pripojenia, kým nie je úplne prijatá požiadavka HTTP, potom zaregistruje obsluhu on_send() na odoslanie odpovede HTTP. Ak klient preruší spojenie, soket sa odregistruje a zatvorí pomocou close().

Zobraziť funkciu on_recv()

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

    // Принимаем входные данные до тех пор, что recv возвратит 0 или ошибку
    ssize_t nread;
    while ((nread = recv(fd, buffer->data + buffer->size,
                         REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
        buffer->size += nread;

    // Клиент оборвал соединение
    if (nread == 0) {
        SAFE_CALL(reactor_deregister(reactor, fd), -1);
        SAFE_CALL(close(fd), -1);
        request_buffer_destroy(buffer);
        return;
    }

    // read вернул ошибку, отличную от ошибки, при которой вызов заблокирует
    // поток
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        request_buffer_destroy(buffer);
        fail("read");
    }

    // Получен полный HTTP запрос от клиента. Теперь регистрируем обработчика
    // событий для отправки данных
    if (request_buffer_is_complete(buffer)) {
        request_buffer_clear(buffer);
        SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
                  -1);
    }
}

Obslužný program udalostí on_send() volaný po tom, čo operačný systém vygeneruje udalosť EPOLLOUT, čo znamená, že pripojenie sa zaregistrovalo on_recv(), pripravený na odosielanie údajov. Táto funkcia odošle HTTP odpoveď obsahujúcu HTML s obrázkom klientovi a potom zmení obsluhu udalosti späť na on_recv().

Zobraziť funkciu 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);
}

A nakoniec v súbore http_server.c, vo funkcii main() vytvoríme I/O reaktor pomocou reactor_new(), vytvorte serverový soket a zaregistrujte ho, spustite reaktor pomocou reactor_run() presne na jednu minútu a potom uvoľníme zdroje a ukončíme program.

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

Skontrolujte, či všetko funguje podľa očakávania. Kompilácia (chmod a+x compile.sh && ./compile.sh v koreňovom adresári projektu) a spustite samonapísaný server, otvorte ho http://127.0.0.1:18470 v prehliadači a uvidíme, čo sme očakávali:

Plne vybavený I/O reaktor s bare-C

Meranie výkonnosti

Ukáž moje špecifikácie auta

$ 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

Poďme zmerať výkon jednovláknového servera. Otvorme dva terminály: v jednom spustíme ./http_server, v inom - wrk. Po minúte sa v druhom termináli zobrazia nasledujúce štatistiky:

$ 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

Náš jednovláknový server bol schopný spracovať viac ako 11 miliónov požiadaviek za minútu pochádzajúcich zo 100 pripojení. Nie je to zlý výsledok, ale dá sa to zlepšiť?

Viacvláknový server

Ako je uvedené vyššie, I/O reaktor môže byť vytvorený v samostatných vláknach, čím sa využívajú všetky jadrá CPU. Uveďme tento prístup do praxe:

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

Teraz každé vlákno vlastní svoje vlastné reaktor:

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

Upozorňujeme, že argument funkcie new_server() akty true. To znamená, že možnosť priradíme serverovému soketu SO_REUSEPORTpoužívať vo viacvláknovom prostredí. Môžete si prečítať viac tu.

Druhý beh

Teraz poďme zmerať výkon viacvláknového servera:

$ 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

Počet žiadostí spracovaných za 1 minútu sa zvýšil ~3.28 krát! K okrúhlemu číslu nám však chýbali len ~XNUMX milióny, tak to skúsme napraviť.

Najprv sa pozrime na vygenerované štatistiky výkon:

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

 Performance counter stats for './http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPUs utilized          
         1 813 074      context-switches          #    0,007 M/sec                  
             4 689      cpu-migrations            #    0,019 K/sec                  
               254      page-faults               #    0,001 K/sec                  
   895 324 830 170      cycles                    #    3,693 GHz                    
   621 378 066 808      instructions              #    0,69  insn per cycle         
   119 926 709 370      branches                  #  494,653 M/sec                  
     3 227 095 669      branch-misses             #    2,69% of all branches        
           808 664      cache-misses                                                

      60,604330670 seconds time elapsed

Použitie CPU Affinity, kompilácia s -march=native, PGO, zvýšenie počtu zásahov cache, zvýšiť MAX_EVENTS a používať EPOLLET nepriniesol výrazný nárast výkonu. Čo sa však stane, ak zvýšite počet súčasných pripojení?

Štatistika pre 352 súčasných spojení:

$ wrk -c352 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
  8 threads and 352 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     2.12ms    3.79ms  68.23ms   87.49%
    Req/Sec    83.78k    12.69k  169.81k    83.59%
  40006142 requests in 1.00m, 5.48GB read
Requests/sec: 665789.26
Transfer/sec:     93.34MB

Dosiahol sa požadovaný výsledok a s ním aj zaujímavý graf znázorňujúci závislosť počtu spracovaných požiadaviek za 1 minútu od počtu spojení:

Plne vybavený I/O reaktor s bare-C

Vidíme, že po niekoľkých stovkách spojení počet spracovaných požiadaviek pre oba servery prudko klesá (vo viacvláknovej verzii je to citeľnejšie). Súvisí to s implementáciou zásobníka TCP/IP v systéme Linux? Svoje predpoklady o tomto správaní grafu a optimalizáciách pre viacvláknové a jednovláknové možnosti pokojne napíšte do komentárov.

Ako poznamenal v komentároch tento výkonnostný test neukazuje správanie I/O reaktora pri reálnom zaťažení, pretože takmer vždy server interaguje s databázou, vydáva protokoly, používa kryptografiu s TLS atď., v dôsledku čoho sa zaťaženie stáva nerovnomerným (dynamickým). Testy spolu s komponentmi tretích strán budú vykonané v článku o I/O proaktore.

Nevýhody I/O reaktora

Musíte pochopiť, že I/O reaktor nie je bez nevýhod, konkrétne:

  • Použitie I/O reaktora vo viacvláknovom prostredí je o niečo náročnejšie, pretože budete musieť manuálne spravovať toky.
  • Prax ukazuje, že vo väčšine prípadov je zaťaženie nerovnomerné, čo môže viesť k protokolovaniu jedného vlákna, zatiaľ čo iné je zaneprázdnené prácou.
  • Ak jedna obsluha udalosti zablokuje vlákno, potom sa zablokuje aj samotný selektor systému, čo môže viesť k ťažko zisteným chybám.

Rieši tieto problémy I/O proaktor, ktorý má často plánovač, ktorý rovnomerne rozdeľuje záťaž na skupinu vlákien, a má aj pohodlnejšie API. Povieme si o tom neskôr, v mojom inom článku.

Záver

Tu sa naša cesta od teórie priamo k výfuku profilera skončila.

Nemali by ste sa tým zaoberať, pretože existuje mnoho ďalších rovnako zaujímavých prístupov k písaniu sieťového softvéru s rôznymi úrovňami pohodlia a rýchlosti. Zaujímavé, podľa môjho názoru, odkazy sú uvedené nižšie.

Až do budúcnosti!

Zaujímavé projekty

Čo ešte čítať?

Zdroj: hab.com

Pridať komentár