Täysin varusteltu bare-C I/O-reaktori

Täysin varusteltu bare-C I/O-reaktori

Esittely

I/O-reaktori (yksikierteinen tapahtumasilmukka) on malli raskaan ohjelmiston kirjoittamiseen, jota käytetään monissa suosituissa ratkaisuissa:

Tässä artikkelissa tarkastellaan I/O-reaktorin ja sen toimintatapoja, kirjoitetaan toteutus alle 200 koodirivillä ja tehdään yksinkertainen HTTP-palvelinprosessi yli 40 miljoonalla pyynnöllä/min.

Esipuhe

  • Artikkeli on kirjoitettu auttamaan ymmärtämään I/O-reaktorin toimintaa ja siten ymmärtämään sen käytön riskejä.
  • Artikkelin ymmärtäminen edellyttää perusasioiden tuntemusta. C-kieli ja jonkin verran kokemusta verkkosovellusten kehittämisestä.
  • Kaikki koodi on kirjoitettu C-kielellä tiukasti (varoitus: pitkä PDF) C11-standardin mukaan Linuxille ja saatavilla GitHub.

Miksi se?

Internetin kasvavan suosion myötä web-palvelimet alkoivat joutua käsittelemään suurta määrää yhteyksiä samanaikaisesti, ja siksi kokeiltiin kahta lähestymistapaa: I/O:n estäminen suurella määrällä käyttöjärjestelmäsäikeitä ja ei-esto I/O yhdessä tapahtumailmoitusjärjestelmä, jota kutsutaan myös "järjestelmän valitsimeksi" (epoll/kqueue/IOCP/jne).

Ensimmäinen lähestymistapa sisälsi uuden käyttöjärjestelmäsäikeen luomisen jokaiselle saapuvalle yhteydelle. Sen haittana on huono skaalautuvuus: käyttöjärjestelmän on otettava käyttöön monia kontekstin siirtymät и järjestelmäpuhelut. Ne ovat kalliita toimintoja ja voivat johtaa vapaan RAM-muistin puutteeseen vaikuttavalla määrällä yhteyksiä.

Muokattu versio korostaa kiinteä määrä lankoja (säievarasto), mikä estää järjestelmää keskeyttämästä suoritusta, mutta samalla tuo mukanaan uuden ongelman: jos säiepooli on tällä hetkellä tukossa pitkien lukutoimintojen takia, muut pistokkeet, jotka jo pystyvät vastaanottamaan tietoja, eivät pysty tee niin.

Toinen lähestymistapa käyttää tapahtumailmoitusjärjestelmä (järjestelmän valitsin), jonka käyttöjärjestelmä tarjoaa. Tässä artikkelissa käsitellään yleisintä järjestelmän valitsintyyppiä, joka perustuu hälytyksiin (tapahtumat, ilmoitukset) I/O-toimintojen valmiudesta sen sijaan, että ilmoitukset niiden valmistumisesta. Yksinkertaistettu esimerkki sen käytöstä voidaan esittää seuraavalla lohkokaaviolla:

Täysin varusteltu bare-C I/O-reaktori

Ero näiden lähestymistapojen välillä on seuraava:

  • I/O-toimintojen estäminen keskeyttää käyttäjävirta siihen asti kunkunnes käyttöjärjestelmä on kunnossa eheytys saapuva IP-paketit tavuvirtaan (TCP, vastaanottaa tietoja) tai sisäisissä kirjoituspuskureissa ei ole tarpeeksi tilaa myöhempää lähettämistä varten NIC (lähettää dataa).
  • Järjestelmän valitsin ajan myötä ilmoittaa ohjelmalle, että käyttöjärjestelmä jo eheytetyt IP-paketit (TCP, tiedon vastaanotto) tai riittävästi tilaa sisäisissä kirjoituspuskureissa jo käytettävissä (lähetetään tietoja).

Yhteenvetona voidaan todeta, että käyttöjärjestelmäsäikeen varaaminen kullekin I/O:lle on laskentatehon tuhlausta, koska todellisuudessa säikeet eivät tee hyödyllistä työtä (tämä termi "ohjelmiston keskeytys"). Järjestelmän valitsin ratkaisee tämän ongelman, jolloin käyttäjäohjelma voi käyttää CPU-resursseja paljon taloudellisemmin.

I/O-reaktorin malli

I/O-reaktori toimii kerroksena järjestelmävalitsimen ja käyttäjäkoodin välillä. Sen toimintaperiaate on kuvattu seuraavalla lohkokaaviolla:

Täysin varusteltu bare-C I/O-reaktori

  • Haluan muistuttaa, että tapahtuma on ilmoitus siitä, että tietty socket pystyy suorittamaan estävän I/O-toiminnon.
  • Tapahtumakäsittelijä on I/O-reaktorin kutsuma toiminto, kun tapahtuma vastaanotetaan, ja joka sitten suorittaa estävän I/O-operaation.

On tärkeää huomata, että I/O-reaktori on määritelmän mukaan yksisäikeinen, mutta mikään ei estä konseptia käyttämästä monisäikeisessä ympäristössä suhteessa 1 säie:1 reaktori, mikä kierrättää kaikki CPU:n ytimet.

Реализация

Sijoitamme julkisen käyttöliittymän tiedostoon reactor.h, ja toteutus - sisään reactor.c. reactor.h koostuu seuraavista ilmoituksista:

Näytä ilmoitukset reaktorissa.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);

I/O-reaktorin rakenne koostuu tiedostokuvaaja селектора epoll и hash-taulukoita GHashTable, joka yhdistää jokaisen pistorasian CallbackData (tapahtumakäsittelijän rakenne ja käyttäjäargumentti sille).

Näytä reaktori- ja takaisinsoittotiedot

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

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

Huomaa, että olemme ottaneet käyttöön mahdollisuuden käsitellä epätäydellinen tyyppi indeksin mukaan. SISÄÄN reactor.h kerromme rakenteen reactorja sisään reactor.c määrittelemme sen ja estämme siten käyttäjää muuttamasta kenttiä erikseen. Tämä on yksi malleista tietojen piilottaminen, joka sopii ytimekkäästi C-semantiikkaan.

Tehtävät reactor_register, reactor_deregister и reactor_reregister päivitä luettelo kiinnostavista pistokkeista ja vastaavista tapahtumakäsittelijöistä järjestelmävalitsimessa ja hash-taulukossa.

Näytä rekisteröintitoiminnot

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

Kun I/O-reaktori on siepannut tapahtuman kuvaajan kanssa fd, se kutsuu vastaavaa tapahtumakäsittelijää, jolle se siirtyy fd, bitti naamio luodut tapahtumat ja käyttäjän osoitin void.

Näytä reactor_run()-funktio

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

Yhteenvetona voidaan todeta, että toimintokutsujen ketju käyttäjäkoodissa on seuraavanlainen:

Täysin varusteltu bare-C I/O-reaktori

Yksisäikeinen palvelin

I/O-reaktorin testaamiseksi suurella kuormituksella kirjoitamme yksinkertaisen HTTP-verkkopalvelimen, joka vastaa kaikkiin pyyntöihin kuvalla.

Pikaviittaus HTTP-protokollaan

HTTP - Tämä on protokolla sovellustaso, jota käytetään ensisijaisesti palvelimen ja selaimen vuorovaikutukseen.

HTTP:tä voidaan helposti käyttää yli kuljetus protokollaa TCP, lähettää ja vastaanottaa viestejä määritetyssä muodossa erittely.

Pyynnön muoto

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

  • CRLF on kahden merkin sarja: r и n, joka erottaa pyynnön ensimmäisen rivin, otsikot ja tiedot.
  • <КОМАНДА> - Yksi CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Selain lähettää komennon palvelimellemme GET, joka tarkoittaa "Lähetä minulle tiedoston sisältö".
  • <URI> - yhtenäinen resurssitunniste. Esimerkiksi jos URI = /index.html, то клиент запрашивает главную страницу сайта.
  • <ВЕРСИЯ HTTP> — HTTP-protokollan versio muodossa HTTP/X.Y. Nykyään yleisimmin käytetty versio on HTTP/1.1.
  • <ЗАГОЛОВОК N> on avain-arvo-pari muodossa <КЛЮЧ>: <ЗНАЧЕНИЕ>, lähetetään palvelimelle lisäanalyysiä varten.
  • <ДАННЫЕ> — palvelimen toiminnon suorittamiseen tarvitsemat tiedot. Usein se on yksinkertaista JSON или любой другой формат.

Vastausmuoto

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

  • <КОД СТАТУСА> on luku, joka edustaa operaation tulosta. Palvelimemme palauttaa aina tilan 200 (onnistunut toiminta).
  • <ОПИСАНИЕ СТАТУСА> — tilakoodin merkkijonoesitys. Tilakoodille 200 tämä on OK.
  • <ЗАГОЛОВОК N> — otsikko, jonka muoto on sama kuin pyynnössä. Palautamme otsikot Content-Length (tiedoston koko) ja Content-Type: text/html (palautustietotyyppi).
  • <ДАННЫЕ> — käyttäjän pyytämät tiedot. Meidän tapauksessamme tämä on polku sisään tulevaan kuvaan HTML.

tiedosto http_server.c (yksisäikeinen palvelin) sisältää tiedoston common.h, который содержит следующие прототипы функций:

Näytä funktion prototyypit yhteisissä.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);

Myös toiminnallinen makro kuvataan SAFE_CALL() ja funktio on määritelty fail(). Makro vertaa lausekkeen arvoa virheeseen, ja jos ehto on tosi, se kutsuu funktiota fail():

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

Toiminto fail() tulostaa päätelaitteelle välitetyt argumentit (esim printf()) ja lopettaa ohjelman koodilla 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);
}

Toiminto new_server() palauttaa järjestelmäkutsujen luoman "palvelin" -socketin tiedostokuvaajan socket(), bind() и listen() ja pystyy vastaanottamaan saapuvia yhteyksiä estotilassa.

Показать функцию 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;
}

  • Huomaa, että pistoke luodaan alun perin estotilassa lippua käyttämällä SOCK_NONBLOCKniin että funktiossa on_accept() (lue lisää) järjestelmäkutsu accept() ei pysäyttänyt langan suorittamista.
  • Jos reuse_port on yhtä suuri kuin true, tämä toiminto määrittää liittimen vaihtoehdolla SO_REUSEPORT kautta setsockopt()käyttää samaa porttia monisäikeisessä ympäristössä (katso kohta "Monisäikeinen palvelin").

Tapahtumakäsittelijä on_accept() kutsutaan sen jälkeen, kun käyttöjärjestelmä on luonut tapahtuman EPOLLIN, mikä tässä tapauksessa tarkoittaa, että uusi yhteys voidaan hyväksyä. on_accept() hyväksyy uuden yhteyden, vaihtaa sen estotilaan ja rekisteröityy tapahtumakäsittelijään on_recv() I/O-reaktorissa.

Näytä on_accept()-funktio

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

Tapahtumakäsittelijä on_recv() kutsutaan sen jälkeen, kun käyttöjärjestelmä on luonut tapahtuman EPOLLIN, mikä tässä tapauksessa tarkoittaa, että yhteys rekisteröity on_accept(), valmis vastaanottamaan tietoja.

on_recv() lukee dataa yhteydestä, kunnes HTTP-pyyntö on vastaanotettu kokonaan, sitten se rekisteröi käsittelijän on_send() lähettääksesi HTTP-vastauksen. Jos asiakas katkaisee yhteyden, pistorasia poistetaan rekisteristä ja suljetaan käyttämällä close().

Näytä funktio 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);
    }
}

Tapahtumakäsittelijä on_send() kutsutaan sen jälkeen, kun käyttöjärjestelmä on luonut tapahtuman EPOLLOUT, mikä tarkoittaa, että yhteys on rekisteröity on_recv(), valmis lähettämään tietoja. Tämä toiminto lähettää asiakkaalle HTTP-vastauksen, joka sisältää HTML-koodin ja kuvan, ja muuttaa sitten tapahtumakäsittelijäksi takaisin on_recv().

Näytä on_send()-funktio

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

Ja lopuksi tiedostossa http_server.c, toiminnassa main() luomme I/O-reaktorin käyttämällä reactor_new(), luo palvelinpistoke ja rekisteröi se, käynnistä reaktori käyttämällä reactor_run() tarkalleen minuutin ajan, minkä jälkeen vapautamme resurssit ja poistumme ohjelmasta.

Näytä 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);
}

Tarkastetaan, että kaikki toimii odotetusti. Kääntäminen (chmod a+x compile.sh && ./compile.sh projektin juuressa) ja käynnistä itsekirjoitettu palvelin, avaa http://127.0.0.1:18470 selaimessa ja katso mitä odotimme:

Täysin varusteltu bare-C I/O-reaktori

Suorituskyvyn mittaus

Näytä auton tekniset tiedot

$ 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

Mittaataan yksisäikeisen palvelimen suorituskykyä. Avataan kaksi terminaalia: toisessa ajamme ./http_server, eri - wrk. Minuutin kuluttua toisessa päätteessä näytetään seuraavat tilastot:

$ 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

Yksisäikeinen palvelimemme pystyi käsittelemään yli 11 miljoonaa pyyntöä minuutissa 100 yhteydestä. Ei huono tulos, mutta voiko sitä parantaa?

Monisäikeinen palvelin

Kuten edellä mainittiin, I/O-reaktori voidaan luoda erillisinä säikeinä, jolloin hyödynnetään kaikkia CPU-ytimiä. Laitetaan tämä lähestymistapa käytäntöön:

Näytä 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);
    }
}

Nyt jokainen lanka omistaa omansa reaktori:

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

Huomaa, että funktion argumentti new_server() säädökset true. Tämä tarkoittaa, että määritämme vaihtoehdon palvelinpistokkeelle SO_REUSEPORTkäyttää sitä monisäikeisessä ympäristössä. Voit lukea lisätietoja täällä.

Toinen juoksu

Mittaa nyt monisäikeisen palvelimen suorituskykyä:

$ 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

1 minuutissa käsiteltyjen pyyntöjen määrä kasvoi ~3.28-kertaiseksi! Mutta meillä oli vain ~XNUMX miljoonaa pulaa pyöreästä numerosta, joten yritetään korjata se.

Katsotaanpa ensin luotuja tilastoja teho:

$ 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

CPU Affinityn käyttäminen, kokoelma kanssa -march=native, PGO, osumien määrän kasvu Käteinen raha, lisääntyä MAX_EVENTS ja käyttää EPOLLET ei lisännyt merkittävästi suorituskykyä. Mutta mitä tapahtuu, jos lisäät samanaikaisten yhteyksien määrää?

Tilastot 352 samanaikaisesta yhteydestä:

$ 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

Haluttu tulos saatiin ja sen mukana mielenkiintoinen kaavio, joka näyttää 1 minuutin aikana käsiteltyjen pyyntöjen määrän riippuvuuden yhteyksien määrästä:

Täysin varusteltu bare-C I/O-reaktori

Näemme, että parin sadan yhteyden jälkeen molempien palvelimien käsiteltyjen pyyntöjen määrä laskee jyrkästi (monisäikeisessä versiossa tämä on havaittavampi). Liittyykö tämä Linuxin TCP/IP-pinon toteutukseen? Voit vapaasti kirjoittaa kommentteihin oletuksesi tästä kaavion käyttäytymisestä ja optimoinnista monisäikeisille ja yksisäikeisille vaihtoehdoille.

miten huomioitu kommenteissa tämä suorituskykytesti ei näytä I/O-reaktorin käyttäytymistä todellisissa kuormituksissa, koska lähes aina palvelin on vuorovaikutuksessa tietokannan kanssa, tulostaa lokeja, käyttää salausta TLS jne., minkä seurauksena kuormitus muuttuu epätasaiseksi (dynaamiseksi). Testit yhdessä kolmannen osapuolen komponenttien kanssa suoritetaan I/O-proactoria käsittelevässä artikkelissa.

I/O-reaktorin haitat

Sinun on ymmärrettävä, että I/O-reaktorissa ei ole haittoja, nimittäin:

  • I/O-reaktorin käyttö monisäikeisessä ympäristössä on jonkin verran vaikeampaa, koska joudut hallitsemaan virtoja manuaalisesti.
  • Käytäntö osoittaa, että useimmissa tapauksissa kuormitus on epätasaista, mikä voi johtaa yhden säikeen kirjautumiseen, kun toinen on kiireinen töissä.
  • Jos yksi tapahtumakäsittelijä estää säiettä, myös järjestelmän valitsin itse estää, mikä voi johtaa vaikeasti löydettäviin virheisiin.

Ratkaisee nämä ongelmat I/O proactor, jossa on usein ajastin, joka jakaa kuorman tasaisesti säikeiden joukkoon, ja jossa on myös kätevämpi API. Puhumme siitä myöhemmin, toisessa artikkelissani.

Johtopäätös

Tässä on matkamme teoriasta suoraan profilerin pakokaasuun päättynyt.

Sinun ei pitäisi jäädä tähän, koska verkkoohjelmistojen kirjoittamiseen on monia muita yhtä mielenkiintoisia tapoja, joilla on erilainen mukavuus ja nopeus. Mielenkiintoisia, mielestäni alla on linkkejä.

Kunnes tapaamme jälleen!

Mielenkiintoisia projekteja

Mitä muuta minun pitäisi lukea?

Lähde: will.com

Lisää kommentti