Desempenho de E/S do lado do servidor: Node vs. PHP vs. Java vs. Go
Publicados: 2022-03-11Compreender o modelo de entrada/saída (E/S) do seu aplicativo pode significar a diferença entre um aplicativo que lida com a carga a que está sujeito e um que se desfaz diante de casos de uso do mundo real. Talvez, embora seu aplicativo seja pequeno e não atenda a altas cargas, isso possa importar muito menos. Mas, à medida que a carga de tráfego do seu aplicativo aumenta, trabalhar com o modelo de E/S errado pode levar você a um mundo de problemas.
E como a maioria das 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 vantagens e desvantagens. Vamos dar um passeio pela paisagem de E/S e ver o que podemos espionar.
Neste artigo, compararemos Node, Java, Go e PHP com Apache, discutindo como as diferentes linguagens modelam suas E/S, as vantagens e desvantagens de cada modelo e concluímos com alguns benchmarks rudimentares. Se você está preocupado com o desempenho de E/S de seu próximo aplicativo Web, este artigo é para você.
Noções básicas de E/S: uma atualização rápida
Para entender os fatores envolvidos com E/S, 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 do seu aplicativo o tempo todo. E os detalhes importam.
Chamadas do sistema
Em primeiro lugar, temos chamadas de sistema, que podem ser descritas da seguinte forma:
- Seu programa (em “terra do usuário”, como eles dizem) deve pedir ao kernel do sistema operacional para realizar uma operação de E/S em seu nome.
- Um “syscall” é o meio pelo qual seu programa pede ao kernel para fazer algo. As especificidades de como isso é implementado variam entre os sistemas operacionais, mas o conceito básico é o mesmo. 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 essa situação). De um modo geral, syscalls estão bloqueando, o que significa que seu programa espera que o kernel retorne ao seu código.
- O kernel executa a operação de E/S subjacente no dispositivo físico em questão (disco, placa de rede, etc.) e responde ao syscall. No mundo real, o kernel pode ter que fazer várias coisas para atender sua solicitação, incluindo esperar que o dispositivo esteja pronto, atualizar seu estado interno etc., mas como desenvolvedor de aplicativos, você não se importa com isso. Esse é o trabalho do kernel.
Chamadas bloqueantes vs. não bloqueantes
Agora, acabei de dizer acima que 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, a coloca na fila ou 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, o suficiente para enfileirar sua solicitação.
Alguns exemplos (de syscalls do Linux) podem ajudar a esclarecer: - read()
é uma chamada de bloqueio - você passa 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á. 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 você pode ver, é certamente mais complexo de usar.
É importante entender a ordem de magnitude da diferença de tempo aqui. Se um núcleo de CPU está rodando a 3GHz, 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 de 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 bloquear E/S (“ler a partir desta conexão de rede e me fornecer os dados”) e E/S sem bloqueio (“dizer-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 dramaticamente diferentes.
Agendamento
A terceira coisa que é fundamental seguir é o que acontece quando você tem muitos encadeamentos 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 relacionada ao desempenho mais notável é que, como as threads compartilham a mesma memória e os processos cada um tem seu próprio espaço de memória, fazer processos separados tende a ocupar muito mais memória. Mas quando estamos falando de agendamento, o que realmente se resume a uma lista de coisas (threads e processos) que cada um precisa 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, será necessário dividir o tempo para que cada um receba seu compartilhamento, com cada núcleo sendo executado por um curto período de tempo e depois passando para o próximo thread. Isso é feito por meio de um “interruptor de contexto”, fazendo com que a CPU passe da execução de um thread/processo para o próximo.
Essas trocas de contexto têm um custo associado a elas - elas levam algum tempo. Em alguns casos rápidos, pode ser inferior a 100 nanossegundos, mas não é incomum que demore 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 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 “só me ligue quando você tiver alguns novos dados ou eventos em uma dessas conexões”. Essas chamadas sem bloqueio são projetadas para lidar com eficiência 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 outros detalhes interessantes.
Como nota, enquanto os exemplos mostrados neste artigo são triviais (e parciais, com apenas os bits relevantes mostrados); acesso ao banco de dados, sistemas de cache externos (memcache, etc.) e qualquer coisa que exija E/S acabará executando 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 “bloqueando” (PHP, Java), as leituras e gravações de solicitação e resposta HTTP são chamadas de bloqueio: Mais uma vez, mais E/S oculta no sistema com seus problemas de desempenho de atendimento para levar em conta.
Existem muitos fatores que influenciam a escolha de uma linguagem de programação para um projeto. Existem ainda muitos fatores quando você considera apenas o desempenho. Mas, se você estiver preocupado que seu programa seja limitado principalmente por E/S, se o desempenho de E/S for bom ou ruim para o seu projeto, essas são coisas que você precisa saber.
A Abordagem “Mantenha Simples”: PHP
Nos anos 90, muitas pessoas usavam tênis Converse e escreviam scripts CGI em Perl. Então surgiu o PHP e, por mais que algumas pessoas gostem de falar mal dele, tornou muito mais fácil tornar as páginas dinâmicas da web.
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 vem 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 tem que fazer (criar processos é, relativamente falando, lento). O Apache chama o PHP e diz para ele executar o arquivo .php
apropriado no disco. O código PHP executa e bloqueia chamadas de E/S. Você chama file_get_contents()
em PHP e sob o capô ele faz syscalls read()
e espera pelos resultados.
E, claro, o código real é simplesmente incorporado à sua página e as operações estão bloqueando:
<?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 isso se integra ao sistema, é assim:
Bastante simples: um processo por solicitação. As chamadas de E/S apenas bloqueiam. Vantagem? É simples e funciona. Desvantagem? Acerte com 20.000 clientes simultaneamente e seu servidor explodirá em chamas. Essa abordagem não escala bem porque as ferramentas fornecidas pelo kernel para lidar com E/S de alto volume (epoll, etc.) não estão sendo usadas. E para piorar, executar um processo separado para cada solicitação tende a usar muitos recursos do sistema, especialmente memória, que geralmente é a primeira coisa que você fica sem em um cenário como esse.
Nota: A abordagem usada para Ruby é muito semelhante à do PHP e, de uma maneira ampla, geral e ondulada, elas podem ser consideradas as mesmas para nossos propósitos.
A abordagem multithread: Java
Então o Java aparece, bem na época em que você comprou seu primeiro nome de domínio e foi legal dizer aleatoriamente “ponto com” depois de uma frase. E Java tem multithreading embutido na linguagem, o que (especialmente para quando foi criado) é bem legal.
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 Java Servlet tende a ser algo como:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }
Como nosso método doGet
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. entre threads porque eles podem acessar a memória uns dos outros, mas o impacto em como ele interage com o agendamento ainda é quase idêntico ao que está sendo feito no PHP exemplo anteriormente. Cada solicitação obtém um novo encadeamento e as várias operações de E/S são bloqueadas dentro desse encadeamento 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 (e uma atualização significativa novamente na 1.7) ganhou a capacidade de fazer chamadas de E/S sem bloqueio. A maioria dos aplicativos, web e outros, não o usa, mas pelo menos está disponível. Alguns servidores web Java tentam tirar vantagem disso de várias maneiras; no entanto, a grande maioria dos aplicativos Java implementados ainda funciona conforme descrito acima.
Java nos aproxima e certamente tem algumas boas funcionalidades prontas para E/S, mas ainda não resolve o problema do que acontece quando você tem um aplicativo fortemente vinculado a E/S que está sendo pressionado o chão com muitos milhares de fios de bloqueio.
E/S sem bloqueio como cidadão de primeira classe: nó
O garoto popular no bloco quando se trata de melhor E/S é 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 E/S 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 alcançada 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 “escrever código aqui para começar a lidar com a solicitação”. Cada vez que você precisar fazer algo que envolva E/S, você faz a solicitação e fornece uma função de retorno de chamada que o Node chamará quando terminar.

O código de nó típico para fazer uma operação de E/S em uma solicitação é assim:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
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 a oportunidade de manipular eficientemente a E/S 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 incomodar 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 E/S 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 E/S 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á uma captura para este modelo. Sob o capô, a razão para isso tem muito mais a ver com a forma como o mecanismo JavaScript V8 (o mecanismo JS do Chrome usado pelo Node) é implementado 1 do que qualquer outra coisa. Todo o código JS que você escreve é executado em um único thread. Pense nisso por um momento. Isso significa que, enquanto a E/S é executada usando técnicas eficientes sem bloqueio, seu JS pode executar operações vinculadas à CPU em um único thread, cada pedaço de código bloqueando o próximo. Um exemplo comum de onde isso pode acontecer é 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:
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 manipule a E/S de forma eficiente, o loop for
no exemplo acima está usando ciclos de CPU dentro de seu único encadeamento principal. Isso significa que, se você tiver 10.000 conexões, esse loop poderá levar todo o aplicativo a um rastreamento, dependendo de quanto tempo levar. Cada solicitação deve compartilhar uma fatia de tempo, uma de cada vez, em seu encadeamento principal.
A premissa em que todo este conceito se baseia é que as operações de E/S são a parte mais lenta, portanto, é mais importante lidar com elas de forma eficiente, mesmo que isso signifique fazer outros processamentos serialmente. 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 Node.
Estamos de volta aos trade-offs. O modelo Node funciona bem se seu principal problema de desempenho for E/S. No entanto, seu calcanhar de Aquiles é que você pode entrar em uma função que está lidando com uma solicitação HTTP e inserir um código com uso intensivo de CPU e trazer todas as conexões para um rastreamento se não for cuidadoso.
Naturalmente sem bloqueio: Vá
Antes de entrar na seção de Go, é apropriado que eu divulgue que sou um fanboy de Go. Já o usei para muitos projetos e sou abertamente um defensor de suas vantagens de produtividade, e as vejo em meu trabalho quando o uso.
Dito isso, vamos ver como ele lida com E/S. 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 SO, funciona com o conceito de “goroutines”. E o tempo de execução Go pode atribuir uma goroutine a um thread do sistema operacional e executá-la, ou suspendê-la e não ser associada a um thread do sistema operacional, com base no que essa goroutine está fazendo. Cada solicitação que vem do servidor HTTP do Go é tratada em uma Goroutine separada.
O diagrama de como o agendador funciona é assim:
Sob o capô, isso é implementado por vários pontos no tempo de execução do Go que implementam a chamada de E/S fazendo a solicitação para gravar/ler/conectar/etc., colocar a goroutine atual para dormir, com as informações para acordar a goroutine de volta quando mais 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 está embutido na implementação da chamada de E/S e interage com o agendador automaticamente. Ele também não sofre com a restrição de ter que ter todo o código do seu manipulador rodando no mesmo thread, Go irá mapear automaticamente seus Goroutines para quantos threads do SO julgar apropriado com base na lógica em seu agendador. O resultado é um código assim:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, 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 de código do que estamos fazendo se assemelha à das abordagens mais simplistas e, ainda assim, alcança E/S sem bloqueio sob o capô.
Na maioria dos casos, isso acaba sendo “o melhor dos dois mundos”. A E/S 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 trata do resto. Não é uma mágica completa, e se você construir um sistema grande, vale a pena dedicar um tempo para entender mais detalhes sobre 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 um modo geral, a maneira como lida com E/S não está entre elas.
Mentiras, Mentiras Malditas e Referências
É difícil dar tempos exatos sobre a mudança de contexto envolvida com esses vários modelos. Eu também poderia argumentar que é menos útil para você. Então, em vez disso, fornecerei alguns benchmarks básicos que comparam o desempenho geral do servidor HTTP desses ambientes de servidor. Tenha em mente 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 número de 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 alguma E/S consistente e uma maneira controlada de aumentar o uso da CPU.
Veja estas notas de benchmark 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á isso:
É difícil tirar uma conclusão apenas deste gráfico, mas isso me parece que, neste volume de conexão e computação, estamos vendo vezes mais a ver com a execução geral das próprias linguagens, muito mais que o E/S. Observe que as linguagens consideradas “linguagens de script” (digitação livre, interpretação dinâmica) apresentam o desempenho mais lento.
Mas o que acontece se aumentarmos N para 1000, ainda com 300 solicitações simultâneas - a mesma carga, mas 100x mais iterações de hash (significativamente mais carga da CPU):
De repente, o desempenho do Node cai significativamente, porque as operações de uso intensivo 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 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 falha não foi insignificante. Para este gráfico, veremos o número total de solicitações por segundo. Quanto 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 na 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 muito de aplicativo para aplicativo, quanto mais você entender sobre o que está acontecendo nos bastidores e as compensações envolvidas, melhor será.
Em suma
Com todos os itens acima, fica bem claro que, à medida que as linguagens evoluíram, as soluções para lidar com aplicativos de grande escala que fazem muita E/S evoluíram com ela.
Para ser justo, tanto o PHP quanto o Java, apesar das descrições neste artigo, possuem implementações de E/S sem bloqueio disponíveis para uso em aplicativos da web. Mas elas não são tão comuns quanto as abordagens descritas acima, e a sobrecarga operacional associada à manutenção de servidores usando essas abordagens precisaria ser levada em consideração. Sem contar que seu código deve ser estruturado de forma que funcione com tais ambientes; seu aplicativo 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:
Linguagem | Threads vs. Processos | E/S sem bloqueio | Fácil de usar |
---|---|---|---|
PHP | Processos | Não | |
Java | Tópicos | Disponível | Requer retornos de chamada |
Node.js | Tópicos | sim | Requer retornos de chamada |
Ir | Tópicos (Goroutines) | sim | Não são necessários retornos de chamada |
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 a E/S sem bloqueio, podemos ver que, pelo menos com os fatores considerados acima, à medida que avançamos na lista, a configuração geral relacionada a E/S melhora. Então, se eu tivesse que escolher um vencedor no concurso acima, certamente seria o Go.
Mesmo assim, na prática, a escolha de um ambiente no qual 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 nos últimos quinze anos ou mais, muito.
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. Boas entradas e saídas!