(React PHP) Conceitos básicos - EventLoop

por Níckolas Silva em 10/10/2018

Este artigo é o primeiro de uma sequência que pretendo publicar aqui. Neste entenderemos o que é React PHP e o que nos possibilita. Outra nota importante é: esta não é uma série introdutória. O foco está em entender como React funciona, deixando de lado introduções didáticas focadas em desenvolver uma aplicação de exemplo.  

Visão Geral

A palavra chave em React é assíncrono. Esta é a maior ideia por trás da coleção de bibliotecas que vemos consigo. PHP, por natureza, é dito "bloqueante". Isto significa que cada procedimento só virá a ser executado após o anterior. Ilustrado na imagem (obrigado @gabrielcouto) e código:

blocking-io

echo 'Obtendo o arquivo...';
// Executa somente após a linha 3
$conteudo = file_get_contents('umArquivoPesado.txt');
// Executa somente após a linha 5, e assim por diante...
if ($conteudo) {
    echo 'Arquivo obtido com sucesso! :)';
}

Estamos de fato acostumados com este cenário. Porém isto passa a ser problemático quando umArquivoPesado.txt leva alguns segundos para ser carregado à memória.

Esta mesma ideia se aplica às requisições a serviços dentro do código PHP, carregamento de configurações (XML, JSON, YAML...), acesso ao banco de dados e por aí vai. O ponto é: PHP bloqueia a cada comando executado e os comandos que fazem entrada/saída (acesso a disco, acesso a rede...) de dados tendem a ser mais demorados que comandos internos de processamento (um echo, um if, um cálculo...).

React PHP vem com o intuito, justamente, de permitir que executemos pedaços de lógica em paralelo. Nada que não pudesse ser feito com PHP antes, mas React traz uma interface orientada a objetos muito bem organizada e que nos facilita esta utilização.  

EventLoop

Para tornar isto possível, React centraliza sua execução em um "EventLoop", que nos permitirá alcançar a ilustração seguinte:

non-blocking-io

EventLoop nada mais é que um laço infinito (infinito até que seja interrompido ou que não possua mais processos a executar) de repetição que organiza e elege blocos de código (como são as funções) para execução. Em sua estrutura ele:

  • Gerencia processos a executar (funções, callbacks...)
  • Identifica atualizações sobre processos em paralelo
  • Executa processos referentes aos processos em paralelo

Com este modelo é possível executar procedimentos de entrada/saída (comumente demorados) em paralelo com a execução de tarefas de CPU. Abaixo exemplifico como o EventLoop gerencia os processos a serem executados:

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

$numeros = range(0, 2);
$letras = range('A', 'C');

$callback01 = function () use (&$numeros) {
 echo current($numeros).' ';
 next($numeros);
};

$callback02 = function () use (&$letras) {
 echo current($letras).' ';
 next($letras);
};

$controle = function () use (&$numeros, &$letras, $loop) {
 // Se lemos o ultimo número e a ultima letra, pare
 if (!current($numeros) && !current($letras)) {
   $loop->stop();
 }
};

$loop->addPeriodicTimer(1, $callback01);
$loop->addPeriodicTimer(1, $callback02);
$loop->addPeriodicTimer(1, $controle);

// Inicia e executa o EventLoop
$loop->run();

// Saída esperada: 0 A B 1 C 2

Acima o EventLoop deverá ter executado, a cada um segundo, os processos $callback01$callback02$controle, sendo que este último identifica que o programa encerrou as leituras necessárias e solicita o fim da execução. É importante ressaltar que $callback01$callback02 e _$controle _não executaram em paralelo, mas o tempo de processamento destes é tão ínfimo que podemos ter esta impressão. Experimente mudar o tempo, em segundos, dos timers (linhas 23 a 25) para visualizar melhor como o EventLoop organiza e elege os processos a serem executados.  

Timers

No exemplo de código anterior vimos como funciona o método addPeriodicTimer(), que se fizessemos um paralelo com o JavaScript seria equiparado ao setInterval(). Temos também o equivalente ao setTimeout(), que com o React se refere como addTimer() como segue:

$start = microtime(true);

$timeout = $loop->addTimer(3, function () use ($start) {
    $intervalo = sprintf('%0.2f s', microtime(true) - $start);
    echo "[{$intervalo}] Timeout veio\n";
});

$interval = $loop->addPeriodicTimer(2, function () use ($start) {
    $intervalo = sprintf('%.2f s', microtime(true) - $start);
    echo "[{$intervalo}] Interval veio\n";
});

$loop->addTimer(15, function () use ($loop, $interval, $start) {
    if ($loop->isTimerActive($interval)) {
        $interval->cancel(); // Alias para $loop->cancelTimer($interval)
        $intervalo = sprintf('%.2f s', microtime(true) - $start);

        echo "[{$intervalo}] Interval infinito cancelado.\n";
    }
});

/*
Saída esperada:
[2.00 s] Interval veio
[3.00 s] Timeout veio
[4.00 s] Interval veio
[6.00 s] Interval veio
[8.00 s] Interval veio
[10.00 s] Interval veio
[12.00 s] Interval veio
[14.00 s] Interval veio
[15.00 s] Interval infinito cancelado.
*/

Analisando o código acima, deverá ser impresso uma única vez a frase "Timeout veio" após 3 segundos do início da execução do programa. E deverá ser impressa infinitamente a frase "Interval veio" a cada 2 segundos. Após 15 segundos de execução do programa realizamos uma ordem de cancelamento para o intervalo infinito caso ele esteja ativo perante o EventLoop. O EventLoop, portanto, não possuirá mais itens na fila de execução e encerra o programa na próxima iteração.  

Streams

Até o momento não vimos nenhuma execução que de fato fosse paralela, apenas trabalhamos com filas de execução muito bem organizadas e temporizadas. O EventLoop traz consigo uma abstração para lidar com Streams que, em php, podem ser trabalhados utilizando wrappers e/ou funções e nenhum dos dois modelos é muito intuitivo. Precisamos sempre ter em mente que streams são encaixados em dois grupos: readable (de onde lemos dados) e writable (por onde escrevemos dados). Alguns tipos de streams se encaixam, inclusive, nos dois grupos. O EventLoop tratará Streams utilizando os métodos addReadStream()addWriteStream(). Abaixo um exemplo de seu funcionamento:

// Iniciando um server em localhost, porta 7171
$serverSock = stream_socket_server('tcp://127.0.0.1:7171');

// Aqui dizemos que ele não bloqueia execução
stream_set_blocking($serverSock, 0);

// Lista contendo todos os clientes conectados
$clients = array();

// Adicionamos um "leitor" que chamará o callback sempre que
// $serverSock estiver pronto para leitura (quando alguém se conectar, neste caso)
$loop->addReadStream($serverSock, function ($serverSock, $loop) use (&$clients) {
    $clientSock = stream_socket_accept($serverSock);
    stream_set_blocking($clientSock, 0);

    // Vamos identificar nossas conexões para entender melhor...
    $username = false;

    // Emite uma mensagem ao $clientSock que acabou de se conectar
    fwrite($clientSock, "Diga-nos seu nome: ");

    // Criamos também um buffer de leitura para o $clientSock
    // Este executa a cada mensagem enviada pelo $clientSock
    $loop->addReadStream($clientSock, function ($clientSock, $loop) use (&$username, &$clients) {

        // $username == false -> Ainda não autenticou-se
        if (!$username && $username = fgets($clientSock)) {
            $username = trim($username);
            fwrite($clientSock, "Bem-vindo, {$username}. Você está no chat maroto!\n\n");

            // Adiciona à lista de clients conhecidos
            $clients[] = $clientSock;
        }

        // Se já se identificou e enviou alguma mensagem, repasse
        if ($username && $text = fgets($clientSock)) {

            // Busco TODOS os clients conhecidos, e redistribuo
            // a mensagem $text para todos que não o remetente
            foreach ($clients as $client) {
                if ($client !== $clientSock) {
                    fwrite($client, "[{$username}] {$text}");
                }
            }
        }
    });
});

Esta aplicação pode ser testada, por exemplo, utilizando o programa "telnet". example

Todo callback passado para addReadStream() será sempre executado quando aquele stream estiver pronto para leitura, e isto varia de acordo com o stream que estiver trabalhando: pode ser uma nova conexão recebida, ou uma mensagem recebida. De forma análoga, addWriteStream() executará os callbacks assim que o stream estiver preparado para escrita.  

Ticks

Por fim, talvez o mais importante, precisamos apresentar os Ticks dentro do EventLoop. Como dito anteriormente, o mecanismo do EventLoop não passa de um loop infinito. Literalmente, veja este trecho retirado de React\EventLoop\StreamSelectLoop:

public function run()
{
    $this->running = true;

    while ($this->running) {
        // Lógica de organização de processos     
    }
}

Cada vez que entramos neste laço (linha 05) o EventLoop dispara filas de execução eleitas para aquela iteração (tick). Na implementação StreamSelectLoop, um tick inicia a execução (nesta ordem):

  • Da fila nextTickQueue
  • Da fila futureTickQueue
  • Dos timers registrados (addTimer()addPeriodicTimer())
  • Dos callbacks registrados para streams (addReadStream()addWriteStream())

Podemos manipular as filas nextTickQueuefutureTickQueue através dos métodos nextTick()futureTick(), respectivamente. Eles recebem funções (callable) como parâmetro:

$loop->nextTick(function() {
    echo "Next tick :D\n";
});

$loop->futureTick(function() {
    echo "Future tick :D\n";
});

/* Saída esperada:
Next tick :D
Future tick :D
*/

Eles realmente parecem fazer a mesma coisa, mas existe uma sutil difereça entre os dois:

  • Next ticks executarão enquanto existirem callbacks em sua fila de execução
  • Future ticks executarão somente os callbacks existentes na fila no momento em que foram inicializados

O exemplo abaixo ilustra melhor esta diferença:

function futureTick() {
    echo "Future tick\n";
    global $loop;

    $loop->futureTick('futureTick');
    $loop->stop();
}

$loop->futureTick('futureTick');

// Saída esperada: Future tick</pre>

Este future tick envia a mesma função para a fila de future ticks assim que executa, depois solicita o fim do EventLoop. A função _futureTick()_, porém, executará uma unica vez, pois quando a fila de future ticks iniciou a execução, existia somente uma ocorrência para executar. Veja a diferença com os next ticks:

<pre class="lang:php decode:true">function nextTick() {
    echo "Next tick\n";
    global $loop;

    $loop->nextTick('nextTick');
    $loop->stop();
}

$loop->nextTick('nextTick');

Este programa entrará num loop infinito, pois ao fim de nextTick() esta função é enviada novamente para a fila de next ticks e esta fila executa enquanto existirem callbacks, independentemente do momento em que foram adicionados.  

Implementações de EventLoop

Se você notar, no início deste texto, instanciamos o EventLoop utilizando a React\EventLoop\Factory. Isto porque, atualmente, existem quatro implementações diferentes do EventLoop (todas devem respeitar a interface React\EventLoop\LoopInterface) e a Factory irá instanciar o primeiro disponível. São as implementações:

  • ExtEventLoop (Utiliza a extensão Event)
  • LibEvLoop (Utiliza a extensão Libev)
  • LibEventLoop (Utiliza a extensão LibEvent)
  • StreamSelectLoop (Não utiliza extensões)

Destas, somente StreamSelectLoop funciona somente com PHP pois faz uso de _stream_select()_. O restante das implementações somente serão instanciadas quando a devida extensão existir. Em questão de performance, StreamSelect é a implementação menos perfeita. As outras implementações delegam a gerência de entrada/saída às extensões. A medição de performance, porém, dependerá do seu cenário de uso e, algumas vezes, pode ser mais interessante instanciar o EventLoop manualmente em vez de depender da Factory para escolher o melhor para você.  

Conclusão

Vimos aqui as funcionalidades básicas do EventLoop, como ele se comporta e como está desacoplado do restante das bibliotecas React. Através destas explicações você já será capaz de criar bibliotecas que interajam com o React, assim como desenvolver novas implementações de EventLoops, modificar existentes ou mesmo contribuir com melhorias neste pacote do projeto.


Artigo originalmente criado em 31/12/2015.

Mais posts deste Autor

Acha que esse conteúdo possui erros ou poderia ser aperfeiçoado? Colabore!