Hyperf na prática: PHP assíncrono com corrotinas para APIs de alta carga
O modelo do PHP-FPM tem um teto, e ele aparece antes do que a maioria imagina. Como o Hyperf usa corrotinas e pool de conexões para levantar esse teto, o que muda no seu código — e as armadilhas que derrubam quem vem do FPM.
▸Neste artigo
Existe um momento na vida de uma API PHP em que adicionar máquina para de resolver. O gargalo não é CPU — o gráfico mostra 30% de uso. Não é o banco — as queries respondem em 3 ms. Mesmo assim as requisições enfileiram e o p99 sobe.
O que está acontecendo é estrutural: no PHP-FPM, um processo atende uma
requisição por vez, do início ao fim. Se essa requisição passa 200 ms
esperando uma API externa responder, o processo fica ocupado esperando. Não
processando: esperando. Com pm.max_children = 50, cinquenta requisições
concorrentes lotam o servidor, e a 51ª entra na fila enquanto a CPU tira uma
soneca.
O Hyperf resolve isso trocando o modelo de execução. Trabalho com ele na Onfly em microsserviços de alta escalabilidade, e este texto é o que eu gostaria de ter lido antes de começar.
O teto do PHP-FPM
Vale entender bem o problema antes de aceitar a solução.
O ciclo do FPM é: recebe requisição → inicializa tudo do zero (framework, container, conexões) → executa → devolve resposta → destrói tudo. A cada requisição, de novo.
Isso tem duas consequências:
Bootstrap repetido. Todo request reconstrói o container de injeção de dependência, relê configuração, reabre conexão com banco e Redis. Em Laravel, esse bootstrap custa entre 15 ms e 60 ms — pagos de novo a cada requisição.
Bloqueio em I/O. É o mais caro. Enquanto o curl espera o gateway de
pagamento, aquele processo PHP inteiro — com seus 40 MB de memória — não faz
mais nada.
Uma API que chama três serviços externos de 100 ms cada, em sequência, gasta 300 ms por requisição praticamente sem usar CPU. Você está pagando servidor para esperar.
O que o Swoole muda
O Hyperf roda sobre o Swoole (ou Swow), uma extensão em C que traz duas coisas ao PHP:
- Um servidor HTTP residente. O processo sobe uma vez, carrega o framework uma vez, e fica no ar atendendo requisições. Bootstrap deixa de ser custo por requisição.
- Corrotinas. Quando o código faz I/O, em vez de bloquear o processo, ele cede o controle e o mesmo processo passa a atender outra requisição. Quando a resposta do I/O chega, ele volta de onde parou.
Corrotina não é thread
Vale desfazer a confusão porque ela leva a bugs:
| Processo (FPM) | Thread | Corrotina | |
|---|---|---|---|
| Custo de criação | ~MB | ~KB | ~8 KB |
| Quem troca o contexto | Sistema operacional | Sistema operacional | O runtime, no ponto de I/O |
| Paralelismo real | Sim | Sim | Não |
| Precisa de mutex | Não (isolado) | Sim | Raramente |
O ponto mais importante: corrotina não é paralelismo, é concorrência. Num único worker, só uma corrotina executa PHP por vez. O ganho vem de nenhuma delas ficar parada esperando I/O.
Para usar todos os núcleos, o Hyperf sobe vários workers — normalmente um por núcleo. Cada worker roda milhares de corrotinas.
Isso significa que Hyperf não acelera código pesado de CPU. Se seu gargalo é processar imagem ou calcular relatório, corrotina não ajuda em nada.
O primeiro serviço
composer create-project hyperf/hyperf-skeleton minha-api
cd minha-api
php bin/hyperf.php startUm controller se parece bastante com o que você já conhece:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Service\PedidoService;
use Hyperf\Di\Annotation\Inject;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\GetMapping;
use Hyperf\HttpServer\Contract\ResponseInterface;
#[Controller(prefix: '/pedidos')]
final class PedidoController
{
#[Inject]
private PedidoService $pedidos;
#[GetMapping(path: '{id:\d+}')]
public function mostrar(int $id, ResponseInterface $response)
{
return $response->json(
$this->pedidos->buscarComContexto($id)
);
}
}As rotas vêm de atributos PHP 8, resolvidos no boot e cacheados. Injeção de
dependência por #[Inject] ou pelo construtor — o container do Hyperf é
compatível com PSR-11.
Onde está o ganho de verdade
Pool de conexões
Esta é a mudança que mais rende, e é a que o FPM nunca pôde ter.
No FPM, cada processo abre sua própria conexão com o banco a cada requisição. 50 processos = 50 conexões, abertas e fechadas o tempo todo. O handshake de TCP e a autenticação do MySQL custam de 1 ms a 5 ms — por requisição.
No Hyperf, o worker mantém um pool de conexões vivas. A corrotina pega uma emprestada, usa, devolve.
return [
'default' => [
'driver' => env('DB_DRIVER', 'mysql'),
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'pool' => [
'min_connections' => 5,
'max_connections' => 50,
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
// Recicla a conexão a cada 60s para não morrer no wait_timeout do MySQL
'max_idle_time' => 60.0,
],
],
];Duas configurações que mordem quem não presta atenção: wait_timeout é quanto
a corrotina espera por uma conexão livre antes de estourar — se você vê
Connection pool exhausted, ou o pool é pequeno demais ou alguma query está
demorando muito e segurando conexão. E max_idle_time precisa ficar abaixo
do wait_timeout do seu MySQL (padrão 8 horas, mas quase sempre reduzido em
produção), senão o pool devolve conexões que o servidor já derrubou.
Executar em paralelo o que era sequencial
Aqui a diferença é visível a olho nu. Uma tela que precisa de três serviços:
public function montarDashboard(int $usuarioId): array
{
$perfil = $this->apiPerfil->buscar($usuarioId); // 100ms
$pedidos = $this->apiPedidos->ultimos($usuarioId); // 100ms
$creditos = $this->apiCreditos->saldo($usuarioId); // 100ms
return compact('perfil', 'pedidos', 'creditos');
}use function Hyperf\Coroutine\parallel;
public function montarDashboard(int $usuarioId): array
{
[$perfil, $pedidos, $creditos] = parallel([
fn () => $this->apiPerfil->buscar($usuarioId),
fn () => $this->apiPedidos->ultimos($usuarioId),
fn () => $this->apiCreditos->saldo($usuarioId),
]);
return compact('perfil', 'pedidos', 'creditos');
}Três chamadas ao mesmo tempo, no mesmo processo, sem thread e sem fila. O tempo total passa a ser o da mais lenta, não a soma.
Quando você precisa de mais controle — como limitar a concorrência para não
inundar o serviço do outro lado — use WaitGroup ou o parâmetro de
concorrência do parallel:
use Hyperf\Coroutine\WaitGroup;
use function Hyperf\Coroutine\co;
$wg = new WaitGroup();
$resultados = [];
foreach ($lotes as $indice => $lote) {
$wg->add();
co(function () use ($wg, $lote, $indice, &$resultados) {
try {
$resultados[$indice] = $this->processar($lote);
} finally {
$wg->done(); // sempre no finally
}
});
}
$wg->wait(30.0); // timeout em segundos
// Ou: no máximo 5 corrotinas simultâneas
$resultados = parallel($tarefas, 5);O finally no done() não é preciosismo. Se a corrotina lançar exceção antes
de chamar done(), o wait() fica pendurado até o timeout.
As armadilhas que derrubam quem vem do FPM
Esta é a parte que os tutoriais não contam, e é onde nasce o bug difícil.
No FPM, toda variável morre no fim da requisição. Essa garantia sumiu. O processo é residente, e o que você guardar num lugar errado sobrevive — e vaza para a requisição do próximo usuário.
Estado em propriedade de singleton
// ERRADO: o serviço é singleton, a propriedade sobrevive à requisição
final class RelatorioService
{
private ?Usuario $usuarioAtual = null; // ⚠️
public function gerar(Usuario $usuario): array
{
$this->usuarioAtual = $usuario;
// Se outra corrotina rodar aqui no meio (qualquer I/O cede o controle),
// $this->usuarioAtual pode ser de OUTRO usuário quando voltar.
$dados = $this->repositorio->buscar();
return $this->formatar($dados, $this->usuarioAtual);
}
}Esse bug é traiçoeiro porque não aparece em teste e não aparece com pouca carga. Ele aparece em produção, com concorrência, e o sintoma é o usuário A vendo dado do usuário B.
final class RelatorioService
{
public function gerar(Usuario $usuario): array
{
$dados = $this->repositorio->buscar();
return $this->formatar($dados, $usuario);
}
}Quando você realmente precisa de estado: Context
Para o caso legítimo de carregar algo pela requisição inteira (usuário
autenticado, ID de correlação), o Hyperf oferece o Context — um armazenamento
isolado por corrotina.
use Hyperf\Context\Context;
// Guarda
Context::set('usuario.id', $usuario->id);
// Recupera, com padrão
$id = Context::get('usuario.id', 0);
// Calcula só uma vez por requisição
$tenant = Context::getOrSet('tenant', fn () => $this->resolverTenant());Uma pegadinha: se você criar uma corrotina filha com co(), ela não herda o
Context do pai automaticamente. Use Hyperf\Coroutine\Coroutine::create() com a
cópia explícita, ou passe o valor como argumento do closure.
static e variáveis globais
Qualquer propriedade static é compartilhada entre todas as requisições do
worker, para sempre. Cache estático que era inofensivo no FPM vira vazamento de
memória — e possivelmente vazamento de dado entre usuários.
// No FPM: cache inofensivo, morre no fim da requisição.
// No Hyperf: cresce até o processo morrer.
private static array $cache = [];
// Use o cache do framework, com TTL e limite
use Psr\SimpleCache\CacheInterface;
$this->cache->set("usuario:{$id}", $dados, 300);Bibliotecas que bloqueiam
O Swoole faz hook das funções nativas de I/O (curl, PDO, streams), então a
maior parte do ecossistema funciona. Mas nem tudo:
- Extensões que fazem I/O em C sem passar pelos hooks (alguns drivers antigos)
sleep()— useHyperf\Coroutine\Coroutine::sleep()file_get_contentsem socket sem hook- Chamadas a binários com
exec()bloqueiam o worker inteiro
Uma única chamada bloqueante trava todas as corrotinas daquele worker. É a causa mais comum de “o Hyperf ficou mais lento que o FPM” — quase sempre há uma biblioteca bloqueando escondida no meio.
Quando não usar Hyperf
Já recomendei não migrar mais vezes do que recomendei migrar. Os casos em que não compensa:
Seu gargalo é CPU, não I/O. Processamento de imagem, relatório pesado, criptografia. Corrotina não cria núcleo de processador.
Você tem menos de algumas centenas de requisições por segundo. Abaixo disso, FPM com OPcache e um Redis bem usado resolve, e você não paga o custo cognitivo do modelo residente.
O time não conhece o modelo. O bug de estado compartilhado é sutil, não aparece em teste e vaza dado entre usuários. Sem alguém que entenda corrotina revisando o código, é questão de tempo.
Você depende de pacotes do ecossistema Laravel. Boa parte assume o ciclo de vida do FPM. O Hyperf tem componentes próprios para quase tudo, mas você vai reescrever integração.
Precisa de deploy simples. FPM recarrega o código a cada requisição. Hyperf é processo residente: todo deploy precisa reiniciar o serviço, com graceful reload para não derrubar requisição em andamento.
Um benchmark honesto
Números que medi num serviço interno de agregação — o cenário em que Hyperf mais brilha: muito I/O, pouca CPU.
| PHP-FPM 8.3 | Hyperf 3.1 | |
|---|---|---|
| Requisições/s | 320 | 2.850 |
| Latência p50 | 78 ms | 12 ms |
| Latência p99 | 410 ms | 47 ms |
| Memória em repouso | 1,2 GB (50 workers) | 180 MB (4 workers) |
O que este benchmark não diz: o endpoint faz três chamadas HTTP externas. É o melhor caso possível para corrotina. Num endpoint que só lê uma linha do MySQL e serializa, a diferença cai para algo entre 2× e 3× — vinda do bootstrap economizado, não da concorrência.
Meça o seu endpoint antes de decidir. Um teste de carga honesto vale mais que qualquer tabela em artigo, inclusive esta.
Checklist para começar
- Confirme que seu gargalo é I/O, não CPU. Se não for, pare aqui.
- Comece por um serviço novo ou um endpoint isolado. Não migre o monólito.
- Ligue
Model::preventLazyLoading()do Hyperf ORM — o N+1 dói mais aqui, porque cada query segura uma conexão do pool. - Audite todo
statice toda propriedade mutável de singleton antes do primeiro deploy. - Configure
max_idle_timeabaixo dowait_timeoutdo banco. - Faça teste de carga com concorrência real. Bug de corrotina só aparece sob carga.
- Monitore memória do worker ao longo do tempo. Curva subindo é vazamento.
O Hyperf não é “Laravel mais rápido”. É outro modelo de execução, com outras regras e outra classe de bugs. Quando o problema é o que ele resolve, o ganho é grande demais para ignorar. Quando não é, você trocou complexidade por nada.