En ĉi tiu artikolo, ni rigardos la enojn de I/O-reaktoro kaj kiel ĝi funkcias, skribos efektivigon en malpli ol 200 linioj de kodo, kaj faros simplan HTTP-servilan procezon pli ol 40 milionoj da petoj/min.
Antaŭparolo
La artikolo estis skribita por helpi kompreni la funkciadon de la I/O-reaktoro, kaj tial kompreni la riskojn kiam oni uzas ĝin.
Por kompreni la artikolon necesas kono de la bazaĵoj. C lingvo kaj iom da sperto en reto-aplika disvolviĝo.
Ĉiu kodo estas skribita en C lingvo strikte laŭ (singardo: longa PDF) al C11-normo por Linukso kaj havebla sur GitHub.
Kial tio estas necesa?
Kun la kreskanta populareco de la Interreto, retserviloj komencis bezoni pritrakti grandan nombron da konektoj samtempe, kaj tial du aliroj estis provitaj: blokado de I/O sur granda nombro da OS-fadenoj kaj ne-blokanta I/O en kombinaĵo kun eventa sciiga sistemo, ankaŭ nomita "sistemelektilo" (epollo/kqueue/IOCP/ktp).
La unua aliro implikis krei novan OS-fadenon por ĉiu envenanta konekto. Ĝia malavantaĝo estas malbona skaleblo: la operaciumo devos efektivigi multajn kunteksttransiroj и sistemaj vokoj. Ili estas multekostaj operacioj kaj povas konduki al manko de libera RAM kun impona nombro da konektoj.
La modifita versio elstaras fiksita nombro da fadenoj (fadena aro), tiel malhelpante la sistemon ĉesigi la ekzekuton, sed samtempe enkondukante novan problemon: se fadena aro estas nuntempe blokita de longaj legadoj, tiam aliaj ingoj, kiuj jam kapablas ricevi datumojn, ne povos faru tion.
La dua aliro uzas sistemo de sciigo de evento (sistema elektilo) provizita de la OS. Ĉi tiu artikolo diskutas la plej oftan specon de sistemelektilo, bazita sur atentigoj (okazaĵoj, sciigoj) pri preteco por I/O-operacioj, prefere ol sur sciigoj pri ilia kompletigo. Simpligita ekzemplo de ĝia uzo povas esti reprezentita per la sekva blokdiagramo:
La diferenco inter ĉi tiuj aliroj estas kiel sekvas:
Blokado de I/O-operacioj interrompi uzantfluo ĝisĝis la OS estas ĝuste defragmentas alvenanta IP-pakoj al bajta fluo (TCP, ricevante datumojn) aŭ ne estos sufiĉe da spaco disponebla en la internaj skribbufroj por posta sendado per NENIO (sendo de datumoj).
Sistemelektilo kun la tempo sciigas al la programo ke la OS jam defragmentitaj IP-pakaĵoj (TCP, datumricevo) aŭ sufiĉe da spaco en internaj skribbufroj jam disponebla (senddatenoj).
Resume, rezervi OS-fadenon por ĉiu I/O estas malŝparo de komputika potenco, ĉar fakte, la fadenoj ne faras utilan laboron (tial la esprimo "programa interrompo"). La sistemelektilo solvas ĉi tiun problemon, permesante al la uzantprogramo uzi CPU-resursojn multe pli ekonomie.
I/O reaktormodelo
La I/O-reaktoro funkcias kiel tavolo inter la sistemelektilo kaj la uzantkodo. La principo de ĝia funkciado estas priskribita per la sekva blokdiagramo:
Mi memorigu vin, ke evento estas sciigo, ke certa ingo kapablas fari ne-blokan I/O-operacion.
Okazaĵtraktilo estas funkcio vokita de la I/O-reaktoro kiam okazaĵo estas ricevita, kiu tiam elfaras ne-blokan I/O-operacion.
Gravas noti, ke la I/O-reaktoro estas laŭdifine unufadena, sed nenio malhelpas la koncepton esti uzata en plurfadena medio je proporcio de 1 fadeno: 1 reaktoro, tiel reciklante ĉiujn CPU-kernojn.
Реализация
Ni metos la publikan interfacon en dosieron reactor.h, kaj efektivigo - en reactor.c. reactor.h konsistos el la jenaj anoncoj:
Montru deklarojn en reaktoro.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);
La I/O-reaktorstrukturo konsistas el dosiera priskribilo elektilo epollo и hashtablojGHashTable, kiu mapas ĉiun ingon al CallbackData (strukturo de okazaĵtraktilo kaj uzantargumento por ĝi).
Bonvolu noti, ke ni ebligis la kapablon pritrakti nekompleta tipo laŭ la indekso. EN reactor.h ni deklaras la strukturon reactorkaj en reactor.c ni difinas ĝin, tiel malhelpante la uzanton eksplicite ŝanĝi ĝiajn kampojn. Ĉi tiu estas unu el la ŝablonoj kaŝante datumojn, kiu koncize kongruas en C-semantiko.
Funkcioj reactor_register, reactor_deregister и reactor_reregister ĝisdatigi la liston de interesaj ingoj kaj respondaj evento-traktiloj en la sistema elektilo kaj hashtabelo.
Post kiam la I/O-reaktoro kaptis la okazaĵon kun la priskribilo fd, ĝi vokas la respondan okazaĵtraktilon, al kiu ĝi pasas fd, bit masko generitaj eventoj kaj uzanta montrilo al void.
Montru funkcion 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;
}
Por resumi, la ĉeno de funkciovokoj en uzantkodo prenos la sekvan formon:
Unufadena servilo
Por testi la I/O-reaktoron sub alta ŝarĝo, ni skribos simplan HTTP-retservilon, kiu respondas al ajna peto per bildo.
Rapida referenco al la HTTP-protokolo
HTTP - jen la protokolo aplika nivelo, ĉefe uzata por interago de servilo-retumilo.
HTTP povas esti facile uzata transporto protokolo TCP, sendante kaj ricevante mesaĝojn en specifita formato specifo.
CRLF estas sinsekvo de du signoj: r и n, apartigante la unuan linion de la peto, kapliniojn kaj datumojn.
<КОМАНДА> - unu el CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. La retumilo sendos komandon al nia servilo GET, signifante "Sendu al mi la enhavon de la dosiero."
<URI> - unuforma rimeda identigilo. Ekzemple, se URI = /index.html, tiam la kliento petas la ĉefpaĝon de la retejo.
<ВЕРСИЯ HTTP> — versio de la HTTP-protokolo en la formato HTTP/X.Y. La plej ofte uzata versio hodiaŭ estas HTTP/1.1.
<ЗАГОЛОВОК N> estas ŝlosil-valora paro en la formato <КЛЮЧ>: <ЗНАЧЕНИЕ>, sendita al la servilo por plia analizo.
<ДАННЫЕ> — datumoj postulataj de la servilo por plenumi la operacion. Ofte ĝi estas simpla JSON aŭ ajna alia formato.
<КОД СТАТУСА> estas nombro reprezentanta la rezulton de la operacio. Nia servilo ĉiam redonos statuson 200 (sukcesa operacio).
<ОПИСАНИЕ СТАТУСА> — ĉenprezento de la statuskodo. Por statuskodo 200 ĉi tio estas OK.
<ЗАГОЛОВОК N> — kaplinio de la sama formato kiel en la peto. Ni resendos la titolojn Content-Length (dosiergrandeco) kaj Content-Type: text/html (revena datumtipo).
<ДАННЫЕ> - datumoj petitaj de la uzanto. En nia kazo, ĉi tiu estas la vojo al la bildo en HTML.
dosiero http_server.c (ununura fadenigita servilo) inkluzivas dosieron common.h, kiu enhavas la sekvajn funkcioprototipojn:
Montri funkcioprototipojn komune.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);
La funkcia makroo ankaŭ estas priskribita SAFE_CALL() kaj la funkcio estas difinita fail(). La makroo komparas la valoron de la esprimo kun la eraro, kaj se la kondiĉo estas vera, vokas la funkcion fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
funkcio fail() presas la pasigitajn argumentojn al la terminalo (kiel printf()) kaj finas la programon per la kodo EXIT_FAILURE:
funkcio new_server() resendas la dosierpriskribilon de la "servilo" ingo kreita per sistemaj vokoj socket(), bind() и listen() kaj kapabla akcepti alvenantajn ligojn en ne-bloka reĝimo.
Notu ke la ingo estas komence kreita en ne-bloka reĝimo uzante la flagon SOCK_NONBLOCKtiel ke en la funkcio on_accept() (legu pli) sistemvoko accept() ne haltigis la fadenekzekuton.
se reuse_port estas egala true, tiam ĉi tiu funkcio agordos la ingon kun la opcio SO_REUSEPORT tra setsockopt()uzi la saman havenon en multfadena medio (vidu sekcion "Multfadena servilo").
Eventa Prizorganto on_accept() vokita post kiam la OS generas eventon EPOLLIN, ĉi-kaze signifante ke la nova konekto povas esti akceptita. on_accept() akceptas novan konekton, ŝanĝas ĝin al ne-bloka reĝimo kaj registras kun evento-traktilo on_recv() en I/O-reaktoro.
Eventa Prizorganto on_recv() vokita post kiam la OS generas eventon EPOLLIN, en ĉi tiu kazo signifante ke la konekto registrita on_accept(), preta ricevi datumojn.
on_recv() legas datumojn de la konekto ĝis la HTTP-peto estas tute ricevita, tiam ĝi registras pritraktilon on_send() por sendi HTTP-respondon. Se la kliento rompas la konekton, la ingo estas deregistrita kaj fermita uzante close().
Montru funkcion 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);
}
}
Eventa Prizorganto on_send() vokita post kiam la OS generas eventon EPOLLOUT, signifante ke la konekto registrita on_recv(), preta sendi datumojn. Ĉi tiu funkcio sendas HTTP-respondon enhavantan HTML kun bildo al la kliento kaj poste ŝanĝas la okazaĵan pritraktilon reen al on_recv().
Kaj fine, en la dosiero http_server.c, en funkcio main() ni kreas I/O-reaktoron uzante reactor_new(), kreu servila ingo kaj registri ĝin, eku la reaktoron reactor_run() dum ekzakte unu minuto, kaj poste ni liberigas rimedojn kaj eliras la programon.
Ni kontrolu, ke ĉio funkcias kiel atendite. Kompilado (chmod a+x compile.sh && ./compile.sh en la projekta radiko) kaj lanĉu la memskribitan servilon, malfermu http://127.0.0.1:18470 en la retumilo kaj vidu, kion ni atendis:
Ni mezuru la rendimenton de unu-fadena servilo. Ni malfermu du terminalojn: en unu ni kuros ./http_server, en malsama - verko. Post minuto, la sekvaj statistikoj estos montrataj en la dua terminalo:
Nia unufadena servilo povis procesi pli ol 11 milionojn da petoj je minuto devenantaj de 100 konektoj. Ne malbona rezulto, sed ĉu ĝi povas esti plibonigita?
Plurfadena servilo
Kiel menciite supre, la I/O-reaktoro povas esti kreita en apartaj fadenoj, tiel utiligante ĉiujn CPU-kernojn. Ni praktiku ĉi tiun aliron:
Bonvolu noti, ke la funkcio argumento new_server() favoroj true. Ĉi tio signifas, ke ni atribuas la opcion al la servila ingo SO_REUSEPORTuzi ĝin en multfadena medio. Vi povas legi pliajn detalojn tie.
Dua kuro
Nun ni mezuru la rendimenton de plurfadena servilo:
Uzante CPU-Afinecon, kompilo kun -march=native, OGP, pliiĝo en la nombro da sukcesoj kaŝaĵo, pliigas MAX_EVENTS kaj uzo EPOLLET ne donis signifan pliiĝon en rendimento. Sed kio okazas se vi pliigas la nombron da samtempaj konektoj?
La dezirata rezulto estis akirita, kaj kun ĝi interesa grafikaĵo montranta la dependecon de la nombro da procesitaj petoj en 1 minuto de la nombro da konektoj:
Ni vidas, ke post kelkaj cent konektoj, la nombro da prilaboritaj petoj por ambaŭ serviloj akre malpliiĝas (en la multfadena versio tio estas pli rimarkebla). Ĉu ĉi tio rilatas al la Linukso TCP/IP-staka efektivigo? Bonvolu skribi viajn supozojn pri ĉi tiu konduto de la grafikaĵo kaj optimumigoj por multfadenaj kaj unufadenaj opcioj en la komentoj.
Kiel notis en la komentoj, ĉi tiu agado-testo ne montras la konduton de la I/O-reaktoro sub realaj ŝarĝoj, ĉar preskaŭ ĉiam la servilo interagas kun la datumbazo, eligas protokolojn, uzas kriptografion kun TLS ktp., sekve de kio la ŝarĝo fariĝas neunuforma (dinamika). Testoj kune kun triaj komponantoj estos faritaj en la artikolo pri la I/O-proactor.
Malavantaĝoj de I/O-reaktoro
Vi devas kompreni, ke la I/O-reaktoro ne estas sen siaj malavantaĝoj, nome:
Uzi I/O-reaktoron en multfadena medio estas iom pli malfacila, ĉar vi devos mane administri la fluojn.
Praktiko montras, ke plejofte la ŝarĝo estas ne-unuforma, kio povas konduki al unu fadendehakado dum alia estas okupata de laboro.
Se unu okazaĵa prizorganto blokas fadenon, tiam la sistemelektilo mem ankaŭ blokos, kio povas konduki al malfacile troveblaj cimoj.
Solvas ĉi tiujn problemojn I/O-proactor, kiu ofte havas planilon, kiu egale distribuas la ŝarĝon al aro da fadenoj, kaj ankaŭ havas pli oportunan API. Pri tio ni parolos poste, en mia alia artikolo.
konkludo
Ĉi tie finiĝis nia vojaĝo de teorio rekte al la profilila ellasilo.
Vi ne devus deteni ĉi tion, ĉar ekzistas multaj aliaj same interesaj aliroj por verki retprogramaron kun malsamaj niveloj de oportuno kaj rapideco. Interesaj, laŭ mi, ligiloj estas donitaj sube.