Guia para modelos de servidor de rede de multiprocessamento

Publicados: 2022-03-11

Como 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.

Qual modelo de servidor de rede devo escolher

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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. 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.