В тази статия ще разгледаме тънкостите на един I/O реактор и как работи, ще напишем имплементация в по-малко от 200 реда код и ще направим прост HTTP сървърен процес над 40 милиона заявки/мин.
предговор
Статията е написана, за да ви помогне да разберете функционирането на I/O реактора и следователно да разберете рисковете при използването му.
Познаването на основите е необходимо, за да разберете статията. C език и известен опит в разработката на мрежови приложения.
Целият код е написан на език C строго според (Внимание: дълъг PDF) по стандарт C11 за Linux и достъпен на GitHub.
Защо го направи?
С нарастващата популярност на Интернет, уеб сървърите започнаха да се нуждаят да обработват голям брой връзки едновременно и затова бяха изпробвани два подхода: блокиране на I/O на голям брой OS нишки и неблокиращ I/O в комбинация с система за уведомяване за събития, наричана още „системен селектор“ (epoll/kqueue/IOCP/и т.н.).
Първият подход включваше създаване на нова OS нишка за всяка входяща връзка. Неговият недостатък е слабата мащабируемост: операционната система ще трябва да внедри много контекстни преходи и системни повиквания. Те са скъпи операции и могат да доведат до липса на свободна RAM памет при внушителен брой връзки.
Модифицираната версия подчертава фиксиран брой нишки (пул от нишки), като по този начин предотвратява срив на системата, но в същото време въвежда нов проблем: ако пул от нишки в момента е блокиран от дълги операции за четене, тогава други сокети, които вече могат да получават данни, няма да могат да правят така.
Вторият подход използва система за известяване на събития (системен селектор), предоставен от операционната система. Тази статия обсъжда най-често срещания тип системен селектор, базиран на предупреждения (събития, уведомления) за готовност за I/O операции, а не на известия за тяхното изпълнение. Опростен пример за използването му може да бъде представен чрез следната блокова диаграма:
Разликата между тези подходи е следната:
Блокиране на I/O операции спирам потребителски поток додокато ОС не се оправи дефрагментира входящи IP пакети към байтов поток (TCP, получаване на данни) или няма да има достатъчно място във вътрешните буфери за запис за последващо изпращане чрез NIC (изпращане на данни).
Системен селектор с течение на времето уведомява програмата, че ОС вече дефрагментирани IP пакети (TCP, приемане на данни) или достатъчно място във вътрешните буфери за запис вече налични (изпращане на данни).
За да обобщим, запазването на OS нишка за всеки I/O е загуба на изчислителна мощност, защото в действителност нишките не вършат полезна работа (оттук идва терминът "софтуерно прекъсване"). Системният селектор решава този проблем, като позволява на потребителската програма да използва ресурсите на процесора много по-икономично.
Модел I/O реактор
I/O реакторът действа като слой между системния селектор и потребителския код. Принципът на неговата работа е описан със следната блокова схема:
Нека ви напомня, че събитието е известие, че определен сокет може да извърши неблокираща I/O операция.
Манипулатор на събитие е функция, извикана от I/O реактора при получаване на събитие, която след това изпълнява неблокираща I/O операция.
Важно е да се отбележи, че I/O реакторът по дефиниция е еднонишков, но нищо не пречи концепцията да се използва в многонишкова среда при съотношение 1 нишка: 1 реактор, като по този начин се рециклират всички CPU ядра.
Изпълнение
Ще поставим публичния интерфейс във файл 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 (структура на манипулатор на събитие и потребителски аргумент за него).
Моля, имайте предвид, че сме активирали възможността за обработка непълен тип според индекса. IN reactor.h ние декларираме структурата reactorИ в reactor.c ние го дефинираме, като по този начин не позволяваме на потребителя изрично да променя неговите полета. Това е една от моделите скриване на данни, което накратко се вписва в семантиката на C.
функции reactor_register, reactor_deregister и reactor_reregister актуализирайте списъка с интересни сокети и съответните манипулатори на събития в системния селектор и хеш таблицата.
След като 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 реактора при високо натоварване, ще напишем прост HTTP уеб сървър, който отговаря на всяка заявка с изображение.
Кратка справка за HTTP протокола
HTTP - това е протоколът ниво на приложение, използван предимно за взаимодействие сървър-браузър.
HTTP може лесно да се използва над транспорт протокол TCP, изпращане и получаване на съобщения в зададен формат спецификация.
CRLF е последователност от два знака: r и n, разделяйки първия ред на заявката, заглавките и данните.
<КОМАНДА> - един от CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Браузърът ще изпрати команда до нашия сървър GET, което означава "Изпратете ми съдържанието на файла."
<КОД СТАТУСА> е число, представляващо резултата от операцията. Нашият сървър винаги ще връща статус 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:
Функция new_server() връща файловия дескриптор на "сървърния" сокет, създаден от системни извиквания socket(), bind() и listen() и способни да приемат входящи връзки в неблокиращ режим.
Имайте предвид, че сокетът първоначално се създава в неблокиращ режим с помощта на флага SOCK_NONBLOCKтака че във функцията on_accept() (прочетете повече) системно повикване accept() не спря изпълнението на нишката.
ако reuse_port е равно на true, тогава тази функция ще конфигурира гнездото с опцията SO_REUSEPORT през setsockopt()да използвате същия порт в многонишкова среда (вижте раздела „Многонишков сървър“).
Манипулатор на събития on_accept() извиква се след като ОС генерира събитие EPOLLIN, в този случай означава, че новата връзка може да бъде приета. on_accept() приема нова връзка, превключва я в неблокиращ режим и се регистрира в манипулатор на събития on_recv() в I/O реактор.
Манипулатор на събития 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().
И накрая, във файла http_server.c, във функция main() ние създаваме I/O реактор, използвайки reactor_new(), създайте сървърен сокет и го регистрирайте, стартирайте реактора, като използвате reactor_run() за точно една минута, след което освобождаваме ресурси и излизаме от програмата.
Нека проверим дали всичко работи според очакванията. Компилиране (chmod a+x compile.sh && ./compile.sh в корена на проекта) и стартирайте самонаписания сървър, отворете http://127.0.0.1:18470 в браузъра и вижте какво очаквахме:
Нека измерим производителността на еднонишков сървър. Нека отворим два терминала: в единия ще стартираме ./http_server, в различен - работа. След минута във втория терминал ще се покаже следната статистика:
Нашият еднонишков сървър успя да обработи над 11 милиона заявки в минута, произхождащи от 100 връзки. Не е лош резултат, но може ли да се подобри?
Многопоточен сървър
Както бе споменато по-горе, I/O реакторът може да бъде създаден в отделни нишки, като по този начин се използват всички процесорни ядра. Нека приложим този подход на практика:
Моля, обърнете внимание, че аргументът на функцията new_server() актове true. Това означава, че присвояваме опцията на сървърния сокет SO_REUSEPORTда го използвате в многонишкова среда. Можете да прочетете повече подробности тук.
Второ бягане
Сега нека измерим производителността на многонишков сървър:
Броят на обработените заявки за 1 минута се увеличи с ~3.28 пъти! Но не ни достигаха само ~XNUMX милиона до кръглото число, така че нека се опитаме да поправим това.
Първо нека да разгледаме генерираната статистика PERF:
Използване на CPU Affinity, компилация с -march=native, PGO, увеличаване на броя на посещенията скривалище, нараства MAX_EVENTS и използвайте EPOLLET не даде значително увеличение на производителността. Но какво се случва, ако увеличите броя на едновременните връзки?
Получи се желаният резултат, а с него и интересна графика, показваща зависимостта на броя обработени заявки за 1 минута от броя на връзките:
Виждаме, че след няколкостотин връзки броят на обработените заявки за двата сървъра рязко спада (в многонишковата версия това е по-забележимо). Това свързано ли е с внедряването на TCP/IP стека на Linux? Чувствайте се свободни да напишете вашите предположения относно това поведение на графиката и оптимизациите за многонишкови и еднонишкови опции в коментарите.
Като отбеляза в коментарите, този тест за производителност не показва поведението на I/O реактора при реални натоварвания, защото почти винаги сървърът взаимодейства с базата данни, извежда регистрационни файлове, използва криптография с TLS и др., в резултат на което натоварването става неравномерно (динамично). Тестовете заедно с компоненти на трети страни ще бъдат извършени в статията за I/O proactor.
Недостатъци на I/O реактора
Трябва да разберете, че I/O реакторът не е без своите недостатъци, а именно:
Използването на I/O реактор в многонишкова среда е малко по-трудно, защото ще трябва ръчно да управлявате потоците.
Практиката показва, че в повечето случаи натоварването е неравномерно, което може да доведе до регистриране на една нишка, докато друга е заета с работа.
Ако един манипулатор на събития блокира нишка, самият системен селектор също ще блокира, което може да доведе до трудни за намиране грешки.
Решава тези проблеми I/O проектор, който често има планировчик, който равномерно разпределя натоварването към набор от нишки, а също така има по-удобен API. Ще говорим за това по-късно, в другата ми статия.
Заключение
Това е мястото, където нашето пътуване от теорията направо към профилиращия ауспух приключи.
Не трябва да се спирате на това, защото има много други също толкова интересни подходи за писане на мрежов софтуер с различни нива на удобство и скорост. Интересни, според мен, връзки са дадени по-долу.