Introdução à programação assíncrona em PHP usando o ReactPHP

Antes de entrarmos no comparativo do modelo síncrono versus assíncrono, veremos uma introdução, o essencial, sobre como uma requisição funciona em uma aplicação PHP tradicional.

A maior parte das aplicações escritas em PHP funcionam no clássico modelo de requisição e resposta de curto tempo de vida. Uma requisição é feita, o código é interpretado e depois compilado, a execução é realizada, dados são retornados e tudo é descarregado da memória na sequência, tudo acontece de forma isolada, sem compartilhar contexto. De forma simplificada, esse é o ciclo de vida da execução de um script no PHP a cada nova requisição feita.

Você pode estar imaginando que isso é muito custoso, ter sempre que passar pela interpretação e compilação a cada nova requisição. Você está certo. Mas o PHP implementa mecanismos que otimizam esse processo, para não ter que interpretar e compilar o código o tempo todo. O PHP interpreta os códigos e os compila (de forma implícita, ou seja, quando ele julga necessário) para bytecodes (uma versão intermediária de código) e coloca isso em memória compartilhada quando ele percebe que aquela parte é muito requisitada/utilizada (tarefa da extensão nativa OPCache). Além disso, o PHP implementa outros mecanismos (de mais baixo nível) de otimização da execução desse código intermediário.

De qualquer forma, mesmo com os mecanismos de otimização, a essência do modelo de requisição e resposta se mantém a mesma. O diagrama abaixo exemplifica como funciona esse ciclo de execução:

Esse diagrama encurtou propositalmente uma etapa, a que passa pelo PHP-FPM, um gerenciador de processos, muito usado junto ao Nginx (servidor web), pois poderíamos ter um artigo só para falar sobre ele. A ideia aqui é entender o básico de como a requisição passa pelo servidor web e depois é retornada para o cliente. O PHP-FPM dispõe de pools de processos, ele cria, controla e encerra, de acordo com a demanda (o que o Nginx está encaminhando pra ele) e capacidade do hardware para tal (memória, principalmente).

Modelo síncrono

Num ambiente síncrono (tradicional) as instruções (partes) do programa são executadas uma por uma e apenas uma por vez, de forma sequencial:

<?php

echo "Hello ";

sleep(4); // Espera 4 segundos

echo "World";

Esse script vai demorar 4 segundos para ser executado e finalizado. A execução acontece linha por linha, de forma bloqueante. Se uma instrução precisa aguardar algum tempo (seja para ler algo do disco ou fazer alguma operação na rede), isso terá de ser concluído para que a próxima instrução seja executada e até mesmo para que ela use os dados previamente recuperados/preparados. Ou seja, parte-se da premissa de que a instrução anterior precisa ter sido concluída com sucesso (sem erros) para que uma nova seja executada (dependência).

Esse modelo funciona muito bem pra operações que usam mais CPU que I/O, pois a resolução de uma operação na CPU é muito mais eficiente do que uma operação que envolva I/O (uma requisição na rede, a leitura de um arquivo, esse tipo de operação é de alta latência).

Só que pense no seguinte problema: você decidiu implementar uma tarefa que precisa verificar se os links de um site estão todos online. No modelo síncrono, teríamos que partir da primeira requisição, aguardar o resultado dela (momento de ociosidade do programa) e então partir para próxima seguindo o mesmo fluxo até a última (sempre de forma sequencial e uma só iniciando após a finalização da outra).

Mas, não seria melhor ao invés de esperarmos a primeira requisição ser finalizada já inicializarmos as outras requisições e depois de um tempo voltar para pegar os resultados produzidos por elas? Pois bem, essa é a ideia central do modelo assíncrono, ele minimiza a ociosidade do programa alternando entre as tarefas. Num código assíncrono as tarefas são intercaladas sem precisar envolver novas threads, ou seja, de forma single-thread (como funciona o PHP e NodeJS, por exemplo).

Modelo assíncrono

Um código assíncrono lida com dependências e ordem de execução de eventos, ou seja, lida basicamente com tempo. É comum associar assincronismo com paralelismo, pois o assincronismo dá essa sensação que muita coisa está sendo executada no mesmo instante de tempo, no entanto, ao invés disso, no assincronismo muita coisa é feita ao mesmo tempo (concorrentemente) só que uma coisa por vez, nunca no mesmo instante de tempo (o fluxo de execução alterna entre as tarefas). Não existe paralelismo num código assíncrono, ou seja, um código assíncrono não tem suas tarefas distribuídas em múltiplas unidades de processamento, igual comentamos anteriormente, é single-thread (apesar de ser possível atingir paralelismo com assincronismo num ambiente multi-thread, mas foge do escopo do nosso artigo e normalmente necessita de algum caso de uso bem específico, devido às dificuldades técnicas de se implementar e sincronizar a comunicação).

Um código assíncrono continua executando uma tarefa por vez, ele apenas não fica preso em ociosidade enquanto uma tarefa ainda está aguardando algum resultado de I/O, por exemplo. Ao invés de ficar “bloqueado” aguardando, ele alterna de tarefa, inicia outros trabalhos e volta nas outras tarefas em um tempo futuro quando elas estiverem prontas. Fazendo uma analogia, vamos supor que você tem uma tarefa que precisa fazer duas requisições na internet, o seu código assíncrono vai lidar dessa forma:

“Faça essa primeira requisição, mas não vou ficar aqui esperando o resultado, me avise quando tudo estiver pronto. Enquanto isso, deixa eu executar a segunda requisição aqui.”

Enquanto no código síncrono seria:

“Faça essa primeira requisição. Eu terei que ficar esperando essa resposta, pois necessito dela para continuar o meu fluxo de trabalho. [ … algum tempo depois …] Obrigado pela resposta, agora, por gentileza, execute essa segunda requisição? Ficarei aqui aguardando o resultado dela. [… algum tempo depois …] Obrigado pela resposta. Agora posso concluir meu trabalho.”

Se você desenvolve um código síncrono para resolver uma operação matemática e porta esse código para um modelo assíncrono, você vai notar que ambos serão executados praticamente no mesmo tempo, sem nenhum levar vantagem sobre o outro. Agora, a história muda completamente se o seu problema precisa realizar alguma operação I/O (que naturalmente é bloqueante) ou quando ele precisa aguardar algum tempo por alguma coisa, nesse tipo de caso, o modelo assíncrono leva muita vantagem, como mostra esse diagrama:

Veja que nesse diagrama as tarefas alternam entre si, o modelo assíncrono tenta sempre evitar ociosidade/espera/bloqueio. Ele só fica bloqueado/aguardando quando nenhuma tarefa pode fazer nenhum progresso, aí ele precisa receber alguma chamada para voltar à sua operação.

A abordagem assíncrona não é a solução para todos os problemas, mas em comparação com o modelo síncrono, ela performa melhor principalmente nos seguintes cenários:

  • Quando o programa contém tarefas que fazem uso intensivo de I/O;
  • Quando o programa contém tarefas independentes, ou seja, quando umas não precisam esperar pelas outras para realizar seus trabalhos (e essas passam por algum estado de progresso em suas atividades).

Quando não faz tanto sentido:

  • Uma aplicação que faz um uso intensivo da CPU em que as operações são dependentes, ou seja, uma precisa ser finalizada para que a outra entre em cena;
  • Uma aplicação que realiza grandes operações de I/O mas que o uso da aplicação em si é infrequente e não há necessidade de escalar;

Levando para exemplos do “mundo real” você vai ver com frequência o uso de programação assíncrona para:

  • Uma API em que o usuário faz uma requisição e precisa de uma resposta rápida sem que precise esperar alguma operação ser finalizada (essa operação pode continuar rodando lá no servidor enquanto o usuário já obteve a resposta dele). Nesse sentido, a interface do usuário não fica congelada esperando uma resposta de uma operação que ele não precisa esperar por ela.
  • Data Streaming (dá pra construir, por exemplo, até um servidor de streaming de vídeo);
  • Aplicação de monitoramento;
  • Criação de chats;
  • Etc;

Apesar de termos dado exemplos clássicos aqui, é perfeitamente possível integrar código assíncrono numa aplicação tradicional (de abordagem síncrona) se você perceber que em determinado momento requisições externas precisam ser feitas ou alguma operação importante que envolva I/O, você pode estudar a possibilidade de implementar um código assíncrono nessa parte para obter o benefício da não ociosidade e melhorar o tempo de resposta do seu usuário. Não existem “regras estritas” aqui, você vai precisar avaliar caso a caso e decidir o que achar melhor. Mas, certamente, é mais comum ver scripts que rodam em linha de comando (CLI) utilizarem a abordagem assíncrona.

PHP Assíncrono

O PHP não dispõe (mas há a intenção de se implementar isso em algum momento da versão 8 do PHP) de mecanismos nativos para lidar com código assíncrono, diferente do JavaScript e C#, por exemplo. Por isso bibliotecas como Amp e ReactPHP se tornaram relevantes, pois elas abstraem isso. Nesse artigo introdutório, usaremos os componentes do ReactPHP.

O ReactPHP é baseado no padrão Reactor (o mesmo usado pelo NodeJS) que é uma implementação de uma arquitetura orientada a eventos (event-driven). A ideia é permitir que iniciemos múltiplas operações I/O sem que precisemos esperar pela finalização delas (não bloqueante). Ao invés disso, somos notificados quando algo importante acontecer e reagimos a esse evento com um callback (se você programa em JavaScript certamente já está familiarizado com isso).

O ReactPHP possui uma série de componentes independentes e o principal deles, que é o seu core é o EventLoop, ele é a base para o funcionamento de todos os outros componentes que o ReactPHP disponibiliza. O componente EventLoop é uma implementação padrão Reactor.

O Event Loop é basicamente um while infinito que faz o papel de ser o Scheduler das operações. Ele sequencialmente processa a fila de eventos e cuida da execução dos callbacks. Ele é o único código sendo executado sincronamente, nenhum outro código é executado em paralelo. E, como já dissemos anteriormente, ele roda em uma única thread. O fato do seu processador ter 16 núcleos ou 1, em nada vai interferir, a execução do Event Loop continuará sendo single-thread. A ideia por trás do ReactPHP é fazer um bom uso do tempo da CPU (sem cair na ociosidade com as operações de I/O) e não exatamente em paralelizar processos (o que demandaria diversos outros problemas de comunicação, troca de estados, trocas de contexto por parte do sistema operacional além, claro, de recursos de hardware).

O funcionamento é mais ou menos assim:

  • Você registra um evento;
  • Você passa a “ouví-lo” (listening);
  • Quando esse evento é disparado, você reage a ele via um handler e executa algum código.

Diagrama simplificado de funcionamento de um Event Loop:

ReactPHP

O ReactPHP possui quatro implementações possíveis de Event Loop e ele por padrão escolhe qual usar a partir da análise das extensões instaladas no seu ambiente PHP.

As implementações são:

  • StreamSelectLoop – Essa implementação funciona nativamente no PHP sem precisar de nenhuma extensão específica, ela executa chamadas de sistema select que resolvem a implementação do event loop, mesmo que não na performance das opções que serão mostradas abaixo.
  • LibEventLoop – Essa opção usa a extensão libevent do repositório pecl.
  • LibEvLoop – Usa a extensão libev. Funciona de forma similar à libevent citada acima.
  • ExtEventLoop – Usa a extensão event. Funciona de forma similar à libevent citada anteriormente. Essa é a minha extensão de escolha, por ser a mais atualizada e um dos desenvolvedores dela também trabalha no core do PHP. Você pode ver mais detalhes sobre ela clicando aqui.

Mas, calma lá! Não é escopo nosso, por enquanto, se preocupar com tudo isso. Se você vai desenvolver uma aplicação para produção que vai usar ReactPHP, ótimo, eu lhe recomendaria muitíssimamente instalar a extensão event. Mas, para o nosso objetivo didático, vamos deixar que o próprio ReactPHP escolha a melhor implementação pra gente, baseando-se no que temos instalado em nosso ambiente. Se não tivermos nenhuma das três últimas extensões, ele vai usar a primeira implementação, a **StreamSelectLoop **(standalone).

Ele possui uma factory que decide qual das implementações acima será usada:

$loop = React\EventLoop\Factory::create();

As soluções escritas usando o ReactPHP não dependem de nada específico de nenhuma das implementações acima, ou seja, não importa qual a implementação será usada, o ReactPHP se comportará da mesma maneira, as interfaces são as mesmas para todas. Isso nos dá a liberdade de não nos preocuparmos em instalar uma extensão para desenvolvermos alguns testes.

Timers

Timer são úteis para executar um determinado código em um momento futuro. Funcionam da mesma forma que setTimeout() and setInterval() do JavaScript.

Por exemplo, o Event Loop dispõe do método addPeriodicTimer() que faz com que o callback informado seja executado repetidamente a cada determinado intervalo de tempo.

Vamos criar o nosso primeiro exemplo? Tudo o que você precisa fazer é criar um diretório no local onde normalmente você escreve suas aplicações. Dentro dessa pasta, crie um arquivo composer.json com o seguinte conteúdo:

{
    "require": {
        "react/event-loop": "^1.1"
    }
}

Pelo terminal, acesse esse diretório e execute:

$ composer install

Por fim, no mesmo diretório, crie um arquivo index.php com o seguinte conteúdo:

<?php

require './vendor/autoload.php';

$loop = React\EventLoop\Factory::create();

$loop->addPeriodicTimer(1, static function () {
    static $count;

    if (null === $count) {
        $count = 0;
    }

    echo $count++ . PHP_EOL;
});

$loop->run();

// Output:

// 0
// 1
// 2
// 3
// 4
// 5
// ...

Para executar o exemplo:

$ php index.php

E você verá o resultado no seu terminal. A cada um segundo o callback (a função anônima que definimos no segundo argumento de addPeriodicTimer() é executada).

Outro método é o addTimer():

<?php

require './vendor/autoload.php';

$loop = React\EventLoop\Factory::create();

$loop->addTimer(2, static function () {
    echo 'World';
});

echo 'Hello ';
$loop->run();

// Output: Hello World

Nesse caso, o callback será executado uma única vez, num tempo futuro (em dois segundos).

Esse exemplo é, de certa forma, parecido com esse, escrito em JavasCript:

setTimeout(function () { 
  console.log('World');
}, 2);

console.log('Hello ');

Ambos os exemplos mostram que não estão seguindo o fluxo síncrono de execução, ademais, uma parte do código foi programada para ser executada em outro momento e isso não bloqueou a linha de execução.

Streams

A documentação do PHP define Streams como uma forma de generalizar arquivo, rede e outras operações que compartilham um conjunto comum de funções e usos. Em outras palavras, streams representam coleções de dados que podem não estar completamente disponíveis de imediato e também não possuem a limitação de ter que caber na memória, isso faz com que streams sejam uma ferramenta poderosa para lidar com grandes quantidades de dados que podem ser obtidas por partes (chunks). Grande parte do que é feito no PHP é sobre streams. Ao ler um arquivo, lidamos com streams. Ao retornar um output para o cliente, lidamos com streams. Ao obter dados de uma conexão TCP/IP, também estamos lidando com streams.

Temos três tipos de Streams:

  • Readable – Esse tipo permite ler (apenas leitura) os dados de uma fonte;
  • Writable – Esse tipo permite escrever (apenas escrita) dados em uma fonte;
  • Duplex (Readable e Writable ao mesmo tempo) – Esse tipo permite ler e/ou escrever (ambos) dados, como é o caso do protocolo TCP/IP (full-duplex).

Por exemplo, vamos supor que você precise avaliar linha a linha um arquivo de log que possui 1GB, se fizer assim:

$log = file_get_content("error.log")

O PHP tentará carregar o arquivo inteiro na memória (e enquanto não for carregado, nada mais pode ser executado, bloqueante por natureza), o que fatalmente acarretará em um erro e a execução do script será interrompida.

Usando a interface Readable Resource Stream do ReactPHP atingimos esse objetivo de forma não bloqueante, performática e com o mínimo uso de memória.

Vamos testar isso na prática? Criaremos um arquivo (na raiz do projeto) com os 10 milhões de números, um por linha, se você usa um sistema baseado em Unix, consegue atingir esse objetivo executando:

$ awk 'BEGIN { n = 1; while (n < 10000000) print (n++) }' > numeros.txt

No arquivo index.php, execute:

$content = file_get_contents('numeros.txt');

echo 'Memória utilizada: ' . (memory_get_peak_usage(true)/1024/1024);

// Memória utilizada: 77.24

O PHP tentará alocar cerca de ~78MB na memória. Se você tentar limitar o consumo de memória pelo script, terá um estouro:

<?php

ini_set('memory_limit', '12M');

$content = file_get_contents('numeros.txt');

// PHP Fatal error: Allowed memory size of 12582912 bytes exhausted (tried to allocate 78897112 bytes)

Agora vamos usar a classe ReadableResourceStrea do ReactPHP:

<?php

require './vendor/autoload.php';

use React\Stream\ReadableResourceStream;

$loop = React\EventLoop\Factory::create();

$stream = new ReadableResourceStream(
    fopen('numeros.txt', 'rb'), $loop
);

$stream->on('data', function ($chunk) {
    // echo "$chunk\n";
});

$stream->on('end', function () {
    echo 'Memória utilizada: ' . (memory_get_peak_usage(true)/1024/1024);
});

$loop->run();

// Memória utilizada: 2

O pico de consumo de memória ficou em 2MB (assim que a informação fica disponível no buffer, já a utilizamos, liberando-o). Poderíamos processar aí um arquivo bem maior, de dezenas ou centenas de gigabytes.

Veja que nesse exemplo implementamos dois eventos: data e end. No data recebemos os chunks (partes) do arquivo que está sendo lido. Em end executamos um callback quando o processo é finalizado.

Nesse exemplo usamos fopen(), função nativa do PHP (que trabalha com streams), mas em uma aplicação verdadeiramente assíncrona, ao invés disso, devemos usar o componente Filesystem do ReacPHP pois, se tiver uma disputa na leitura do arquivo, a aplicação pode ficar congelada (ler qualquer coisa do sistema de arquivos é bloqueante por natureza). Com esse componente, teríamos algo como:

<?php

require './vendor/autoload.php';

use React\Filesystem\Filesystem;

$loop = React\EventLoop\Factory::create();
$filesystem = Filesystem::create($loop);

$filesystem->file('numeros.txt')->open('rb')->then(function($stream) {
    $stream->on('data', function ($chunk) {
        // echo "$chunk\n";
    });

    $stream->on('end', function () {
        //
    });
});

$loop->run();

Ah, para rodar esse exemplo é necessário que você instale o componente no seu projeto:

$ composer require react/filesystem

Se você já usou promises no JavaScript deve ter notado o método then() ali em cima. O conceito é o mesmo. O método open() retorna uma promise e no método then() executamos um callback quando ela (a “promessa”) é cumprida.

Lembra do exemplo que citamos lá no começo do artigo sobre uma tarefa que verifica se os links de um site estão online? Pois bem, ela poderia ser implementada usando a library reactphp-buzz, pois ela abstrai todo o essencial para se fazer requisições HTTP assíncronas.

Um protótipo de como isso poderia ser implementado de forma síncrona:

<?php

$time_start = microtime(true);

function urlsFromHtml(string $html) : array
{
    $dom = new DOMDocument();

    libxml_use_internal_errors(true);
    $dom->loadHTML($html);
    libxml_use_internal_errors(false);

    $urls = [];

    foreach ($dom->getElementsByTagName('a') as $node) {
        $urls[] = $node->getAttribute('href');
    }

    return $urls;
}

function getUrlStatusCode(string $url) : int
{
    $curl = curl_init();

    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'HEAD');
    curl_setopt($curl, CURLOPT_HEADER, 1);
    curl_setopt($curl, CURLOPT_NOBODY, true);
    curl_setopt($curl, CURLOPT_URL, $url);

    curl_exec($curl);
    $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
    curl_close($curl);

    return $code;
}

function getUrlContent(string $url)
{
    $curl = curl_init($url);

    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
    $html = curl_exec($curl);
    curl_close($curl);

    return $html;
}

$urls = urlsFromHtml(
    getUrlContent('https://www.globo.com')
);

foreach ($urls as $url) {
    $status = getUrlStatusCode($url) === 200 ? ' [online]' : ' [offline]';

    echo "{$url} -> {$status} \n";
}

echo 'Tempo total de execução: ' . round(microtime(true) - $time_start);

Dessa forma demora cerca de 150 segundos para “pingar” todas as URLS extraídas. Agora, a mesma implementação usando ReactPHP e e a library reactphp-buzz:

Primeiro instale a dependência dela no projeto:

$ composer require clue/buzz-react:^2.6
<?php

require './vendor/autoload.php';

use Psr\Http\Message\ResponseInterface;

$loop = React\EventLoop\Factory::create();
$browser = new Clue\React\Buzz\Browser($loop);

function urlsFromHtml(string $html) : array
{
    $dom = new DOMDocument();

    libxml_use_internal_errors(true);
    $dom->loadHTML($html);
    libxml_use_internal_errors(false);

    $urls = [];

    foreach ($dom->getElementsByTagName('a') as $node) {
        $urls[] = $node->getAttribute('href');
    }

    return $urls;
}

$browser->get('https://www.globo.com')->then(function (ResponseInterface $response) use ($loop, $browser) {
    $urls = urlsFromHtml($response->getBody());
    foreach ($urls as $url) {
        $browser->head($url)->then(function (ResponseInterface $response) use ($url) {
            $status = $response->getStatusCode() === 200 ? ' [online]' : ' [offline]';

            echo "{$url} -> {$status} \n";
        });
    }
});

$time_start = microtime(true);

$loop->run();

echo 'Tempo total de execução: ' . round(microtime(true) - $time_start);

Já de forma assíncrona custou apenas 18 segundos. Lembrando que esse é apenas um exemplo para comparar a diferença entre os dois modelos.

Concluindo

Esse foi um artigo introdutório sobre programação assíncrona e ReactPHP. Deu pra notar como é poderoso manipular streams, muitas possibilidades são abertas. Tem muito mais o que podemos explorar como Ticks, Promises, trabalhar com sistema de arquivos, trabalhar com websockets, usar funcionalidades do sistema operacional através de processos filhos, entre muitas outras coisas. Existem diversos projetos opensource desenvolvidos em cima do ReactPHP para atingir objetivos diversos, conforme você pode ver no site oficial.

Esse foi apenas o primeiro de outros artigos que virão na sequência sobre esse mesmo assunto.

Até breve!

Deixe seu comentário

Head de desenvolvimento. Vasta experiência em desenvolvimento Web com foco em PHP. Graduado em Sistemas de Informação. Pós-graduando em Arquitetura de Software Distribuído pela PUC Minas. Zend Certified Engineer (ZCE) e Coffee Addicted Person (CAP). @KennedyTedesco

JUNTE-SE A MAIS DE 150.000 PROGRAMADORES