Desempenho de E/S do lado do servidor: Nó vs. PHP vs. Java vs. Go

Out 22, 2021
admin

A compreensão do modelo de Entrada/Saída (E/S) da sua aplicação pode significar a diferença entre uma aplicação que lida com a carga a que está sujeita, e uma que se desmorona face a casos de utilização no mundo real. Talvez, embora a sua aplicação seja pequena e não sirva para cargas elevadas, pode importar muito menos. Mas como a carga de tráfego da sua aplicação aumenta, trabalhar com o modelo de I/O errado pode levá-lo a um mundo de ferimentos.

E como a maioria das situações em que são possíveis múltiplas abordagens, não é apenas uma questão de qual é melhor, é uma questão de compreender os tradeoffs. Vamos dar um passeio pela paisagem I/O e ver o que podemos espiar.

Neste artigo, vamos comparar Node, Java, Go, e PHP com Apache, discutindo como as diferentes linguagens modelam suas I/O, as vantagens e desvantagens de cada modelo, e concluir com alguns benchmarks rudimentares. Se você está preocupado com a performance de I/O da sua próxima aplicação web, este artigo é para você.

I/O Basics: A Quick Refresher

Para entender os fatores envolvidos com I/O, devemos primeiro rever os conceitos no nível do sistema operacional. Embora seja improvável que tenha que lidar com muitos desses conceitos diretamente, você lida com eles indiretamente através do ambiente de tempo de execução da sua aplicação o tempo todo. E os detalhes importam.

Chamadas de Sistema

Primeiro, temos chamadas de sistema, que podem ser descritas da seguinte forma:

  • Seu programa (em “user land”, como dizem) deve pedir ao kernel do sistema operacional para executar uma operação de E/S em seu nome.
  • Uma “syscall” é o meio pelo qual seu programa pede ao kernel para fazer algo. As especificidades de como isto é implementado variam entre os SOs, mas o conceito básico é o mesmo. Vai haver alguma instrução específica que transfere o controle do seu programa para o kernel (como uma chamada de função mas com algum molho especial especificamente para lidar com esta situação). Geralmente falando, os syscalls estão bloqueando, significando que seu programa espera que o kernel retorne ao seu código.
  • O kernel executa a operação I/O subjacente no dispositivo físico em questão (disco, placa de rede, etc.) e responde à chamada ao syscall. No mundo real, o kernel pode ter que fazer uma série de coisas para atender seu pedido, incluindo esperar que o dispositivo esteja pronto, atualizar seu estado interno, etc., mas como desenvolvedor de aplicações, você não se importa com isso. Esse é o trabalho do kernel.

Bloqueio vs. Chamadas Não Bloqueio

Agora, eu acabei de dizer acima que os syscalls estão bloqueando, e isso é verdade em um sentido geral. Entretanto, algumas chamadas são categorizadas como “non-blocking”, o que significa que o kernel leva o seu pedido, coloca-o em fila ou buffer em algum lugar, e então retorna imediatamente sem esperar que a E/S real ocorra. Então ele “bloqueia” apenas por um período de tempo muito curto, apenas o suficiente para perguntar seu pedido.

Alguns exemplos (de syscalls do Linux) podem ajudar a esclarecer:- read() é uma chamada de bloqueio – você passa-lhe um handle dizendo qual arquivo e um buffer de onde entregar os dados que ele lê, e a chamada retorna quando os dados estão lá. Note que isto tem a vantagem de ser agradável e simples. – epoll_create(), epoll_ctl() e epoll_wait() são chamadas que, respectivamente, permitem-lhe criar um grupo de handles para ouvir, adicionar/remover manipuladores desse grupo e depois bloquear até que haja qualquer actividade. Isto permite que você controle eficientemente um grande número de operações de E/S com um único tópico, mas eu estou ficando à frente de mim mesmo. Isto é ótimo se você precisa da funcionalidade, mas como você pode ver é certamente mais complexo de usar.

É importante entender a ordem de magnitude da diferença de tempo aqui. Se um núcleo da CPU está rodando em 3GHz, sem entrar em otimizações que a CPU pode fazer, ela está executando 3 bilhões de ciclos por segundo (ou 3 ciclos por nanossegundo). Uma chamada de sistema sem bloqueio pode assumir a ordem de 10s de ciclos para completar – ou “um número relativamente pequeno de nanossegundos”. Uma chamada que bloqueia a informação recebida através da rede pode demorar muito mais tempo – digamos, por exemplo, 200 milissegundos (1/5 de segundo). E digamos, por exemplo, que a chamada sem bloqueio demorou 20 nanossegundos, e a chamada com bloqueio demorou 200.000.000 nanossegundos. Seu processo apenas esperou 10 milhões de vezes mais pela chamada de bloqueio.

O kernel fornece os meios para fazer tanto I/O de bloqueio (“leia desta conexão de rede e me dê os dados”) quanto I/O sem bloqueio (“me diga quando qualquer uma destas conexões de rede tem novos dados”). E qual mecanismo é usado irá bloquear o processo de chamada por períodos de tempo dramaticamente diferentes.

Pagamento

A terceira coisa que é crítica a 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, uma vez que os threads compartilham a mesma memória, e cada processo tem seu próprio espaço de memória, fazendo com que processos separados tendam a ocupar muito mais memória. Mas quando estamos falando de programação, o que realmente se resume a uma lista de coisas (threads e processos da mesma forma) que cada um precisa obter uma fatia do tempo de execução nos núcleos de CPU disponíveis. Se você tem 300 threads rodando e 8 núcleos para executá-los, você tem que dividir o tempo para que cada um receba sua parte, com cada núcleo rodando por um curto período de tempo e depois passar para a próxima thread. Isto é feito através de uma “mudança de contexto”, fazendo com que a CPU passe de rodar uma thread/processo para a próxima.

Estas mudanças de contexto têm um custo associado a elas – elas levam algum tempo. Em alguns casos rápidos, pode levar menos de 100 nanossegundos, mas não é raro que leve 1000 nanossegundos ou mais, dependendo dos detalhes de implementação, velocidade/arquitetura do processador, cache da CPU, etc.

E quanto mais threads (ou processos), mais mudança de contexto. Quando estamos falando de milhares de threads, e centenas de nanossegundos para cada um, as coisas podem ficar muito lentas.

No entanto, chamadas sem bloqueio em essência dizem ao kernel “só me ligue quando você tiver algum dado novo ou evento em alguma dessas conexões”. Essas chamadas não-bloqueio são projetadas para lidar eficientemente com grandes cargas de E/S e reduzir a mudança de contexto.

Comigo até agora? Porque agora vem a parte divertida: Vamos ver o que algumas linguagens populares fazem com estas ferramentas e tirar algumas conclusões sobre os tradeoffs entre facilidade de uso e desempenho… e outros tidbits interessantes.

Como nota, enquanto os exemplos mostrados neste artigo são triviais (e parciais, com apenas os bits relevantes mostrados); acesso a banco de dados, sistemas de cache externos (memcache, et. all) e qualquer coisa que requer I/O vai acabar executando algum tipo de chamada I/O sob o capô que terá o mesmo efeito que os exemplos simples mostrados. Também, para os cenários onde a E/S é descrita como “bloqueio” (PHP, Java), a requisição HTTP e as respostas lidas e escritas são elas próprias chamadas de bloqueio: Mais uma vez, mais I/O escondidas no sistema com seus problemas de performance para levar em conta.

Existem muitos fatores que vão na escolha de uma linguagem de programação para um projeto. Há até mesmo muitos fatores quando você só considera o desempenho. Mas, se você está preocupado que seu programa será limitado principalmente por E/S, se o desempenho de E/S é feito ou quebrado para o seu projeto, estas são coisas que você precisa saber.

A Abordagem “Keep It Simple”: PHP

Voltar nos anos 90, muitas pessoas estavam usando sapatos Converse e escrevendo scripts CGI em Perl. Depois veio o PHP e, por mais que algumas pessoas gostem de fazer rag nele, ele tornou as páginas dinâmicas muito mais fáceis.

O modelo que o PHP usa é bastante simples. Há algumas variações nele, mas seu servidor PHP médio parece:

Um pedido HTTP vem do navegador de um usuário e chega ao seu servidor web Apache. O Apache cria um processo separado para cada requisição, com algumas otimizações para reutilizá-las a fim de minimizar quantos processos ele tem que fazer (criar processos é, relativamente falando, lento). O Apache chama o PHP e diz a ele para executar o arquivo .php apropriado no disco. Você chama file_get_contents() em PHP e sob o hood ele faz read() syscalls e espera pelos resultados.

E claro que o código real é simplesmente embutido na sua página, e as operações são bloqueadas:

<?php// blocking file I/O$file_data = file_get_contents('/path/to/file.dat');// blocking network I/O$curl = curl_init('http://example.com/example-microservice');$result = curl_exec($curl);// some more blocking network I/O$result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100');?>

Em termos de como isto se integra com o sistema, é assim:

>

>Pretty simples: um processo por pedido. Chamadas de E/S apenas bloqueiam. Vantagem? É simples e funciona. Desvantagem? Acerte-o com 20.000 clientes ao mesmo tempo e o seu servidor irá explodir em chamas. Esta abordagem não é bem escalada porque as ferramentas fornecidas pelo kernel para lidar com I/O de alto volume (epoll, etc.) não estão sendo usadas. E para adicionar insulto a ferimentos, correr um processo separado para cada requisição tende a usar muitos recursos do sistema, especialmente memória, que é muitas vezes a primeira coisa de que se fica sem num cenário como este.

Note: A abordagem usada para Ruby é muito semelhante à do PHP, e de uma forma ampla, geral, mão-agulha podem ser considerados iguais para os nossos propósitos.

A Abordagem Multithreaded: Java

Então o Java aparece, na altura em que comprou o seu primeiro nome de domínio e era fixe dizer aleatoriamente “dot com” depois de uma frase. E o Java tem multithreading incorporado na linguagem, que (especialmente para quando foi criada) é bastante impressionante.

Muitos servidores web Java funcionam iniciando uma nova thread de execução para cada requisição que chega e então nesta thread eventualmente chamando a função que você, como desenvolvedor da aplicação, escreveu.

Fazer E/S num Servlet Java tende a parecer algo como:

public void doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException, IOException{// blocking file I/OInputStream fileIs = new FileInputStream("/path/to/file");// blocking network I/OURLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection();InputStream netIs = urlConnection.getInputStream();// some more blocking network I/Oout.println("...");}

Desde que o nosso método doGet acima corresponde a uma requisição e é executado na sua própria thread, em vez de um processo separado para cada requisição que requer a sua própria memória, nós temos uma thread separada. Isto tem algumas vantagens, como poder compartilhar estado, dados em cache, etc. entre threads porque elas podem acessar a memória uma da outra, mas o impacto em como ela interage com a agenda ainda é quase idêntico ao que está sendo feito no exemplo anterior do PHP. Cada requisição recebe uma nova thread e as várias operações de I/O bloqueiam dentro dessa thread até que a requisição seja totalmente tratada. As threads são agrupadas para minimizar o custo de criação e destruição das mesmas, 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 Java (e uma atualização significativa novamente na 1.7) ganhou a capacidade de fazer chamadas de E/S sem bloqueio. A maioria das aplicações, web e outras, não o utilizam, mas pelo menos está disponível. Alguns servidores web Java tentam tirar proveito disso de várias maneiras; no entanto, a grande maioria das aplicações Java implementadas ainda funcionam como descrito acima.

Java nos aproxima e certamente tem alguma boa funcionalidade out-of-the-box para I/O, mas ainda não resolve realmente o problema do que acontece quando você tem uma aplicação fortemente I/O vinculada que está sendo esmagada no chão com muitos milhares de linhas de bloqueio.

Non-blocking I/O como um cidadão de primeira classe: Nó

O miúdo popular no bloco quando se trata de melhores E/S é o Nó.js. A qualquer pessoa que tenha tido a mais breve introdução ao Node, foi dito que ele é “non-blocking” e que lida com E/S de forma eficiente. E isto é verdade em um sentido geral. Mas o diabo está nos detalhes e os meios pelos quais esta bruxaria foi alcançada importam quando se trata de performance.

Essencialmente a mudança de paradigma que o Node implementa é que ao invés de essencialmente dizer “escreva seu código aqui para lidar com o pedido”, eles dizem “escreva código aqui para começar a lidar com o pedido”. 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 Nó chamará quando estiver feito.

Código do Nó Típico para fazer uma operação de I/O em uma requisição vai assim:

http.createServer(function(request, response) {fs.readFile('/path/to/file', 'utf8', function(err, data) {response.end(data);});});

Como você pode ver, há duas funções de callback aqui. A primeira é chamada quando uma solicitação começa, e a segunda é chamada quando os dados do arquivo estão disponíveis.

O que isto faz é basicamente dar ao Nó uma oportunidade de lidar eficientemente com a E/S entre estas chamadas de retorno. Um cenário onde seria ainda mais relevante é onde você está fazendo uma chamada de banco de dados no Nó, mas eu não vou me preocupar com o exemplo porque é exatamente o mesmo princípio: você inicia a chamada de banco de dados, e dá ao Nó uma função de callback, ele executa as operações de I/O separadamente usando chamadas sem bloqueio e então invoca a sua função de callback quando os dados que você pediu estão disponíveis. Este mecanismo de enfileirar chamadas de E/S e deixar o Nó tratar disso e, em seguida, obter uma chamada de retorno é chamado de “Loop de Evento”. E funciona muito bem.

Existe no entanto um senão para este modelo. Por baixo do capô, o motivo tem muito mais a ver com a forma como o motor JavaScript do V8 (motor JS do Chrome que é usado pelo Node) é implementado 1 do que qualquer outra coisa. O código JS que você escreve tudo é executado em um único tópico. Pense sobre isso por um momento. Isso significa que enquanto E/S é executado usando técnicas eficientes de não bloqueio, o seu JS pode que está fazendo operações ligadas à CPU é executado em uma única thread, cada pedaço de código bloqueando a próxima. Um exemplo comum de onde isso pode surgir é o looping sobre os registros do banco de dados para processá-los de alguma forma antes de enviá-los para o cliente. Aqui está um exemplo que mostra como isso funciona:

var handler = function(request, response) {connection.query('SELECT ...', function (err, rows) {if (err) { throw err };for (var i = 0; i < rows.length; i++) {// do processing on each row}response.end(...); // write out the results})};

Embora o Node lide com a E/S eficientemente, aquele for loop no exemplo acima está usando ciclos de CPU dentro de sua única e principal thread. Isso significa que se você tiver 10.000 conexões, esse loop pode trazer toda a sua aplicação para uma rastejada, dependendo de quanto tempo ela leva. Cada pedido deve compartilhar uma fatia de tempo, um de cada vez, em sua thread principal.

A premissa em que todo esse conceito se baseia é que as operações de E/S são a parte mais lenta, portanto é mais importante lidar com elas eficientemente, mesmo que isso signifique fazer outro processamento em série. Isto é verdade em alguns casos, mas não em todos.

O outro ponto é que, e embora isto seja apenas uma opinião, pode ser bastante cansativo escrever um monte de callbacks aninhados e alguns argumentam que isto torna o código significativamente mais difícil de seguir. Não é incomum ver callbacks aninhados quatro, cinco ou até mais níveis dentro do código do Nodo.

Estamos de volta aos trade-offs. O modelo de Nodo funciona bem se o seu principal problema de desempenho for I/O. Entretanto, seu calcanhar de aquiles é que você pode entrar em uma função que está manipulando uma requisição HTTP e colocar em código intensivo de CPU e trazer cada conexão para um crawl se você não tiver cuidado.

Naturally Nonblocking: Go

Antes de entrar na secção para Go, é apropriado para mim revelar que sou um fanboy Go. Já o usei para muitos projetos e sou abertamente um defensor de suas vantagens de produtividade, e as vejo no meu trabalho quando o uso.

Posto isso, vamos ver como ele lida com I/O. Uma característica chave da linguagem Go é que ela contém seu próprio agendador. Ao invés de cada thread de execução corresponder a um único thread de SO, ele funciona com o conceito de “goroutines”. E o Go runtime pode atribuir um goroutine a um tópico OS e fazer com que ele execute, ou suspendê-lo e não ser associado a um tópico OS, com base no que esse goroutine está fazendo. Cada requisição que vem do servidor HTTP do Go é tratada em um Goroutine separado.

O diagrama de como o agendador funciona se parece com isto:

>

Até baixo do hood, isto é implementado por vários pontos no Go runtime que implementam a chamada I/O fazendo a requisição para escrever/ler/conectar/etc.., colocar o goroutine atual para dormir, com a informação para acordar o goroutine de volta quando mais ações podem ser tomadas.

Em efeito, o Go runtime está fazendo algo não muito diferente do que o Node está fazendo, exceto que o mecanismo de retorno é construído na implementação da chamada 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 manipulador executado na mesma thread, Go irá automaticamente mapear seus Goroutines para quantas threads de SO ele julgar apropriado baseado na lógica em seu agendador. O resultado é um código como este:

func ServeHTTP(w http.ResponseWriter, r *http.Request) {// the underlying network call here is non-blockingrows, err := db.Query("SELECT ...")for _, row := range rows {// do something with the rows,// each request in its own goroutine}w.Write(...) // write the response, also non-blocking}

Como você pode ver acima, a estrutura básica do código do que estamos fazendo se assemelha à das abordagens mais simplistas, e ainda alcança I/O sem bloqueio sob o capô.

Na maioria dos casos, isto acaba sendo “o melhor dos dois mundos”. I/O não-bloqueio é usado para todas as coisas importantes, mas seu código parece estar bloqueando e assim tende a ser mais simples de entender e manter. A interação entre o agendador de Go e o agendador de SO trata do resto. Não é magia completa, e se você construir um sistema grande, vale a pena dedicar tempo para entender mais detalhes sobre como ele funciona; mas ao mesmo tempo, o ambiente que você obtém “out-of-the-box” funciona e escala bastante bem.

Go pode ter suas falhas, mas geralmente falando, a forma como ele lida com I/O não está entre eles.

Mentiras, Mentiras Malditas e Benchmarks

É difícil dar timings exatos sobre a mudança de contexto envolvido com estes vários modelos. Eu também poderia argumentar que isso é menos útil para você. Então, ao invés disso, vou dar alguns benchmarks básicos que comparam a performance geral do servidor HTTP destes ambientes de servidor. Tenha em mente que muitos fatores estão envolvidos na performance de todo o caminho de requisição/resposta HTTP ponta a ponta, e os números apresentados aqui são apenas alguns exemplos que eu juntei para dar uma comparação básica.

Para cada um desses ambientes, eu escrevi o código apropriado para ler em um arquivo de 64k com bytes aleatórios, executei um hash SHA-256 nele N número de vezes (N sendo especificado na string de consulta da URL, por exemplo, .../test.php?n=100) e imprimo 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 benchmark para um pouco mais de detalhe sobre os ambientes utilizados.

Primeiro, vamos olhar para alguns exemplos de baixa concorrência. Rodando 2000 iterações com 300 solicitações simultâneas e apenas um hash por solicitação (N=1) nos dá isto:

>

Tempos são o número médio de milissegundos para completar uma solicitação em todas as solicitações simultâneas. Lower is better.

É difícil tirar uma conclusão a partir deste gráfico, mas isto parece-me que, neste volume de conexão e computação, estamos vendo tempos que têm mais a ver com a execução geral das próprias linguagens, muito mais para que as I/O. Note que as linguagens que são consideradas “linguagens de script” (digitação solta, interpretação dinâmica) executam as mais lentas.

Mas o que acontece se aumentarmos N para 1000, ainda com 300 requisições simultâneas – a mesma carga mas 100x mais iterações de hash (significativamente mais carga de CPU):

Times são o número médio de milissegundos para completar uma requisição em todas as requisições simultâneas. Mais baixo é 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 bate o Java neste teste. (Vale notar que no PHP a implementação SHA-256 é escrita em C e o caminho de execução está passando muito mais tempo nesse loop, já que estamos fazendo 1000 iterações de hash agora).

Agora vamos tentar 5000 conexões simultâneas (com N=1) – ou o mais próximo disso que eu poderia chegar. Infelizmente, para a maioria desses ambientes, a taxa de falhas não foi insignificante. Para este gráfico, vamos olhar para o número total de pedidos por segundo. Quanto maior, melhor:

Número total de pedidos por segundo. Quanto maior, 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 desova de novos processos e a memória adicional associada a ela em PHP+Apache parece se tornar um fator dominante e aquece o desempenho do PHP. Claramente, Go é o vencedor aqui, seguido por Java, Node e finalmente PHP.

Embora os fatores envolvidos com o seu rendimento geral sejam muitos e também variem muito de aplicação para aplicação, quanto mais você entender sobre as entranhas do que está acontecendo sob o capô e os tradeoffs envolvidos, melhor será a sua situação.

Em resumo

Com tudo o que foi dito acima, é bastante claro que à medida que as linguagens evoluíram, as soluções para lidar com aplicações de larga escala que fazem muitas I/O evoluíram com ele.

Para ser justo, tanto PHP como Java, apesar das descrições neste artigo, têm implementações de I/O não-bloqueio disponíveis para uso em aplicações web. Mas estas não são tão comuns como as abordagens descritas acima, e a sobrecarga operacional decorrente da manutenção de servidores usando tais abordagens precisaria ser levada em conta. Sem mencionar que seu código deve ser estruturado de forma a funcionar com tais ambientes; sua aplicação web PHP ou Java “normal” normalmente não será executada sem modificações significativas em tal ambiente.

Como comparação, se considerarmos alguns fatores significativos que afetam o desempenho, bem como a facilidade de uso, obtemos isto:

>

Língua Temas vs. Processos Não-E/S de bloqueio Facilidade de uso
PHP Processos Não
Java Temas Disponível Requer Callbacks
Nó.js Temas Sim Requer callbacks
Vai Temas (Goroutines) Sim Não são necessárias chamadas de retorno

Tópicos geralmente serão muito mais eficientes na memória do que os processos, uma vez que partilham o mesmo espaço de memória enquanto os processos não o fazem. Combinando isso com os fatores relacionados à E/S sem bloqueio, podemos ver que pelo menos com os fatores considerados acima, à medida que descemos na lista, a configuração geral à medida que ela se relaciona com E/S melhora. Portanto, se eu tivesse que escolher um vencedor no concurso acima, certamente seria Go.

Even, assim, na prática, escolher um ambiente no qual construir sua aplicação está intimamente ligado à familiaridade de sua equipe com esse ambiente, e à produtividade geral que você pode alcançar com ele. Portanto, pode não fazer sentido para toda equipe apenas mergulhar e começar a desenvolver aplicações e serviços web no Node ou Go. De fato, encontrar desenvolvedores ou a familiaridade da sua equipe interna é frequentemente citada como a principal razão para não usar uma linguagem e/ou ambiente diferente. Dito isto, os tempos mudaram ao longo dos últimos quinze anos mais ou menos, muito.

E esperamos que o acima descrito ajude a pintar uma imagem mais clara do que está acontecendo sob o capô e lhe dê algumas idéias de como lidar com a escalabilidade do mundo real para a sua aplicação. Feliz entrada e saída!

Deixe uma resposta

O seu endereço de email não será publicado.