Ebben a cikkben megvizsgáljuk az I/O reaktor csínját-bínját és működését, egy implementációt írunk le kevesebb mint 200 kódsorból, és egy egyszerű HTTP-kiszolgáló folyamatot készítünk 40 millió kérés/perc felett.
Előszó
A cikk azért készült, hogy segítsen megérteni az I/O reaktor működését, és ezáltal megérteni a használat során felmerülő kockázatokat.
A cikk megértéséhez az alapok ismerete szükséges. C nyelv és némi tapasztalat hálózati alkalmazások fejlesztésében.
Minden kód C nyelven van írva szigorúan a (Figyelem: hosszú PDF) C11 szabvány szerint Linuxra és a következőn érhető el GitHub.
Miért is?
Az internet népszerűségének növekedésével a webszervereknek egyre nagyobb számú kapcsolatot kellett egyszerre kezelniük, ezért két megközelítést próbáltak ki: az I/O blokkolását nagyszámú operációs rendszer szálon és a nem blokkoló I/O-t a eseményértesítési rendszer, más néven „rendszerválasztó” (epoll/kqueue/IOCP/stb).
Az első megközelítés egy új operációs rendszer-szál létrehozását jelentette minden bejövő kapcsolathoz. Hátránya a rossz skálázhatóság: az operációs rendszernek sokat kell implementálnia kontextus átmenetek и rendszerhívások. Ezek költséges műveletek, és a szabad RAM hiányához vezethetnek lenyűgöző számú kapcsolat mellett.
A módosított változat kiemeli fix számú szál (szálkészlet), ezzel megakadályozva, hogy a rendszer megszakítsa a végrehajtást, ugyanakkor új problémát vet fel: ha egy szálkészletet jelenleg hosszú olvasási műveletek blokkolnak, akkor a többi socket, amely már képes adatokat fogadni, nem fogja tudni tehát csináld meg.
A második megközelítést alkalmazza eseményértesítési rendszer (rendszerválasztó) az operációs rendszer által biztosított. Ez a cikk a rendszerválasztó leggyakoribb típusát tárgyalja, amely az I/O műveletekre való készenlétről szóló riasztásokon (események, értesítések) alapul, nem pedig a értesítések azok befejezéséről. Használatának egyszerűsített példája a következő blokkdiagrammal ábrázolható:
E megközelítések közötti különbség a következő:
I/O műveletek blokkolása felfüggeszti felhasználói áramlás amígamíg az operációs rendszer megfelelő nem lesz töredezettségmentesítése beérkező IP-csomagok bájtos adatfolyamhoz (TCP, adatok fogadása), vagy nem lesz elegendő hely a belső írási pufferekben a későbbi küldéshez NIC (adatküldés).
Rendszerválasztó túlóra értesíti a programot, hogy az OS már töredezettségmentesített IP-csomagok (TCP, adatfogadás) vagy elegendő hely a belső írási pufferekben már elérhető (adatok küldése).
Összefoglalva, minden I/O számára egy operációs rendszer szál lefoglalása számítási teljesítmény pazarlása, mivel a valóságban a szálak nem végeznek hasznos munkát (ezért a kifejezés "szoftver megszakítás"). A rendszerválasztó megoldja ezt a problémát, lehetővé téve a felhasználói program számára, hogy sokkal gazdaságosabban használja fel a CPU erőforrásait.
I/O reaktor modell
Az I/O reaktor rétegként működik a rendszerválasztó és a felhasználói kód között. Működésének elvét a következő blokkdiagram írja le:
Hadd emlékeztesselek arra, hogy az esemény egy értesítés arról, hogy egy bizonyos socket nem blokkoló I/O műveletet tud végrehajtani.
Az eseménykezelő egy olyan funkció, amelyet az I/O reaktor hív meg, amikor esemény érkezik, és ezután egy nem blokkoló I/O műveletet hajt végre.
Fontos megjegyezni, hogy az I/O reaktor definíció szerint egyszálú, de semmi akadálya annak, hogy a koncepciót többszálas környezetben 1 szál: 1 reaktor arányban használják, ezáltal az összes CPU magot újrahasznosítsák.
Реализация
A nyilvános felületet egy fájlban helyezzük el reactor.h, és megvalósítás - in reactor.c. reactor.h a következő közleményekből fog állni:
Deklarációk megjelenítése a reaktorban.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);
Az I/O reaktor szerkezete a következőkből áll fájlleíró választó epoll и hash táblázatokGHashTable, amely leképezi az egyes aljzatokat CallbackData (egy eseménykezelő szerkezete és egy felhasználói argumentum hozzá).
Felhívjuk figyelmét, hogy engedélyeztük a kezelési képességet hiányos típus az index szerint. BAN BEN reactor.h kijelentjük a szerkezetet reactor, és be reactor.c definiáljuk, ezzel megakadályozva, hogy a felhasználó kifejezetten módosítsa a mezőit. Ez az egyik minta adatok elrejtése, ami tömören beleillik a C szemantikába.
függvények reactor_register, reactor_deregister и reactor_reregister frissítse az érdeklődésre számot tartó socketek listáját és a megfelelő eseménykezelőket a rendszerválasztóban és a hash-táblázatban.
Miután az I/O reaktor elfogta az eseményt a leíróval fd, meghívja a megfelelő eseménykezelőt, amelyhez továbbad fd, bit maszk generált eseményeket és egy felhasználói mutatót void.
Reactor_run() függvény megjelenítése
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;
}
Összefoglalva, a függvényhívások lánca a felhasználói kódban a következő formában jelenik meg:
Egyszálas szerver
Az I/O reaktor nagy terhelés alatti teszteléséhez egy egyszerű HTTP webszervert fogunk írni, amely minden kérésre képpel válaszol.
Gyors hivatkozás a HTTP protokollra
HTTP - ez a protokoll alkalmazási szint, elsősorban szerver-böngésző interakcióhoz használják.
A HTTP könnyen használható szállítás jegyzőkönyv TCP, üzenetek küldése és fogadása meghatározott formátumban leírás.
CRLF két karakter sorozata: r и n, amely elválasztja a kérés első sorát, a fejléceket és az adatokat.
<КОМАНДА> - az egyik CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. A böngésző parancsot küld a szerverünknek GET, azaz "Küldje el a fájl tartalmát."
<КОД СТАТУСА> a művelet eredményét jelző szám. Szerverünk mindig 200-as állapotot ad vissza (sikeres művelet).
<ОПИСАНИЕ СТАТУСА> — az állapotkód karakterlánc reprezentációja. A 200-as állapotkód esetében ez OK.
<ЗАГОЛОВОК N> — a kérésben szereplő formátumú fejléc. Visszaküldjük a címeket Content-Length (fájl mérete) és Content-Type: text/html (visszaküldési adattípus).
<ДАННЫЕ> — a felhasználó által kért adatok. Esetünkben ez az út a képbe HTML.
fájl http_server.c (egyszálú szerver) fájlt tartalmaz common.h, amely a következő függvényprototípusokat tartalmazza:
Közös függvényprototípusok megjelenítése.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);
Leírjuk a funkcionális makrót is SAFE_CALL() és a függvény definiálva van fail(). A makró összehasonlítja a kifejezés értékét a hibával, és ha a feltétel igaz, meghívja a függvényt fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funkció fail() kiírja az átadott argumentumokat a terminálnak (pl printf()), és leállítja a programot a kóddal EXIT_FAILURE:
Funkció new_server() a rendszerhívások által létrehozott "szerver" socket fájlleíróját adja vissza socket(), bind() и listen() és képes a bejövő kapcsolatok fogadására nem blokkoló módban.
Vegye figyelembe, hogy a socket kezdetben nem blokkoló módban jön létre a zászló használatával SOCK_NONBLOCKígy a függvényben on_accept() (tovább) rendszerhívás accept() nem állította le a szál végrehajtását.
Ha reuse_port egyenlő true, akkor ez a funkció konfigurálja a foglalatot az opcióval SO_REUSEPORT keresztül setsockopt()ugyanazt a portot használja többszálú környezetben (lásd a „Többszálú szerver”).
Eseménykezelő on_accept() azután hívják meg, hogy az operációs rendszer eseményt generált EPOLLIN, ebben az esetben azt jelenti, hogy az új kapcsolat elfogadható. on_accept() elfogad egy új kapcsolatot, nem blokkoló módba kapcsolja és regisztrál egy eseménykezelőben on_recv() I/O reaktorban.
Eseménykezelő on_recv() azután hívják meg, hogy az operációs rendszer eseményt generált EPOLLIN, ebben az esetben azt jelenti, hogy a kapcsolat regisztrálva van on_accept(), készen áll az adatok fogadására.
on_recv() beolvassa a kapcsolat adatait a HTTP kérés teljes beérkezéséig, majd regisztrál egy kezelőt on_send() HTTP-válasz küldéséhez. Ha az ügyfél megszakítja a kapcsolatot, a socket regisztrációja törlődik, és a használatával bezárásra kerül close().
Az on_recv() függvény megjelenítése
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);
}
}
Eseménykezelő on_send() azután hívják meg, hogy az operációs rendszer eseményt generált EPOLLOUT, ami azt jelenti, hogy a kapcsolat regisztrálva van on_recv(), készen áll az adatok küldésére. Ez a függvény HTML-kódot tartalmazó HTTP-választ küld egy képpel az ügyfélnek, majd visszaállítja az eseménykezelőt on_recv().
És végül a fájlban http_server.c, funkcióban main() segítségével létrehozunk egy I/O reaktort reactor_new(), hozzon létre egy szerver socketet és regisztrálja, indítsa el a reaktort a használatával reactor_run() pontosan egy percig, majd felszabadítjuk az erőforrásokat, és kilépünk a programból.
Ellenőrizzük, hogy minden a várt módon működik-e. Összeállítás (chmod a+x compile.sh && ./compile.sh a projektgyökérben) és indítsa el a saját maga által írt szervert, nyissa meg http://127.0.0.1:18470 a böngészőben, és nézze meg, mire számítottunk:
Mérjük meg egy egyszálú szerver teljesítményét. Nyissunk két terminált: az egyikben futunk ./http_server, más- wrk. Egy perc múlva a következő statisztikák jelennek meg a második terminálon:
Egyszálú szerverünk percenként több mint 11 millió kérést tudott feldolgozni 100 kapcsolatból. Nem rossz eredmény, de lehet javítani?
Többszálú szerver
Ahogy fentebb említettük, az I/O reaktor külön szálakból hozható létre, ezáltal az összes CPU magot felhasználva. Alkalmazzuk ezt a megközelítést a gyakorlatban:
Felhívjuk figyelmét, hogy a függvény argumentuma new_server() szószólói true. Ez azt jelenti, hogy a lehetőséget hozzárendeljük a szerver sockethez SO_REUSEPORThogy többszálú környezetben használjuk. További részleteket olvashat itt.
Második futam
Most mérjük meg egy többszálú szerver teljesítményét:
CPU Affinity használata, összeállítás a -march=native, PGO, a találatok számának növekedése gyorsítótár, növekedés MAX_EVENTS és használja EPOLLET nem hozott jelentős teljesítménynövekedést. De mi történik, ha növeli az egyidejű kapcsolatok számát?
Megkaptuk a kívánt eredményt, és ezzel egy érdekes grafikont, amely az 1 perc alatt feldolgozott kérések számának a kapcsolatok számától való függését mutatja:
Azt látjuk, hogy pár száz csatlakozás után mindkét szerver esetében meredeken csökken a feldolgozott kérések száma (a többszálas verzióban ez jobban észrevehető). Ez kapcsolódik a Linux TCP/IP verem megvalósításához? Nyugodtan írja meg a megjegyzésekben a grafikon viselkedésével és a többszálú és egyszálú opciók optimalizálásával kapcsolatos feltételezéseit.
Mint neves a megjegyzésekben ez a teljesítményteszt nem mutatja meg az I/O reaktor viselkedését valós terhelés alatt, mert szinte mindig a szerver interakcióba lép az adatbázissal, naplókat ad ki, titkosítást használ TLS stb., aminek következtében a terhelés egyenetlenné (dinamikussá) válik. A harmadik féltől származó komponensekkel együtt végzett teszteket az I/O proaktorról szóló cikkben végezzük el.
Az I/O reaktor hátrányai
Meg kell értenie, hogy az I/O reaktornak nincsenek hátrányai, nevezetesen:
Az I/O reaktor használata többszálú környezetben valamivel nehezebb, mert manuálisan kell kezelnie az áramlásokat.
A gyakorlat azt mutatja, hogy a legtöbb esetben a terhelés nem egyenletes, ami az egyik szál naplózásához vezethet, míg a másik munkával van elfoglalva.
Ha az egyik eseménykezelő blokkol egy szálat, akkor maga a rendszerválasztó is blokkol, ami nehezen fellelhető hibákhoz vezethet.
Megoldja ezeket a problémákat I/O proaktor, amely gyakran rendelkezik egy ütemezővel, amely egyenletesen osztja el a terhelést egy szálkészlet között, és rendelkezik egy kényelmesebb API-val is. Erről később, másik cikkemben lesz szó.
Következtetés
Itt ért véget az utunk az elmélettől egyenesen a profiler kipufogójáig.
Nem szabad ezen elidőzni, mert sok más, hasonlóan érdekes megközelítés létezik a hálózati szoftverek írására, különböző kényelmi és sebességű. Érdekes, véleményem szerint a linkek alább találhatók.