Samuel Arendt

PHP 8.6 reduz o custo de closures sem alterar o código da aplicação

A RFC de closure optimizations para o PHP 8.6 foi aprovada. E o que ela faz é quase invisível, mas relevante pra qualquer projeto que use closures com frequência.

Closures são funções anônimas que capturam variáveis do escopo onde foram criadas. Em PHP, aparecem o tempo todo: callbacks, middlewares, collections do Laravel, event listeners. Qualquer código moderno usa dezenas delas.

Duas mudanças:

Primeira: o engine vai inferir automaticamente quais closures podem ser estáticas. Se uma closure não usa $this, ela vira static sozinha. Nos testes com o Symfony Demo, 68 de 87 closures foram convertidas. Isso é 78%.

Segunda: closures que são estáticas e não capturam variáveis passam a ser cacheadas entre execuções. Em vez de recriar o objeto toda vez, o PHP reutiliza.

O autor da RFC, Ilija Tovilo, testou com código de produção do Symfony. Menos alocações de memória, menos overhead por request.

E o ponto que mais me interessa: isso não exige nenhuma mudança no seu código. Zero. Performance de graça na próxima minor version.

Quem já marca closures como static manualmente sabe que é um daqueles detalhes que todo mundo esquece. Agora o compilador cuida disso. E quem nunca se preocupou com isso vai ter o benefício sem nem perceber.

PHP continua nessa trajetória silenciosa de ficar mais rápido sem pedir nada em troca. Partial function application, pipe operator, agora closures otimizadas. A linguagem está ficando mais expressiva e mais eficiente ao mesmo tempo, e a maioria dos projetos vai colher esses ganhos só de atualizar a versão.

Fico pensando quantas equipes ainda tratam atualização de PHP como risco, quando na prática é a forma mais barata de ganhar performance.

O 8.6 entrega cache; a inferência ficou para depois

A votação cobriu as duas otimizações. Depois que o voto fechou, o próprio texto da RFC ganhou um errata. Alguns caminhos internos ainda disparam chamada de instância a partir de um callable nominal: array_map com 'Foo::instanceCall', callables em array, e outras funções internas que reutilizam o frame anterior. O comportamento é esotérico e, enquanto não for deprecado e removido, assumir que a closure "não usa $this" continua inseguro.

Por isso só a parte de cache de closures sem estado entrou. Closures já static, sem variáveis capturadas e sem static interno, passam a ser reutilizadas. A inferência automática de static não veio junto.

Isso muda o cálculo da atualização. Os 78% do Symfony Demo mediam a inferência, com os static explícitos removidos para o teste. Esse número não é meta de produção no 8.6. O ganho que o runtime passou a oferecer depende de closures que o código já declara estáticas e sem captura.

Equipes que escrevem static function () {} nos callbacks colhem o cache. Equipes que esperavam o compilador adivinhar continuam alocando no caminho antigo. O "zero mudança" do lead segue válido no sentido de que ninguém precisa reescrever lógica de negócio. Deixa de ser válido se o plano interno era "atualiza o 8.6 e as closures viram static sozinhas".

Onde o cache aparece na request

Laravel, Symfony e WordPress criam closures em boot, rotas, middlewares, listeners e collections. O microbenchmark da RFC, dez milhões de instanciações da mesma closure sem estado, melhorou cerca de 80% na máquina do autor. No template Laravel, as duas otimizações juntas evitariam 2384 de 3637 instanciações, cerca de 3%. Com só o cache, o teto é menor e depende de quantas closures do hot path já são estáticas.

Três por cento num template descrevem ordem de grandeza. O SLA da sua aplicação continua dependendo do hot path que vocês medirem, com o opcache que vocês usam.

O efeito colateral que importa para teste é identidade. Duas closures sem estado criadas no mesmo ponto léxico passam a ser o mesmo objeto. test() === test() pode virar verdadeiro. Suites que usam identidade como prova de "instância nova a cada chamada" quebram. Compare comportamento, não o handle do objeto Closure.

A RFC também listava destrutores disparando mais cedo porque ciclos com $this deixariam de existir. Esse ponto pesava na inferência. Como a inferência não entrou, o risco cai. O cache ainda muda *quando* o objeto Closure é criado, o que pode afetar testes que contam alocações.

Closure::bind() e bindTo() tinham uma exceção planejada para closures apenas inferidas como static, para não quebrar código que de repente perdesse $this. Sem a inferência, essa exceção pesa menos. Closures explicitamente static continuam recusando bind de objeto como antes.

Benchmark da minor, não do framework

Atualizar para 8.6 mistura parser, stdlib, JIT, extensões e esta RFC. Atribuir qualquer queda de p95 às closures é chute. Um ensaio útil isola o runtime:

  1. mesma aplicação e mesmo opcache, PHP 8.5 versus 8.6;
  2. recorte de rotas e jobs que realmente constroem closures;
  3. memória por request e tempo, não só throughput sintético;
  4. PHPUnit e testes de integração que criam closures em loop ou comparam identidade.

Se o ganho não aparecer, a minor ainda pode valer por correções e por outras features. Só não deveria ser vendida internamente como "performance automática de closures". Sem série temporal do 8.5 no mesmo hardware, não há linha de base. Sem o recorte de rotas, um benchmark de php -r não fala da aplicação.

Eu também olharia o relatório de alocações só depois de aquecer opcache. Comparar request fria com request fria, quente com quente. Misturar as duas esconde o cache da linguagem atrás do cache de opcode.

Marcar static continua no code review

A RFC já dizia que marcar static continua preferível. Depois do errata, isso deixou de ser nitpick. O cache só pega o caso estático sem captura. Arrow functions e closures que fecham $this ou variáveis do escopo seguem no caminho antigo.

Eu pediria no review o static em callbacks de collection, rotas e listeners que não usam a instância. É uma linha. Evita o ciclo com $this mesmo sem a inferência. Também deixa o código pronto se a inferência voltar numa versão futura, depois da depreciação que o errata pede.

Não marque static em closure que usa $this de verdade. O engine recusa. O hábito útil é o caso óbvio, o mesmo que o autor mediu no Demo quando tirou os modificadores para o experimento.

Há um segundo hábito barato: evitar use desnecessário. Cada variável capturada tira a closure do cache. Capturar $logger "por precaução" em um callback que não o usa custa alocação em todo request. O review que já cobra static pode cobrar captura vazia no mesmo olhar.

A atualização para 8.6 continua sendo, na maior parte dos times, a forma mais barata de colher melhorias de engine. O recorte honesto desta RFC é mais estreito do que o voto sugeria: cache para quem já escreve closure sem estado, regressão de identidade para quem testa objeto, e nenhum milagre para o callback que fecha meia dúzia de variáveis do controller.

Referências

#Arquitetura, #Performance, #Qualidade de Software