Повнофункціональний I/O реактор на голому Си

Повнофункціональний I/O реактор на голому Си

Запровадження

I/O реактор (однопотоковий цикл подій) - це патерн для написання високонавантаженого ПЗ, що використовується в багатьох популярних рішеннях:

У цій статті ми розглянемо підноготну I/O реактора та принцип його роботи, напишемо реалізацію на менше, ніж 200 рядків коду та змусимо простий HTTP сервер обробляти понад 40 мільйонів запитів/хв.

Передмова

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

Навіщо це потрібно?

Зі зростанням популярності Інтернету веб-серверам почало обробляти велику кількість з'єднань одночасно, у зв'язку з чим було випробувано два підходи: блокуючий I/O на великій кількості потоків ОС і неблокуючий I/O в комбінації із системою оповіщення про події, ще званою «системним». селектором» (еполл/kqueue/IOCP/і т.д.).

Перший підхід мав на увазі створення нового потоку ОС для кожного вхідного з'єднання. Його недоліком є ​​погана масштабованість: операційній системі доведеться здійснювати безліч переходів контексту и системних викликів. Вони є дорогими операціями і можуть призвести до нестачі вільної ОЗП при значній кількості сполук.

Модифікована версія виділяє фіксована кількість потоків (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 реактора складається з файлового дескриптора селектора еполл и хеш-таблиці 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> - yніфікований ідентифікатор ресурсу. Наприклад, якщо 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. За хвилину в другому терміналі висвітиться наступна статистика:

$ 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

Додати коментар або відгук