Поўнафункцыянальны I/O рэактар ​​на голым Сі

Поўнафункцыянальны I/O рэактар ​​на голым Сі

Увядзенне

I/O рэактар (аднаструменны цыкл падзей) - гэта патэрн для напісання высоканагружанага ПА, які выкарыстоўваецца ў многіх папулярных рашэннях:

У дадзеным артыкуле мы разгледзім паднаготную I/O рэактара і прынцып яго працы, напішам рэалізацыю на менш, чым 200 радкоў кода і прымусім просты HTTP сервер апрацоўваць звыш 40 мільёнаў запытаў/мін.

Прадмова

  • Артыкул напісаны з мэтай дапамагчы разабрацца ў функцыянаванні I/O рэактара, а значыць і ўсвядоміць рызыкі пры яго выкарыстанні.
  • Для засваення артыкула патрабуецца веданне асноў мовы Сі і невялікі досвед распрацоўкі сеткавых прыкладанняў.
  • Увесь код напісаны на мове Сі строга па (асцярожна: доўгі PDF) стандарту C11 для Linux і даступны на GitHub.

Навошта гэта трэба?

З ростам папулярнасці Інтэрнэту вэб-серверам стала трэба апрацоўваць вялікую колькасць злучэнняў адначасова, у сувязі з чым было апрабавана два падыходу: блакавальнае I/O на вялікім ліку струменяў АС і неблакавальнае I/O у камбінацыі з сістэмай абвесткі аб падзеях, яшчэ званай «сістэмным». селектарам» (epoll/kqueue/IOCP/etc).

Першы падыход меў на ўвазе стварэнне новага струменя АС для кожнага ўваходнага злучэння. Яго недахопам з'яўляецца дрэнная маштабаванасць: аперацыйнай сістэме давядзецца ажыццяўляць мноства пераходаў кантэксту и сістэмных выклікаў. Яны з'яўляюцца дарагімі аперацыямі і могуць прывесці да недахопу вольнай АЗП пры вялікім ліку злучэнняў.

Мадыфікаваная версія вылучае фіксаваны лік патокаў (thread pool), тым самым не дазваляючы сістэме аварыйна спыніць выкананне, але разам з тым прыўносіць новую праблему: калі ў дадзены момант часу пул патокаў блакуюць працяглыя аперацыі чытання, то іншыя сокеты, якія ўжо ў стане прыняць дадзеныя, не змогуць гэтага зрабіць.

Другі падыход выкарыстоўвае сістэму абвесткі аб падзеях (сістэмны селектар), якую падае АС. У дадзеным артыкуле разгледжаны найболей часта сустракаемы выгляд сістэмнага селектара, заснаваны на абвестках (падзеях, апавяшчэннях) аб гатовасці да I/O аперацыям, чым на абвестках аб іх завяршэнні. Спрошчаны прыклад яго выкарыстання можна ўявіць наступнай блок-схемай:

Поўнафункцыянальны I/O рэактар ​​на голым Сі

Розніца паміж дадзенымі падыходамі заключаецца ў наступным:

  • Блакавальныя I/O аперацыі прыпыняюць карыстацкі паток да таго часу, пакуль АС належным чынам не дэфрагментуе абітурыенты IP пакеты у паток байт (TCP, атрыманне дадзеных) або не вызваліцца дастаткова месца ва ўнутраных буферах запісы для наступнай адпраўкі праз NIC (адпраўка дадзеных).
  • Сістэмны селектар праз некаторы час паведамляе праграму аб тым, што АС ўжо дэфрагментавала IP пакеты (TCP, атрыманне дадзеных) або дастаткова месца ва ўнутраных буферах запісу ўжо даступна (адпраўка дадзеных).

Падводзячы вынік, рэзерваванне струменя АС для кожнага I/O – пустое марнаванне вылічальнай моцы, бо насамрэч, струмені не занятыя карыснай працай (адгэтуль бярэ свае карані тэрмін «праграмнае перапыненне»). Сістэмны селектар вырашае гэтую праблему, дазваляючы карыстацкай праграме расходаваць рэсурсы ЦПУ значна эканомней.

Мадэль I/O рэактара

I/O рэактар ​​выступае як праслойка паміж сістэмным селектарам і карыстацкім кодам. Прынцып яго працы апісаны наступнай блок-схемай:

Поўнафункцыянальны I/O рэактар ​​на голым Сі

  • Нагадаю, што падзея – гэта апавяшчэнне аб тым, што пэўны сокет у стане выканаць неблакіруючую I/O аперацыю.
  • Апрацоўшчык падзей – гэта функцыя, якая выклікаецца I/O рэактарам пры атрыманні падзеі, якая далей здзяйсняе неблакіруючую I/O аперацыю.

Важна адзначыць, што I/O рэактар ​​па вызначэнні однопоточен, але нічога не мяшае выкарыстоўваць канцэпт у многопточной асяроддзі ў стаўленні 1 струмень: 1 рэактар, тым самым утылізуючы ўсе ядры ЦПУ.

Рэалізацыя

Публічны інтэрфейс мы змесцім у файл reactor.h, а рэалізацыю - у reactor.c. reactor.h будзе складацца з наступных аб'яваў:

Паказаць аб'явы ў reactor.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 рэактара складаецца з файлавага дэскрыптара селектара epoll и хэш-табліцы GHashTable, якая кожны сокет супастаўляе з CallbackData (структура з апрацоўшчыка падзеі і аргумента карыстальніка для яго).

Паказаць Reactor і CallbackData

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

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

Звярніце ўвагу, што мы задзейнічалі магчымасць абыходжання з няпоўным тыпам па паказальніку. У reactor.h мы аб'яўляем структуру reactor, а ў reactor.c яе вызначаем, тым самым не дазваляючы карыстачу відавочна змяняць яе палі. Гэта адзін з патэрнаў утойвання дадзеных, які лаканічна ўпісваецца ў семантыку Сі.

функцыі reactor_register, reactor_deregister и reactor_reregister абнаўляюць спіс цікавых сокетаў і адпаведных апрацоўшчыкаў падзей у сістэмным селектары і ў хэш-табліцы.

Паказаць функцыі рэгістрацыі

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

Пасля таго, як I/O рэактар ​​перахапіў падзею з дэскрыптарам fd, ён выклікае адпаведнага апрацоўшчыка падзеі, у які перадае fd, бітавую маску згенераваных падзей і карыстацкі паказальнік на void.

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

Падводзячы вынік, ланцужок выклікаў функцый у карыстацкім кодзе будзе прымаць наступны выгляд:

Поўнафункцыянальны I/O рэактар ​​на голым Сі

Аднаструменны сервер

Для таго каб пратэставаць I/O рэактар ​​на высокай нагрузцы, мы напішам просты HTTP вэб-сервер, на любы запыт які адказвае выявай.

Кароткая даведка па пратоле HTTP

HTTP - гэта пратакол прыкладнога ўзроўню, пераважна выкарыстоўваецца для ўзаемадзеяння сервера з браўзэрам.

HTTP можна з лёгкасцю выкарыстоўваць па-над транспартнага пратаколу TCP, адпраўляючы і прымаючы паведамленні фармату, вызначанага спецыфікацыяй.

Фармат запыту

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

  • CRLF - Гэта паслядоўнасць з двух знакаў: r и n, якая падзяляе першы радок запыту, загалоўкі і дадзеныя.
  • <КОМАНДА> - Адно з CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Браўзэр нашаму серверу будзе адпраўляць каманду GET, якая азначае «Дашлі мне змесціва файла».
  • <URI> - уніфікаваны ідэнтыфікатар рэсурсу. Напрыклад, калі URI = /index.html, то кліент запытвае галоўную старонку сайта.
  • <ВЕРСИЯ HTTP> - версія пратаколу HTTP у фармаце HTTP/X.Y. Найбольш часта выкарыстоўваная версія на сённяшні дзень. HTTP/1.1.
  • <ЗАГОЛОВОК N> - гэта пара ключ-значэнне ў фармаце <КЛЮЧ>: <ЗНАЧЕНИЕ>, якая адпраўляецца серверу для далейшага аналізу.
  • <ДАННЫЕ> - Дадзеныя, патрабаваныя серверу для выканання аперацыі. Часта гэта проста JSON ці любы іншы фармат.

Фармат адказу

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

  • <КОД СТАТУСА> - Гэты лік, якое ўяўляе сабой вынік аперацыі. Наш сервер будзе заўсёды вяртаць статут 200 (паспяховая аперацыя).
  • <ОПИСАНИЕ СТАТУСА> - Радковае прадстаўленне кода статусу. Для кода статусу 200 - гэта OK.
  • <ЗАГОЛОВОК N> - загаловак таго ж фармату, што і ў запыце. Мы будзем вяртаць загалоўкі Content-Length (памер файла) і Content-Type: text/html (тып якія вяртаюцца дадзеных).
  • <ДАННЫЕ> - Запытаныя карыстальнікам дадзеныя. У нашым выпадку гэта шлях да выявы ў HTML.

файл http_server.c (аднаструменны сервер) уключае файл common.h, які змяшчае наступныя прататыпы функцый:

Паказаць прататыпы функцый у 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);

Таксама апісаны функцыянальны макрас SAFE_CALL() і вызначана функцыя fail(). Макрос параўноўвае значэнне выказвання з памылкай, і калі ўмова выпанілася, выклікае функцыю fail():

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

Функцыя fail() друкуе перададзеныя аргументы ў тэрмінал (як printf()) і завяршае працу праграмы з кодам 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);
}

Функцыя new_server() вяртае файлавы дэскрыптар «сервернага» сокета, створанага сістэмнымі выклікамі socket(), bind() и listen() і здольнага прымаць уваходныя злучэнні ў неблакавальным рэжыме.

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

  • Звярніце ўвагу, што сокет першапачаткова ствараецца ў неблакіруючым рэжыме з дапамогай сцяга. SOCK_NONBLOCK, Каб у функцыі on_accept() (чытаць далей) сістэмны выклік accept() не спыніў выкананне патоку.
  • Калі reuse_port роўны true, то дадзеная функцыя сканфігуруе сокет з опцыяй SO_REUSEPORT з дапамогай setsockopt(), каб выкарыстоўваць адзін і той жа порт у шматструменным асяроддзі (глядзець секцыю «Шматструменны сервер»).

Апрацоўшчык падзей on_accept() выклікаецца пасля таго, як АС згенеруе падзею EPOLLIN, у дадзеным выпадку азначае, што новае злучэнне можа быць прынята. on_accept() прымае новае злучэнне, перамыкае яго ў неблакіруючы рэжым і рэгіструе з апрацоўшчыкам падзеі on_recv() у I/O рэактары.

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

Апрацоўшчык падзей on_recv() выклікаецца пасля таго, як АС згенеруе падзею EPOLLIN, у дадзеным выпадку азначае, што злучэнне, зарэгістраванае on_accept(), гатова да прыняцця дадзеных.

on_recv() счытвае дадзеныя са злучэння да таго часу, пакуль HTTP запыт цалкам не будзе атрыманы, затым яна рэгіструе апрацоўшчык on_send() для адпраўкі HTTP адказу. Калі кліент абарваў злучэнне, то сокет дэрэгіструецца і зачыняецца пасродкам close().

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

Апрацоўшчык падзей on_send() выклікаецца пасля таго, як АС згенеруе падзею EPOLLOUT, Якое азначае, што злучэнне, зарэгістраванае on_recv(), гатова да адпраўкі дадзеных. Гэтая функцыя адпраўляе HTTP адказ, які змяшчае HTML з выявай, кліенту, а затым мяняе апрацоўшчык падзей зноў на on_recv().

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

І нарэшце, у файле http_server.c, у функцыі main() мы ствараем I/O рэактар ​​пасродкам reactor_new(), ствараем серверны сокет і рэгіструем яго, запускаем рэактар ​​з дапамогай reactor_run() роўна на адну хвіліну, а затым вызваляем рэсурсы і выходзім з праграмы.

Паказаць 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);
}

Праверым, што ўсё працуе як мае быць. Кампілюем (chmod a+x compile.sh && ./compile.sh у корані праекта) і запускаем самапісны сервер, адкрываем http://127.0.0.1:18470 у браўзэры і назіраем тое, што і чакалі:

Поўнафункцыянальны I/O рэактар ​​на голым Сі

Замер прадукцыйнасці

Паказаць характарыстыкі маёй машыны

$ 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

Вымераем прадукцыйнасць аднаструменнага сервера. Адкрыем два тэрміналы: у адным запусцім ./http_server, у іншым - праца. Праз хвіліну ў другім тэрмінале высветліцца наступная статыстыка:

$ 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

Наш аднаструменны сервер змог апрацаваць звыш 11 мільёнаў запытаў у хвіліну, якія зыходзіць з 100 злучэнняў. Нядрэнны вынік, але ці можна яго палепшыць?

Шматструменны сервер

Як было сказана вышэй, I/O рэактар ​​можна ствараць у асобных патоках, тым самым утылізуючы ўсе ядры ЦПУ. Ужывальны дадзены падыход на практыцы:

Паказаць 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);
    }
}

Цяпер кожны паток валодае ўласным рэактарам:

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

Звярніце ўвагу на тое, што аргументам функцыі new_server() выступае true. Гэта значыць, што мы прысвойваем сервернаму сокету опцыю SO_REUSEPORT, Каб выкарыстоўваць яго ў шматструменным асяроддзі. Падрабязней можаце пачытаць тут.

Другі заход

Зараз вымераем прадукцыйнасць шматструменнага сервера:

$ 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 хвіліну ўзрасла ў ~3.28 разы! Але да круглага ліку не хапіла ўсяго ~два мільёны, паспрабуем гэта выправіць.

Спачатку паглядзім на статыстыку, згенераваную перф:

$ 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

Выкарыстанне афіннасці ЦПУ, кампіляцыя з -march=native, Генпракуратура, павелічэнне колькасці трапленняў у кэш, павелічэнне MAX_EVENTS і выкарыстанне EPOLLET не дало значнага прыросту ў прадукцыйнасці. Але што атрымаецца, калі павялічыць колькасць адначасовых злучэнняў?

Статыстыка пры 352 адначасовых злучэннях:

$ 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

Жаданы вынік атрыманы, а разам з ім і цікавы графік, які дэманструе залежнасць колькасці апрацаваных запытаў за 1 хвіліну ад колькасці злучэнняў:

Поўнафункцыянальны I/O рэактар ​​на голым Сі

Бачым, што пасля пары сотняў злучэнняў лік апрацаваных запытаў у абодвух сервераў рэзка падае (у шматструменнага варыянту гэта больш прыкметна). Ці злучана гэта з рэалізацыяй TCP/IP стэка Linux? Свае здагадкі наконт такіх паводзінаў графіка і аптымізацый шматструменнага і аднаструменнага варыянтаў смела пішыце ў каментарах.

Як адзначылі у каментарах, дадзены тэст прадукцыйнасці не паказвае паводзін I/O рэактара на рэальных нагрузках, бо амаль заўсёды сервер узаемадзейнічае з БД, выводзіць логі, выкарыстае крыптаграфію з TLS і г.д., з прычыны чаго нагрузка становіцца неаднароднай (дынамічнай). Тэсты разам з іншымі кампанентамі будуць праведзены ў артыкуле пра I/O праактар.

Недахопы I/O рэактара

Трэба разумець, што I/O рэактар ​​не пазбаўлены недахопаў, а менавіта:

  • Карыстацца I/O рэактарам у шматструменным асяроддзі некалькі складаней, т.я. давядзецца ўручную кіраваць патокамі.
  • Практыка паказвае, што ў большасці выпадкаў нагрузка неаднастайная, што можа прывесці да таго, што адзін струмень будзе прастаўляць, пакуль іншы будзе загружаны працай.
  • Калі адзін апрацоўшчык падзеі заблакуе струмень, то таксама заблакуецца і сам сістэмны селектар, што можа прывесці да цяжкаадлоўных багаў.

Гэтыя праблемы вырашае I/O праактар, часта які мае планавальнік, які раўнамерна размяркоўвае нагрузку ў пул струменяў, і да таго ж які мае больш зручны API. Гаворка пра яго пойдзе пазней, у маім іншым артыкуле.

Заключэнне

На гэтым наша вандраванне з тэорыі наўпрост у выхлап прафайлера падышло да канца.

Не варта на гэтым спыняцца, бо існуюць мноства іншых не менш цікавых падыходаў да напісання сеткавага ПЗ з розным узроўнем зручнасці і хуткасці. Цікавыя, на мой погляд, спасылкі прыведзены ніжэй.

Да новых сустрэч!

Цікавыя праекты

Што яшчэ пачытаць?

Крыніца: habr.com

Дадаць каментар