Šajā rakstā mēs apskatīsim I/O reaktora smalkumus un nepilnības, kā arī tā darbību, rakstīsim implementāciju mazāk nekā 200 koda rindās un izveidosim vienkāršu HTTP servera procesu, kas pārsniedz 40 miljonus pieprasījumu/min.
priekšvārds
Raksts tika uzrakstīts, lai palīdzētu izprast I/O reaktora darbību un tādējādi izprastu riskus, to lietojot.
Lai saprastu rakstu, ir nepieciešamas pamatzināšanas. C valoda un zināma pieredze tīkla lietojumprogrammu izstrādē.
Viss kods ir rakstīts C valodā stingri saskaņā ar (Uzmanību: garš PDF) atbilstoši C11 standartam operētājsistēmai Linux un pieejams GitHub.
Kāpēc tā?
Pieaugot interneta popularitātei, tīmekļa serveriem bija nepieciešams vienlaikus apstrādāt lielu skaitu savienojumu, un tāpēc tika izmēģinātas divas pieejas: I/O bloķēšana lielā skaitā OS pavedienu un nebloķēšana I/O kombinācijā ar notikumu paziņošanas sistēma, ko sauc arī par "sistēmas atlasītāju" (epoll/kqueue/IOCP/ utt).
Pirmā pieeja bija jauna OS pavediena izveide katram ienākošajam savienojumam. Tās trūkums ir slikta mērogojamība: operētājsistēmai būs jāievieš daudzi konteksta pārejas и sistēmas zvani. Tās ir dārgas darbības un var izraisīt bezmaksas RAM trūkumu ar iespaidīgu savienojumu skaitu.
Modificētā versija izceļ fiksēts diegu skaits (pavedienu pūls), tādējādi neļaujot sistēmai pārtraukt izpildi, bet tajā pašā laikā ieviešot jaunu problēmu: ja pavedienu pūls pašlaik ir bloķēts ilgstošu lasīšanas darbību dēļ, tad citas ligzdas, kas jau spēj saņemt datus, nevarēs dari tā.
Otrā pieeja izmanto notikumu paziņošanas sistēma (sistēmas atlasītājs), ko nodrošina OS. Šajā rakstā ir apskatīts visizplatītākais sistēmas atlasītāja veids, kura pamatā ir brīdinājumi (notikumi, paziņojumi) par gatavību I/O operācijām, nevis paziņojumus par to pabeigšanu. Vienkāršotu tā izmantošanas piemēru var attēlot ar šādu blokshēmu:
Atšķirība starp šīm pieejām ir šāda:
I/O darbību bloķēšana apturēt lietotāju plūsma līdzlīdz OS ir pareizi defragmenti ienākošais IP paketes uz baitu straumi (TCP, saņemot datus), vai arī iekšējos rakstīšanas buferos nebūs pietiekami daudz vietas turpmākai sūtīšanai, izmantojot NIC (sūtot datus).
Sistēmas atlasītājs laika gaitā paziņo programmai, ka OS jau defragmentētas IP paketes (TCP, datu saņemšana) vai pietiekami daudz vietas iekšējos rakstīšanas buferos jau pieejams (sūtot datus).
Rezumējot, OS pavediena rezervēšana katram I/O ir skaitļošanas jaudas izniekošana, jo patiesībā pavedieni neveic lietderīgu darbu (tātad šis termins "programmatūras pārtraukums"). Sistēmas selektors atrisina šo problēmu, ļaujot lietotāja programmai izmantot CPU resursus daudz ekonomiskāk.
I/O reaktora modelis
I/O reaktors darbojas kā slānis starp sistēmas selektoru un lietotāja kodu. Tās darbības princips ir aprakstīts šādā blokshēmā:
Atgādināšu, ka notikums ir paziņojums, ka noteikta ligzda spēj veikt nebloķējošu I/O darbību.
Notikumu apstrādātājs ir funkcija, ko izsauc I/O reaktors, kad tiek saņemts notikums, un pēc tam veic nebloķējošu I/O darbību.
Ir svarīgi atzīmēt, ka I/O reaktors pēc definīcijas ir ar vienu vītni, taču nekas neliedz šo koncepciju izmantot daudzpavedienu vidē ar attiecību 1 pavediens: 1 reaktors, tādējādi pārstrādājot visus CPU kodolus.
Ieviešana
Publisko saskarni ievietosim failā reactor.h, un ieviešana - in reactor.c. reactor.h sastāvēs no šādiem paziņojumiem:
Rādīt deklarācijas reaktorā.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 reaktora struktūra sastāv no faila deskriptors atlasītājs epoll и hash tabulasGHashTable, kas kartē katru ligzdu CallbackData (notikumu apstrādātāja struktūra un lietotāja arguments par to).
Lūdzu, ņemiet vērā, ka esam iespējojuši iespēju apstrādāt nepilnīgs tips saskaņā ar indeksu. IN reactor.h mēs deklarējam struktūru reactorun reactor.c mēs to definējam, tādējādi neļaujot lietotājam skaidri mainīt tā laukus. Šis ir viens no modeļiem slēpjot datus, kas kodolīgi iekļaujas C semantikā.
Funkcijas reactor_register, reactor_deregister и reactor_reregister atjaunināt interesējošo ligzdu sarakstu un atbilstošos notikumu apdarinātājus sistēmas atlasītājā un jaucēj tabulā.
Pēc tam, kad I/O reaktors ir pārtvēris notikumu ar deskriptoru fd, tas izsauc atbilstošo notikumu apdarinātāju, kuram tas pāriet fd, bitu maska ģenerētie notikumi un lietotāja rādītājs void.
Rādīt funkciju 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;
}
Rezumējot, funkciju izsaukumu ķēde lietotāja kodā būs šāda:
Viens vītņots serveris
Lai pārbaudītu I/O reaktoru ar lielu slodzi, mēs uzrakstīsim vienkāršu HTTP tīmekļa serveri, kas uz jebkuru pieprasījumu atbild ar attēlu.
Ātra atsauce uz HTTP protokolu
HTTP - tas ir protokols pielietojuma līmenis, ko galvenokārt izmanto servera un pārlūkprogrammas mijiedarbībai.
HTTP var viegli izmantot transports protokols TCP, sūtot un saņemot ziņas norādītajā formātā specifikācija.
CRLF ir divu rakstzīmju secība: r и n, atdala pieprasījuma pirmo rindiņu, galvenes un datus.
<КОМАНДА> - viens no CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Pārlūkprogramma nosūtīs komandu uz mūsu serveri GET, kas nozīmē "Nosūtiet man faila saturu".
<URI> Sākot no vienots resursa identifikators. Piemēram, ja URI = /index.html, tad klients pieprasa vietnes galveno lapu.
<ВЕРСИЯ HTTP> — HTTP protokola versija formātā HTTP/X.Y. Mūsdienās visbiežāk izmantotā versija ir HTTP/1.1.
<ЗАГОЛОВОК N> ir atslēgas-vērtības pāris formātā <КЛЮЧ>: <ЗНАЧЕНИЕ>, nosūtīts uz serveri turpmākai analīzei.
<ДАННЫЕ> — dati, kas nepieciešami serverim, lai veiktu darbību. Bieži vien tas ir vienkārši JSON vai jebkurā citā formātā.
<КОД СТАТУСА> ir skaitlis, kas apzīmē darbības rezultātu. Mūsu serveris vienmēr atgriezīs statusu 200 (veiksmīga darbība).
<ОПИСАНИЕ СТАТУСА> — statusa koda virknes attēlojums. Statusa kodam 200 tas ir OK.
<ЗАГОЛОВОК N> — tāda paša formāta galvene kā pieprasījumā. Mēs atgriezīsim titulus Content-Length (faila lielums) un Content-Type: text/html (atgriešanas datu tips).
<ДАННЫЕ> — lietotāja pieprasītie dati. Mūsu gadījumā tas ir ceļš uz attēlu iekšā HTML.
fails http_server.c (viena pavediena serveris) ietver failu common.h, kurā ir šādi funkciju prototipi:
Rādīt funkciju prototipus kopīgos.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);
Ir aprakstīts arī funkcionālais makro SAFE_CALL() un funkcija ir definēta fail(). Makro salīdzina izteiksmes vērtību ar kļūdu un, ja nosacījums ir patiess, izsauc funkciju fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funkcija fail() izdrukā terminālī nodotos argumentus (piemēram, printf()) un pārtrauc programmu ar kodu EXIT_FAILURE:
Funkcija new_server() atgriež sistēmas izsaukumu izveidotās "servera" ligzdas faila deskriptoru socket(), bind() и listen() un spēj pieņemt ienākošos savienojumus nebloķējošā režīmā.
Ņemiet vērā, ka ligzda sākotnēji tiek izveidota nebloķēšanas režīmā, izmantojot karogu SOCK_NONBLOCKtā ka funkcijā on_accept() (lasīt vairāk) sistēmas izsaukums accept() neapturēja pavediena izpildi.
Ja reuse_port ir vienāds ar true, tad šī funkcija konfigurēs ligzdu ar opciju SO_REUSEPORT cauri setsockopt()lai izmantotu vienu un to pašu portu daudzpavedienu vidē (skatiet sadaļu “Daudzpavedienu serveris”).
Pasākumu apstrādātājs on_accept() izsauc pēc tam, kad OS ģenerē notikumu EPOLLIN, šajā gadījumā tas nozīmē, ka jauno savienojumu var pieņemt. on_accept() pieņem jaunu savienojumu, pārslēdz to uz nebloķēšanas režīmu un reģistrējas notikumu apstrādātājā on_recv() I/O reaktorā.
Pasākumu apstrādātājs on_recv() izsauc pēc tam, kad OS ģenerē notikumu EPOLLIN, šajā gadījumā tas nozīmē, ka savienojums ir reģistrēts on_accept(), gatavs datu saņemšanai.
on_recv() nolasa datus no savienojuma, līdz tiek pilnībā saņemts HTTP pieprasījums, pēc tam reģistrē apstrādātāju on_send() lai nosūtītu HTTP atbildi. Ja klients pārtrauc savienojumu, kontaktligzda tiek dereģistrēta un aizvērta, izmantojot close().
Rādīt funkciju 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);
}
}
Pasākumu apstrādātājs on_send() izsauc pēc tam, kad OS ģenerē notikumu EPOLLOUT, kas nozīmē, ka savienojums ir reģistrēts on_recv(), gatavs datu nosūtīšanai. Šī funkcija klientam nosūta HTTP atbildi, kas satur HTML ar attēlu, un pēc tam maina notikumu apstrādātāju atpakaļ uz on_recv().
Un visbeidzot failā http_server.c, funkcijā main() mēs izveidojam I/O reaktoru, izmantojot reactor_new(), izveidojiet servera ligzdu un reģistrējiet to, iedarbiniet reaktoru, izmantojot reactor_run() tieši vienu minūti, un tad mēs atbrīvojam resursus un izejam no programmas.
Pārbaudīsim, vai viss darbojas, kā paredzēts. Kompilēšana (chmod a+x compile.sh && ./compile.sh projekta saknē) un palaidiet pašrakstīto serveri, atveriet http://127.0.0.1:18470 pārlūkprogrammā un skatiet, ko mēs gaidījām:
Izmērīsim viena pavediena servera veiktspēju. Atvērsim divus termināļus: vienā mēs darbosimies ./http_server, citā - wrk. Pēc minūtes otrajā terminālī tiks parādīta šāda statistika:
Mūsu viena pavediena serveris spēja apstrādāt vairāk nekā 11 miljonus pieprasījumu minūtē no 100 savienojumiem. Nav slikts rezultāts, bet vai to var uzlabot?
Daudzpavedienu serveris
Kā minēts iepriekš, I/O reaktoru var izveidot atsevišķos pavedienos, tādējādi izmantojot visus CPU kodolus. Pielietosim šo pieeju praksē:
Lūdzu, ņemiet vērā, ka funkcijas arguments new_server() advokāti true. Tas nozīmē, ka mēs piešķiram iespēju servera ligzdai SO_REUSEPORTlai to izmantotu daudzpavedienu vidē. Jūs varat lasīt sīkāku informāciju šeit.
Otrais brauciens
Tagad mērīsim vairāku pavedienu servera veiktspēju:
Izmantojot CPU Affinity, apkopojums ar -march=native, PGO, trāpījumu skaita pieaugums kešatmiņa, palielināt MAX_EVENTS un izmantot EPOLLET nedeva būtisku veiktspējas pieaugumu. Bet kas notiks, ja palielināsiet vienlaicīgu savienojumu skaitu?
Tika iegūts vēlamais rezultāts un līdz ar to interesants grafiks, kas parāda 1 minūtē apstrādāto pieprasījumu skaita atkarību no savienojumu skaita:
Redzam, ka pēc pāris simtiem savienojumu strauji krītas apstrādāto pieprasījumu skaits abiem serveriem (vairāku pavedienu versijā tas ir pamanāmāk). Vai tas ir saistīts ar Linux TCP/IP steka ieviešanu? Jūtieties brīvi komentāros rakstīt savus pieņēmumus par šo diagrammas darbību un vairākpavedienu un viena pavediena opciju optimizāciju.
Kā atzīmēja komentāros šis veiktspējas tests neuzrāda I/O reaktora uzvedību pie reālām slodzēm, jo gandrīz vienmēr serveris mijiedarbojas ar datu bāzi, izvada žurnālus, izmanto kriptogrāfiju ar TLS tml., kā rezultātā slodze kļūst nevienmērīga (dinamiska). Pārbaudes kopā ar trešo pušu komponentiem tiks veiktas rakstā par I/O proaktoru.
I/O reaktora trūkumi
Jums jāsaprot, ka I/O reaktoram nav arī trūkumi, proti:
I/O reaktora izmantošana daudzpavedienu vidē ir nedaudz grūtāka, jo jums būs manuāli jāpārvalda plūsmas.
Prakse rāda, ka vairumā gadījumu slodze ir nevienmērīga, kā rezultātā viens pavediens var reģistrēties, kamēr cits ir aizņemts ar darbu.
Ja viens notikumu apstrādātājs bloķē pavedienu, bloķēs arī pats sistēmas atlasītājs, kas var izraisīt grūti atrodamas kļūdas.
Atrisina šīs problēmas I/O proaktors, kuram bieži ir plānotājs, kas vienmērīgi sadala slodzi pavedienu kopai, un tam ir arī ērtāks API. Mēs par to runāsim vēlāk, manā citā rakstā.
Secinājums
Šeit ir beidzies mūsu ceļojums no teorijas līdz profilētāja izplūdei.
Jums nevajadzētu kavēties pie tā, jo ir daudz citu tikpat interesantu pieeju tīkla programmatūras rakstīšanai ar dažādu ērtību un ātrumu. Interesanti, manuprāt, zemāk ir dotas saites.