
Introduzione
(a thread singolo ) — è un pattern per la scrittura di software ad alta intensità di carico, utilizzato in molte soluzioni popolari:
- …
In questo articolo esamineremo il funzionamento interno del reattore I/O e il suo principio di funzionamento, scrivendo un'implementazione in meno di 200 righe di codice e facendo in modo che un semplice server HTTP gestisca oltre 40 milioni di richieste al minuto.
Prefazione
- L'articolo è scritto per aiutare a comprendere il funzionamento del reattore I/O e, quindi, riconoscere i rischi del suo utilizzo.
- Per comprendere l'articolo è necessaria una conoscenza di base e una piccola esperienza nello sviluppo di applicazioni di rete.
- Tutto il codice è scritto in linguaggio C rigorosamente conforme allo standardAttenzione: PDF lungo) per Linux ed è disponibile su .
A cosa serve?
Con l'aumento della popolarità di Internet, i server web hanno iniziato a dover gestire un gran numero di connessioni simultaneamente, e sono stati provati due approcci: I/O bloccante su un gran numero di thread di sistema operativo e I/O non bloccante combinato con un sistema di notifica di eventi, noto anche come «selettore di sistema» (///etc).
Il primo approccio prevedeva la creazione di un nuovo thread del sistema operativo per ogni connessione in entrata. Il suo svantaggio è la scarsa scalabilità: il sistema operativo dovrà effettuare numerosi e . Esse sono operazioni costose e possono portare a una carenza di RAM libera con un numero elevato di connessioni.
Una versione modificata assegna (thread pool), impedendo così al sistema di terminare bruscamente l'esecuzione, ma porta con sé un nuovo problema: se al momento il pool di thread è occupato in operazioni di lettura prolungate, altri socket che sono già in grado di ricevere dati non potranno farlo.
Il secondo approccio utilizza (selettore di sistema) fornito dal sistema operativo. In questo articolo viene trattato il tipo di selettore di sistema più comune, basato su notifiche (eventi, avvisi) di prontezza per operazioni I/O, piuttosto che su . Un esempio semplificato del suo utilizzo può essere rappresentato dal seguente diagramma di flusso:

La differenza tra questi approcci è la seguente:
- Operazioni I/O bloccanti mettono in pausa il flusso utente fino a quandoil sistema operativo non i pacchetti in un flusso di byte (, ricezione dei dati) o non si libera spazio sufficiente nei buffer interni per la successiva trasmissione attraverso (invio dei dati).
- Il selettore di sistema dopo un po' notifica il programma che il sistema operativo ha già deframmentato i pacchetti IP (TCP, ricezione dei dati) o che c'è spazio sufficiente nei buffer interni ha già disponibile (invio dei dati).
In sintesi, riservare un flusso del sistema operativo per ogni I/O è uno spreco di potenza computazionale, poiché in realtà i flussi non sono occupati in lavori utili (da qui deriva il termine ). Il selettore di sistema affronta questo problema, consentendo al programma utente di sfruttare le risorse della CPU in modo molto più efficiente.
Modello I/O reattivo
L'I/O reattivo funge da intermediario tra il selettore di sistema e il codice utente. Il suo funzionamento è descritto dal seguente diagramma di flusso:

- Ricordo che un evento è una notifica che un determinato socket è in grado di eseguire un'operazione I/O non bloccante.
- Un gestore di eventi è una funzione chiamata dal reattore I/O al ricevimento di un evento, che poi esegue un'operazione I/O non bloccante.
È importante notare che il reattore I/O è per definizione monothread, ma non c'è nulla che impedisca di utilizzare il concetto in un ambiente multithread con 1 thread: 1 reattore, sfruttando così tutti i core della CPU.
Implementazione
Inseriremo l'interfaccia pubblica nel file , mentre l'implementazione sarà in . reactor.h sarà composta dalle seguenti dichiarazioni:
Mostra le dichiarazioni in reactor.h
typedef struct reactor Reactor;
/*
* Puntatore alla funzione che sarà chiamato dal reattore I/O quando
* si verifica un evento dal selettore di sistema.
*/
typedef void (*Callback)(void *arg, int fd, uint32_t events);
/*
* Restituisce `NULL` in caso di errore, un puntatore non-`NULL` a `Reactor` in
* caso contrario.
*/
Reactor *reactor_new(void);
/*
* Libera il selettore di sistema, tutti i socket registrati in questo
* momento e il reattore I/O stesso.
*
* Le seguenti funzioni restituiscono -1 in caso di errore, 0 in caso di successo.
*/
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);
/*
* Avvia un ciclo di eventi con un timeout di `timeout`.
*
* Questa funzione passerà il controllo al codice chiamante se il tempo
* scade oppure in assenza di socket registrati.
*/
int reactor_run(const Reactor *reactor, time_t timeout);La struttura del reattore I/O consiste in selettore e , che associa ogni socket a CallbackData (struttura del gestore di eventi e argomento utente per esso).
Mostra Reactor e CallbackData
struct reactor {
int epoll_fd;
GHashTable *table; // (int, CallbackData)
};
typedef struct {
Callback callback;
void *arg;
} CallbackData;Si prega di notare che abbiamo implementato la possibilità di gestire tramite puntatore. Nella reactor.h dichiarazione della struttura reactor, e nella reactor.c sua definizione, impedendo così all'utente di modificare esplicitamente i suoi campi. Questo è uno dei modelli , che si integra in modo conciso nella semantica del C.
Le funzioni reactor_register, reactor_deregister e reactor_reregister aggiornano l'elenco dei socket di interesse e i relativi handler di eventi nel selettore di sistema e nella tabella hash.
Mostra le funzioni di registrazione
#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;
}Dopo che il reattore I/O ha intercettato un evento con il descrittore fd, chiama il rispettivo handler di eventi, a cui passa fd, degli eventi generati e un puntatore utente a void.
Mostra la funzione 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) {
// Errore
case -1:
perror("epoll_wait");
result = -1;
goto cleanup;
// Timeout scaduto
case 0:
result = 0;
goto cleanup;
// Operazione riuscita
default:
// Chiamare i gestori degli eventi
for (int i = 0; i table, &fd);
callback->callback(callback->arg, fd, events[i].events);
}
}
}
cleanup:
free(events);
return result;
}In sintesi, la catena di chiamate di funzioni nel codice dell'utente sarà la seguente:

Server monothread
Per testare il reattore I/O sotto carico, scriveremo un semplice server web HTTP che risponde con un'immagine a qualsiasi richiesta.
Breve guida sul protocollo HTTP
è un protocollo , principalmente utilizzato per l'interazione tra il server e il browser.
HTTP può essere facilmente utilizzato sopra di trasporto , inviando e ricevendo messaggi in un formato specificato .
Formato della richiesta
CRLF
CRLF
CRLF
CRLF CRLFCRLF— è una sequenza di due caratteri:ren, che separa la prima riga della richiesta, le intestazioni e i dati.<КОМАНДА>— uno deiCONNECT,DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT,TRACE. Il browser invierà al nostro server il comandoGET, che significa «Restituisci il contenuto del file».<URI>— . Ad esempio, se URI =/index.html, il client richiede la home page del sito.<ВЕРСИЯ HTTP>— versione del protocollo HTTP nel formatoHTTP/X.Y. La versione più comunemente usata è attualmenteHTTP/1.1.— è una coppia chiave-valore nel formato:, inviata al server per un'analisi successiva.— dati richiesti al server per eseguire l'operazione. Spesso è semplicemente o qualsiasi altro formato.
Formato della risposta
CRLF
CRLF
CRLF
CRLF CRLF<КОД СТАТУСА>— è un numero che rappresenta il risultato di un'operazione. Il nostro server restituirà sempre lo stato 200 (operazione riuscita).<ОПИСАНИЕ СТАТУСА>— rappresentazione in stringa del codice di stato. Per il codice di stato 200 — èOK.— intestazione nello stesso formato della richiesta. Restituiremo le intestazioniContent-Length(dimensione del file) eContent-Type: text/html(tipo di dati restituiti).— dati richiesti dall'utente. Nel nostro caso, si tratta del percorso all'immagine in .
File (server monothread) include il file , che contiene i seguenti prototipi di funzioni:
Mostra i prototipi di funzioni in 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);È anche descritto il macro funzionale SAFE_CALL() ed è definita la funzione fail(). Il macro confronta il valore dell'espressione con l'errore e, se la condizione è soddisfatta, chiama la funzione fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)Funzione fail() stampa gli argomenti passati nel terminale (come ) e termina il programma con codice 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);
}Funzione new_server() restituisce un descrittore di file del socket "server" creato con chiamate di sistema , e e capace di accettare connessioni in modo non bloccante.
Mostra la funzione 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;
}- Si noti che il socket è inizialmente creato in modalità non bloccante utilizzando il flag
SOCK_NONBLOCK, affinché nella funzioneon_accept()(continua a leggere) la chiamata di sistemaaccept()non blocchi l'esecuzione del thread. - Se
reuse_portè uguale atrue, questa funzione configurerà il socket con l'opzione attraverso , per utilizzare la stessa porta in un ambiente multithread (vedi sezione "Server multithread").
Il gestore degli eventi on_accept() viene chiamato dopo che il sistema operativo genera un evento EPOLLIN, che in questo caso significa che una nuova connessione può essere accettata. on_accept() accetta una nuova connessione, la imposta in modalità non bloccante e la registra con un gestore eventi on_recv() nel reattore I/O.
Mostra la funzione 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);
}Il gestore degli eventi on_recv() viene chiamato dopo che il sistema operativo genera un evento EPOLLIN, in questo caso significando che la connessione registrata on_accept(), è pronta a ricevere dati.
on_recv() legge i dati dalla connessione finché la richiesta HTTP non è completamente ricevuta, quindi registra il gestore on_send() per inviare la risposta HTTP. Se il client interrompe la connessione, il socket viene deregistrato e chiuso tramite .
Mostra la funzione on_recv()
static void on_recv(void *arg, int fd, uint32_t events) {
RequestBuffer *buffer = arg;
// Riceviamo i dati in ingresso fino a quando recv restituisce 0 o un errore
ssize_t nread;
while ((nread = recv(fd, buffer->data + buffer->size,
REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
buffer->size += nread;
// Il client ha interrotto la connessione
if (nread == 0) {
SAFE_CALL(reactor_deregister(reactor, fd), -1);
SAFE_CALL(close(fd), -1);
request_buffer_destroy(buffer);
return;
}
// read ha restituito un errore diverso da un errore che bloccherebbe il
// thread
if (errno != EAGAIN && errno != EWOULDBLOCK) {
request_buffer_destroy(buffer);
fail("read");
}
// È stata ricevuta una richiesta HTTP completa dal cliente. Ora registriamo il
// gestore degli eventi per l'invio dei dati
if (request_buffer_is_complete(buffer)) {
request_buffer_clear(buffer);
SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
-1);
}
}Il gestore degli eventi on_send() viene chiamato dopo che il sistema operativo genera un evento EPOLLOUT, che indica che la connessione registrata on_recv(), è pronta per l'invio dei dati. Questa funzione invia una risposta HTTP contenente HTML con un'immagine al cliente, e poi cambia di nuovo il gestore degli eventi in on_recv().
Mostra la funzione 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);
}E infine, nel file http_server.c, nella funzione main() creiamo il reattore I/O tramite reactor_new(), creiamo un socket server e lo registriamo, avviamo il reattore usando reactor_run() esattamente un minuto, quindi liberiamo le risorse e usciamo dal programma.
Mostra 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);
}Verifichiamo che tutto funzioni correttamente. Compiliamo (chmod a+x compile.sh && ./compile.sh nella radice del progetto) e avviamo il server personalizzato, apriamo nel browser e osserviamo ciò che ci aspettavamo:

Misurazione delle prestazioni
Mostra le specifiche della mia macchina
$ 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 Risoluzione: 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 Tema WM: Mint-Y-Dark (Mint-Y)
NMm dMM` ..` ... ydm. dMM Tema GTK: Mint-Y [GTK2/3]
hMM- +MMd/-------...-:sdds dMM Tema icona: 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
.MMMMMMMMMMMMMMMMMMMMisureremo le prestazioni del server a singolo thread. Apriamo due terminali: in uno lanceremo ./http_server, nell'altro — . Dopo un minuto nel secondo terminale apparirà la seguente statistica:
$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: it-IT,it;q=0.5" -H "Connection: keep-alive"
Esecuzione del test di 1m su http://127.0.0.1:18470
8 thread e 100 connessioni
Statistiche Thread Avg Stdev Max +/− Stdev
Latency 493.52us 76.70us 17.31ms 89.57%
Req/Sec 24.37k 1.81k 29.34k 68.13%
11657769 richieste in 1.00m, 1.60GB letti
Richieste/sec: 193974.70
Trasferimento/sec: 27.19MBIl nostro server a thread singolo è riuscito a gestire oltre 11 milioni di richieste al minuto provenienti da 100 connessioni. Un risultato notevole, ma possiamo migliorarlo?
Server a più thread
Come detto in precedenza, il reattore I/O può essere creato in thread separati, sfruttando così tutti i core della CPU. Applichiamo questo approccio nella pratica:
Mostra 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);
}
}Ora ogni thread reattore:
static Reactor *reactor;
#pragma omp threadprivate(reactor)Nota che l'argomento della funzione new_server() è true. Questo significa che assegniamo al socket del server l'opzione , per utilizzarlo in un ambiente multithread. Puoi leggere di più .
Seconda battuta
Ora misureremo le prestazioni del server a più thread:
$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: it-IT,it;q=0.5" -H "Connection: keep-alive"
Esecuzione del test di 1 m @ http://127.0.0.1:18470
8 thread e 100 connessioni
Statistiche sui thread Media Deviazione standard Max +/− Deviazione standard
Latenza 1.14ms 2.53ms 40.73ms 89.98%
Richieste/Sec 79.98k 18.07k 154.64k 78.65%
38208400 richieste in 1.00m, 5.23GB letti
Richieste/sec: 635876.41
Trasferimento/sec: 89.14MBIl numero di richieste elaborate in 1 minuto è aumentato di circa 3.28 volte! Ma ci mancavano solo circa due milioni per raggiungere un numero tondo, proviamo a correggere questo.
Iniziamo a dare un'occhiata alle statistiche generate :
$ sudo perf stat -B -e task-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,branches,branch-misses,cache-misses ./http_server_multithreaded
Statistiche del contatore delle prestazioni per './http_server_multithreaded':
242446,314933 task-clock (msec) # 4,000 CPU utilizzate
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 cicli # 3,693 GHz
621 378 066 808 istruzioni # 0,69 insn per ciclo
119 926 709 370 rami # 494,653 M/sec
3 227 095 669 errori su ramificazioni # 2,69% di tutti i rami
808 664 errori di cache
60,604330670 secondi di tempo trascorso, compilazione con -march=native, , aumento del numero di hit in , aumento MAX_EVENTS e utilizzo EPOLLET non ha comportato un significativo incremento delle prestazioni. Ma cosa succede se aumentiamo il numero di connessioni simultanee?
Statistiche con 352 connessioni simultanee:
$ 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"
Esecuzione test di 1m @ http://127.0.0.1:18470
8 thread e 352 connessioni
Statistiche Thread Avg Stdev Max +/ - Stdev
Latency 2.12ms 3.79ms 68.23ms 87.49%
Req/Sec 83.78k 12.69k 169.81k 83.59%
40006142 richieste in 1.00m, 5.48GB letti
Richieste/sec: 665789.26
Trasferimento/sec: 93.34MBIl risultato desiderato è stato ottenuto, insieme a un grafico interessante che mostra la dipendenza del numero di richieste elaborate in 1 minuto dal numero di connessioni:

Possiamo vedere che dopo un paio di centinaia di connessioni, il numero di richieste elaborate da entrambi i server scende drasticamente (questo è più evidente nella versione multithreaded). È correlato all'implementazione dello stack TCP/IP di Linux? Sentitevi liberi di condividere nei commenti le vostre ipotesi riguardo questo comportamento del grafico e le ottimizzazioni delle versioni multi e single-thread.
Come nei commenti, questo test delle performance non mostra il comportamento del reattore I/O sotto carichi reali, poiché quasi sempre il server interagisce con il DB, genera log, utilizza la crittografia con ecc., il che rende il carico non omogeneo (dinamico). I test insieme a componenti esterni saranno eseguiti in un articolo sul reattore I/O.
Svantaggi del reattore I/O
È importante capire che il reattore I/O non è privo di svantaggi, precisamente:
- Utilizzare un reattore I/O in un ambiente multithreading è un po' più complesso, poiché è necessario gestire manualmente i thread.
- La pratica dimostra che nella maggior parte dei casi il carico non è omogeneo, il che può portare a situazioni in cui un thread elabora mentre un altro è sovraccarico di lavoro.
- Se un gestore di eventi blocca un thread, anche il selettore di sistema sarà bloccato, il che può portare a bug difficili da individuare.
Questi problemi sono risolti dal , che di solito ha un pianificatore che distribuisce uniformemente il carico nel pool di thread, e ha anche un'API più conveniente. Di questo parlerò più avanti, nel mio altro articolo.
Conclusione
Con questo, il nostro viaggio dalla teoria alla pratica dell'analisi del profiler si conclude.
Non fermiamoci qui, ci sono molte altre interessanti avventure nella scrittura di software di rete, con diversi livelli di comodità e velocità. Di seguito trovi i link che considero interessanti.
A presto!
Progetti interessanti
- C
Cosa leggere ancora?
Fonte: habr.com
