У овом чланку ћемо погледати све детаље И/О реактора и како он функционише, написати имплементацију у мање од 200 линија кода и направити једноставан ХТТП серверски процес преко 40 милиона захтева/мин.
Предговор
Чланак је написан да би помогао у разумевању функционисања И/О реактора, а самим тим и разумевању ризика при његовом коришћењу.
За разумевање чланка потребно је познавање основа. Ц језик и одређено искуство у развоју мрежних апликација.
Сав код је написан на језику Ц стриктно према (опрез: дугачак ПДФ) према стандарду Ц11 за Линук и доступно на ГитХуб.
Зашто то?
Са растућом популарношћу Интернета, веб сервери су почели да имају потребу да истовремено руководе великим бројем конекција, па су стога покушана два приступа: блокирање И/О на великом броју ОС нити и неблокирање И/О у комбинацији са систем за обавештавање о догађајима, који се такође назива „селектор система“ (еполл/ккуеуе/ИОЦП/етц).
Први приступ је укључивао креирање нове ОС нити за сваку долазну везу. Његов недостатак је лоша скалабилност: оперативни систем ће морати да имплементира многе прелазе контекста и системски позиви. Оне су скупе операције и могу довести до недостатка слободне РАМ меморије са импресивним бројем конекција.
Измењена верзија истиче фиксни број нити (скуп нити), чиме се спречава систем да прекине извршење, али у исто време уводи нови проблем: ако је скуп нити тренутно блокиран дугим операцијама читања, онда друге утичнице које већ могу да примају податке неће моћи да урадити тако.
Други приступ користи систем обавештавања о догађајима (селектор система) који обезбеђује ОС. Овај чланак говори о најчешћем типу селектора система, заснованом на упозорењима (догађаји, обавештења) о спремности за И/О операције, а не на обавештења о њиховом завршетку. Поједностављени пример његове употребе може бити представљен следећим блок дијаграмом:
Разлика између ових приступа је следећа:
Блокирање И/О операција суспендовати проток корисника све докдок ОС не буде исправно дефрагментира долазни ИП пакети у ток бајтова (ТЦП, пријем података) или неће бити довољно слободног простора у интерним баферима за уписивање за накнадно слање путем НИЦ (слање података).
Системски бирач током времена обавештава програм да ОС већ дефрагментирани ИП пакети (ТЦП, пријем података) или довољно простора у интерним баферима за писање већ доступно (слање података).
Да сумирамо, резервисање ОС нити за сваки И/О је губитак рачунарске снаге, јер у стварности, нити не обављају користан посао (отуда термин "софтверски прекид"). Системски селектор решава овај проблем, дозвољавајући корисничком програму да много економичније користи ЦПУ ресурсе.
Модел И/О реактора
И/О реактор делује као слој између селектора система и корисничког кода. Принцип његовог рада описан је следећим блок дијаграмом:
Дозволите ми да вас подсетим да је догађај обавештење да је одређена утичница у стању да изврши неблокирајућу И/О операцију.
Руковалац догађаја је функција коју позива И/О реактор када се прими догађај, а која затим изводи неблокирајућу У/И операцију.
Важно је напоменути да је И/О реактор по дефиницији једнонитни, али ништа не спречава да се концепт користи у вишенитном окружењу у односу 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 дефинишемо га, чиме спречавамо корисника да експлицитно мења своја поља. Ово је један од образаца скривање података, што се сажето уклапа у семантику Ц.
Функције reactor_register, reactor_deregister и reactor_reregister ажурирати листу утичница од интереса и одговарајућих руковалаца догађаја у системском бирачу и хеш табели.
Након што је И/О реактор пресрео догађај са дескриптором fd, позива одговарајући обрађивач догађаја, на који пролази fd, бит маска генерисани догађаји и показивач корисника на void.
Прикажи функцију реацтор_рун().
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;
}
Да резимирамо, ланац позива функција у корисничком коду ће имати следећи облик:
Сервер са једним навојем
Да бисмо тестирали И/О реактор под великим оптерећењем, написаћемо једноставан ХТТП веб сервер који на сваки захтев одговара сликом.
Брза референца на ХТТП протокол
ХТТП - ово је протокол ниво апликације, првенствено се користи за интеракцију између сервера и претраживача.
ХТТП се може лако користити преко транспорт protokola ТЦП, слање и примање порука у одређеном формату спецификација.
CRLF је низ од два знака: r и n, одвајајући први ред захтева, заглавља и податке.
<КОМАНДА> - један од CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Прегледач ће послати команду нашем серверу GET, што значи "Пошаљи ми садржај датотеке."
<КОД СТАТУСА> је број који представља резултат операције. Наш сервер ће увек враћати статус 200 (успешна операција).
<ОПИСАНИЕ СТАТУСА> — стринг приказ статусног кода. За статусни код 200 ово је OK.
<ЗАГОЛОВОК N> — заглавље истог формата као у захтеву. Вратићемо наслове Content-Length (величина датотеке) и Content-Type: text/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() чита податке из везе све док ХТТП захтев није у потпуности примљен, а затим региструје руковалац on_send() да бисте послали ХТТП одговор. Ако клијент прекине везу, утичница се одјављује и затвара помоћу close().
Прикажи функцију он_рецв()
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(), спреман за слање података. Ова функција шаље клијенту ХТТП одговор који садржи ХТМЛ са сликом, а затим враћа обрађивач догађаја на 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, ПГО, повећање броја погодака готовина, повећати MAX_EVENTS и користити EPOLLET није дало значајно повећање перформанси. Али шта се дешава ако повећате број истовремених веза?
Добијен је жељени резултат, а са њим и занимљив графикон који показује зависност броја обрађених захтева за 1 минут од броја веза:
Видимо да након пар стотина конекција број обрађених захтева за оба сервера нагло опада (у верзији са више нити то је уочљивије). Да ли је ово повезано са имплементацијом Линук ТЦП/ИП стека? Слободно напишите своје претпоставке о оваквом понашању графикона и оптимизацијама за вишенитне и једнонитне опције у коментарима.
Као приметио у коментарима, овај тест перформанси не показује понашање И/О реактора под реалним оптерећењем, јер скоро увек сервер комуницира са базом података, излази евиденције, користи криптографију са ТЛС итд., услед чега оптерећење постаје неуједначено (динамичко). Тестови заједно са компонентама треће стране биће спроведени у чланку о И/О проактору.
Недостаци И/О реактора
Морате схватити да И/О реактор није без својих недостатака, а то су:
Коришћење И/О реактора у вишенитном окружењу је нешто теже, јер мораћете ручно да управљате токовима.
Пракса показује да је оптерећење у већини случајева неуједначено, што може довести до евидентирања једне нити док је друга заузета послом.
Ако један обрађивач догађаја блокира нит, онда ће и сам системски бирач блокирати, што може довести до грешака које је тешко пронаћи.
Решава ове проблеме И/О проацтор, који често има планер који равномерно распоређује оптерећење на скуп нити, а такође има и практичнији АПИ. О томе ћемо касније, у мом другом чланку.
Закључак
Ово је место где се наше путовање од теорије директно до издувног система профилера завршило.
Не би требало да се задржавате на томе, јер постоји много других подједнако занимљивих приступа писању мрежног софтвера са различитим нивоима погодности и брзине. Занимљиво, по мом мишљењу, линкови су дати испод.