Guia para modelos de servidor de rede de multiprocessamento
Publicados: 2022-03-11Como alguém que escreve código de rede de alto desempenho há vários anos (minha tese de doutorado foi sobre o tópico de um servidor de cache para aplicativos distribuídos adaptados a sistemas multicore), vejo muitos tutoriais sobre o assunto que perdem completamente ou omitem qualquer discussão dos fundamentos dos modelos de servidores de rede. Este artigo, portanto, pretende ser uma visão geral e uma comparação útil dos modelos de servidores de rede, com o objetivo de desvendar um pouco do mistério de escrever código de rede de alto desempenho.
Este artigo é destinado a “programadores de sistemas”, ou seja, desenvolvedores de back-end que irão trabalhar com os detalhes de baixo nível de suas aplicações, implementando código de servidor de rede. Isso geralmente será feito em C++ ou C, embora hoje em dia a maioria das linguagens e frameworks modernos ofereçam funcionalidade de baixo nível decente, com vários níveis de eficiência.
Levarei como conhecimento comum que, como é mais fácil dimensionar CPUs adicionando núcleos, é natural adaptar o software para usar esses núcleos da melhor maneira possível. Assim, a questão torna-se como particionar software entre threads (ou processos) que podem ser executados em paralelo em várias CPUs.
Também tomarei como certo que o leitor está ciente de que “simultaneidade” basicamente significa “multitarefa”, ou seja, várias instâncias de código (se o mesmo código ou diferente, não importa), que estão ativas ao mesmo tempo. A simultaneidade pode ser alcançada em uma única CPU e, antes da era moderna, geralmente era. Especificamente, a simultaneidade pode ser alcançada alternando rapidamente entre vários processos ou threads em uma única CPU. É assim que os antigos sistemas de CPU única conseguiam executar muitos aplicativos ao mesmo tempo, de uma maneira que o usuário perceberia como aplicativos sendo executados simultaneamente, embora na verdade não fossem. Paralelismo, por outro lado, significa especificamente que o código está sendo executado ao mesmo tempo, literalmente, por várias CPUs ou núcleos de CPU.
Particionando um aplicativo (em vários processos ou threads)
Para o propósito desta discussão, não é relevante se estamos falando de threads ou processos completos. Os sistemas operacionais modernos (com a notável exceção do Windows) tratam os processos quase tão leves quanto as threads (ou, em alguns casos, vice-versa, as threads ganharam recursos que as tornam tão pesadas quanto os processos). Atualmente, a principal diferença entre processos e threads está nos recursos de comunicação entre processos ou entre threads e compartilhamento de dados. Onde a distinção entre processos e threads for importante, farei uma observação apropriada, caso contrário, é seguro considerar as palavras “thread” e “process” nesta seção como intercambiáveis.
Tarefas comuns de aplicativos de rede e modelos de servidor de rede
Este artigo trata especificamente do código do servidor de rede, que necessariamente implementa as três tarefas a seguir:
- Tarefa nº 1: Estabelecendo (e desfazendo) conexões de rede
- Tarefa nº 2: comunicação de rede (IO)
- Tarefa #3: Trabalho útil; ou seja, a carga útil ou a razão pela qual o aplicativo existe
Existem vários modelos gerais de servidor de rede para particionar essas tarefas entre processos; nomeadamente:
- MP: Multiprocesso
- SPED: Processo Único, Orientado a Eventos
- SEDA: Arquitetura Orientada a Eventos Encenados
- AMPED: Asymmetric Multi-Process Event-Driven
- SYMPED: SYmmetric Multi-Process Event-Driven
Esses são os nomes de modelo de servidor de rede usados na comunidade acadêmica, e eu me lembro de encontrar sinônimos “na natureza” para pelo menos alguns deles. (Os nomes em si são, obviamente, menos importantes – o valor real está em como raciocinar sobre o que está acontecendo no código.)
Cada um desses modelos de servidor de rede é descrito com mais detalhes nas seções a seguir.
O Modelo Multiprocesso (MP)
O modelo de servidor de rede MP é aquele que todos costumavam aprender primeiro, especialmente, ao aprender sobre multithreading. No modelo MP, existe um processo “mestre” que aceita conexões (Tarefa #1). Depois que uma conexão é estabelecida, o processo mestre cria um novo processo e passa o soquete de conexão para ele, portanto, há um processo por conexão. Esse novo processo geralmente funciona com a conexão de uma maneira simples, sequencial e com etapas de bloqueio: ele lê algo dela (Tarefa #2), depois faz algum cálculo (Tarefa #3) e escreve algo nela (Tarefa #2) novamente).
O modelo MP é muito simples de implementar e, na verdade, funciona extremamente bem, desde que o número total de processos permaneça bastante baixo. Quão baixo? A resposta realmente depende do que as Tarefas #2 e #3 envolvem. Como regra geral, digamos que o número de processos ou threads não deve exceder cerca de duas vezes o número de núcleos de CPU. Uma vez que há muitos processos ativos ao mesmo tempo, o sistema operacional tende a gastar muito tempo trabalhando (ou seja, fazendo malabarismos com os processos ou threads nos núcleos de CPU disponíveis) e esses aplicativos geralmente acabam gastando quase toda a CPU tempo no código “sys” (ou kernel), fazendo pouco trabalho realmente útil.
Prós: Muito simples de implementar, funciona muito bem desde que o número de conexões seja pequeno.
Contras: Tende a sobrecarregar o sistema operacional se o número de processos crescer muito e pode ter instabilidade de latência, pois a E/S de rede aguarda até que a fase de carga útil (computação) termine.
O Modelo Orientado a Eventos de Processo Único (SPED)
O modelo de servidor de rede SPED ficou famoso por alguns aplicativos de servidor de rede de alto perfil relativamente recentes, como o Nginx. Basicamente, ele faz todas as três tarefas no mesmo processo, multiplexando entre elas. Para ser eficiente, requer algumas funcionalidades de kernel bastante avançadas, como epoll e kqueue. Nesse modelo, o código é conduzido por conexões de entrada e “eventos” de dados e implementa um “loop de eventos” que se parece com isso:
- Pergunte ao sistema operacional se existem novos “eventos” de rede (como novas conexões ou dados de entrada)
- Se houver novas conexões disponíveis, estabeleça-as (Tarefa #1)
- Se houver dados disponíveis, leia-os (Tarefa #2) e aja de acordo com eles (Tarefa #3)
- Repita até que o servidor saia
Tudo isso é feito em um único processo e pode ser feito de forma extremamente eficiente porque evita completamente a alternância de contexto entre os processos, o que geralmente mata o desempenho no modelo MP. As únicas trocas de contexto aqui vêm das chamadas do sistema, e elas são minimizadas agindo apenas nas conexões específicas que possuem alguns eventos anexados a elas. Esse modelo pode lidar com dezenas de milhares de conexões simultaneamente, desde que o trabalho de carga útil (Tarefa nº 3) não seja excessivamente complicado ou intensivo em recursos.
Existem duas grandes desvantagens, no entanto, dessa abordagem:
- Como todas as três tarefas são feitas sequencialmente em uma única iteração de loop, o trabalho de carga útil (Tarefa #3) é feito de forma síncrona com todo o resto, o que significa que, se demorar muito tempo para calcular uma resposta aos dados recebidos pelo cliente, todo o resto pára enquanto isso está sendo feito, introduzindo flutuações potencialmente enormes na latência.
- Apenas um único núcleo de CPU é usado. Isso tem o benefício, novamente, de limitar absolutamente o número de comutações de contexto exigidas do sistema operacional, o que aumenta o desempenho geral, mas tem a desvantagem significativa de que qualquer outro núcleo de CPU disponível não está fazendo nada.
É por essas razões que modelos mais avançados são necessários.

Prós: Pode ser de alto desempenho e fácil no sistema operacional (ou seja, requer intervenção mínima do sistema operacional). Requer apenas um único núcleo de CPU.
Contras: Utiliza apenas uma única CPU (independentemente do número disponível). Se o trabalho da carga útil não for uniforme, resultará em latência não uniforme das respostas.
O modelo Staged Event-Driven Architecture (SEDA)
O modelo de servidor de rede SEDA é um pouco complicado. Ele decompõe um aplicativo complexo e orientado a eventos em um conjunto de estágios conectados por filas. Se não for implementado com cuidado, porém, seu desempenho pode sofrer o mesmo problema que o caso do MP. Funciona assim:
- O trabalho de carga útil (Tarefa #3) é dividido em tantos estágios, ou módulos, quanto possível. Cada módulo implementa uma única função específica (pense em “microsserviços” ou “microkernels”) que reside em seu próprio processo separado, e esses módulos se comunicam entre si por meio de filas de mensagens. Essa arquitetura pode ser representada como um grafo de nós, onde cada nó é um processo e as arestas são filas de mensagens.
- Um único processo executa a Tarefa nº 1 (geralmente seguindo o modelo SPED), que descarrega novas conexões para nós de ponto de entrada específicos. Esses nós podem ser nós de rede puros (Tarefa #2) que passam os dados para outros nós para computação, ou podem implementar o processamento de carga útil (Tarefa #3) também. Geralmente não existe um processo “mestre” (por exemplo, um que coleta e agrega as respostas e as envia de volta pela conexão), pois cada nó pode responder por si mesmo.
Em teoria, este modelo pode ser arbitrariamente complexo, com o grafo de nós possivelmente tendo loops, conexões com outras aplicações similares, ou onde os nós estão realmente executando em sistemas remotos. Na prática, porém, mesmo com mensagens bem definidas e filas eficientes, pode se tornar difícil pensar e raciocinar sobre o comportamento do sistema como um todo. A sobrecarga de passagem de mensagens pode destruir o desempenho desse modelo, comparado ao modelo SPED, se o trabalho realizado em cada nó for curto. A eficiência deste modelo é significativamente menor do que a do modelo SPED, e por isso é normalmente empregado em situações onde o trabalho de carga útil é complexo e demorado.
Prós: O sonho do arquiteto de software definitivo: tudo é segregado em módulos independentes e organizados.
Contras: A complexidade pode explodir apenas com o número de módulos, e o enfileiramento de mensagens ainda é muito mais lento que o compartilhamento direto de memória.
O Modelo Asymmetric Multi-Process Event-Driven (AMPED)
O servidor de rede AMPED é uma versão mais simples e fácil de modelar do SEDA. Não há tantos módulos e processos diferentes, nem tantas filas de mensagens. Veja como funciona:
- Implemente as Tarefas #1 e #2 em um único processo “mestre”, no estilo SPED. Este é o único processo que faz E/S de rede.
- Implemente a Tarefa nº 3 em um processo “trabalhador” separado (possivelmente iniciado em várias instâncias), conectado ao processo mestre com uma fila (uma fila por processo).
- Quando os dados são recebidos no processo “mestre”, encontre um processo de trabalho subutilizado (ou ocioso) e passe os dados para sua fila de mensagens. O processo mestre recebe uma mensagem do processo quando uma resposta está pronta, e nesse ponto ele passa a resposta para a conexão.
O importante aqui é que o trabalho de carga útil seja realizado em um número fixo (geralmente configurável) de processos, que é independente do número de conexões. Os benefícios aqui são que a carga útil pode ser arbitrariamente complexa e não afetará a E/S da rede (o que é bom para a latência). Há também a possibilidade de aumentar a segurança, pois apenas um único processo está fazendo a E/S de rede.
Prós: Separação muito limpa de IO de rede e trabalho de carga útil.
Contras: Utiliza uma fila de mensagens para passar dados entre processos, o que, dependendo da natureza do protocolo, pode se tornar um gargalo.
O modelo SYmmetric Multi-Process Event-Driven (SYMPED)
O modelo de servidor de rede SYMPED é, em muitos aspectos, o “santo graal” dos modelos de servidor de rede, porque é como ter várias instâncias de processos “trabalhadores” independentes do SPED. Ele é implementado tendo um único processo aceitando conexões em um loop e, em seguida, passando-as para os processos de trabalho, cada um dos quais possui um loop de eventos do tipo SPED. Isso tem algumas consequências muito favoráveis:
- As CPUs são carregadas exatamente para o número de processos gerados, que em todos os momentos estão fazendo E/S de rede ou processamento de carga útil. Não há como aumentar ainda mais a utilização da CPU.
- Se as conexões forem independentes (como com HTTP), não haverá comunicação entre processos entre os processos de trabalho.
Isso é, de fato, o que as versões mais recentes do Nginx fazem; eles geram um pequeno número de processos de trabalho, cada um dos quais executa um loop de eventos. Para tornar as coisas ainda melhores, a maioria dos sistemas operacionais fornece uma função pela qual vários processos podem escutar conexões de entrada em uma porta TCP de forma independente, eliminando a necessidade de um processo específico dedicado a trabalhar com conexões de rede. Se o aplicativo em que você está trabalhando puder ser implementado dessa maneira, recomendo fazê-lo.
Prós: Limite máximo de uso da CPU, com um número controlável de loops do tipo SPED.
Contras: Como cada um dos processos tem um loop do tipo SPED, se o trabalho de carga útil não for uniforme, a latência pode variar novamente, assim como no modelo SPED normal.
Alguns truques de baixo nível
Além de selecionar o melhor modelo de arquitetura para seu aplicativo, existem alguns truques de baixo nível que podem ser usados para aumentar ainda mais o desempenho do código de rede. Aqui está uma breve lista de alguns dos mais eficazes:
- Evite alocação dinâmica de memória. Como explicação, basta olhar para o código para os alocadores de memória populares - eles usam estruturas de dados complexas, mutexes, e simplesmente há muito código neles (jemalloc, por exemplo, é cerca de 450 KiB de código C!). A maioria dos modelos acima pode ser implementada com rede completamente estática (ou pré-alocada) e/ou buffers que apenas alteram a propriedade entre threads quando necessário.
- Use o máximo que o SO pode fornecer. A maioria dos sistemas operacionais permite que vários processos escutem em um único soquete e implementam recursos em que uma conexão não será aceita até que o primeiro byte (ou mesmo uma primeira solicitação completa!) seja recebido no soquete. Use sendfile() se puder.
- Entenda o protocolo de rede que você está usando! Por exemplo, geralmente faz sentido desabilitar o algoritmo de Nagle, e pode fazer sentido desabilitar a demora se a taxa de (re)conexão for alta. Aprenda sobre algoritmos de controle de congestionamento TCP e veja se faz sentido tentar um dos mais recentes.
Posso falar mais sobre isso, bem como técnicas e truques adicionais para empregar, em um post futuro no blog. Mas, por enquanto, esperamos que isso forneça uma base útil e informativa sobre as opções arquitetônicas para escrever código de rede de alto desempenho e suas vantagens e desvantagens relativas.
