Во оваа статија, ќе ги погледнеме критиките на влезниот/излезниот реактор и како тој работи, ќе напишеме имплементација во помалку од 200 линии код и ќе направиме едноставен процес на HTTP сервер над 40 милиони барања/мин.
предговорот
Статијата е напишана за да помогне да се разбере функционирањето на В/И реакторот и затоа да се разберат ризиците при неговото користење.
Потребно е познавање на основите за да се разбере статијата. C јазик и одредено искуство во развој на мрежни апликации.
Целиот код е напишан на C јазик строго според (претпазливост: долг PDF) до стандардот C11 за Linux и достапни на GitHub.
Зошто го направи тоа?
Со растечката популарност на Интернетот, веб-серверите почнаа да имаат потреба да работат со голем број врски истовремено, и затоа беа испробани два пристапа: блокирање на I/O на голем број нишки на оперативниот систем и неблокирање на I/O во комбинација со систем за известување за настани, исто така наречен „системски избирач“ (епол/редица/IOCP/ итн).
Првиот пристап вклучува создавање на нова нишка на ОС за секоја дојдовна врска. Неговиот недостаток е слабата приспособливост: оперативниот систем ќе мора да имплементира многу контекстни транзиции и системски повици. Тие се скапи операции и може да доведат до недостаток на слободна RAM меморија со импресивен број на конекции.
Изменетата верзија нагласува фиксен број на нишки (thread pool), со што се спречува системот да го прекине извршувањето, но во исто време воведува нов проблем: ако нишкиот базен во моментот е блокиран од операциите за долго читање, тогаш другите сокети кои веќе се способни да примаат податоци нема да можат да направи така.
Вториот пристап користи систем за известување за настани (системски избирач) обезбеден од ОС. Оваа статија го разгледува најчестиот тип на системски избирач, базиран на предупредувања (настани, известувања) за подготвеноста за I/O операции, наместо на известувања за нивното завршување. Поедноставен пример за неговата употреба може да се претстави со следниов блок дијаграм:
Разликата помеѓу овие пристапи е како што следува:
Блокирање на I/O операции суспендира кориснички тек додекадодека ОС не е правилно дефрагменти дојдовен IP пакети до бајт поток (TCP, примање податоци) или нема да има доволно простор на располагање во внатрешните бафери за пишување за последователно испраќање преку NIC (испраќање податоци).
Системски избирач прекувремено ја известува програмата дека ОС веќе дефрагментирани IP пакети (TCP, прием на податоци) или доволно простор во внатрешните бафери за запишување веќе достапни (испраќање податоци).
Да се сумира, резервирањето на нишка на ОС за секое I/O е губење на компјутерската моќ, бидејќи во реалноста, нишките не вршат корисна работа (оттука и терминот „софтверски прекин“). Изборот на системот го решава овој проблем, дозволувајќи му на корисничката програма да ги користи ресурсите на процесорот многу поекономично.
I/O модел на реактор
Влезниот/излезен реакторот делува како слој помеѓу избирачот на системот и корисничкиот код. Принципот на неговото функционирање е опишан со следниов блок дијаграм:
Да ве потсетам дека настанот е известување дека одреден сокет може да изврши неблокирачка I/O операција.
Ракувач на настани е функција која се повикува од В/И реакторот кога се прима настан, кој потоа врши неблокирана В/И операција.
Важно е да се напомене дека В/И реакторот е по дефиниција со еднонавој, но ништо не го спречува концептот да се користи во опкружување со повеќе нишки во сооднос од 1 нишка: 1 реактор, со што се рециклираат сите јадра на процесорот.
Реализация
Јавниот интерфејс ќе го поставиме во датотека reactor.h, и имплементација - во reactor.c. 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);
Структурата на реакторот В/И се состои од дескриптор на датотека селектор епол и хаш табелиGHashTable, кој го мапира секој сокет на CallbackData (структура на управувач со настани и кориснички аргумент за него).
Ве молиме имајте предвид дека ја овозможивме можноста за ракување нецелосен тип според индексот. ВО reactor.h ја декларираме структурата reactorи внатре reactor.c го дефинираме, со што го спречуваме корисникот експлицитно да ги менува своите полиња. Ова е еден од шаблоните криење податоци, што накратко се вклопува во C семантиката.
Функции reactor_register, reactor_deregister и reactor_reregister ажурирајте го списокот со сокети од интерес и соодветните управувачи на настани во избирачот на системот и табелата за хаш.
Откако В/И реакторот ќе го пресретне настанот со дескрипторот 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;
}
Да резимираме, синџирот на повици на функции во корисничкиот код ќе ја има следната форма:
Сервер со една нишка
За да го тестираме В/И реакторот под големо оптоварување, ќе напишеме едноставен веб-сервер 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, кој ги содржи следните прототипови на функции:
Прикажи заеднички прототипови на функции.ч
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов принять новое соединение.
*/
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() во В/И реактор.
Ракувач на настани 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() создаваме В/И реактор користејќи reactor_new(), креирајте приклучок за сервер и регистрирајте го, стартувајте го реакторот со користење reactor_run() точно една минута, а потоа ослободуваме ресурси и излегуваме од програмата.
Ајде да провериме дали сè работи како што се очекуваше. Составување (chmod a+x compile.sh && ./compile.sh во коренот на проектот) и стартувајте го самонапишаниот сервер, отворете http://127.0.0.1:18470 во прелистувачот и видете што очекувавме:
Ајде да ги измериме перформансите на серверот со една нишка. Ајде да отвориме два терминали: во едниот ќе работиме ./http_server, во поинаква - зглоб. По една минута, следната статистика ќе се прикаже во вториот терминал:
Нашиот сервер со една нишка можеше да обработи над 11 милиони барања во минута кои потекнуваат од 100 конекции. Не е лош резултат, но дали може да се подобри?
Сервер со повеќе нишки
Како што беше споменато погоре, В/И реакторот може да се креира во посебни нишки, со што се користат сите јадра на процесорот. Ајде да го примениме овој пристап во пракса:
Ве молиме имајте предвид дека аргументот на функцијата new_server() застапници true. Ова значи дека ја доделуваме опцијата на серверот сокет SO_REUSEPORTда го користите во опкружување со повеќе нишки. Можете да прочитате повеќе детали тука.
Второ трчање
Сега да ги измериме перформансите на серверот со повеќе нишки:
Бројот на обработени барања за 1 минута се зголеми за ~3.28 пати! Но, ни недостигаа само ~ XNUMX милиони од кругот, па ајде да се обидеме да го поправиме тоа.
Прво да ја погледнеме генерираната статистика перф:
Користење на афинитет на процесорот, компилација со -march=native, PGO, зголемување на бројот на хитови кешот, зголемување MAX_EVENTS и употреба EPOLLET не даде значително зголемување на перформансите. Но, што ќе се случи ако го зголемите бројот на истовремени врски?
Добиен е посакуваниот резултат, а со него и интересен графикон кој ја прикажува зависноста на бројот на обработени барања за 1 минута од бројот на конекции:
Гледаме дека по неколку стотици врски, бројот на обработени барања за двата сервери нагло опаѓа (во верзијата со повеќе нишки ова е позабележително). Дали е ова поврзано со имплементацијата на оџакот на Linux TCP/IP? Слободно можете да ги напишете вашите претпоставки за ваквото однесување на графикот и оптимизациите за опциите со повеќе нишки и со една нишка во коментарите.
Како забележано во коментарите, овој тест за изведба не го покажува однесувањето на В/И реакторот при реални оптоварувања, бидејќи скоро секогаш серверот е во интеракција со базата на податоци, излегува логови, користи криптографија со TLS итн., како резултат на што товарот станува нерамномерен (динамичен). Тестовите заедно со компонентите од трети страни ќе бидат извршени во написот за I/O проакторот.
Недостатоци на В/И реакторот
Треба да разберете дека В/И реакторот не е без недостатоци, имено:
Користењето на В/И реактор во опкружување со повеќе навој е нешто потешко, бидејќи ќе мора рачно да управувате со тековите.
Практиката покажува дека во повеќето случаи оптоварувањето е нерамномерно, што може да доведе до сечење на една нишка додека друга е зафатена со работа.
Ако еден управувач со настани блокира нишка, самиот избирач на системот исто така ќе блокира, што може да доведе до грешки кои тешко се наоѓаат.
Ги решава овие проблеми I/O проактор, кој често има распоредувач кој рамномерно го распределува оптоварувањето на базен од нишки, а исто така има и поудобен API. Ќе зборуваме за тоа подоцна, во мојата друга статија.
Заклучок
Ова е местото каде што заврши нашето патување од теоријата директно во издувните гасови на профилерот.
Не треба да се задржувате на ова, бидејќи има многу други подеднакво интересни пристапи за пишување мрежен софтвер со различни нивоа на практичност и брзина. Интересно, според мое мислење, линковите се дадени подолу.