
Въведение
(единичен поток ) — това е шаблон за писане на софтуер с високо натоварване, използван в много популярни решения:
- …
В тази статия ще разгледаме основите на I/O реактора и принципа на работа, ще напишем реализация с по-малко от 200 реда код и ще накараме прост HTTP сървър да обработва над 40 милиона заявки/мин.
Предговор
- Статията е написана с цел да помогне да се разбере функционирането на I/O реактора и да осъзнаем рисковете при неговото използване.
- За усвояването на статията е необходимо познание в основите и малък опит в разработването на мрежови приложения.
- Целият код е написан на C строго по (внимание: дълъг PDF) за Linux и е достъпен на .
Защо е необходимо?
С разрастването на интернет, уеб сървърите се нуждаят от обработка на голям брой връзки едновременно, поради което бяха изпробвани два подхода: блокиращо I/O на множество потокове на ОС и неблокиращо I/O в комбинация с система за известяване на събития, известна още като 'системен селектор' (///etc).
Първият подход предвиждаше създаване на нов поток на ОС за всяко входящо свързване. Неговият недостатък е лошата скалируемост: операционната система ще трябва да изпълнява множество и . Те са скъпи операции и могат да доведат до недостатък на свободната памет при голям брой връзки.
Модифицирана версия предвижда (пул от потоци), което не позволява на системата да прекрати изпълнението, но същевременно внася нова проблема: ако в този момент пул от потоци блокира продължителни операции по четене, то другите сокети, които вече могат да приемат данни, не могат да го направят.
Вторият подход използва (системен селектор), който предоставя ОС. В тази статия се разглежда най-често срещаният вид системен селектор, основан на известия (събития, уведомления) за готовност за I/O операции, а не на . Опростен пример за използването му може да бъде представен с следната блок-схема:

Разликата между различните подходи е следната:
- Блокиращи I/O операции спират потребителския поток докатоОС-то не постъпващите в поток от байтове (, получаване на данни) или не се освободи достатъчно място във вътрешните буфери за запис за последващо изпращане през (изпращане на данни).
- Системният селектор след известно време информира програмата, че ОС-то вече е дефрагментирало IP пакети (TCP, получаване на данни) или достатъчно място във вътрешните буфери за запис вече е достъпно (изпращане на данни).
В заключение, резервирането на потока на ОС за всяко I/O е безсмислено изразходване на изчислителна мощност, тъй като на практика, потокът не е зает с полезна работа (оттук произлиза термина ). Системният селектор решава този проблем, позволявайки на потребителската програма да изразходва ресурси на CPU значително по-икономично.
Моделът на I/O реактора
I/O реакторът е прослойка между системния селектор и потребителския код. Принципът на работа е описан с следната блок-схема:

- Напомням, че събитие е уведомление, че определен сокет е в състояние да извърши неблокираща I/O операция.
- Обработчикът на събития е функция, извиквана от I/O реактора при получаване на събитие, която впоследствие извършва неблокираща I/O операция.
Важно е да се отбележи, че I/O реакторът по дефиниция е еднопотеден, но нищо не пречи да се използва концепцията в многопоточна среда по отношение на 1 поток: 1 реактор, като по този начин се използват всички ядра на CPU.
Реализация
Публичният интерфейс ще сложим в файл , а реализацията — в . 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 реактора се състои от селектор и , която съпоставя всеки сокет с 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 table, &fd);
callback->callback(callback->arg, fd, events[i].events);
}
}
}
cleanup:
free(events);
return result;
}В заключение, веригата от функции в потребителския код ще изглежда по следния начин:

Однопоточен сървър
За да тестваме I/O реактора под висока натовареност, ще напишем прост HTTP уеб сървър, който да отговаря с изображение на всяка заявка.
Кратко ръководство по HTTP протокола
— това е протокол , който се използва главно за взаимодействие между сървър и браузър.
HTTP може лесно да бъде използван над протокол , изпращайки и получавайки съобщения в формат, определен от .
Формат на заявката
CRLF
CRLF
CRLF
CRLF CRLFCRLF— това е последователност от два символа:rиn, която разделя първия ред на заявката, заглавките и данните.<КОМАНДА>— едно отCONNECT,DELETE,ИЗИСКВАНЕ,HEAD,OPTIONS,PATCH,POST,PUT,TRACE. Браузерът ни ще изпрати на сървъра команди,ИЗИСКВАНЕ, което означава "Изпрати ми съдържанието на файла".уникален идентификатор на ресурса— . Например, ако URI =/index.html, клиентът запазва главната страница на сайта.— версия на протокола HTTP в формат— версия на HTTP протокола в форматHTTP/X.Y. Най-широко използваната версия в момента еHTTP/1.1.— това е двойка ключ-стойност в формат— това е двойка ключ-стойност в формат:, изпратена на сървъра за по-нататъшен анализ.— данни, които сървърът изисква, за да извърши операцията. Често това е просто— данни, необходими на сървъра за изпълнение на операцията. Често това е просто или всеки друг формат.
Формат на отговора
CRLF
CRLF
CRLF
CRLF CRLF<КОД СТАТУСА>— това число представлява резултата от операцията. Нашият сървър винаги ще връща статус 200 (успешна операция).<ОПИСАНИЕ СТАТУСА>— строково представяне на статус кода. За статус код 200 — това еOK.— това е двойка ключ-стойност в формат— заглавие в същия формат, както при заявката. Ние ще връщаме заглавиятаContent-Length(размер на файла) иContent-Type: text/html(тип на връщаните данни).— данни, които сървърът изисква, за да извърши операцията. Често това е просто— поисканите от потребителя данни. В нашия случай това е пътят до изображението в .
файл (еднопоточен сървър) включва файла , който съдържа следните прототипи на функции:
Показване на прототипите на функциите в 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() печатa предадените аргументи в терминала (както ) и завършва програмата с код 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() връща файлов дескриптор на "сървърен" сокет, създаден чрез системни извиквания , и и който може да приема входящи връзки в необвързващ режим.
Показване на функцията 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е равно наистинно, тогава тази функция ще конфигурира сокета с опцията чрез , за да използва един и същ порт в многопоточна среда (вижте секцията „Многопоточен сървър“).
Обработчикът на събития 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 отговор. Ако клиентът прекъсне връзката, сокетът се дерегистрира и затваря чрез .
Покажи функция 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()
статичен 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 в корена на проекта) и стартираме самостоятелния сървър, отваряме в браузъра и наблюдаваме това, което очаквахме:

Замерване на производителността
Покажи характеристиките на машината ми
$ 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() е истинно. Това означава, че присвояваме на сървърния сокет опция , за да може да се използва в многопоточна среда. Можете да прочетете повече .
Второ преброяване
Сега ще измерим производителността на многопоточния сървър:
$ 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 минута от броя на връзките:

Виждаме, че след няколко стотин връзки, броят на обработените заявки рязко спада и при двата сървъра (при многопоточния вариант е по-забележимо). Свързано ли е това с реализацията на TCP/IP стека в Linux? Вашите предположения относно такова поведение на графика и оптимизациите на многопоточния и еднопоточния варианти смело пишете в коментарите.
Как в коментарите, този тест за производителност не показва поведението на I/O реактора при реални натоварвания, тъй като почти винаги сървърът взаимодейства с БД, извежда логове, използва криптография с и т.н., в резултат на което натоварването става нееднородно (динамично). Тестовете, заедно с външни компоненти, ще бъдат проведени в статия за I/O реактора.
Недостатъци на I/O реактора
Трябва да разберем, че I/O реакторът не е лишен от недостатъци, а именно:
- Използването на I/O реактор в многопоточна среда е малко по-сложно, тъй като ще трябва ръчно да се управляват потоките.
- Практиката показва, че в повечето случаи натоварването е неоднородно, което може да доведе до ситуация, в която един поток ще работи, докато другият е зает с друга работа.
- Ако един обработчик на събития блокира потока, то също така ще блокира и самия системен селектор, което може да доведе до трудни за откриване бъгове.
Тези проблеми се решават от , който често има планировчик, който равномерно разпределя натоварването в пул от потоци и предлага по-удобно API. За него ще говорим по-късно в моята друга статия.
Заключение
С това нашето пътуване от теория директно към изхода на профайлера завършва.
Не бива да спираме тук, тъй като съществуват много други, не по-малко интересни подходи за писане на мрежови приложения с различно ниво на удобство и бързина. Интересни, по мое мнение, връзки са приведени по-долу.
До нови срещи!
Интересни проекти
- C
Какво друго да прочетем?
Източник: habr.com
