Neste artigo, veremos os meandros de um reator de E/S e como ele funciona, escreveremos uma implementação em menos de 200 linhas de código e faremos um servidor HTTP simples processar mais de 40 milhões de solicitações/min.
Prefácio
O artigo foi escrito para ajudar a entender o funcionamento do reator de E/S e, portanto, entender os riscos ao utilizá-lo.
É necessário conhecimento do básico para entender o artigo. Linguagem C e alguma experiência em desenvolvimento de aplicativos de rede.
Todo o código é escrito em linguagem C estritamente de acordo com (cuidado: PDF longo) para o padrão C11 para Linux e disponível em GitHub.
Por que isso?
Com a crescente popularidade da Internet, os servidores web começaram a precisar lidar com um grande número de conexões simultaneamente e, portanto, duas abordagens foram tentadas: bloqueio de E/S em um grande número de threads do sistema operacional e E/S sem bloqueio em combinação com um sistema de notificação de eventos, também chamado de “seletor de sistema” (epoll/kqueue/IOCP/etc).
A primeira abordagem envolveu a criação de um novo thread de sistema operacional para cada conexão de entrada. Sua desvantagem é a baixa escalabilidade: o sistema operacional terá que implementar muitos transições de contexto и chamadas do sistema. São operações caras e podem levar à falta de RAM livre com um número impressionante de conexões.
A versão modificada destaca número fixo de threads (pool de threads), evitando assim que o sistema aborte a execução, mas ao mesmo tempo introduzindo um novo problema: se um pool de threads estiver atualmente bloqueado por operações de leitura longas, então outros soquetes que já são capazes de receber dados não serão capazes de faça isso.
A segunda abordagem usa sistema de notificação de eventos (seletor de sistema) fornecido pelo sistema operacional. Este artigo discute o tipo mais comum de seletor de sistema, baseado em alertas (eventos, notificações) sobre prontidão para operações de E/S, em vez de notificações sobre sua conclusão. Um exemplo simplificado de sua utilização pode ser representado pelo seguinte diagrama de blocos:
A diferença entre essas abordagens é a seguinte:
Bloqueando operações de E/S suspender fluxo do usuário até entãoaté que o sistema operacional esteja corretamente desfragmenta entrada Pacotes IP para fluxo de bytes (TCP, recebendo dados) ou não haverá espaço suficiente disponível nos buffers de gravação internos para posterior envio via NIC (enviando dados).
Seletor de sistema hora extra notifica o programa de que o sistema operacional já pacotes IP desfragmentados (TCP, recepção de dados) ou espaço suficiente em buffers de gravação internos já disponível (envio de dados).
Resumindo, reservar um thread do sistema operacional para cada E/S é um desperdício de poder de computação, porque, na realidade, os threads não estão realizando um trabalho útil (daí o termo "interrupção de software"). O seletor de sistema resolve esse problema, permitindo que o programa do usuário utilize os recursos da CPU de maneira muito mais econômica.
Modelo de reator de E/S
O reator de E/S atua como uma camada entre o seletor do sistema e o código do usuário. O princípio de seu funcionamento é descrito pelo seguinte diagrama de blocos:
Deixe-me lembrá-lo de que um evento é uma notificação de que um determinado soquete é capaz de executar uma operação de E/S sem bloqueio.
Um manipulador de eventos é uma função chamada pelo reator de E/S quando um evento é recebido, que então executa uma operação de E/S sem bloqueio.
É importante notar que o reator de E/S é, por definição, single-threaded, mas nada impede que o conceito seja usado em um ambiente multi-threaded na proporção de 1 thread: 1 reator, reciclando assim todos os núcleos da CPU.
Implementação
Colocaremos a interface pública em um arquivo reactor.he implementação - em reactor.c. reactor.h consistirá nos seguintes anúncios:
Mostrar declarações em reactor.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);
A estrutura do reator de E/S consiste em descritor de arquivo seletor epoll и tabelas hashGHashTable, que mapeia cada soquete para CallbackData (estrutura de um manipulador de eventos e um argumento de usuário para ele).
Observe que ativamos a capacidade de lidar com tipo incompleto de acordo com o índice. EM reactor.h declaramos a estrutura reactore em reactor.c nós o definimos, evitando assim que o usuário altere explicitamente seus campos. Este é um dos padrões ocultando dados, que se encaixa sucintamente na semântica C.
funções reactor_register, reactor_deregister и reactor_reregister atualize a lista de soquetes de interesse e manipuladores de eventos correspondentes no seletor do sistema e na tabela hash.
Após o reator de E/S ter interceptado o evento com o descritor fd, ele chama o manipulador de eventos correspondente, para o qual passa fd, máscara de bits eventos gerados e um ponteiro do usuário para void.
Mostrar função 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;
}
Para resumir, a cadeia de chamadas de função no código do usuário terá a seguinte forma:
Servidor de thread único
Para testar o reator de E/S sob alta carga, escreveremos um servidor web HTTP simples que responda a qualquer solicitação com uma imagem.
Uma referência rápida ao protocolo HTTP
HTTP - este é o protocolo nível de aplicação, usado principalmente para interação servidor-navegador.
HTTP pode ser facilmente usado em transporte protocolo TCP, enviando e recebendo mensagens em um formato especificado especificação.
CRLF é uma sequência de dois caracteres: r и n, separando a primeira linha da solicitação, cabeçalhos e dados.
<КОМАНДА> - um de CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. O navegador enviará um comando para o nosso servidor GET, que significa "Envie-me o conteúdo do arquivo".
<КОД СТАТУСА> é um número que representa o resultado da operação. Nosso servidor sempre retornará o status 200 (operação bem-sucedida).
<ОПИСАНИЕ СТАТУСА> — representação em string do código de status. Para o código de status 200, isso é OK.
<ЗАГОЛОВОК N> — cabeçalho do mesmo formato da solicitação. Devolveremos os títulos Content-Length (tamanho do arquivo) e Content-Type: text/html (tipo de dados de retorno).
<ДАННЫЕ> — dados solicitados pelo usuário. No nosso caso, este é o caminho para a imagem em HTML.
arquivo http_server.c (servidor de thread único) inclui arquivo common.h, que contém os seguintes protótipos de função:
Mostrar protótipos de funções em comum.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);
A macro funcional também é descrita SAFE_CALL() e a função é definida fail(). A macro compara o valor da expressão com o erro e, se a condição for verdadeira, chama a função fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Função fail() imprime os argumentos passados no terminal (como printf()) e finaliza o programa com o código EXIT_FAILURE:
Função new_server() retorna o descritor de arquivo do soquete "servidor" criado pelas chamadas do sistema socket(), bind() и listen() e capaz de aceitar conexões de entrada em modo sem bloqueio.
Observe que o soquete é inicialmente criado em modo sem bloqueio usando o sinalizador SOCK_NONBLOCKde modo que na função on_accept() (leia mais) chamada de sistema accept() não interrompeu a execução do thread.
Se reuse_port é igual a true, então esta função irá configurar o soquete com a opção SO_REUSEPORT através de setsockopt()usar a mesma porta em um ambiente multithread (consulte a seção “Servidor multithread”).
Manipulador de eventos on_accept() chamado depois que o sistema operacional gera um evento EPOLLIN, neste caso significando que a nova conexão pode ser aceita. on_accept() aceita uma nova conexão, alterna para o modo sem bloqueio e registra-se em um manipulador de eventos on_recv() em um reator de E/S.
Manipulador de eventos on_recv() chamado depois que o sistema operacional gera um evento EPOLLIN, neste caso significando que a conexão registrada on_accept(), pronto para receber dados.
on_recv() lê os dados da conexão até que a solicitação HTTP seja completamente recebida e então registra um manipulador on_send() para enviar uma resposta HTTP. Se o cliente interromper a conexão, o soquete será cancelado e fechado usando close().
Mostrar função 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);
}
}
Manipulador de eventos on_send() chamado depois que o sistema operacional gera um evento EPOLLOUT, o que significa que a conexão registrada on_recv(), pronto para enviar dados. Esta função envia uma resposta HTTP contendo HTML com uma imagem para o cliente e então altera o manipulador de eventos de volta para on_recv().
E finalmente, no arquivo http_server.c, em função main() criamos um reator de E/S usando reactor_new(), crie um soquete de servidor e registre-o, inicie o reator usando reactor_run() por exatamente um minuto, e então liberamos recursos e saímos do programa.
Vamos verificar se tudo está funcionando conforme o esperado. Compilando (chmod a+x compile.sh && ./compile.sh na raiz do projeto) e inicie o servidor auto-escrito, abra http://127.0.0.1:18470 no navegador e veja o que esperávamos:
Vamos medir o desempenho de um servidor de thread único. Vamos abrir dois terminais: em um rodaremos ./http_server, em um diferente - trabalhar. Após um minuto, as seguintes estatísticas serão exibidas no segundo terminal:
Nosso servidor single-threaded foi capaz de processar mais de 11 milhões de solicitações por minuto provenientes de 100 conexões. Não é um resultado ruim, mas pode ser melhorado?
Servidor multithread
Conforme mencionado acima, o reator de E/S pode ser criado em threads separados, utilizando assim todos os núcleos da CPU. Vamos colocar essa abordagem em prática:
Observe que o argumento da função new_server() defensores true. Isso significa que atribuímos a opção ao soquete do servidor SO_REUSEPORTpara usá-lo em um ambiente multithread. Você pode ler mais detalhes aqui.
Segunda corrida
Agora vamos medir o desempenho de um servidor multithread:
O número de solicitações processadas em 1 minuto aumentou cerca de 3.28 vezes! Mas faltavam apenas cerca de XNUMX milhões para o número redondo, então vamos tentar consertar isso.
Primeiro vamos dar uma olhada nas estatísticas geradas perf:
Usando afinidade de CPU, compilação com -march=native, PGO, um aumento no número de acessos tarde, aumentar MAX_EVENTS e usar EPOLLET não deu um aumento significativo no desempenho. Mas o que acontece se você aumentar o número de conexões simultâneas?
O resultado desejado foi obtido, e com ele um gráfico interessante mostrando a dependência do número de solicitações processadas em 1 minuto do número de conexões:
Vemos que após algumas centenas de conexões, o número de solicitações processadas para ambos os servidores cai drasticamente (na versão multithread isso é mais perceptível). Isso está relacionado à implementação da pilha TCP/IP do Linux? Sinta-se à vontade para escrever suas suposições sobre esse comportamento do gráfico e otimizações para opções multithread e single-thread nos comentários.
Как anotado nos comentários, este teste de desempenho não mostra o comportamento do reator de E/S sob cargas reais, pois quase sempre o servidor interage com o banco de dados, gera logs, usa criptografia com TLS etc., como resultado a carga se torna não uniforme (dinâmica). Testes em conjunto com componentes de terceiros serão realizados no artigo sobre o proator de I/O.
Desvantagens do reator de E/S
Você precisa entender que o reator de E/S tem suas desvantagens, a saber:
Usar um reator de E/S em um ambiente multithread é um pouco mais difícil, porque você terá que gerenciar manualmente os fluxos.
A prática mostra que na maioria dos casos a carga não é uniforme, o que pode levar ao log de um thread enquanto outro está ocupado com o trabalho.
Se um manipulador de eventos bloquear um thread, o próprio seletor do sistema também bloqueará, o que pode levar a bugs difíceis de encontrar.
Resolve esses problemas Proator de E/S, que geralmente possui um agendador que distribui uniformemente a carga para um pool de threads e também possui uma API mais conveniente. Falaremos sobre isso mais tarde, em meu outro artigo.
Conclusão
É aqui que nossa jornada da teoria direto para o escapamento do perfilador chegou ao fim.
Você não deve insistir nisso, porque existem muitas outras abordagens igualmente interessantes para escrever software de rede com diferentes níveis de conveniência e velocidade. Interessantes, na minha opinião, os links são fornecidos abaixo.