Explicando Generators com PHP

Gustavo H. S. Andrade
5 min readJun 8, 2022

--

Um generator permite criar um objeto que seja iterável, sem precisar criar a complexidade de uma classe que implemente um Iterator, e o mais importante de tudo: sem precisar retornar um array completo em memória.

O uso de generators é bom comum, mas como ele funciona de verdade? Como ele permite um ganho de performance e uma redução tão grande do uso de memória?

Antes de começar, precisamos deixar claro a diferença entre paralelismo e concorrência!

Paralelismo

Quando temos mais de um núcleo no nosso processador, podemos fazer com que threads e processos seja executados de forma paralela, ou seja, são executados ao mesmo tempo, aqui sim, nos tempos multi task de verdade.

Concorrência

Na maior parte do tempo, os seus processos e threads rodam em um modelo de concorrência, de forma resumida, cada um tempo tem um tempo para poder rodar no processador, o processo 1 executa, depois o processo 2, depois o processo 3 e assim vai, o gerenciamento desses processos é feito pelo sistema operacional, cada processo roda em seu contexto, não compartilha memória, e mudar de um processo para o outro tem um custo, essa mudança é chamada de context switch.

Processos vs Threads

Tudo que executado no seu sistema operacional está dentro de processos, eles possuem o seu espaço de memória virtual, sim, existe uma memória virtual e a memória real, seus processos, que são os seus programas, não acessam a memória real, eles acessam a memória virtual e o sistema operacional faz o gerenciamento dos endereços para você.

💡 Um processo não pode acessar o espaço de memória de outro processo, o contexto é isolado.

Já as threads, são criadas dentro dos processos, mas elas conseguem acessar os recursos do processador da mesma forma que um processo, ou seja, ela entra na concorrência de execução do processador no mesmo nível dos processos, MAS, e isso é muito importante, elas compartilham memória, com isso, elas podem acessar o mesmo endereço de memória durante uma execução, isso pode gerar MUITOS problemas, imagine threads acessando o mesmo espaço de memória ao mesmo tempo, não vou me aprofundar nesse assunto pois o conteúdo é gigante, aconselho pesquisar sobre, começando por mutex.

Corrotinas

Show, agora sabemos o que é concorrência, paralelismo, um processo e uma thread, e sabemos que o context switch gasta tempo, ou seja, é pesado ficar mudando entre processos e threads. Para melhorar isso foram criadas as corrotinas, também chamadas de green threads.

Imagine que você está dentro de um processo(ou thread), e dentro dele você pode pausar a execução de uma função e chamar outra, tudo dentro do mesmo processo, ou seja, sem necessidade de context switch, sem necessidade de chamar o sistema operacional, tudo “dentro de casa”, outro detalhe, criar uma thread dentro de um processo custa em média 1mb por thread, uma corrotina é muito mais leve que isso, como por exemplo 2kb.

Se você já usou generators no PHP, Python ou qualquer outra linguagem que tenha suporte, deve ter usado o comando yield para chamar um método que deve ser iterado em uma lista, o generator é uma forma de corrotina.

O conceito de generator é parecido com o de corrotina, mas não são a mesma coisa, corrotinas permitem o envio de informações em tempo de execução, possuem um scheduler e outras características implementadas pela linguagem, em um próximo artigo vou entrar em mais detalhes sobre corrotinas, fibers e programação concorrente.

Quando você chama o yield o que você está fazendo é pausar a função atual e chamar outra, sim, vamos ver na prática, fica mais fácil de entender.

Generators

Um generator permite criar um objeto que seja iterável, sem precisar criar a complexidade de uma classe que implemente um Iterator, e o mais importante de tudo: sem precisar retornar um array completo em memória.

Quando usamos métodos como o range no PHP, nos criamos um array completo em memória e depois iteramos por ele usando um foreach, a mesma lógica é aplicada para leitura de arquivos usando o file_get_contents, todo conteúdo é carregado em memória, e isso não é eficiente, podemos ter sérios problemas de uso de memória com essas abordagens.

A grande diferença de um generator, é o método yield, quando usamos um return, nos finalizamos a execução da função e retornamos seu resultado. Quando usamos o yield, nos fazemos uma pausa na execução da função, salvamos o seu estado atual e podemos executar outra coisa naquele momento. Deu pra pegar a referência as corrotinas?

Vamos ver isso no código!

Primeiro vamos medir o uso de memória de um método range para criar um array com 1 milhão de inteiros, e iterar sobre eles sem fazer nenhuma operação, apenas para medir o uso de memória:

<?php
$array = range(1, 1_000_000);
foreach ($array as $i) {
// nothing here :p
}
echo memory_get_peak_usage() / 1024 / 1024; // 32.38264465332

Veja que o uso de memória foi de 32mb, agora vamos fazer o mesmo loop usando um generator:

<?php
function frange($start, $end): Generator
{
for ($i = $start; $i <= $end; $i++) {
yield $i;
}
}
$array = frange(1, 1_000_000);foreach ($array as $i) {
// nothing here
}
echo memory_get_peak_usage() / 1024 / 1024; // 0.4156494140625

Veja a diferença absurda de uso de memória, caiu para 0.41mb isso acontece basicamente por que não estamos criando os 1 milhão de registros do array.

Nesse caso cada registro é criado no momento do loop, vamos ver o passo a passo para entender melhor o que está acontecendo:

  1. Quando criamos o generator usando o $array = frange(1, 1_000_000); ele não é executado ainda, por isso se dermos um var_dump, teremos apenas um objeto Generator:
object(Generator)#1 (0) { }

Uma classe Generator, implementa um Iterator do PHP, permitindo interação dentro dela, por isso podemos usar o foreach para percorrer cada elemento, esses elemento são criados em tempo de execução, coloquei um echo dentro do método que cria o generator para ver em que momento ele é executado:

function frange($start, $end): Generator
{
echo "frange";
for ($i = $start; $i <= $end; $i++) {
yield $i;
}
}
$array = frange(1, 1_000_000);

Se executar esse código você vai ver que nada é impresso na tela, isso por que o generator é um objeto com métodos que podemos usar para percorrê-lo. Vamos começar a percorrer nosso generator usando os métodos de interação current e next

<?php
function frange($start, $end): Generator
{
echo "frange";
for ($i = $start; $i <= $end; $i++) {
yield $i;
}
}
$array = frange(1, 1_000_000);// Imprimindo o elemento atual, que é o primeiro elemento
echo $array->current();
// Agora devemos ir para o próximo elemento usando o next
$array->next();
// E imprimimos novamente o valor atual
echo $array->current();
// resultado final:
// frange12

Entendendo passo a passo a execução

  1. O grande segredo aqui que precisamos entender é que quando chamamos o current(), o for que fica dentro do métodofrange é executado.
  2. Ele bate no yield que PAUSA a execução da função, salva seu estado, e retorna o valor de $i para a função pai.
  3. Ao retornar para função pai, ele executa o echo, e passa para a próxima linha.
  4. Nessa próxima linha temos o next(), voltamos para dentro do generator, que bate novamente no yield.
  5. Fazendo a mesma coisa, pausa a execução, salva seu estado e retorna o valor $i para a função pai.

É uma programação concorrente.

Em um próximo artigo vou falar mais sobre programação concorrente, dar exemplo de fibers, swoole e outras tecnologias que estão sendo implementadas no PHP.

--

--