Como alguém que escreve código de rede de alto desempenho há vários anos (minha dissertação de doutorado foi sobre o tema de um Servidor de cache para aplicativos distribuídos adaptados a sistemas multicore ), Vejo muitos tutoriais sobre o assunto que omitem ou omitem completamente qualquer discussão sobre os fundamentos dos modelos de servidor de rede. Este artigo, portanto, pretende ser uma visão geral útil e comparação de modelos de servidor 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 sistema', ou seja, desenvolvedores de back-end que trabalhará com os detalhes de baixo nível de seus aplicativos, implementando o código do 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 decente de baixo nível, com vários níveis de eficiência.
Vou tomar como conhecimento comum que, uma vez que é 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 é como particionar o software entre threads (ou processos) que podem ser executados em paralelo em várias CPUs.
Também vou assumir que o leitor está ciente de que 'simultaneidade' basicamente significa 'multitarefa', ou seja, várias instâncias de código (seja 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 sistemas antigos com uma única CPU conseguiam executar muitos aplicativos ao mesmo tempo, de uma forma 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.
Para o propósito desta discussão, não é muito relevante se estamos falando sobre threads ou processos completos. Os sistemas operacionais modernos (com a notável exceção do Windows) tratam os processos quase tão leves quanto os threads (ou, em alguns casos, vice-versa, os threads ganharam recursos que os tornam tão pesados quanto os processos). Hoje em dia, a principal diferença entre processos e threads está nas capacidades de comunicação e compartilhamento de dados entre processos ou threads. Onde a distinção entre processos e threads é importante, farei uma observação apropriada, caso contrário, é seguro considerar as palavras 'thread' e 'processo' nesta seção como intercambiáveis.
Este artigo trata especificamente do código do servidor de rede, que necessariamente implementa as três tarefas a seguir:
Existem vários modelos gerais de servidor de rede para particionar essas tarefas nos processos; nomeadamente:
Esses são os nomes de modelo de servidor de rede usados na comunidade acadêmica, e lembro-me de encontrar sinônimos 'na selva' 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 de servidor de rede MP é aquele que todos costumavam aprender primeiro, especialmente quando aprendiam 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 travada: ele lê algo dela (Tarefa nº 2), depois faz alguns cálculos (Tarefa nº 3) e, em seguida, escreve algo nela (Tarefa nº 2 novamente).
O modelo MP é muito simples de implementar e realmente funciona muito 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 da CPU. Uma vez que há muitos processos ativos ao mesmo tempo, o sistema operacional tende a gastar muito tempo se debatendo (ou seja, fazendo malabarismos com os processos ou threads nos núcleos da CPU disponíveis) e tais aplicativos geralmente acabam gastando quase toda a sua 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 ficar muito grande e pode ter instabilidade de latência à medida que a rede IO espera até que a fase de carga útil (computação) termine.
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 executa 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 . Neste modelo, o código é orientado por conexões de entrada e 'eventos' de dados e implementa um 'loop de evento' semelhante a este:
Tudo isso é feito em um único processo, e pode ser feito de forma extremamente eficiente, pois evita completamente a troca de contexto entre os processos, o que geralmente mata o desempenho no modelo MP. As únicas alternâncias de contexto aqui vêm de chamadas do sistema, e essas são minimizadas atuando apenas nas conexões específicas que possuem alguns eventos anexados a elas. Este modelo pode lidar com dezenas de milhares de conexões simultaneamente, contanto que o trabalho de carga útil (Tarefa nº 3) não seja excessivamente complicado ou consuma muitos recursos.
Existem duas desvantagens principais, porém, dessa abordagem:
É 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 de carga útil não for uniforme, resultará em latência não uniforme de respostas.
O modelo de servidor de rede SEDA é um pouco complicado. Ele decompõe um aplicativo complexo baseado em eventos em um conjunto de estágios conectados por filas. Se não for implementado com cuidado, entretanto, seu desempenho pode sofrer do mesmo problema do case do MP. Funciona assim:
Em teoria, este modelo pode ser arbitrariamente complexo, com o gráfico de nós possivelmente tendo loops, conexões com outros aplicativos semelhantes 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 deste modelo, em comparação com o modelo SPED, se o trabalho que está sendo feito em cada nó for curto. A eficiência deste modelo é significativamente menor do que a do modelo SPED e, portanto, geralmente é empregado em situações onde o trabalho de carga útil é complexo e demorado.
Prós: O sonho final do arquiteto de software: tudo é segregado em módulos independentes organizados.
Contras: A complexidade pode explodir apenas com o número de módulos, e o enfileiramento de mensagens ainda é muito mais lento do que o compartilhamento direto de memória.
O servidor de rede AMPED é uma versão domesticada e mais fácil de modelar do SEDA. Não há tantos módulos e processos diferentes, nem tantas filas de mensagens. É assim que funciona:
O importante aqui é que o trabalho de carga útil seja executado 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á o IO da rede (o que é bom para a latência). Também existe a possibilidade de aumentar a segurança, já que apenas um único processo está realizando IO de rede.
Prós: Separação muito clara 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 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' SPED independentes. É implementado com um único processo aceitando conexões em um loop e, em seguida, transmitindo-as aos processos de trabalho, cada um dos quais com um loop de evento semelhante ao SPED. Isso tem algumas consequências muito favoráveis:
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 no qual você está trabalhando puder ser implementado dessa forma, recomendo fazê-lo.
Prós: Limite máximo de uso de CPU estrito, com um número controlável de loops semelhantes a SPED.
Contras: Como cada um dos processos tem um loop semelhante ao SPED, se o trabalho da carga útil não for uniforme, a latência pode variar novamente, assim como com o modelo SPED normal.
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:
Posso falar mais sobre isso, bem como técnicas e truques adicionais a serem empregados, em uma postagem futura no blog. Mas, por enquanto, espero que isso forneça uma base útil e informativa com relação às escolhas arquitetônicas para escrever código de rede de alto desempenho e suas vantagens e desvantagens relativas.