Pilnībā aprīkots tukšstrāvas ievades/izvades reaktors

Pilnībā aprīkots tukšstrāvas ievades/izvades reaktors

Ievads

I/O reaktors (vienvītnes notikumu cilpa) ir lielas slodzes programmatūras rakstīšanas paraugs, ko izmanto daudzos populāros risinājumos:

Šajā rakstā mēs apskatīsim I/O reaktora smalkumus un nepilnības, kā arī tā darbību, rakstīsim implementāciju mazāk nekā 200 koda rindās un izveidosim vienkāršu HTTP servera procesu, kas pārsniedz 40 miljonus pieprasījumu/min.

priekšvārds

  • Raksts tika uzrakstīts, lai palīdzētu izprast I/O reaktora darbību un tādējādi izprastu riskus, to lietojot.
  • Lai saprastu rakstu, ir nepieciešamas pamatzināšanas. C valoda un zināma pieredze tīkla lietojumprogrammu izstrādē.
  • Viss kods ir rakstīts C valodā stingri saskaņā ar (Uzmanību: garš PDF) atbilstoši C11 standartam operētājsistēmai Linux un pieejams GitHub.

Kāpēc tā?

Pieaugot interneta popularitātei, tīmekļa serveriem bija nepieciešams vienlaikus apstrādāt lielu skaitu savienojumu, un tāpēc tika izmēģinātas divas pieejas: I/O bloķēšana lielā skaitā OS pavedienu un nebloķēšana I/O kombinācijā ar notikumu paziņošanas sistēma, ko sauc arī par "sistēmas atlasītāju" (epoll/kqueue/IOCP/ utt).

Pirmā pieeja bija jauna OS pavediena izveide katram ienākošajam savienojumam. Tās trūkums ir slikta mērogojamība: operētājsistēmai būs jāievieš daudzi konteksta pārejas и sistēmas zvani. Tās ir dārgas darbības un var izraisīt bezmaksas RAM trūkumu ar iespaidīgu savienojumu skaitu.

Modificētā versija izceļ fiksēts diegu skaits (pavedienu pūls), tādējādi neļaujot sistēmai pārtraukt izpildi, bet tajā pašā laikā ieviešot jaunu problēmu: ja pavedienu pūls pašlaik ir bloķēts ilgstošu lasīšanas darbību dēļ, tad citas ligzdas, kas jau spēj saņemt datus, nevarēs dari tā.

Otrā pieeja izmanto notikumu paziņošanas sistēma (sistēmas atlasītājs), ko nodrošina OS. Šajā rakstā ir apskatīts visizplatītākais sistēmas atlasītāja veids, kura pamatā ir brīdinājumi (notikumi, paziņojumi) par gatavību I/O operācijām, nevis paziņojumus par to pabeigšanu. Vienkāršotu tā izmantošanas piemēru var attēlot ar šādu blokshēmu:

Pilnībā aprīkots tukšstrāvas ievades/izvades reaktors

Atšķirība starp šīm pieejām ir šāda:

  • I/O darbību bloķēšana apturēt lietotāju plūsma līdzlīdz OS ir pareizi defragmenti ienākošais IP paketes uz baitu straumi (TCP, saņemot datus), vai arī iekšējos rakstīšanas buferos nebūs pietiekami daudz vietas turpmākai sūtīšanai, izmantojot NIC (sūtot datus).
  • Sistēmas atlasītājs laika gaitā paziņo programmai, ka OS jau defragmentētas IP paketes (TCP, datu saņemšana) vai pietiekami daudz vietas iekšējos rakstīšanas buferos jau pieejams (sūtot datus).

Rezumējot, OS pavediena rezervēšana katram I/O ir skaitļošanas jaudas izniekošana, jo patiesībā pavedieni neveic lietderīgu darbu (tātad šis termins "programmatūras pārtraukums"). Sistēmas selektors atrisina šo problēmu, ļaujot lietotāja programmai izmantot CPU resursus daudz ekonomiskāk.

I/O reaktora modelis

I/O reaktors darbojas kā slānis starp sistēmas selektoru un lietotāja kodu. Tās darbības princips ir aprakstīts šādā blokshēmā:

Pilnībā aprīkots tukšstrāvas ievades/izvades reaktors

  • Atgādināšu, ka notikums ir paziņojums, ka noteikta ligzda spēj veikt nebloķējošu I/O darbību.
  • Notikumu apstrādātājs ir funkcija, ko izsauc I/O reaktors, kad tiek saņemts notikums, un pēc tam veic nebloķējošu I/O darbību.

Ir svarīgi atzīmēt, ka I/O reaktors pēc definīcijas ir ar vienu vītni, taču nekas neliedz šo koncepciju izmantot daudzpavedienu vidē ar attiecību 1 pavediens: 1 reaktors, tādējādi pārstrādājot visus CPU kodolus.

Ieviešana

Publisko saskarni ievietosim failā reactor.h, un ieviešana - in reactor.c. reactor.h sastāvēs no šādiem paziņojumiem:

Rādīt deklarācijas reaktorā.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 reaktora struktūra sastāv no faila deskriptors atlasītājs epoll и hash tabulas GHashTable, kas kartē katru ligzdu CallbackData (notikumu apstrādātāja struktūra un lietotāja arguments par to).

Rādīt reaktora un atzvanīšanas datus

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

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

Lūdzu, ņemiet vērā, ka esam iespējojuši iespēju apstrādāt nepilnīgs tips saskaņā ar indeksu. IN reactor.h mēs deklarējam struktūru reactorun reactor.c mēs to definējam, tādējādi neļaujot lietotājam skaidri mainīt tā laukus. Šis ir viens no modeļiem slēpjot datus, kas kodolīgi iekļaujas C semantikā.

Funkcijas reactor_register, reactor_deregister и reactor_reregister atjaunināt interesējošo ligzdu sarakstu un atbilstošos notikumu apdarinātājus sistēmas atlasītājā un jaucēj tabulā.

Rādīt reģistrācijas funkcijas

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

Pēc tam, kad I/O reaktors ir pārtvēris notikumu ar deskriptoru fd, tas izsauc atbilstošo notikumu apdarinātāju, kuram tas pāriet fd, bitu maska ģenerētie notikumi un lietotāja rādītājs void.

Rādīt funkciju 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 < 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;
}

Rezumējot, funkciju izsaukumu ķēde lietotāja kodā būs šāda:

Pilnībā aprīkots tukšstrāvas ievades/izvades reaktors

Viens vītņots serveris

Lai pārbaudītu I/O reaktoru ar lielu slodzi, mēs uzrakstīsim vienkāršu HTTP tīmekļa serveri, kas uz jebkuru pieprasījumu atbild ar attēlu.

Ātra atsauce uz HTTP protokolu

HTTP - tas ir protokols pielietojuma līmenis, ko galvenokārt izmanto servera un pārlūkprogrammas mijiedarbībai.

HTTP var viegli izmantot transports protokols TCP, sūtot un saņemot ziņas norādītajā formātā specifikācija.

Pieprasījuma formāts

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

  • CRLF ir divu rakstzīmju secība: r и n, atdala pieprasījuma pirmo rindiņu, galvenes un datus.
  • <КОМАНДА> - viens no CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Pārlūkprogramma nosūtīs komandu uz mūsu serveri GET, kas nozīmē "Nosūtiet man faila saturu".
  • <URI> Sākot no vienots resursa identifikators. Piemēram, ja URI = /index.html, tad klients pieprasa vietnes galveno lapu.
  • <ВЕРСИЯ HTTP> — HTTP protokola versija formātā HTTP/X.Y. Mūsdienās visbiežāk izmantotā versija ir HTTP/1.1.
  • <ЗАГОЛОВОК N> ir atslēgas-vērtības pāris formātā <КЛЮЧ>: <ЗНАЧЕНИЕ>, nosūtīts uz serveri turpmākai analīzei.
  • <ДАННЫЕ> — dati, kas nepieciešami serverim, lai veiktu darbību. Bieži vien tas ir vienkārši JSON vai jebkurā citā formātā.

Atbildes formāts

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

  • <КОД СТАТУСА> ir skaitlis, kas apzīmē darbības rezultātu. Mūsu serveris vienmēr atgriezīs statusu 200 (veiksmīga darbība).
  • <ОПИСАНИЕ СТАТУСА> — statusa koda virknes attēlojums. Statusa kodam 200 tas ir OK.
  • <ЗАГОЛОВОК N> — tāda paša formāta galvene kā pieprasījumā. Mēs atgriezīsim titulus Content-Length (faila lielums) un Content-Type: text/html (atgriešanas datu tips).
  • <ДАННЫЕ> — lietotāja pieprasītie dati. Mūsu gadījumā tas ir ceļš uz attēlu iekšā HTML.

fails http_server.c (viena pavediena serveris) ietver failu common.h, kurā ir šādi funkciju prototipi:

Rādīt funkciju prototipus kopīgos.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);

Ir aprakstīts arī funkcionālais makro SAFE_CALL() un funkcija ir definēta fail(). Makro salīdzina izteiksmes vērtību ar kļūdu un, ja nosacījums ir patiess, izsauc funkciju fail():

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

Funkcija fail() izdrukā terminālī nodotos argumentus (piemēram, printf()) un pārtrauc programmu ar kodu 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);
}

Funkcija new_server() atgriež sistēmas izsaukumu izveidotās "servera" ligzdas faila deskriptoru socket(), bind() и listen() un spēj pieņemt ienākošos savienojumus nebloķējošā režīmā.

Rādīt funkciju 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;
}

  • Ņemiet vērā, ka ligzda sākotnēji tiek izveidota nebloķēšanas režīmā, izmantojot karogu SOCK_NONBLOCKtā ka funkcijā on_accept() (lasīt vairāk) sistēmas izsaukums accept() neapturēja pavediena izpildi.
  • Ja reuse_port ir vienāds ar true, tad šī funkcija konfigurēs ligzdu ar opciju SO_REUSEPORT cauri setsockopt()lai izmantotu vienu un to pašu portu daudzpavedienu vidē (skatiet sadaļu “Daudzpavedienu serveris”).

Pasākumu apstrādātājs on_accept() izsauc pēc tam, kad OS ģenerē notikumu EPOLLIN, šajā gadījumā tas nozīmē, ka jauno savienojumu var pieņemt. on_accept() pieņem jaunu savienojumu, pārslēdz to uz nebloķēšanas režīmu un reģistrējas notikumu apstrādātājā on_recv() I/O reaktorā.

Rādīt funkciju 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);
}

Pasākumu apstrādātājs on_recv() izsauc pēc tam, kad OS ģenerē notikumu EPOLLIN, šajā gadījumā tas nozīmē, ka savienojums ir reģistrēts on_accept(), gatavs datu saņemšanai.

on_recv() nolasa datus no savienojuma, līdz tiek pilnībā saņemts HTTP pieprasījums, pēc tam reģistrē apstrādātāju on_send() lai nosūtītu HTTP atbildi. Ja klients pārtrauc savienojumu, kontaktligzda tiek dereģistrēta un aizvērta, izmantojot close().

Rādīt funkciju 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);
    }
}

Pasākumu apstrādātājs on_send() izsauc pēc tam, kad OS ģenerē notikumu EPOLLOUT, kas nozīmē, ka savienojums ir reģistrēts on_recv(), gatavs datu nosūtīšanai. Šī funkcija klientam nosūta HTTP atbildi, kas satur HTML ar attēlu, un pēc tam maina notikumu apstrādātāju atpakaļ uz on_recv().

Rādīt funkciju 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);
}

Un visbeidzot failā http_server.c, funkcijā main() mēs izveidojam I/O reaktoru, izmantojot reactor_new(), izveidojiet servera ligzdu un reģistrējiet to, iedarbiniet reaktoru, izmantojot reactor_run() tieši vienu minūti, un tad mēs atbrīvojam resursus un izejam no programmas.

Rādīt 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);
}

Pārbaudīsim, vai viss darbojas, kā paredzēts. Kompilēšana (chmod a+x compile.sh && ./compile.sh projekta saknē) un palaidiet pašrakstīto serveri, atveriet http://127.0.0.1:18470 pārlūkprogrammā un skatiet, ko mēs gaidījām:

Pilnībā aprīkots tukšstrāvas ievades/izvades reaktors

Veiktspējas mērīšana

Parādiet manas automašīnas specifikācijas

$ 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

Izmērīsim viena pavediena servera veiktspēju. Atvērsim divus termināļus: vienā mēs darbosimies ./http_server, citā - wrk. Pēc minūtes otrajā terminālī tiks parādīta šāda statistika:

$ 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

Mūsu viena pavediena serveris spēja apstrādāt vairāk nekā 11 miljonus pieprasījumu minūtē no 100 savienojumiem. Nav slikts rezultāts, bet vai to var uzlabot?

Daudzpavedienu serveris

Kā minēts iepriekš, I/O reaktoru var izveidot atsevišķos pavedienos, tādējādi izmantojot visus CPU kodolus. Pielietosim šo pieeju praksē:

Rādīt 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);
    }
}

Tagad katrs pavediens pieder savējais reaktors:

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

Lūdzu, ņemiet vērā, ka funkcijas arguments new_server() advokāti true. Tas nozīmē, ka mēs piešķiram iespēju servera ligzdai SO_REUSEPORTlai to izmantotu daudzpavedienu vidē. Jūs varat lasīt sīkāku informāciju šeit.

Otrais brauciens

Tagad mērīsim vairāku pavedienu servera veiktspēju:

$ 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 minūtē apstrādāto pieprasījumu skaits pieauga ~3.28 reizes! Bet mums pietrūka tikai ~XNUMX miljoni līdz apaļajam skaitlim, tāpēc mēģināsim to labot.

Vispirms apskatīsim ģenerēto statistiku ideāls:

$ 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

Izmantojot CPU Affinity, apkopojums ar -march=native, PGO, trāpījumu skaita pieaugums kešatmiņa, palielināt MAX_EVENTS un izmantot EPOLLET nedeva būtisku veiktspējas pieaugumu. Bet kas notiks, ja palielināsiet vienlaicīgu savienojumu skaitu?

Statistika par 352 vienlaicīgiem savienojumiem:

$ 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

Tika iegūts vēlamais rezultāts un līdz ar to interesants grafiks, kas parāda 1 minūtē apstrādāto pieprasījumu skaita atkarību no savienojumu skaita:

Pilnībā aprīkots tukšstrāvas ievades/izvades reaktors

Redzam, ka pēc pāris simtiem savienojumu strauji krītas apstrādāto pieprasījumu skaits abiem serveriem (vairāku pavedienu versijā tas ir pamanāmāk). Vai tas ir saistīts ar Linux TCP/IP steka ieviešanu? Jūtieties brīvi komentāros rakstīt savus pieņēmumus par šo diagrammas darbību un vairākpavedienu un viena pavediena opciju optimizāciju.

atzīmēja komentāros šis veiktspējas tests neuzrāda I/O reaktora uzvedību pie reālām slodzēm, jo ​​gandrīz vienmēr serveris mijiedarbojas ar datu bāzi, izvada žurnālus, izmanto kriptogrāfiju ar TLS tml., kā rezultātā slodze kļūst nevienmērīga (dinamiska). Pārbaudes kopā ar trešo pušu komponentiem tiks veiktas rakstā par I/O proaktoru.

I/O reaktora trūkumi

Jums jāsaprot, ka I/O reaktoram nav arī trūkumi, proti:

  • I/O reaktora izmantošana daudzpavedienu vidē ir nedaudz grūtāka, jo jums būs manuāli jāpārvalda plūsmas.
  • Prakse rāda, ka vairumā gadījumu slodze ir nevienmērīga, kā rezultātā viens pavediens var reģistrēties, kamēr cits ir aizņemts ar darbu.
  • Ja viens notikumu apstrādātājs bloķē pavedienu, bloķēs arī pats sistēmas atlasītājs, kas var izraisīt grūti atrodamas kļūdas.

Atrisina šīs problēmas I/O proaktors, kuram bieži ir plānotājs, kas vienmērīgi sadala slodzi pavedienu kopai, un tam ir arī ērtāks API. Mēs par to runāsim vēlāk, manā citā rakstā.

Secinājums

Šeit ir beidzies mūsu ceļojums no teorijas līdz profilētāja izplūdei.

Jums nevajadzētu kavēties pie tā, jo ir daudz citu tikpat interesantu pieeju tīkla programmatūras rakstīšanai ar dažādu ērtību un ātrumu. Interesanti, manuprāt, zemāk ir dotas saites.

Līdz nākamajai reizei!

Interesanti projekti

Ko vēl vajadzētu lasīt?

Avots: www.habr.com

Pievieno komentāru