
Introduktion
(enkernel ) — er et mønster til at skrive højt belastet software, der bruges i mange populære løsninger:
- …
I denne artikel vil vi undersøge de indre funktioner af I/O reaktoren og princippet bag dens drift, implementere en løsning på mindre end 200 linjer kode og få en simpel HTTP-server til at håndtere over 40 millioner forespørgsler/min.
Forord
- Artiklen er skrevet med det formål at hjælpe med at forstå funktionen af I/O reaktoren, og dermed også at erkende risiciene ved dens brug.
- For at forstå artiklen kræves kendskab til grundlæggende og lidt erfaring med udvikling af netapplikationer.
- Al koden er skrevet i C-strengt i henhold til (advarsel: lang PDF) til Linux og er tilgængelig på .
Hvorfor er det nødvendigt?
Med den stigende popularitet af Internettet blev det nødvendigt for webservere at kunne håndtere et stort antal forbindelser samtidigt, hvilket resulterede i to tilgange: blokkerende I/O med mange OS-tråde og ikke-blokerende I/O i kombination med et begivenhedsovervågningssystem, også kaldet 'systemvælger' (///etc).
Den første tilgang omfattede oprettelsen af en ny OS-tråd for hver indgående forbindelse. Dens ulempe er dårlig skalérbarhed: operativsystemet skal håndtere mange og Disse er dyre operationer, og de kan føre til mangel på ledig RAM ved et stort antal forbindelser.
Den modificerede version tildeler (trådpool), hvilket forhindrer systemet i at stoppe udførelsen af sig selv, men samtidig introducerer et nyt problem: hvis trådpoolen i øjeblikket blokerer langvarige læseoperationer, vil andre sockets, der allerede kan modtage data, ikke være i stand til at gøre dette.
Den anden tilgang anvender (systemvælger), som tilbydes af OS. I denne artikel dækkes den mest almindeligt anvendte type systemvælger, der er baseret på meddelelser (begivenheder, notifikationer) om beredskab til I/O-operationer snarere end på Et forenklet eksempel på brugen kan repræsenteres ved følgende flowdiagram:

Forskellen mellem de to tilgange er som følger:
- Blokerende I/O-operationer suspenderer brugertråden indtil, indtil OS korrekt de indkommende til byte-strøm (, datahentning) eller ikke frigør tilstrækkelig plads i interne skrivebuffere til efterfølgende afsendelse via (dataafsendelse).
- Systemvælgeren underretter programmet efter et stykke tid om, at OS har defragmenteret IP-pakker (TCP, datahentning) eller tilstrækkelig plads i interne skrivebuffere allerede er tilgængelig (dataafsendelse). allerede For at opsummere, at reservere OS-strømmen for hver I/O er spild af computerkraft, fordi strømme i virkeligheden ikke er optaget af nyttigt arbejde (her stammer termen
"programafbrydelse" I/O-reaktor-modellen
I/O-reaktor fungerer som et lag mellem systemvælgeren og brugerens kode. Princippet for dets arbejde er beskrevet i følgende flowdiagram:
Lad mig minde om, at en hændelse er en underretning om, at en bestemt socket er i stand til at udføre en ikke-blokerende I/O-operation.

- Hændelseshåndterer er en funktion, som kaldes af I/O-reaktoren ved modtagelse af en hændelse, som derefter udfører en ikke-blokerende I/O-operation.
- Det er vigtigt at bemærke, at I/O-reaktoren per definition er enkeltrådet, men intet forhindrer at anvende konceptet i en flertrådet miljø i forhold til 1 tråd: 1 reaktor, hvilket udnytter alle CPU-kerner.
Den offentlige grænseflade placerer vi i filen
Implementering
reactor.h reactor.c . , og implementeringen i Vis erklæringerne i reactor.h
Vis annoncer i reactor.h
typedef struct reactor Reactor;
/*
* En pegep som vil blive kaldt af I/O reaktoren, når der modtages
* et event fra systemvælgeren.
*/
typedef void (*Callback)(void *arg, int fd, uint32_t events);
/*
* Returnerer `NULL` ved fejl, ikke-`NULL` pointer til `Reactor` i
* modsætning tilfældet.
*/
Reactor *reactor_new(void);
/*
* Frigør systemvælgeren, alle registrerede sockets på nuværende
* tidspunkt og selve I/O reaktoren.
*
* Følgende funktioner returnerer -1 ved fejl, 0 ved succes.
*/
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);
/*
* Starter en event-loop med timeout `timeout`.
*
* Denne funktion vil overgive kontrol til den kaldende kode, hvis den tildelte tid er udløbet
* eller/og der ikke er registrerede sockets.
*/
int reactor_run(const Reactor *reactor, time_t timeout);Strukturen af I/O reaktoren består af vælgeren og , som matcher hver socket med CallbackData (struktur fra event handler og brugerargument til den).
Vis Reactor og CallbackData
struct reactor {
int epoll_fd;
GHashTable *table; // (int, CallbackData)
};
typedef struct {
Callback callback;
void *arg;
} CallbackData;Bemærk, at vi har udnyttet evnen til at håndtere ved pointer. I , og implementeringen i deklærer vi strukturen reactor, og der sendes noget til den, vil flytning af denne task til sektionen vil bestå af følgende erklæringer: vi definerer den, hvilket forhindrer brugeren i at ændre dens felter direkte. Dette er et af mønstrene , der elegant passer ind i C's semantik.
Functions reactor_register, reactor_deregister og reactor_reregister opdaterer listen over interessante sockets og de tilsvarende event-handlere i systemvælgeren og i hash-tabellen.
Vis registreringsfunktioner
#define REACTOR_CTL(reactor, op, fd, interest)
if (epoll_ctl(reactor->epoll_fd, op, fd,
&(struct epoll_event){.events = interest,
.data = {.fd = fd}}) == -1) {
perror("epoll_ctl");
return -1;
}
int reactor_register(const Reactor *reactor, int fd, uint32_t interest,
Callback callback, void *callback_arg) {
REACTOR_CTL(reactor, EPOLL_CTL_ADD, fd, interest)
g_hash_table_insert(reactor->table, int_in_heap(fd),
callback_data_new(callback, callback_arg));
return 0;
}
int reactor_deregister(const Reactor *reactor, int fd) {
REACTOR_CTL(reactor, EPOLL_CTL_DEL, fd, 0)
g_hash_table_remove(reactor->table, &fd);
return 0;
}
int reactor_reregister(const Reactor *reactor, int fd, uint32_t interest,
Callback callback, void *callback_arg) {
REACTOR_CTL(reactor, EPOLL_CTL_MOD, fd, interest)
g_hash_table_insert(reactor->table, int_in_heap(fd),
callback_data_new(callback, callback_arg));
return 0;
}Når I/O reaktoren har opfanget et event fra descriptoren fd, kalder den den tilsvarende event-handler, som sender fd, for de genererede events og brugerpegeren til void.
Vis funktionen 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) {
// Fejl
case -1:
perror("epoll_wait");
result = -1;
goto cleanup;
// Tid udløbet
case 0:
result = 0;
goto cleanup;
// Operationen var succesfuld
default:
// Udfør event-handlerne
for (int i = 0; i table, &fd);
callback->callback(callback->arg, fd, events[i].events);
}
}
}
cleanup:
free(events);
return result;
}Sammenfattende vil kæden af funktionsopkald i brugerens kode se således ud:

Enkelt-trådet server
For at teste I/O-reaktoren under høj belastning, vil vi skrive en simpel HTTP-webserver, der svarer med et billede på enhver anmodning.
Kort information om HTTP-protokollen
— er en protokol , der primært anvendes til kommunikation mellem server og browser.
HTTP kan nemt anvendes ovenfor protokol , ved at sende og modtage beskeder i det format, der er defineret af .
Anmodningsformat
CRLF
CRLF
CRLF
CRLF CRLF
CRLF— er en sekvens af to tegn:rogn, der adskiller den første linje af anmodningen, hovedoverskrifterne og dataene.<КОМАНДА>— en afCONNECT,DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT,TRACE. Browseren vil sende kommandoen til vores server, der betyder 'Send mig indholdet af filen'.GET, hvilket betyder "Send mig indholdet af filen."den ensartede ressourceidentifikator— , vil klienten anmode om startsiden af websitet./index.html, så klienten anmoder om hovedsiden på webstedet.— versionen af HTTP-protokollen i formatetHTTP/X.Y.. Den mest anvendte version i dag erHTTP/1.1.— dette er et nøgle-værdi par i formatet:, der sendes til serveren til videre behandling.— de data, serveren har brug for for at udføre operationen. Ofte er det bloteller et hvilket som helst andet format. Responsformat
CRLFCRLFCRLFCRLF CRLF
CRLF
CRLF
CRLF
CRLF CRLF <КОД СТАТУСА>— dette tal repræsenterer resultatet af en operation. Vores server vil altid returnere status 200 (vellykket operation).<ОПИСАНИЕ СТАТУСА>— den strenge repræsentation af statuskoden. For statuskode 200 er detOK.— dette er et nøgle-værdi par i formatet— headeren i samme format som i anmodningen. Vi vil returnere headersContent-Length(filstørrelse) ogContent-Type: text/html(typen af de returnerede data).— de data, serveren har brug for for at udføre operationen. Ofte er det blot— de data, der anmodes om af brugeren. I vores tilfælde er det stien til billedet i .
Fil (enkelttrådet server) inkluderer filen , som indeholder følgende funktionsprototyper:
Vis funktionsprototyper i 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);Derudover er der beskrevet en funktionel makro SAFE_CALL() og funktionen fail(). Makroen sammenligner værdien af udtrykket med fejlen, og hvis betingelsen er opfyldt, kaldes funktionen fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)Funktion fail() den udskriver de angivne argumenter til terminalen (som ) og afslutter programmet med koden EXIT_FAILURE:
static noreturn void fail(const char *format, ...) {
va_list args;
va_start(args, format);
vfprintf(stderr, format, args);
va_end(args);
fprintf(stderr, ": %sn", strerror(errno));
exit(EXIT_FAILURE);
}Funktion new_server() returnerer en filbeskrivelses 'server' socket, oprettet af systemopkaldene , og og i stand til at acceptere indgående forbindelser i ikke-blokerende tilstand.
Vis funktionen new_server()
static int new_server(bool reuse_port) {
int fd;
SAFE_CALL((fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP)),
-1);
if (reuse_port) {
SAFE_CALL(
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &(int){1}, sizeof(int)),
-1);
}
struct sockaddr_in addr = {.sin_family = AF_INET,
.sin_port = htons(SERVER_PORT),
.sin_addr = {.s_addr = inet_addr(SERVER_IPV4)},
.sin_zero = {0}};
SAFE_CALL(bind(fd, (struct sockaddr *)&addr, sizeof(addr)), -1);
SAFE_CALL(listen(fd, SERVER_BACKLOG), -1);
return fd;
}- Bemærk, at socket'en oprindeligt oprettes i ikke-blokerende tilstand med flaget
SOCK_NONBLOCK, så systemopkaldet i funktionenon_accept()(læs videre) stopper ikke tråden.accept()reuse_port - Hvis
er lig med, så denne funktion konfigurerer socket'en med indstillingentrueSO_REUSEPORT setsockopt() Hændelseshåndtereren
kaldes, efter at OS har genereret en hændelse on_accept() EPOLLIN , hvilket i dette tilfælde betyder, at en ny forbindelse kan accepteres.accepterer en ny forbindelse, skifter den til ikke-blokerende tilstand og registrerer med hændelseshåndtereren on_accept() on_recv() i I/O reaktoren. Vis funktionen on_accept()
Vis funktionen on_accept()
static void on_accept(void *arg, int fd, uint32_t events) {
int incoming_conn;
SAFE_CALL((incoming_conn = accept(fd, NULL, NULL)), -1);
set_nonblocking(incoming_conn);
SAFE_CALL(reactor_register(reactor, incoming_conn, EPOLLIN, on_recv,
request_buffer_new()),
-1);
}kaldes, efter at OS har genereret en hændelse i I/O reaktoren. EPOLLIN , hvilket i dette tilfælde betyder, at en ny forbindelse kan accepteres., hvilket betyder, at forbindelsen, der er registreret on_accept(), er klar til at modtage data.
i I/O reaktoren. læser data fra forbindelsen, indtil HTTP-anmodningen er helt modtaget, hvorefter den registrerer behandleren on_send() til at sende HTTP-svaret. Hvis klienten afbryder forbindelsen, bliver socket'en afregistreret og lukket ved hjælp af .
Vis funktionen on_recv()
static void on_recv(void *arg, int fd, uint32_t events) {
RequestBuffer *buffer = arg;
// Modtager input, indtil recv returnerer 0 eller en fejl
ssize_t nread;
while ((nread = recv(fd, buffer->data + buffer->size,
REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
buffer->size += nread;
// Klienten afbrød forbindelsen
if (nread == 0) {
SAFE_CALL(reactor_deregister(reactor, fd), -1);
SAFE_CALL(close(fd), -1);
request_buffer_destroy(buffer);
return;
}
// read returnerede en fejl, der ikke er en fejl, hvor kaldet blokerer
// tråden
if (errno != EAGAIN && errno != EWOULDBLOCK) {
request_buffer_destroy(buffer);
fail("read");
}
// Fuld HTTP-anmodning modtaget fra klienten. Nu registrerer vi behandleren
// for events for at sende data
if (request_buffer_is_complete(buffer)) {
request_buffer_clear(buffer);
SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
-1);
}
}kaldes, efter at OS har genereret en hændelse on_send() EPOLLIN EPOLLOUT, hvilket betyder, at forbindelsen, der er registreret i I/O reaktoren., er klar til at sende data. Denne funktion sender et HTTP-svar, der indeholder HTML med et billede, til klienten, og ændrer derefter begivenhedsbehandleren tilbage til i I/O reaktoren..
Vis funktionen on_send()
static void on_send(void *arg, int fd, uint32_t events) {
const char *content = "<img "
"src="https://habrastorage.org/webt/oh/wl/23/"
"ohwl23va3b-dioerobq_mbx4xaw.jpeg">";
char response[1024];
sprintf(response,
"HTTP/1.1 200 OK" CRLF "Content-Length: %zd" CRLF "Content-Type: "
"text/html" DOUBLE_CRLF "%s",
strlen(content), content);
SAFE_CALL(send(fd, response, strlen(response), 0), -1);
SAFE_CALL(reactor_reregister(reactor, fd, EPOLLIN, on_recv, arg), -1);
}Og til slut, i filen http_server.c, i funktionen main() opretter vi en I/O reaktor ved hjælp af reactor_new(), opretter en server socket og registrerer den, kører reaktoren med reactor_run() i præcist et minut, og derefter frigiver vi ressourcerne og forlader programmet.
Vis http_server.c
#include "reactor.h"
static Reactor *reactor;
#include "common.h"
int main(void) {
SAFE_CALL((reactor = reactor_new()), NULL);
SAFE_CALL(
reactor_register(reactor, new_server(false), EPOLLIN, on_accept, NULL),
-1);
SAFE_CALL(reactor_run(reactor, SERVER_TIMEOUT_MILLIS), -1);
SAFE_CALL(reactor_destroy(reactor), -1);
}Lad os kontrollere, at alt fungerer som det skal. Kompilerer (chmod a+x compile.sh && ./compile.sh i rodmappen af projektet) og starter den hjemmebyggede server, åbner i browseren og observerer det, vi forventede:

Benchmarking
Vis specifikationerne for min maskine
$ screenfetch
MMMMMMMMMMMMMMMMMMMMMMMMMmds+. OS: Mint 19.1 tessa
MMm----::-://///////////oymNMd+` Kernel: x86_64 Linux 4.15.0-20-generic
MMd /++ -sNMd: Uptime: 2h 34m
MMNso/` dMM `.::-. .-::.` .hMN: Packages: 2217
ddddMMh dMM :hNMNMNhNMNMNh: `NMm Shell: bash 4.4.20
NMm dMM .NMN/-+MMM+-/NMN` dMM Resolution: 1920x1080
NMm dMM -MMm `MMM dMM. dMM DE: Cinnamon 4.0.10
NMm dMM -MMm `MMM dMM. dMM WM: Muffin
NMm dMM .mmd `mmm yMM. dMM WM Theme: Mint-Y-Dark (Mint-Y)
NMm dMM` ..` ... ydm. dMM GTK Theme: Mint-Y [GTK2/3]
hMM- +MMd/-------...-:sdds dMM Icon Theme: Mint-Y
-NMm- :hNMNNNmdddddddddy/` dMM Font: Noto Sans 9
-dMNs-``-::::-------.`` dMM CPU: Intel Core i7-6700 @ 8x 4GHz [52.0°C]
`\/dMNmy+/:-------------:/yMMM GPU: NV136
.\/ydNMMMMMMMMMMMMMMMMMMMMM RAM: 2544MiB / 7926MiB
.MMMMMMMMMMMMMMMMMMMMål præstationen af en enkelttrådet server. Åbn to terminaler: kør ./http_server, i den anden — . Efter et minut vil den anden terminal vise følgende statistik:
$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
8 threads and 100 connections
Thread Stats Avg Stdev Max +/ Stdev
Latency 493.52us 76.70us 17.31ms 89.57%
Req/Sec 24.37k 1.81k 29.34k 68.13%
11657769 requests in 1.00m, 1.60GB read
Requests/sec: 193974.70
Transfer/sec: 27.19MBVores enkelttrådet server var i stand til at behandle over 11 millioner anmodninger per minut, der kom fra 100 forbindelser. Et godt resultat, men kan det forbedres?
Multitrådet server
Som nævnt tidligere, kan I/O reaktoren oprettes i separate tråde, hvilket udnytter alle CPU-kerner. Lad os anvende denne tilgang i praksis:
Vis http_server_multithreaded.c
#include "reactor.h"
static Reactor *reactor;
#pragma omp threadprivate(reactor)
#include "common.h"
int main(void) {
#pragma omp parallel
{
SAFE_CALL((reactor = reactor_new()), NULL);
SAFE_CALL(reactor_register(reactor, new_server(true), EPOLLIN,
on_accept, NULL),
-1);
SAFE_CALL(reactor_run(reactor, SERVER_TIMEOUT_MILLIS), -1);
SAFE_CALL(reactor_destroy(reactor), -1);
}
}Nu ejer hver tråd reaktor:
static Reactor *reactor;
#pragma omp threadprivate(reactor)Bemærk, at argumentet i funktionen new_server() fremtiden vil komme true. Dette betyder, at vi tildeler serverens socket en mulighed , for at bruge den i et multitrådet miljø. Læs mere .
Anden omgang
Nu måler vi ydeevnen af den multitrådede server:
$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
8 threads and 100 connections
Thread Stats Avg Stdev Max +/ Stdev
Latency 1.14ms 2.53ms 40.73ms 89.98%
Req/Sec 79.98k 18.07k 154.64k 78.65%
38208400 requests in 1.00m, 5.23GB read
Requests/sec: 635876.41
Transfer/sec: 89.14MBAntallet af behandlede anmodninger per minut er steget med ~3.28 gange! Men vi mangler kun ~to millioner for at nå et rundt tal, lad os prøve at rette det.
Lad os først se på statistikken, der er genereret :
$ sudo perf stat -B -e task-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,branches,branch-misses,cache-misses ./http_server_multithreaded
Performance counter stats for './http_server_multithreaded':
242446,314933 task-clock (msec) # 4,000 CPUs utilized
1 813 074 context-switches # 0,007 M/sec
4 689 cpu-migrations # 0,019 K/sec
254 page-faults # 0,001 K/sec
895 324 830 170 cycles # 3,693 GHz
621 378 066 808 instructions # 0,69 insn per cycle
119 926 709 370 branches # 494,653 M/sec
3 227 095 669 branch-misses # 2,69% of all branches
808 664 cache-misses
60,604330670 seconds time elapsed, kompilering med -march=native, , stigningen i antallet af hits i , stigningen MAX_EVENTS og brugen af EPOLLET gav ikke en væsentlig stigning i ydeevnen. Men hvad sker der, hvis vi øger antallet af samtidige forbindelser?
Statistik ved 352 samtidige forbindelser:
$ wrk -c352 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
8 tråde og 352 forbindelser
Trådstatistik Gennemsnit Stdev Max +/ - Stdev
Latency 2.12ms 3.79ms 68.23ms 87.49%
Req/Sec 83.78k 12.69k 169.81k 83.59%
40006142 anmodninger i 1.00m, 5.48GB læst
Anmodninger/s: 665789.26
Overførsel/s: 93.34MBDet ønskede resultat er opnået, og samtidig en interessant graf, der viser afhængigheden af antallet af behandlede anmodninger pr. minut fra antallet af forbindelser:

Vi ser, at efter et par hundrede forbindelser falder antallet af behandlede anmodninger dramatisk for begge servere (for den multitrådede variant er det mere udtalt). Er dette relateret til implementeringen af TCP/IP-stakken i Linux? Del gerne dine antagelser om denne graf og optimering af de multitrådede og enkelttrådede varianter i kommentarerne.
siger i kommentarerne, denne ydeevnetest viser ikke opførselen af I/O-reaktoren under reelle belastninger, da serveren næsten altid interagerer med databasen, udskriver logs, bruger kryptografi med osv., hvilket gør belastningen uensartet (dynamisk). Tests sammen med tredjeparts komponenter vil blive udført i artiklen om I/O-reaktoren.
Ulemper ved I/O-reaktoren
Man skal forstå, at I/O-reaktoren ikke er uden ulemper, nemlig:
- Det er noget mere kompliceret at bruge I/O-reaktoren i et multitrådet miljø, da man skal styre trådene manuelt.
- Praktik viser, at belastningen i de fleste tilfælde er ujævn, hvilket kan føre til, at én tråd bliver overbelastet, mens en anden er optaget af arbejde.
- Hvis én hændelsesbehandler blokerer en tråd, vil systemvælgeren også blive blokeret, hvilket kan føre til svære fejl at spore.
Disse problemer løser , som ofte har en planlægger, der jævnt fordeler belastningen i trådpuljen, og som også har et mere brugervenligt API. Det vil blive diskuteret senere i min anden artikel.
Konklusion
Hermed er vores rejse fra teori direkte til profilerens udslip afsluttet.
Man skal ikke stoppe her, da der findes mange andre interessante tilgange til at skrive netværkssoftware med forskellige niveauer af brugervenlighed og hastighed. Interessante links, efter min mening, er nævnt nedenfor.
På gensyn!
Interessante projekter
- C
Hvad ellers at læse?
Kilde: habr.com
