V tomto článku se podíváme na výhody a nevýhody I/O reaktoru a na to, jak funguje, napíšeme implementaci v méně než 200 řádcích kódu a vytvoříme jednoduchý proces HTTP serveru přes 40 milionů požadavků/min.
předmluva
Článek byl napsán tak, aby pomohl porozumět fungování I/O reaktoru, a tudíž porozumět rizikům při jeho používání.
Pro pochopení článku je nutná znalost základů. jazyk C a určité zkušenosti s vývojem síťových aplikací.
Veškerý kód je napsán v jazyce C přesně podle (upozornění: dlouhé PDF) podle standardu C11 pro Linux a dostupné na GitHub.
Proč to udělal?
S rostoucí popularitou internetu začaly webové servery potřebovat obsluhovat velké množství připojení současně, a proto byly vyzkoušeny dva přístupy: blokování I/O na velkém počtu vláken OS a neblokování I/O v kombinaci s systém oznamování událostí, nazývaný také „výběr systému“ (epoll/kqueue/IOCP/atd).
První přístup zahrnoval vytvoření nového vlákna operačního systému pro každé příchozí připojení. Jeho nevýhodou je špatná škálovatelnost: operační systém jich bude muset implementovat mnoho kontextové přechody и systémová volání. Jsou to drahé operace a mohou vést k nedostatku volné paměti RAM s působivým počtem připojení.
Upravená verze zdůrazňuje pevný počet vláken (pool vláken), čímž se zabrání systému v přerušení provádění, ale zároveň se zavádí nový problém: pokud je fond vláken aktuálně blokován dlouhými operacemi čtení, pak ostatní sokety, které jsou již schopny přijímat data, nebudou moci Učiň tak.
Druhý přístup využívá systém oznamování událostí (selektor systému) poskytovaný OS. Tento článek pojednává o nejběžnějším typu selektoru systému založeného na výstrahách (událostech, upozorněních) o připravenosti na I/O operace, spíše než na oznámení o jejich dokončení. Zjednodušený příklad jeho použití může být znázorněn na následujícím blokovém schématu:
Rozdíl mezi těmito přístupy je následující:
Blokování I/O operací pozastavit uživatelský tok dokuddokud nebude OS správně defragmentuje přicházející IP pakety do byte streamu (TCP, přijímání dat) nebo nebude v interních vyrovnávací paměti pro zápis dostatek místa pro následné odeslání přes NIC (odeslání dat).
Volič systému přesčas upozorní program, že OS již defragmentované IP pakety (TCP, příjem dat) nebo dostatek místa v interních vyrovnávací paměti pro zápis již k dispozici (odesílání dat).
Abych to shrnul, rezervování vlákna OS pro každý I/O je plýtváním výpočetního výkonu, protože ve skutečnosti vlákna nedělají užitečnou práci (odtud tento termín pochází "softwarové přerušení"). Selektor systému tento problém řeší a umožňuje uživatelskému programu využívat zdroje CPU mnohem ekonomičtěji.
Model I/O reaktoru
I/O reaktor funguje jako vrstva mezi selektorem systému a uživatelským kódem. Princip jeho činnosti popisuje následující blokové schéma:
Dovolte mi připomenout, že událost je upozornění, že určitý soket je schopen provést neblokující I/O operaci.
Obsluha události je funkce volaná I/O reaktorem, když je přijata událost, která pak provede neblokující I/O operaci.
Je důležité poznamenat, že I/O reaktor je z definice jednovláknový, ale nic nebrání tomu, aby byl koncept použit ve vícevláknovém prostředí v poměru 1 vlákno: 1 reaktor, čímž se recyklují všechna jádra CPU.
uskutečnění
Veřejné rozhraní umístíme do souboru reactor.h, a implementace - in reactor.c. reactor.h se bude skládat z následujících oznámení:
Zobrazit deklarace v reaktoru.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);
Vezměte prosím na vědomí, že jsme povolili možnost zpracování neúplný typ podle indexu. V reactor.h deklarujeme strukturu reactorA reactor.c definujeme jej, čímž zabráníme uživateli explicitně měnit jeho pole. Toto je jeden ze vzorů skrytí dat, který stručně zapadá do sémantiky C.
funkce reactor_register, reactor_deregister и reactor_reregister aktualizujte seznam zájmových soketů a odpovídajících obslužných rutin událostí v systémovém selektoru a hashovací tabulce.
Poté, co I/O reaktor zachytil událost pomocí deskriptoru fd, zavolá odpovídající obsluhu události, které předá fd, bitová maska generované události a uživatelský ukazatel void.
Zobrazit funkci reaktor_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;
}
Abychom to shrnuli, řetězec volání funkcí v uživatelském kódu bude mít následující podobu:
Jednovláknový server
Abychom otestovali I/O reaktor při vysoké zátěži, napíšeme jednoduchý HTTP webový server, který na jakýkoli požadavek odpoví obrázkem.
Rychlý odkaz na protokol HTTP
HTTP - toto je protokol aplikační úroveň, primárně používaný pro interakci server-prohlížeč.
HTTP lze snadno použít přes doprava protokol TCP, odesílání a přijímání zpráv v určeném formátu Specifikace.
CRLF je posloupnost dvou znaků: r и n, oddělující první řádek požadavku, hlavičky a data.
<КОМАНДА> - jeden z CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Prohlížeč odešle příkaz našemu serveru GET, což znamená "Pošlete mi obsah souboru."
<КОД СТАТУСА> je číslo představující výsledek operace. Náš server vždy vrátí stav 200 (úspěšná operace).
<ОПИСАНИЕ СТАТУСА> — řetězcová reprezentace stavového kódu. Pro stavový kód 200 to je OK.
<ЗАГОЛОВОК N> — záhlaví stejného formátu jako v požadavku. Tituly vrátíme Content-Length (velikost souboru) a Content-Type: text/html (návratový datový typ).
<ДАННЫЕ> — údaje požadované uživatelem. V našem případě je to cesta k obrazu in HTML.
Soubor http_server.c (server s jedním vláknem) obsahuje soubor common.h, který obsahuje následující prototypy funkcí:
Zobrazit prototypy funkcí společně.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);
Je také popsáno funkční makro SAFE_CALL() a funkce je definována fail(). Makro porovná hodnotu výrazu s chybou, a pokud je podmínka pravdivá, zavolá funkci fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funkce fail() vypíše předané argumenty do terminálu (např printf()) a ukončí program pomocí kódu EXIT_FAILURE:
Funkce new_server() vrací deskriptor souboru soketu "server" vytvořený systémovými voláními socket(), bind() и listen() a schopný přijímat příchozí spojení v neblokujícím režimu.
Všimněte si, že soket je zpočátku vytvořen v neblokujícím režimu pomocí příznaku SOCK_NONBLOCKtakže ve funkci on_accept() (čtěte více) systémové volání accept() nezastavil provádění vlákna.
Jestliže reuse_port rovná true, pak tato funkce nakonfiguruje zásuvku s možností SO_REUSEPORT přes setsockopt()používat stejný port ve vícevláknovém prostředí (viz část „Vícevláknový server“).
Obsluha události on_accept() voláno poté, co operační systém vygeneruje událost EPOLLIN, což v tomto případě znamená, že nové připojení může být přijato. on_accept() přijme nové připojení, přepne jej do neblokovacího režimu a zaregistruje se pomocí obsluhy události on_recv() v I/O reaktoru.
Obsluha události on_recv() voláno poté, co operační systém vygeneruje událost EPOLLIN, v tomto případě to znamená, že se připojení zaregistrovalo on_accept(), připravena přijímat data.
on_recv() čte data z připojení, dokud není zcela přijat HTTP požadavek, poté zaregistruje handler on_send() k odeslání odpovědi HTTP. Pokud klient přeruší připojení, je soket odregistrován a uzavřen pomocí close().
Zobrazit funkci 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);
}
}
Obsluha události on_send() voláno poté, co operační systém vygeneruje událost EPOLLOUT, což znamená, že se připojení zaregistrovalo on_recv(), připraven k odeslání dat. Tato funkce odešle HTTP odpověď obsahující HTML s obrázkem klientovi a poté změní obslužnou rutinu události zpět na on_recv().
A nakonec v souboru http_server.c, ve funkci main() vytváříme I/O reaktor pomocí reactor_new(), vytvořte serverový soket a zaregistrujte jej, spusťte reaktor pomocí reactor_run() přesně na jednu minutu a poté uvolníme prostředky a ukončíme program.
Zkontrolujeme, zda vše funguje podle očekávání. Kompilace (chmod a+x compile.sh && ./compile.sh v kořenovém adresáři projektu) a spusťte samostatně psaný server, otevřete http://127.0.0.1:18470 v prohlížeči a uvidíme, co jsme očekávali:
Pojďme změřit výkon jednovláknového serveru. Otevřeme dva terminály: v jednom spustíme ./http_server, v jiném - vlnit. Po minutě se na druhém terminálu zobrazí následující statistika:
Náš jednovláknový server byl schopen zpracovat více než 11 milionů požadavků za minutu pocházejících ze 100 připojení. Není to špatný výsledek, ale dá se to zlepšit?
Vícevláknový server
Jak bylo zmíněno výše, I/O reaktor může být vytvořen v samostatných vláknech, čímž se využívají všechna jádra CPU. Uveďme tento přístup do praxe:
Vezměte prosím na vědomí, že argument funkce new_server() obhajuje true. To znamená, že možnost přiřadíme serverovému soketu SO_REUSEPORTpro použití ve vícevláknovém prostředí. Můžete si přečíst více zde.
Použití CPU Affinity, kompilace s -march=native, PGO, zvýšení počtu zásahů cache, zvýšit MAX_EVENTS a použít EPOLLET nepřineslo výrazné zvýšení výkonu. Ale co se stane, když zvýšíte počet současných připojení?
Požadovaný výsledek byl získán a s ním i zajímavý graf ukazující závislost počtu zpracovaných požadavků za 1 minutu na počtu spojení:
Vidíme, že po několika stovkách připojení počet zpracovaných požadavků pro oba servery prudce klesá (u vícevláknové verze je to znatelnější). Souvisí to s implementací zásobníku TCP/IP v Linuxu? Své domněnky o tomto chování grafu a optimalizacích pro vícevláknové a jednovláknové možnosti klidně napište do komentářů.
Jak poznamenal v komentářích tento test výkonu neukazuje chování I/O reaktoru při skutečném zatížení, protože téměř vždy server komunikuje s databází, vydává protokoly, používá kryptografii s TLS atd., v důsledku čehož se zatížení stává nerovnoměrným (dynamickým). Testy spolu s komponentami třetích stran budou provedeny v článku o I/O proaktoru.
Nevýhody I/O reaktoru
Musíte pochopit, že I/O reaktor není bez svých nevýhod, jmenovitě:
Použití I/O reaktoru ve vícevláknovém prostředí je poněkud obtížnější, protože budete muset ručně spravovat toky.
Praxe ukazuje, že ve většině případů je zatížení nerovnoměrné, což může vést k protokolování jednoho vlákna, zatímco jiné je zaneprázdněno prací.
Pokud jedna obsluha události zablokuje vlákno, pak se zablokuje i samotný selektor systému, což může vést k těžko dohledatelným chybám.
Řeší tyto problémy I/O proaktor, který má často plánovač, který rovnoměrně rozděluje zátěž do fondu vláken, a má také pohodlnější API. Budeme o tom mluvit později, v mém jiném článku.
Závěr
Tady naše cesta od teorie přímo k výfuku profileru skončila.
Neměli byste se tím zabývat, protože existuje mnoho dalších stejně zajímavých přístupů k psaní síťového softwaru s různou úrovní pohodlí a rychlosti. Zajímavé, podle mého názoru, odkazy jsou uvedeny níže.