Múltiplas aplicações num mesmo servidor… Má idéia!

Essa é uma discussão antiga e os adeptos de ambientes como o Microsoft Internet Information Services não gostam muito dos argumentos contra a hospedagem de diversas aplicações num servidor único… Eu quero apresentar aqui os motivos técnicos do porquê essa prática é uma péssima idéia e, de forma ideal e prática, do porquê aplicações diferentes devem ser implementadas em servidores diferentes. Também quero mostrar uma técnica, empírica, de calcular a quantidade de processadores necessários para que um servidor garanta uma média de carga máxima de 70% com uma pequena latência entre threads, para o dimensionamento máximo de usuários simultâneos…

Web servers, no Linux e no Windows

Ambos ApacheIIS, para citar os mais badalados, trabalham essencialmente da mema forma: Existe um processo pai que cria um socket que “ouve” os pedidos de conexão. Quando uma conexão é recebida, pelo processo pai, um socket de conexão é criado (de acordo com os critérios da configuração do serviço) e um processo (ou thread) filho é criado. Esse processo filho, menos privilegiado do que o pai, será o responsável pelo uso do socket onde o cliente está conectado…

A diferença entre Linux e Windows, neste caso, é que “processos” custam muito caro para o Windows. A Microsoft dá preferência aos “processos leves”, chamadas de threads. No caso do Linux, processos são mais “baratos”. Tudo o que o Linux faz ao criar um novo processo é derivar um que já exista, criando uma cópia parcial do processo original… Threads e processos, no Linux, são coisas mais ou menos equivalentes, exceto pelo fato de que um processo pode exigir uma pilha muito grande (8 MB do Debian, 10 MB no RedHat, por exemplo). Esse dado pode ser lido neste artigo, inclusive alguma coisa sobre dimensionamento do Apache web server.

Threads e processsos

Um processo contém uma thread principal e pode conter diversas threads secundárias. Uma thread é uma “unidade de execução” que, fora o espaço de endereçamento, tem contexto próprio (pilha, armazenamento local e registradores).

Os processadores modernos contém vários “núcleos” de execução. Cada “núcleo” (core) é capaz de executar instruções separado do outro núcleo e em paralelo. É como se cada “núcleo” fosse um “processador lógico” dentro do seu processador físico… A coisa não é bem assim: Cada núcleo contém dois processadores lógicos que executam instruções em paralelo, mas com time slicing. Ou seja, cada processador lógico é colocado em espera, de tempos em tempos, para que o outro (dentro do mesmo núcleo) possa executar alguma coisa… Essa mágica a Intel chama de HyperThreading.

Num processador Core i7, por exemplo, podemos ter 8 processadores lógicos. Neste caso, temos apenas 4 “cores”. Assim, se tivermos 8 threads em execução, é garantido que apenas 4 delas estarão com paralelismo simétrico (outro termo para “realmente em paralelo”).

Por que esse tópico sobre threads, paralelismo e time slicing? É que saber como a “multitarefa” funciona no seu processador é fundamental para configuração de serviços como Apache e IIS… Se meu servidor só tem 8 “cores” e, se desconsiderarmos o time slicing causado pelo agrupamento de processadores lógicos, isso significa que se eu tiver mais que 8 threads em execução, as threads dividirão o tempo de processamento com todas as demais… Acontece que esse fatiamento de tempo é feito por um processo chamado task switching.

Para “chavear tarefas” o processador (e o sistema operacional) mantém registros que contém todo o “contexto” das tarefas. “Contexto”, aqui, significa “o estado dos registradores usados na thread”, todos eles, incluindo registradores que controlam o mapeamento da memória virtual do processo onde a thread se encontra… O “chaveamento” é feito através de uma interrupção (que, em média, ocorre a cada 20 milissegundos), onde o processador pára a thread atual, salta para o kernel, numa rotina chamada task scheduler, guarda o contexto da thread que estava sendo executada, decide se deve ou não “pular” para outra thread (baseado em informações como “prioridade”, por exemplo) e, se for o caso, carrega o contexto da outra thread e salta para ela.

O chaveamento de threads é bem traumático, do ponto de vista de performance. Especialmente porque envolve ajustes ainda mais drásticos para o processador… Sempre que uma thread é chaveada, o processador lógico precisa ser informado das configurações do espaço de endereçamento virtual do processo para onde ele vai saltar. Isso envolve a carga de registrador de controle das tabelas de páginas (memória virtual) que desfaz caches, gastando milhares de ciclos de clock no processo…

Quanto mais threads, mais lerdo…

Se um sistema está configurado para executar uma quantidade de threads maior que a quantidade de processadores lógicos então task switching é inevitável. Mas podemos limitar essa quantidade em valores mais práticos! Um cálculo superficial pode mostrar que se tivermos 200 threads em um único processador lógico e todas elas estejam ativas (fora de estado de espera), e sabendo que cada task switching ocorre em 20 milissegundos, podemos afirmar que cada uma das threads recebe uma fatia de tempo de menos que 20 ms de 4 em 4 segundos.

O cálculo acima leva em consideração que todas essas 200 threads serão executadas em round robin, ou seja, de forma sequencial. Se cada chaveamento ocorre em 20 ms, 200 chaveamentos ocorrerão em 4 segundos. Ou seja, para a primeira thread voltar a ser executada passarão 4 segundos!

É claro que, na prática, isso não acontece bem assim… Leva-se em conta que a maioria das threads estarão em estado de espera (idle) e que teremos, de fato, poucas threads concorrentes num mesmo processador lógico. Mais adiante você obtervará aquilo que chamei de coeficiente de carga efetiva, onde considero esse fator de “uso” das threads.

A carga média total do processador pode ser medida via um parâmetro chamado load average (média de carga). Ele indica, entre outras coisas, a quantidade de task switchings nos processadores lógicos, por intervalo de tempo. Como mostrei, neste artigo, o ideal é que seu servidor seja dimensionado para manter uma média de carga, máxima, de 70% do número de processadores lógicos.

Cálculo empírico para dimensionamento de uma única aplicação

Considerando que a média de carga deve ser mantida, de preferência, em 70% do número de processadores, que o coeficiente de carga efetiva de todas as threads não é de 100%, que operações com sockets são lentas e, por isso, podeos ter algum coeficiente de task switching… A seguinte fórmula pode ser uma ajuda para dimensionamento estimado:

N_p = \left \lceil 2 \cdot K_{la} \cdot K_{el} \cdot K_{ts} \cdot U_{max} \right \rceil

Onde N_p é o número de processadores lógicos que desejamos obter. K_{la} é um coeficiente que considera a média de carga. K_{el} é um “coefiente efetivo de carga” que considera a carga efetiva do conjunto total de threds (ou seja, quantas threads estarão efetivamente exigindo o esforço do processador em qualquer determinado momento). K_{ts} é o coefiente que considera o máximo de tempo entre task switchings de uma thread em particular. E, por fim, U_{max} é o número máximo de threads que você espera que sejam disparadas (aproximadamente o número máximo de usuários simultâneos).

As constantes acima podem ser definidas como: K_{la}=\frac{1}{0.7}, 0 < K_{el} \leqslant 1, K_{ts} = \frac{0.02}{t_{ts}}, Onde a constante 0.7 no recíproco que define K_{la} é a média de carga máxima desejada (empírica). Empírito também é K_{el}, para uma única thread esse coeficiente será sempre maior que zero e quase nunca igual a 1. E o tempo t_{ts} é o intervalo entre chaveamentos de tarefas, em segundos. Esse valor deverá ser sempre maior que 20 ms (o numerador da divisão).

A constante 2 está lá na fórmula porque cada núcleo tem 2 processadores lógicos não simétricos. O cálculo tenta paralelizar ao máximo as threads! Ainda, para máquinas virtuais (VMWare e outros virtualizadores) esse valor cria um balanceamento para o fato de que uma máquina virtual não tem a mesma performance do que uma máquina real com as mesmas características e, se você pensar bem, mesmo um VM Host, hoje em dia, funciona com múltiplos processadores Intel!

Outra consideração é quanto ao valor constante de Kts, 0.02. Ele é uma média do tempo de latência do chaveamento de tarefas tanto do Linux, quanto do Windows. No Linux, você pode obter esse tempo em nanossegundos com:

$ cat /proc/sys/kernel/sched_latency_ns 
18000000

Esse tempo de latência pode ser configurado (no Linux apenas!) mudando alguma variável de configuração do kernel (ou, talvez, no próprio procfs), mas alterá-lo para menos implica que cada thread terá menos tempo disponível para execução, tornando o seu sistema ainda mais lento! E tempos maiores podem causar latências maiores entre as múltimas threads… Usar 20 ms como base de cálculo é uma boa pedida para ambos os sistemas operacionais.

Como temos duas variáveis empíricas (K_{el} e t_{ts}), N_p pode ser escrito em função de U_{max} e das outras duas variáveis…

N_p(U_{max},K_{el},t_{ts}) = \left \lceil \frac{0.0057 \cdot K_{el} \cdot U_{max}}{t_{ts}} \right \rceil

Exemplo de uso: Considere que sua aplicação terá o limite de 300 threads (usuários simultâneos), a carga média de cada thread será de 30% (ou seja, no máximo umas 90 threads estarão 100% ativas) e é aceitável que o o tempo máximo entre task switchings seja de 1 segundo:

N_p(300,0.3,1) = \left \lceil \frac{0.0057 \cdot 0.3 \cdot 300}{1} \right \rceil = 6

Nessas condições conseguimos 6 processadores lógicos. É fácil notar que quanto mais usuários simultâneos, quanto maior a carga efetiva e quanto menor o tempo entre chaveamentos de contextos de tarefa, maior será a quantidade de processadores logicos necessários no servidor.

Os valores de K_{el} e t_{ts} devem ser escolhidos cuidadosamente com base nas características e espectavivas de comportamento da aplicação. Se muito processamento é feito então K_{el} deve refletir isso. Se tivermos uma carga máxima, ou seja, se esse coeficiente for 1, então teríamos um máximo de 18 processadores, se os outros parâmetros forem mantidos. Considero uma carga efetiva de 30% um bom parâmetro mínimo.

A consideração que devo fazer para t_{ts} é que ele deve diminuir, para processos com grande carga efetiva… Infelizmente isso aumentará ainda mais a quantidade de processadores necessários para suportar uma boa performance do sistema! Mas, nem sempre… Por exemplo: se tivermos carga efetiva média de 0.5 (150 das 300 threads “no talo”!), então poderíamos diminuir o tempo t_{ts} para 500 ms, para garantir que todas as threads terão latência pequena. Isso nos dará também 18 processadores lógicos!

Com esses critérios, para atender 300 requisições/segundo (aprox), com tempo entre task switching máximo de 1 segundo, teríamos que ter entre 6 e 18 processadores para este servidor.

Deixe-me lembrá-lo que essa função é empírica. Ela só oferece uma base mais sólida do que a adivinhação.

Voltando ao assunto…

Então, por que colocar dezenas ou centenas de virtual hosts, com aplicações diferentes, num mesmo web servernão é uma boa idéia? Considere o caso de um servidor com 50 aplicações diferentes, cada uma com suporte máximo de 300 usuários, com coeficiente de carga efetiva média de 30% e tempo de latência entre threads de 1 segundo… Temos 15000 threads para atender, com alguma garantia de atendimento de 3000 threads (30%). Para tanto teremos que ter um servidor com, no mínimo, 257 processadores (use a fórmula) e, no máximo, 855!

Neste ponto você pode estar coçando a cabeça e tentando me dizer: “Mas… meu servidor suporta 80 aplicações diferentes, com 8 processadores, sem problemas! Segundo seus cálculos eu teria que ter 410 processadores, no mínimo!”… De fato! Mas, considere que suas aplicações provavelmente têm um coeficiente efetivo de carga média “real” muito pequeno, ou seja, dos 300 usuários simultâneos por aplicação, talvez apenas uns 3 ou 4 estejam requisitando processamento das threads… Com um coefiente efetivo assim, a fórmula mostra que 14 processadores seriam suficientes para dar uma garantia mínima. Ao usar 8, nessas condições, seu servidor encontra-se só um pouco acima da média de carga de 70%.

Considere que, se algumas dessas aplicações começarem a exigir mais que 1% de coeficiente efetivo de carga média, então você terá um problemão de performance nas mãos! Pior ainda, o problema de performance vai ser distribuído entre todas as aplicações no servidor, já que a distribuição de threads entre os processadores lógicos não é tão previsível… Trabalhar com afinidade de threads geralmente leva a baixa performance (porque bagunça com a lógica do task scheduler!).

A moral da história é: Dimensionar um seridor para uma única aplicação, tentando garantir a média de carga de 70% dos processadores, é simples… Se tentar fazer o mesmo para múltiplas aplicações a coisa pode ficar bem complexa… Fica ainda melhor dimensionar servidores “menores” através da divisão de trabalho (usando um proxy, por exemplo, para direcionar requisições entre vários servidores com a mesma aplicação – usando técnicas de clustering e/ou HA [High Availability]).

Anúncios

Um comentário sobre “Múltiplas aplicações num mesmo servidor… Má idéia!

Deixe um comentário

Faça o login usando um destes métodos para comentar:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s