Entender o modelo de entrada/saída (E/S) de seu aplicativo pode significar a diferença entre um aplicativo que lida com a carga à qual está sujeito, e um que se deforma diante de casos de uso do mundo real.
Talvez, embora seu aplicativo seja pequeno e não atenda a altas cargas, isso pode importar muito menos.
Porém, à medida que a carga de tráfego de seu aplicativo aumenta, trabalhar com o modelo de I/O errado pode levá-lo a um mundo de dor.
E, como em quase todas as situações em que várias abordagens são possíveis, não é apenas uma questão de qual é a melhor, é uma questão de entender as compensações. Vamos dar uma volta pelo cenário de I/O e ver o que podemos espionar.
Neste artigo, compararemos Node, Java, Go e PHP com Apache, discutindo como as diferentes linguagens modelam sua I/O, as vantagens e desvantagens de cada modelo e concluímos com alguns benchmarks rudimentares.
Se você está preocupado com o desempenho de I/O de seu próximo aplicativo da Web, este artigo é para você.
Para entender os fatores envolvidos com I/O, devemos primeiro revisar os conceitos no nível do sistema operacional. Embora seja improvável que você tenha que lidar com muitos desses conceitos diretamente, você lida com eles indiretamente por meio do ambiente de tempo de execução de seu aplicativo o tempo todo. E os detalhes importam.
Em primeiro lugar, temos as chamadas de sistema, que podem ser descritas da seguinte forma:
Agora, acabei de dizer acima que as syscalls estão bloqueando, e isso é verdade em um sentido geral. No entanto, algumas chamadas são categorizadas como “sem bloqueio”, o que significa que o kernel recebe sua solicitação, coloca-a na fila ou no buffer em algum lugar e retorna imediatamente sem esperar que a E/S real ocorra. Portanto, ele “bloqueia” apenas por um período de tempo muito breve, apenas o tempo suficiente para colocar sua solicitação na fila.
Alguns exemplos (de syscalls do Linux) podem ajudar a esclarecer: - read()é uma chamada de bloqueio - você passa um identificador dizendo qual arquivo e um buffer de onde entregar os dados que ele lê, e a chamada retorna quando os dados estão lá. Observe que isso tem a vantagem de ser agradável e simples. - epoll_create(), epoll_ctl()e epoll_wait()são chamadas que, respectivamente, permitem criar um grupo de handles para escutar, adicionar/remover handlers desse grupo e então bloquear até que haja alguma atividade. Isso permite que você controle com eficiência um grande número de operações de E/S com um único thread, mas estou me adiantando. Isso é ótimo se você precisar da funcionalidade, mas, como pode ver, certamente é mais complexo de usar.
É importante entender a ordem de magnitude da diferença no tempo aqui. Se um núcleo de CPU estiver rodando a 3 GHz, sem entrar nas otimizações que a CPU pode fazer, ele está executando 3 bilhões de ciclos por segundo (ou 3 ciclos por nanossegundo). Uma chamada de sistema sem bloqueio pode levar cerca de 10s de ciclos para ser concluída - ou “relativamente poucos nanossegundos”. Uma chamada que bloqueia o recebimento de informações pela rede pode levar muito mais tempo - digamos, por exemplo, 200 milissegundos (1/5 de segundo). E digamos, por exemplo, que a chamada sem bloqueio levou 20 nanossegundos e a chamada com bloqueio levou 200.000.000 nanossegundos. Seu processo acabou de esperar 10 milhões de vezes mais pela chamada de bloqueio.
O kernel fornece os meios para fazer I/O de bloqueio (“leia desta conexão de rede e me dê os dados”) e I/O de não bloqueio (“avise-me quando qualquer uma dessas conexões de rede tiver novos dados”). E qual mecanismo é usado bloqueará o processo de chamada por períodos de tempo drasticamente diferentes.
A terceira coisa que é importante seguir é o que acontece quando você tem muitos threads ou processos que começam a bloquear.
Para nossos propósitos, não há uma grande diferença entre um thread e um processo. Na vida real, a diferença mais notável relacionada ao desempenho é que, como os threads compartilham a mesma memória e cada processo tem seu próprio espaço de memória, fazer processos separados tende a ocupar muito mais memória. Mas quando estamos falando sobre agendamento, o que realmente se resume é uma lista de coisas (tanto threads quanto processos) que cada um precisa para obter uma fatia do tempo de execução nos núcleos de CPU disponíveis. Se você tiver 300 threads em execução e 8 núcleos para executá-los, precisará dividir o tempo para que cada um receba sua parte, com cada núcleo executando por um curto período de tempo e depois passando para o próximo thread. Isso é feito por meio de uma “troca de contexto”, fazendo com que a CPU mude de executar um thread/processo para o próximo.
Essas trocas de contexto têm um custo associado - elas levam algum tempo. Em alguns casos rápidos, pode ser inferior a 100 nanossegundos, mas não é incomum que demore 1.000 nanossegundos ou mais, dependendo dos detalhes da implementação, velocidade/arquitetura do processador, cache da CPU, etc.
E quanto mais threads (ou processos), mais troca de contexto. Quando estamos falando de milhares de threads e centenas de nanossegundos para cada um, as coisas podem ficar muito lentas.
No entanto, as chamadas sem bloqueio, em essência, dizem ao kernel “apenas me ligue quando tiver novos dados ou eventos em uma dessas conexões”. Essas chamadas sem bloqueio são projetadas para lidar eficientemente com grandes cargas de E/S e reduzir a alternância de contexto.
Comigo até agora? Porque agora vem a parte divertida: vamos ver o que algumas linguagens populares fazem com essas ferramentas e tirar algumas conclusões sobre as vantagens e desvantagens entre facilidade de uso e desempenho... e outras informações interessantes.
Como observação, embora os exemplos mostrados neste artigo sejam triviais (e parciais, com apenas os bits relevantes mostrados); acesso ao banco de dados, sistemas de cache externo (memcache, etc.) e qualquer coisa que exija E/S acabará realizando algum tipo de chamada de E/S sob o capô, que terá o mesmo efeito que os exemplos simples mostrados. Além disso, para os cenários em que a E/S é descrita como “bloqueadora” (PHP, Java), as leituras e gravações de solicitação e resposta HTTP são chamadas de bloqueio: Novamente, mais E/S oculta no sistema com seus problemas de desempenho correspondentes para levar em conta.
Há muitos fatores que influenciam na escolha de uma linguagem de programação para um projeto. Existem até muitos fatores quando você considera apenas o desempenho. Mas, se você está preocupado com o fato de seu programa ser limitado principalmente por I/O, se o desempenho de I/O é decisivo para seu projeto, essas são as coisas que você precisa saber.
Nos anos 90, muitas pessoas usavam sapatos Converse e escreviam scripts CGI em Perl. Então surgiu o PHP e, por mais que algumas pessoas gostem de falar mal dele, ele tornou a criação de páginas da Web dinâmicas muito mais fácil.
O modelo que o PHP usa é bastante simples. Existem algumas variações, mas seu servidor PHP médio se parece com:
Uma solicitação HTTP chega do navegador de um usuário e atinge seu servidor web Apache. O Apache cria um processo separado para cada solicitação, com algumas otimizações para reutilizá-los a fim de minimizar quantos ele precisa fazer (criar processos é, relativamente falando, lento). O Apache chama o PHP e diz para ele executar o .phparquivo no disco. O código PHP é executado e bloqueia as chamadas de E/S. Você chama file_get_contents()em PHP e sob o capô ele faz read()syscalls e aguarda os resultados.
E, claro, o código real é simplesmente incorporado à sua página e as operações estão bloqueando:
Em termos de como isso se integra ao sistema, é assim:
Bastante simples: um processo por solicitação. As chamadas de E/S são simplesmente bloqueadas. Vantagem? É simples e funciona. Desvantagem? Bata com 20.000 clientes simultaneamente e seu servidor explodirá em chamas. Esta abordagem não escala bem porque as ferramentas fornecidas pelo kernel para lidar com alto volume de I/O (epoll, etc.) não estão sendo usadas. E para piorar ainda mais, executar um processo separado para cada solicitação tende a usar muitos recursos do sistema, especialmente memória, que costuma ser a primeira coisa que falta em um cenário como esse.
Nota: A abordagem usada para Ruby é muito semelhante à do PHP e, de uma maneira ampla, geral e manual, eles podem ser considerados iguais para nossos propósitos.
Então, o Java aparece, bem na época em que você comprou seu primeiro nome de domínio e foi legal dizer “ponto com” aleatoriamente após uma frase. E o Java tem multithreading embutido na linguagem, o que (especialmente para quando foi criado) é incrível.
A maioria dos servidores da Web Java funciona iniciando um novo encadeamento de execução para cada solicitação que chega e, em seguida, nesse encadeamento eventualmente chamando a função que você, como desenvolvedor do aplicativo, escreveu.
Fazer I/O em um Servlet Java tende a se parecer com:
Uma vez que nossa doGet método acima corresponde a uma solicitação e é executado em seu próprio thread, em vez de um processo separado para cada solicitação que requer sua própria memória, temos um thread separado. Isso tem algumas vantagens interessantes, como poder compartilhar estado, dados em cache etc. exemplo anteriormente. Cada solicitação obtém um novo thread e as várias operações de I/O são bloqueadas dentro desse thread até que a solicitação seja totalmente tratada. Os threads são agrupados para minimizar o custo de criá-los e destruí-los, mas ainda assim, milhares de conexões significam milhares de threads, o que é ruim para o agendador.
Um marco importante é que na versão 1.4 o Java em diante ganhou a capacidade de fazer chamadas de I/O sem bloqueio. A maioria dos aplicativos, web e outros, não o utiliza, mas pelo menos está disponível. Alguns servidores Web Java tentam tirar proveito disso de várias maneiras; no entanto, a grande maioria dos aplicativos Java implantados ainda funcionam conforme descrito acima.
Java nos aproxima e certamente tem algumas boas funcionalidades prontas para uso para I/O, mas ainda não resolve o problema do que acontece quando você tem um aplicativo fortemente vinculado a I/O que está sendo sobrecarregado o chão com muitos milhares de threads de bloqueio.
O garoto popular no quarteirão quando se trata de melhor I/O é o Node.js. Qualquer um que tenha tido uma breve introdução ao Node foi informado de que ele é “sem bloqueio” e que lida com I/O de forma eficiente. E isso é verdade em um sentido geral. Mas o diabo está nos detalhes e os meios pelos quais essa feitiçaria foi realizada importam quando se trata de desempenho.
Essencialmente, a mudança de paradigma que o Node implementa é que, em vez de dizer essencialmente “escreva seu código aqui para lidar com a solicitação”, eles dizem “escreva o código aqui para começar a lidar com a solicitação”. Cada vez que você precisa fazer algo que envolve I/O, você faz a requisição e dá uma função de callback que o Node irá chamar quando terminar.
O código Node típico para fazer uma operação de I/O em uma solicitação é assim:
Como você pode ver, existem duas funções de retorno de chamada aqui. O primeiro é chamado quando uma solicitação é iniciada e o segundo é chamado quando os dados do arquivo estão disponíveis.
O que isso faz é basicamente dar ao Node uma oportunidade de lidar eficientemente com a I/O entre esses retornos de chamada. Um cenário em que seria ainda mais relevante é onde você está fazendo uma chamada de banco de dados no Node, mas não vou me preocupar com o exemplo porque é exatamente o mesmo princípio: você inicia a chamada do banco de dados e dá ao Node uma função de retorno de chamada, executa as operações de I/O separadamente usando chamadas sem bloqueio e, em seguida, invoca sua função de retorno de chamada quando os dados solicitados estão disponíveis. Esse mecanismo de enfileirar chamadas de I/O e deixar o Node lidar com isso e, em seguida, obter um retorno de chamada é chamado de “Event Loop”. E funciona muito bem.
No entanto, há um problema neste modelo. Sob o capô, a razão para isso tem muito mais a ver com a forma como o mecanismo JavaScript V8 (mecanismo JS do Chrome usado pelo Node) é implementadodo que qualquer outra coisa. O código JS que você escreve é executado em um único thread. Pense nisso por um momento. Isso significa que, embora a I/O seja executada usando técnicas eficientes sem bloqueio, seu JS que está executando operações vinculadas à CPU é executado em um único thread, cada bloco de código bloqueando o próximo. Um exemplo comum de onde isso pode ocorrer é fazer um loop nos registros do banco de dados para processá-los de alguma forma antes de enviá-los ao cliente. Aqui está um exemplo que mostra como isso funciona:
Embora o Node lide com a I/O de forma eficiente, isso forloop no exemplo acima está usando ciclos de CPU dentro de seu único thread principal. Isso significa que, se você tiver 10.000 conexões, esse loop poderá travar todo o aplicativo, dependendo de quanto tempo demorar. Cada solicitação deve compartilhar uma fatia de tempo, uma de cada vez, em seu thread principal.
A premissa em que todo esse conceito se baseia é que as operações de I/O são a parte mais lenta, portanto, é mais importante lidar com elas de maneira eficiente, mesmo que isso signifique fazer outros processamentos em série. Isso é verdade em alguns casos, mas não em todos.
O outro ponto é que, embora isso seja apenas uma opinião, pode ser bastante cansativo escrever um monte de retornos de chamada aninhados e alguns argumentam que isso torna o código significativamente mais difícil de seguir. Não é incomum ver callbacks aninhados em quatro, cinco ou até mais níveis dentro do código do Node.
Estamos de volta aos trade-offs. O modelo Node funciona bem se o seu principal problema de desempenho for I/O. No entanto, seu calcanhar de Aquiles é que você pode entrar em uma função que está lidando com uma solicitação HTTP e colocar um código intensivo de CPU e trazer todas as conexões para um rastreamento, se você não for cuidadoso.
Antes de entrar na seção do Go, é apropriado revelar que sou um fanboy do Go.
Eu o usei em muitos projetos e sou um defensor aberto de suas vantagens de produtividade, e as vejo em meu trabalho quando o uso.
Dito isso, vamos ver como ele lida com I/O. Um recurso importante da linguagem Go é que ela contém seu próprio agendador. Em vez de cada thread de execução corresponder a um único thread do sistema operacional, ele funciona com o conceito de “goroutines”. E o tempo de execução do Go pode atribuir uma goroutine a um thread do SO e executá-lo, ou suspendê-lo e não associá-lo a um thread do SO, com base no que esse goroutine está fazendo. Cada solicitação que chega do servidor HTTP do Go é tratada em uma Goroutine separada.
O diagrama de como o agendador funciona fica assim:
Sob o capô, isso é implementado por vários pontos no tempo de execução Go que implementam a chamada de I/O fazendo a solicitação para escrever/ler/conectar/etc., colocar a goroutine atual para dormir, com as informações para ativar a goroutine de volta até quando outras ações podem ser tomadas.
Na verdade, o tempo de execução do Go está fazendo algo não muito diferente do que o Node está fazendo, exceto que o mecanismo de retorno de chamada é integrado à implementação da chamada de I/O e interage com o agendador automaticamente. Ele também não sofre com a restrição de ter que ter todo o seu código de manipulador executado no mesmo thread, o Go mapeará automaticamente suas Goroutines para quantos threads de SO considerar apropriado com base na lógica de seu agendador. O resultado é um código como este:
Como você pode ver acima, a estrutura de código básica do que estamos fazendo se assemelha àquela das abordagens mais simplistas e, ainda assim, consegue I/O sem bloqueio sob o capô.
Na maioria dos casos, isso acaba sendo “o melhor dos dois mundos”. I/O sem bloqueio é usada para todas as coisas importantes, mas seu código parece estar bloqueando e, portanto, tende a ser mais simples de entender e manter. A interação entre o agendador Go e o agendador do SO cuida do resto. Não é uma mágica completa e, se você construir um sistema grande, vale a pena investir tempo para entender mais detalhadamente como ele funciona; mas, ao mesmo tempo, o ambiente que você obtém “pronto para uso” funciona e se adapta muito bem.
O Go pode ter suas falhas, mas, de modo geral, a maneira como ele lida com I/O não está entre elas.
É difícil dar tempos exatos na mudança de contexto envolvida com esses vários modelos. Eu também poderia argumentar que é menos útil para você. Em vez disso, darei a você alguns benchmarks básicos que comparam o desempenho geral do servidor HTTP desses ambientes de servidor. Lembre-se de que muitos fatores estão envolvidos no desempenho de todo o caminho de solicitação/resposta HTTP de ponta a ponta, e os números apresentados aqui são apenas alguns exemplos que reuni para fornecer uma comparação básica.
Para cada um desses ambientes, escrevi o código apropriado para ler em um arquivo de 64k com bytes aleatórios, executei um hash SHA-256 nele N várias vezes (N sendo especificado na string de consulta da URL, por exemplo, .../test.php?n=100) e imprima o hash resultante em hexadecimal. Eu escolhi isso porque é uma maneira muito simples de executar os mesmos benchmarks com algumas I/O consistentes e uma maneira controlada de aumentar o uso da CPU.
Veja essas notas de referência para um pouco mais de detalhes sobre os ambientes usados.
Primeiro, vamos ver alguns exemplos de baixa simultaneidade. A execução de 2.000 iterações com 300 solicitações simultâneas e apenas um hash por solicitação (N=1) nos dá o seguinte:
Os tempos são o número médio de milissegundos para concluir uma solicitação em todas as solicitações simultâneas. Menor é melhor.
É difícil tirar uma conclusão apenas deste gráfico, mas isso para mim parece que, neste volume de conexão e computação, estamos vendo tempos mais a ver com a execução geral das próprias linguagens, muito mais que o I/O. Note que as linguagens que são consideradas “linguagens de script” (digitação solta, interpretação dinâmica) têm o desempenho mais lento.
Mas o que acontece se aumentarmos N para 1000, ainda com 300 solicitações simultâneas - a mesma carga, mas 100 vezes mais iterações de hash (significativamente mais carga de CPU):
Os tempos são o número médio de milissegundos para concluir uma solicitação em todas as solicitações simultâneas. Menor é melhor.
De repente, o desempenho do nó cai significativamente, porque as operações intensivas da CPU em cada solicitação estão bloqueando umas às outras. E curiosamente, o desempenho do PHP fica muito melhor (em relação aos outros) e supera o Java neste teste. (Vale a pena notar que em PHP a implementação SHA-256 é escrita em C e o caminho de execução está gastando muito mais tempo nesse loop, já que estamos fazendo 1000 iterações de hash agora).
Agora vamos tentar 5.000 conexões simultâneas (com N = 1) - ou o mais próximo possível disso. Infelizmente, para a maioria desses ambientes, a taxa de falha não foi insignificante. Para este gráfico, veremos o número total de solicitações por segundo. Quanto mais alto melhor :
Número total de solicitações por segundo. Mais alto é melhor.
E a imagem parece bem diferente. É um palpite, mas parece que em alto volume de conexão, a sobrecarga por conexão envolvida com a geração de novos processos e a memória adicional associada a ele no PHP+Apache parece se tornar um fator dominante e prejudica o desempenho do PHP. Claramente, Go é o vencedor aqui, seguido por Java, Node e finalmente PHP.
Embora os fatores envolvidos com sua taxa de transferência geral sejam muitos e também variem amplamente de aplicativo para aplicativo, quanto mais você entender sobre o que está acontecendo nos bastidores e as compensações envolvidas, melhor você estará.
Com tudo o que foi dito acima, fica bastante claro que conforme as linguagens evoluíram, as soluções para lidar com aplicativos de grande escala que fazem muitas I/O também evoluíram.
Para ser justo, tanto o PHP quanto o Java, apesar das descrições neste artigo, têm implementações de I/O sem bloqueio disponíveis para uso em aplicativos da web . Mas isso não é tão comum quanto as abordagens descritas acima, e a sobrecarga operacional decorrente da manutenção de servidores usando essas abordagens precisaria ser levada em consideração. Sem falar que seu código deve ser estruturado de forma que funcione com tais ambientes; seu aplicativo da web PHP ou Java “normal” geralmente não será executado sem modificações significativas em tal ambiente.
Como comparação, se considerarmos alguns fatores significativos que afetam o desempenho e a facilidade de uso, obtemos o seguinte:
Os threads geralmente serão muito mais eficientes em termos de memória do que os processos, pois compartilham o mesmo espaço de memória, enquanto os processos não. Combinando isso com os fatores relacionados à I/O sem bloqueio, podemos ver que, pelo menos com os fatores considerados acima, à medida que descemos na lista, a configuração geral relacionada à I/O melhora. Então, se eu tivesse que escolher um vencedor no concurso acima, certamente seria o Go.
Ainda assim, na prática, a escolha de um ambiente para construir seu aplicativo está intimamente ligada à familiaridade que sua equipe tem com esse ambiente e à produtividade geral que você pode obter com ele. Portanto, pode não fazer sentido para todas as equipes apenas mergulhar e começar a desenvolver aplicativos e serviços da Web em Node ou Go. De fato, encontrar desenvolvedores ou a familiaridade de sua equipe interna é frequentemente citado como o principal motivo para não usar uma linguagem e/ou ambiente diferente. Dito isto, os tempos mudaram muito nos últimos quinze anos.
Espero que o que foi dito acima ajude a pintar uma imagem mais clara do que está acontecendo nos bastidores e lhe dê algumas ideias de como lidar com a escalabilidade do mundo real para seu aplicativo. Feliz entrada e saída!