Voltar ao Blog
Hyperf10 min de leitura

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:

  1. 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.
  2. 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

Instalação
composer create-project hyperf/hyperf-skeleton minha-api
cd minha-api
php bin/hyperf.php start

Um controller se parece bastante com o que você já conhece:

app/Controller/PedidoController.php
<?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.

config/autoload/databases.php
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:

Sequencial — 300ms
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');
}
Concorrente — ~100ms
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:

Controlando a concorrência
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

O bug clássico
// 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.

A correção: passe adiante, não guarde
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.

Estado seguro por requisição
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.

Cache estático vira problema
// 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() — use Hyperf\Coroutine\Coroutine::sleep()
  • file_get_contents em 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

  1. Confirme que seu gargalo é I/O, não CPU. Se não for, pare aqui.
  2. Comece por um serviço novo ou um endpoint isolado. Não migre o monólito.
  3. Ligue Model::preventLazyLoading() do Hyperf ORM — o N+1 dói mais aqui, porque cada query segura uma conexão do pool.
  4. Audite todo static e toda propriedade mutável de singleton antes do primeiro deploy.
  5. Configure max_idle_time abaixo do wait_timeout do banco.
  6. Faça teste de carga com concorrência real. Bug de corrotina só aparece sob carga.
  7. 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.

HyperfPHPSwooleCorrotinasPerformance

Leia também