У дадзеным артыкуле мы разгледзім паднаготную I/O рэактара і прынцып яго працы, напішам рэалізацыю на менш, чым 200 радкоў кода і прымусім просты HTTP сервер апрацоўваць звыш 40 мільёнаў запытаў/мін.
Прадмова
Артыкул напісаны з мэтай дапамагчы разабрацца ў функцыянаванні I/O рэактара, а значыць і ўсвядоміць рызыкі пры яго выкарыстанні.
Для засваення артыкула патрабуецца веданне асноў мовы Сі і невялікі досвед распрацоўкі сеткавых прыкладанняў.
Увесь код напісаны на мове Сі строга па (асцярожна: доўгі PDF) стандарту C11 для Linux і даступны на GitHub.
Навошта гэта трэба?
З ростам папулярнасці Інтэрнэту вэб-серверам стала трэба апрацоўваць вялікую колькасць злучэнняў адначасова, у сувязі з чым было апрабавана два падыходу: блакавальнае I/O на вялікім ліку струменяў АС і неблакавальнае I/O у камбінацыі з сістэмай абвесткі аб падзеях, яшчэ званай «сістэмным». селектарам» (epoll/kqueue/IOCP/etc).
Першы падыход меў на ўвазе стварэнне новага струменя АС для кожнага ўваходнага злучэння. Яго недахопам з'яўляецца дрэнная маштабаванасць: аперацыйнай сістэме давядзецца ажыццяўляць мноства пераходаў кантэксту и сістэмных выклікаў. Яны з'яўляюцца дарагімі аперацыямі і могуць прывесці да недахопу вольнай АЗП пры вялікім ліку злучэнняў.
Мадыфікаваная версія вылучае фіксаваны лік патокаў (thread pool), тым самым не дазваляючы сістэме аварыйна спыніць выкананне, але разам з тым прыўносіць новую праблему: калі ў дадзены момант часу пул патокаў блакуюць працяглыя аперацыі чытання, то іншыя сокеты, якія ўжо ў стане прыняць дадзеныя, не змогуць гэтага зрабіць.
Другі падыход выкарыстоўвае сістэму абвесткі аб падзеях (сістэмны селектар), якую падае АС. У дадзеным артыкуле разгледжаны найболей часта сустракаемы выгляд сістэмнага селектара, заснаваны на абвестках (падзеях, апавяшчэннях) аб гатовасці да I/O аперацыям, чым на абвестках аб іх завяршэнні. Спрошчаны прыклад яго выкарыстання можна ўявіць наступнай блок-схемай:
Розніца паміж дадзенымі падыходамі заключаецца ў наступным:
Блакавальныя I/O аперацыі прыпыняюць карыстацкі паток да таго часу, пакуль АС належным чынам не дэфрагментуе абітурыенты IP пакеты у паток байт (TCP, атрыманне дадзеных) або не вызваліцца дастаткова месца ва ўнутраных буферах запісы для наступнай адпраўкі праз NIC (адпраўка дадзеных).
Сістэмны селектар праз некаторы час паведамляе праграму аб тым, што АС ўжо дэфрагментавала IP пакеты (TCP, атрыманне дадзеных) або дастаткова месца ва ўнутраных буферах запісу ўжо даступна (адпраўка дадзеных).
Падводзячы вынік, рэзерваванне струменя АС для кожнага 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 рэактара складаецца з файлавага дэскрыптара селектара epoll и хэш-табліцыGHashTable, якая кожны сокет супастаўляе з CallbackData (структура з апрацоўшчыка падзеі і аргумента карыстальніка для яго).
Звярніце ўвагу, што мы задзейнічалі магчымасць абыходжання з няпоўным тыпам па паказальніку. У reactor.h мы аб'яўляем структуру reactor, а ў reactor.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:
Звярніце ўвагу, што сокет першапачаткова ствараецца ў неблакіруючым рэжыме з дапамогай сцяга. 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, Каб выкарыстоўваць яго ў шматструменным асяроддзі. Падрабязней можаце пачытаць тут.
Другі заход
Зараз вымераем прадукцыйнасць шматструменнага сервера:
Выкарыстанне афіннасці ЦПУ, кампіляцыя з -march=native, Генпракуратура, павелічэнне колькасці трапленняў у кэш, павелічэнне MAX_EVENTS і выкарыстанне EPOLLET не дало значнага прыросту ў прадукцыйнасці. Але што атрымаецца, калі павялічыць колькасць адначасовых злучэнняў?
Жаданы вынік атрыманы, а разам з ім і цікавы графік, які дэманструе залежнасць колькасці апрацаваных запытаў за 1 хвіліну ад колькасці злучэнняў:
Бачым, што пасля пары сотняў злучэнняў лік апрацаваных запытаў у абодвух сервераў рэзка падае (у шматструменнага варыянту гэта больш прыкметна). Ці злучана гэта з рэалізацыяй TCP/IP стэка Linux? Свае здагадкі наконт такіх паводзінаў графіка і аптымізацый шматструменнага і аднаструменнага варыянтаў смела пішыце ў каментарах.
Як адзначылі у каментарах, дадзены тэст прадукцыйнасці не паказвае паводзін I/O рэактара на рэальных нагрузках, бо амаль заўсёды сервер узаемадзейнічае з БД, выводзіць логі, выкарыстае крыптаграфію з TLS і г.д., з прычыны чаго нагрузка становіцца неаднароднай (дынамічнай). Тэсты разам з іншымі кампанентамі будуць праведзены ў артыкуле пра I/O праактар.
Недахопы I/O рэактара
Трэба разумець, што I/O рэактар не пазбаўлены недахопаў, а менавіта:
Карыстацца I/O рэактарам у шматструменным асяроддзі некалькі складаней, т.я. давядзецца ўручную кіраваць патокамі.
Практыка паказвае, што ў большасці выпадкаў нагрузка неаднастайная, што можа прывесці да таго, што адзін струмень будзе прастаўляць, пакуль іншы будзе загружаны працай.
Калі адзін апрацоўшчык падзеі заблакуе струмень, то таксама заблакуецца і сам сістэмны селектар, што можа прывесці да цяжкаадлоўных багаў.
Гэтыя праблемы вырашае I/O праактар, часта які мае планавальнік, які раўнамерна размяркоўвае нагрузку ў пул струменяў, і да таго ж які мае больш зручны API. Гаворка пра яго пойдзе пазней, у маім іншым артыкуле.
Заключэнне
На гэтым наша вандраванне з тэорыі наўпрост у выхлап прафайлера падышло да канца.
Не варта на гэтым спыняцца, бо існуюць мноства іншых не менш цікавых падыходаў да напісання сеткавага ПЗ з розным узроўнем зручнасці і хуткасці. Цікавыя, на мой погляд, спасылкі прыведзены ніжэй.