Generators

Corrotinas e código assíncrono em PHP usando Generators

Nos últimos artigos eu tenho escrito sobre programação assíncrona em PHP com ReactPHP e Swoole. O Swoole tem seu mecanismo interno próprio de corrotinas, mas o que muita gente não sabe é que podemos trabalhar com corrotinas usando PHP puro, através de generators.

Eu achei que fosse interessante escrever sobre isso até mesmo como uma forma de apresentar às pessoas o conceito de generators e também como uma forma de instigá-las a buscar mais sobre o tema.

A ideia desse artigo é construir um simples (muito simples) scheduler de corrotinas bem primitivo e didático. Em outros artigos eu escrevi de forma mais abrangente sobre generators, código síncrono, assíncrono, corrotinas etc, conceitos esses que serão importantes você ter para entender o funcionamento do nosso exemplo aqui. Por esse motivo, eu recomendo que você leia esses artigos:

Opcionalmente, se você tiver interesse, também recomendo a leitura deste artigo:

Esse artigo que você está lendo é, de certa forma, uma ramificação deste:

Portanto, o artigo Generators no PHP é realmente uma leitura necessária para entender os códigos que desenvolveremos aqui.

Desenvolvedor PHP Júnior
Formação: Desenvolvedor PHP Júnior
Nesta formação você aprenderá todos os fundamentos necessário para iniciar do modo correto com a linguagem PHP, uma das mais utilizadas no mercado. Além dos conceitos de base, você também conhecerá as características e a sintaxe da linguagem de forma prática.
CONHEÇA A FORMAÇÃO

Gênesis

Sabemos que generators são como se fossem funções que podem ser interrompidas e resumidas a qualquer momento. Também sabemos que assincronismo é sobre fluxo de execução. Captando essas duas ideias centrais, podemos chegar na seguinte conclusão lógica: se um generator pode ser interrompido para que outro seja executado, eu posso usar isso para manipular o fluxo de uma execução e então atingir uma modelagem de código assíncrono.

Primeiro vamos ver um exemplo de código síncrono:

<?php

declare(strict_types=1);

$booksTask = static function () {
    for ($i = 1; $i <= 4; ++$i) {
        echo "Book $i\n";
    }
};

$moviesTask = static function () {
    for ($i = 1; $i <= 8; ++$i) {
        echo "Movie $i\n";
    }
};

$booksTask();
$moviesTask();

O resultado dessa execução:

Book 1
Book 2
Book 3
Book 4
Movie 1
Movie 2
Movie 3
Movie 4
Movie 5
Movie 6
Movie 7
Movie 8

A execução é síncrona, linear e previsível. Temos duas tarefas, mas a segunda ($moviesTask) só terá oportunidade de desempenhar seu trabalho depois que a primeira ($booksTask) terminar tudo o que tem para ser feito.

Podemos transformar esse exemplo em um código de modelo assíncrono usando generators e trabalhando com a ideia de que cada task é uma corrotina. Para isso, dois princípios são importantíssimos:

  • Uma Task (tarefa) será apenas um decorator de um generator;
  • Um Scheduler cuidará da fila de tarefas e da execução delas;

O exemplo pode ser encontrado no GitHub: https://github.com/KennedyTedesco/coroutines-php

Uma tarefa será representada pela classe Task:

<?php

declare(strict_types=1);

namespace Coral;

use Generator;

final class Task
{
    private $coroutine;
    protected $firstYield = true;

    public function __construct(Generator $coroutine)
    {
        $this->coroutine = $coroutine;
    }

    public function run(): void
    {
        if ($this->firstYield) {
            $this->firstYield = false;
            $this->coroutine->current();
        } else {
            $this->coroutine->next();
        }
    }

    public function finished(): bool
    {
        return ! $this->coroutine->valid();
    }
}

A tarefa é apenas um decorator de um generator.

E o Scheduler será representado pela classe de seu próprio nome:

<?php

declare(strict_types=1);

namespace Coral;

use SplQueue;

final class Scheduler
{
    private $tasks;

    public function __construct()
    {
        $this->tasks = new SplQueue();
    }

    public function schedule(Task $task): void
    {
        $this->tasks->enqueue($task);
    }

    public function handle(): void
    {
        while (! $this->tasks->isEmpty()) {
            /** @var Task $task */
            $task = $this->tasks->dequeue();

            $task->run();
            if (! $task->finished()) {
                $this->schedule($task);
            }
        }
    }
}

O Scheduler mantém uma fila de tarefas a serem executadas e possui um método público schedule() para adicionar tarefas nessa fila. O método handle() itera nas tarefas executando-as. Antes de entrarmos em mais detalhes, ao executar o exemplo:

<?php

declare(strict_types=1);

require 'vendor/autoload.php';

use Coral\Task;
use Coral\Scheduler;

$scheduler = new Scheduler();

$booksTask = static function () {
    for ($i = 1; $i <= 4; ++$i) {
        echo "Book $i\n";

        yield;
    }
};

$moviesTask = static function () {
    for ($i = 1; $i <= 8; ++$i) {
        echo "Movie $i\n";

        yield;
    }
};

$scheduler->schedule(new Task($booksTask()));
$scheduler->schedule(new Task($moviesTask()));

$scheduler->handle();

Nota: As duas tarefas retornam um generator, que depois é passado para o construtor de Task.

Temos o seguinte resultado:

Book 1
Movie 1
Book 2
Movie 2
Book 3
Movie 3
Book 4
Movie 4
Movie 5
Movie 6
Movie 7
Movie 8

Diferentemente do exemplo síncrono mostrado anteriormente, neste temos a alternância da execução das tarefas, no sentido de que são colaborativas, uma abre espaço para que a outra também tenha oportunidade de ser executada. Isso acontece pois o Scheduler executa a tarefa, o valor corrente dela é impresso, nisso ela volta novamente para a fila do Scheduler para ser executada novamente em outro momento. As tarefas sempre voltam para a fila enquanto ainda tiverem valores a serem processados:

$task->run();
if (! $task->finished()) {
    $this->schedule($task);
}

Essa é uma estratégia para mantê-las em sua essência colaborativas, ou seja, a tarefa abre mão do seu tempo de execução para que outra tarefa também tenha oportunidade.

Considerações finais

Este foi um simples exemplo de como podemos ter uma operação assíncrona utilizando generators. E aqui nem estamos nos referindo a multiplexing de I/O, mas poderíamos usar esse mesmo conceito de generators e implementar I/O não bloqueante (assíncrono) usando algum padrão como o Reactor, usado pelo ReactPHP ou algo mais “simples” usando diretamente a função stream_select(). Inclusive, O Nikita Popov (desenvolvedor do core do PHP) escreveu exatamente sobre isso no artigo Cooperative multitasking using coroutines (in PHP!), que por sinal, é a principal referência desse artigo aqui. Recomendo essa leitura pois ele também fez com que o generator se comunicasse com outros generators e com o Scheduler (que é a realmente essência de uma corrotina), numa espécie de canal de comunicação, o que torna as coisas ainda mais poderosas. O framework assíncrono Amp faz um uso bem intensivo de generators, também vale a pena testá-lo.

Ah, não poderia deixar de pontuar novamente: se você tem interesse por programação assíncrona com PHP, vale a pena a leitura desses artigos:

Até a próxima!

Generators no PHP

Generators foram adicionados no PHP na versão 5.5 (meados de 2013) e aqui estamos nós, quase cinco anos depois, falando sobre eles. É que esse ainda não é um assunto muito difundido, digo, não é trivial encontrar casos e mais casos de uso para eles no contexto padrão de desenvolvimento para o PHP.

Primeiro de tudo, é importante entendermos o que são Iterators, ademais, Iterators e Generators são assuntos intrinsicamente relacionados.

PHP Básico
Curso de PHP Básico
CONHEÇA O CURSO

O que é um Iterator?

No contexto prático do PHP, Iterator é um mecanismo que permite que um objeto seja iterado e ele próprio fica no controle dessa iteração. Mas o seu uso não limita a essa “feature”, é o que veremos mais pra frente.

Há de se destacar que é possível iterar um objeto “limpo” (digo, aquele que não implementa nenhuma interface específica) e o que será levado em consideração são os seus atributos públicos.

Um exemplo:

<?php

class Maiable
{
    public $from =  'email@domain.com';
    public $to = 'email@domain.com';
    public $subject = "Assunto";
    protected $type = 1;
}

$maiable = new Maiable();

foreach($maiable as $atributo => $valor) {
    echo "<strong>{$atributo}:</strong> {$valor} <br>";
}

O resultado será:

from: email@domain.com 
to: email@domain.com 
subject: Assunto 

No entanto, temos no PHP a Iterator, uma interface que quando implementada, os objetos provenientes ganham a capacidade de serem iterados com base nas seguintes assinaturas de métodos:

Iterator extends Traversable
{
    abstract public mixed current ( void )
    abstract public scalar key ( void )
    abstract public void next ( void )
    abstract public void rewind ( void )
    abstract public boolean valid ( void )
}
  • current() – Retorna elemento corrente;
  • key() – Obtêm a chave corrente;
  • next() – Avança o cursor para o próximo elemento;
  • rewind() – Retornar o cursor para o início;
  • valid() – Checa se a posição atual existe;

Vamos então a um exemplo:

class BookStore implements Iterator
{
    private $books = [];
    private $index;

    public function __construct()
    {
        $this->index = 0;

        $this->books = [
            'Book 1',
            'Book 2',
            'Book 3',
            'Book 4',
            'Book 5',
        ];
    }

    public function current()
    {
        return $this->books[$this->index];
    }

    public function key()
    {
        return $this->index;
    }

    public function next()
    {
        $this->index++;
    }

    public function rewind()
    {
        $this->index = 0;
    }

    public function valid()
    {
        return array_key_exists($this->index, $this->books);
    }
}

Nota: Esse é um exemplo puramente didático. Estamos inicializando o array de forma estática e ele por si só não justifica o uso de Iterator. A ideia aqui é passar a essência do mecanismo. Adiante veremos casos de usos mais proximos da realidade, usando Generators.

Observe que os atributos são protegidos e que mantemos no $index o “cursor” atual da iteração.

Uma possível forma de iterar sobre esse objeto:

$books = new BookStore();

while ($books->valid()) {
    echo "<strong>[{$books->key()}]</strong> = {$books->current()} <br>";

    $books->next();
}

Resultado:

[0] = Book 1 
[1] = Book 2 
[2] = Book 3 
[3] = Book 4 
[4] = Book 5 

Essa interface faz com que o nosso objeto tenha a capacidade de decidir como e o que iterar. E destaco esses termos em negrito pois eles são a essência dos interators.

Enquanto existir um índice válido a ser recuperado os valores serão impressos. Na última iteração, $books->next() incrementa em mais um o valor de $index chegando ao valor 5, no entanto, no array $books o maior índice existente é o 4, logo, o while é interrompido.

Outra forma possível seria:

for ($books->rewind(); $books->valid(); $books->next()) {
    echo "<strong>[{$books->key()}]</strong> = {$books->current()} <br>";
}

Essas são as formas mais “primitivas” de iterar o nosso objeto iterável. Estamos manualmente cuidando de avançar o cursor usando $books->next();.

Podemos facilitar isso se usarmos a estrutura foreach, ela reconhece quando o objeto implementa a interface Traversable e trata de avançar o cursor e recuperar os valores automaticamente.

A interface Iterator estende a Traversable.

A nossa implementação pode ser simplificada para:

$books = new BookStore();

foreach($books as $key => $value) {
    echo "<strong>[{$key}]</strong> = {$value} <br>";
}

Refatorando para ArrayIterator

O lado chato da abordagem de implementar diretamente a interface Iterator é a necessidade de implementar todos os seus métodos. É util quando a lógica para se iterar ou avançar o cursor precisa ser específica. Agora, se ela é trivial, como é o caso do nosso exemplo, podemos usar a classe ArrayIterator, também nativa do PHP.

A ArrayIterator implementa não só a interface Iterator como várias outras (que não fazem parte do escopo desse artigo):

ArrayIterator implements ArrayAccess, SeekableIterator, Countable, Serializable {

Ela recebe no seu construtor um array e itera sobre ele, abstraindo de nós a necessidade de implementar os métodos necessários da interface Iterator.

Podemos simplificar e alterar a nossa classe para implementar a interface IteratorAggregate que assina um único método, chamado getIterator(). E, nele, tudo o que temos que retornar é um Iterator, no caso, vamos retornar a instância da ArrayIterator:

class BookStore implements IteratorAggregate
{
    private $books;

    public function __construct()
    {
        $this->books = [
            'Book 1',
            'Book 2',
            'Book 3',
            'Book 4',
            'Book 5',
        ];
    }

    public function getIterator()
    {
        return new ArrayIterator($this->books);
    }
}

Qual o principal benefício de se usar um Iterator?

Os exemplos anteriores não tiveram o objetivo de explicitar isso, mas um grande benefício do uso de Iterators trata-se de um melhor aproveitamento da memória. Com eles, não precisamos carregar grandes datasets de uma só uma vez, podemos carregar item a item, sob demanda, quando preciso. Digamos, é uma implementação “lazy loading”.

Nos tópicos seguintes, sobre Generators, veremos alguns casos de uso mais reais e então entenderemos como os Iterators são importantes para lidar com grandes coleções de dados.

PHP Intermediário
Curso de PHP Intermediário
CONHEÇA O CURSO

O que é um Generator?

Em termos práticos Generators são uma forma prática de se implementar Iterators. Isso quer dizer que, com Generators, podemos implementar complexos Iterators sem que precisemos criar objetos, implementar interfaces e toda àquela complexidade que vimos anteriormente. Generators dão para uma função a capacidade de retornar uma sequência de valores.

Para criar uma função Generator, basta que ela possua a palavra reservada yield. O operador yield é uma espécie de return, só que com algumas particularidades. E uma função desse tipo retorna um objeto da classe Generator, que é uma classe especial e específica para esse contexto, não sendo possível utilizá-la de outra forma. Esse objeto retornado pode ser iterado. É aí que entra a nossa base de Iterators que aprendemos anteriormente. A classe Generator implementa a interface Iterator.

Bom, vamos praticar um pouco? O exemplo mais elementar possível de uma função generator:

function getLinhas() {
    yield "Linha 1";
    yield "Linha 2";
    yield "Linha 3";
    yield "Linha 4";
    yield "Linha 5";
}

var_dump(getLinhas());

O resultado:

object(Generator)#1 (0) { }

Já confirmamos o que anteriormente foi explicado. Sempre que uma função usa o operador yield ela vai retornar um objeto do tipo Generator.

E, se Generator é um Iterator, logo, podemos iterar sobre ele, certo? Sim!

Vejamos:

<?php

function getLinhas() {
    yield "Linha 1";
    yield "Linha 2";
    yield "Linha 3";
    yield "Linha 4";
    yield "Linha 5";
}

foreach (getLinhas() as $linha) {
    echo "{$linha} <br>";
}

Resultado:

Linha 1 
Linha 2 
Linha 3 
Linha 4 
Linha 5 

Não precisamos de um yield para cada registro da nossa coleção. Normalmente o que vamos ver é um yield dentro de um laço como, por exemplo:

<?php

function getLinhas() {
    for ($i = 0; $i < 100; $i++) {
        yield "Linha {$i}";
    }
}

foreach (getLinhas() as $linha) {
    echo "{$linha} <br>";
}

E é possível que o yield retorne um conjunto de chave => valor:

function getLinhas() {
    for ($i = 0; $i < 100; $i++) {
        yield $i => 'Linha ' . $i * 2;
    }
}

foreach (getLinhas() as $chave => $valor) {
    echo "[{$chave}] => {$valor} <br>";
}

E a parte que “toca” a memória?

Comentamos anteriormente que o principal benefício de se usar um Iterator está no baixo consumo de memória associado. Com um Iterator recuperamos a informação sob demanda, sem alocar toda a coleção na memória.

É relativamente comum aplicações que recuperam milhares de dados de uma base de dados (quando não podem por algum requisito usar paginação) precisarem carregar esses dados em um array e em seguida formatar esses dados carregando-os em um novo array. O pico de consumo de memória nesses casos pode ser altíssimo.

Vamos emular uma situação onde possamos ver o benefício de usar Generators?

<?php

function getRegistros() {
    $registros = [];

    for ($i = 0; $i < 10000; $i++) {
        $registros[] = "Registro $i";
    }

    return $registros;
}

function formataRegistros($registros) {
    $new = [];

    foreach ($registros as $index => $registro) {
        $new[] = "[$index] -> {$registro}";
    }

    return $new;
}

$registros = formataRegistros(getRegistros());

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB<hr><br>';

foreach ($registros as $registro) {
    echo "{$registro} <br>";
}

A getRegistros() aloca na memória (em um array) milhares de registros e os retorna. A formataRegistros() recebe uma coleção de dados, formata-os, aloca-os na memória (em um array) e então os retorna.

O resultado da memória total gasta nessa operação foi:

Memória: 2.19 MB

Faça um paralelo disso com uma aplicação sua, real, que precisa recuperar milhares de registros, depois formatá-los de alguma maneira ou relacioná-los com outros registros etc.

Agora, refatorando o exemplo para que as duas funções se transformem em generators:

<?php

function getRegistros() {
    for ($i = 0; $i < 10000; $i++) {
        yield "Registro $i";
    }
}

function formataRegistros($registros) {
    foreach ($registros as $registro) {
        yield "-> {$registro}";
    }
}

$registros = formataRegistros(getRegistros());

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB<hr><br>';

foreach ($registros as $registro) {
    echo "{$registro} <br>";
}

O resultado:

Memória: 0.38 MB

Uma diferença muito grande. Quanto maior o dataset, maior a diferença será.

Outro possível caso de uso: Você precisa ler um documento de texto linha a linha e tratar essas informações de alguma maneira. Usando a forma tradicional de lidar com isso, teríamos algo como:

<?php

function getLinhas($arquivo) {
    $handle = fopen($arquivo, 'r');

    $linhas = [];

    while (($buffer = fgets($handle, 4096)) !== false) {
        $linhas[] = $buffer;
    }

    fclose($handle);

    return $linhas;
}

$linhas = getLinhas('file.txt');

foreach($linhas as $linha) {
    // TODO
}

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB';

Se o seu sistema é unix você pode gerar um arquivo com dados randômicos de 10MB (para testar o exemplo) executando:

base64 /dev/urandom | head -c 10000000 > file.txt

O resultado na minha máquina foi:

Memória: 19.56 MB

Se refatorarmos isso para um Generator:

<?php

function getLinhas($arquivo) {
    $handle = fopen($arquivo, 'r');

    while (($buffer = fgets($handle, 4096)) !== false) {
        yield $buffer;
    }

    fclose($handle);
}

$linhas = getLinhas('file.txt');

foreach($linhas as $linha) {
    // TODO
}

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB';

O resultado:

Memória: 0.37 MB

Veja que a diferença do consumo de memória entre as duas abordagens é enorme. Se o tamanho do arquivo que você está lendo ou precisa processar for muito grande você pode, inclusive, estourar o limite de memória do PHP se não usar um Iterator.

Com os Generators é possível que processemos arquivos de dezenas ou centenas de GB’s sem estourar o limite de memória disponível para a aplicação.

Concluindo

O uso de Generators é indicado e muitas vezes se faz necessário quando é preciso iterar uma grande coleção de dados de acesso sequencial.

Não são muitos os casos de uso para Generators na programação regular (digo, do dia a dia). Mas, certamente possuem o seu espaço. Generators são, inclusive, a base para programação assíncrona com PHP. E um framework que se destaca utilizando-os é o AMP.

PHP Avançado
Curso de PHP Avançado
CONHEÇA O CURSO