
Olá Habr!
Costumamos escrever e falar sobre o desempenho do PHP:
como lidamos com isso em geral,
como economizamos US $ 1 milhão ao mudar para o PHP 7.0 e também
traduzimos vários materiais sobre esse tópico. Isso se deve ao fato de o público-alvo de nossos produtos estar crescendo e o dimensionamento do back-end do PHP com ferro é muito caro - temos 600 servidores com PHP-FPM. Portanto, investir tempo em otimização é benéfico para nós.
Antes, conversávamos principalmente sobre as formas usuais e já estabelecidas de trabalhar com produtividade. Mas a comunidade PHP está em alerta! O JIT aparecerá no PHP 8, o pré-carregamento aparecerá no PHP 7.4 e estruturas fora do núcleo do desenvolvimento do PHP serão desenvolvidas, assumindo que o PHP funcione como um daemon. É hora de experimentar algo novo e ver o que isso pode nos dar.
Como o lançamento do PHP 8 ainda está muito longe, e as estruturas assíncronas são pouco adequadas para nossas tarefas (por que - vou dizer a seguir), hoje vamos nos concentrar no pré-carregamento, que aparecerá no PHP 7.4, e na estrutura para demonizar o PHP, RoadRunner.
Esta é a versão em texto do meu relatório com o
Badoo PHP Meetup # 3 . Vídeo de todos os discursos que
reunimos neste post .
PHP-FPM, Apache mod_php e maneiras semelhantes de executar scripts PHP e processar solicitações (que são executadas pela grande maioria dos sites e serviços; por simplicidade, vou chamá-los de PHP "clássico") funcionam com base
em nada compartilhado no sentido amplo do termo:
- estado não é vasculhado entre trabalhadores de PHP;
- o estado não é vasculhado entre vários pedidos.
Considere isso com um exemplo de um script simples:
Para cada solicitação, o script é executado da primeira à última linha: apesar de a inicialização, provavelmente, não diferir da solicitação da solicitação e poder ser executada uma vez (economizando recursos), você ainda precisa repeti-lo para cada solicitação. Não podemos simplesmente pegar e salvar variáveis (por exemplo,
$app
) entre solicitações devido às peculiaridades de como o PHP “clássico” funciona.
Como seria se fôssemos além do escopo do PHP "clássico"? Por exemplo, nosso script poderia ser executado independentemente da solicitação, inicializar e ter um loop de consulta dentro dele, no qual ele esperaria o próximo, processaria e repetiria o loop sem limpar o ambiente (a seguir chamarei essa solução de "PHP como um daemon" ").
Conseguimos não apenas nos livrar da inicialização repetida para cada solicitação, mas também salvar a lista de cidades uma vez na variável
$cities
e usá-la em várias solicitações sem acessar qualquer lugar, exceto a memória (esta é a maneira mais rápida de obter dados).
O desempenho dessa solução é potencialmente significativamente maior que o do PHP "clássico". Mas geralmente o aumento da produtividade não é dado de graça - você precisa pagar um preço por isso. Vamos ver o que pode ser no nosso caso.
Para fazer isso, vamos complicar um pouco o nosso script e, em vez de exibir a variável
$name
, preencheremos a matriz:
- $name = $cities[$req->getCookie('city_id')]; + $names[] = $cities[$req->getCookie('city_id')];
No caso do PHP "clássico", não haverá problemas - no final da consulta, a variável
$name
será destruída e cada solicitação subsequente funcionará conforme o esperado. No caso de iniciar o PHP como um daemon, cada solicitação adicionará outra cidade a essa variável, o que levará a um crescimento descontrolado da matriz até que a memória se esgote na máquina.
Em geral, não apenas a memória pode terminar - podem ocorrer outros erros que levarão à morte do processo. Com esses problemas, o PHP "clássico" lida automaticamente. No caso de iniciar o PHP como um daemon, precisamos monitorar de alguma forma esse daemon, reiniciá-lo se ele travar.
Erros desse tipo são desagradáveis, mas existem soluções eficazes para eles. É muito pior se, devido a um erro, o script não cair, mas alterar imprevisivelmente os valores de algumas variáveis (por exemplo, limpa a matriz
$cities
). Nesse caso, todas as solicitações subsequentes funcionarão com dados incorretos.
Resumindo, é mais fácil escrever código para PHP “clássico” (PHP-FPM, Apache mod_php e similares) - isso nos liberta de vários problemas e erros. Mas por isso pagamos com desempenho.
A partir dos exemplos acima, vemos que em algumas partes do código, o PHP gasta recursos que não poderiam ter sido gastos (ou desperdiçados uma vez) no processamento de cada solicitação da "clássica". Estas são as seguintes áreas:
- conexão de arquivo (incluir, exigir, etc.);
- inicialização (estrutura, bibliotecas, contêiner DI, etc.);
- solicitar dados do armazenamento externo (em vez de armazenar na memória).
O PHP existe há muitos anos e pode até se tornar popular graças a este modelo de trabalho. Durante esse período, muitos métodos de graus variados de sucesso foram desenvolvidos para resolver o problema descrito. Eu mencionei alguns deles no meu
artigo anterior. Hoje, abordaremos duas soluções relativamente novas para a comunidade: preload e RoadRunner.
Pré-carregamento
Dos três pontos listados acima, a
pré-carga foi projetada para lidar com a primeira sobrecarga ao conectar arquivos. À primeira vista, isso pode parecer estranho e sem sentido, porque o PHP já possui o OPcache, que foi criado apenas para esse fim. Para entender a essência, vamos traçar um perfil real com a ajuda do
perf
, sobre a qual o OPcache está ativado, com taxa de acerto igual a 100%.

Apesar do OPcache, vemos que
persistent_compile_file
ocupa 5,84% do tempo de execução da consulta.
Para entender por que isso acontece, podemos ver as fontes de
zend_accel_load_script . Pode-se ver com eles que, apesar da presença do OPcache, com cada chamada para
include/require
assinaturas de classes e funções são copiadas da memória compartilhada para a memória do processo do operador, e vários trabalhos auxiliares são realizados. E esse trabalho deve ser feito para cada solicitação, pois ao final dele a memória do processo do trabalhador é limpa.

Isso é composto pelo grande número de chamadas de inclusão / necessidade que geralmente fazemos em uma única solicitação. Por exemplo, o Symfony 4 inclui cerca de 310 arquivos antes de executar a primeira linha de código útil. Às vezes, isso acontece implicitamente: para criar uma instância da classe A, mostrada abaixo, o PHP carrega automaticamente todas as outras classes (B, C, D, E, F, G). E especialmente a esse respeito, as dependências do Composer que declaram funções se destacam: para garantir que essas funções estejam disponíveis durante a execução do código do usuário, o Composer sempre deve conectá-las independentemente do uso, pois o PHP não possui funções de carregamento automático e elas não podem ser carregado no momento da chamada.
class A extends \B implements \C { use \D; const SOME_CONST = \E::E1; private static $someVar = \F::F1; private $anotherVar = \G::G1; }
Como a pré-carga funciona
O pré-carregamento possui uma única configuração principal, opcache.preload, na qual o caminho para o script PHP é passado. Este script será executado uma vez ao iniciar o PHP-FPM / Apache / etc., e todas as assinaturas de classes, métodos e funções declaradas neste arquivo estarão disponíveis para todos os scripts que processam solicitações da primeira linha de sua execução (importante nota: isso não se aplica a variáveis e constantes globais - seus valores serão redefinidos para zero após o final da fase de pré-carga). Não é mais necessário incluir / exigir chamadas e copiar assinaturas de função / classe da memória compartilhada para a memória do processo: todas são declaradas
imutáveis e, por isso, todos os processos podem se referir ao mesmo local de memória que as contém.
Normalmente, as classes e funções que precisamos estão em arquivos diferentes e é inconveniente combiná-las em um script de pré-carregamento. Mas isso não precisa ser feito: como o preload é um script PHP comum, podemos usar include / require ou opcache_compile_file () do script preload para todos os arquivos que precisamos. Além disso, como todos esses arquivos serão carregados uma vez, o PHP poderá fazer otimizações adicionais que não puderam ser feitas enquanto os arquivos foram conectados separadamente no momento da consulta. O PHP faz otimizações somente dentro da estrutura de cada arquivo separado, mas no caso de pré-carregamento, para todo o código carregado na fase de pré-carregamento.
Pré-carregamento de benchmarks
Para demonstrar na prática os benefícios do pré-carregamento, usei um ponto final associado à CPU, o Badoo. Nosso back-end geralmente é caracterizado por carga ligada à CPU. Esse fato é a resposta para a pergunta por que não consideramos estruturas assíncronas: elas não oferecem nenhuma vantagem no caso de carga ligada à CPU e, ao mesmo tempo, complicam ainda mais o código (ele precisa ser escrito de forma diferente), bem como para trabalhar com uma rede, disco, etc. drivers assíncronos especiais são necessários.
Para apreciar completamente os benefícios do pré-carregamento, para o experimento, baixei com ele todos os arquivos necessários para o script testado no trabalho e carreguei-o com uma aparência de uma carga de produção normal usando o
wrk2 - um análogo mais avançado do Apache Benchmark, mas igualmente simples .
Para experimentar o pré-carregamento, você deve primeiro atualizar para o PHP 7.4 (agora temos o PHP 7.2). Eu medi o desempenho do PHP 7.2, PHP 7.4 sem pré-carregamento e PHP 7.4 com pré-carregamento. O resultado é uma imagem:

Portanto, a transição do PHP 7.2 para o PHP 7.4 fornece + 10% ao desempenho em nosso nó de extremidade e o pré-carregamento fornece outros 10% acima.
No caso de pré-carregamento, os resultados dependerão muito do número de arquivos conectados e da complexidade da lógica executável: se muitos arquivos estiverem conectados e a lógica for simples, o pré-carregamento fornecerá mais do que se houver poucos arquivos e a lógica for complexa.
As nuances da pré-carga
O que aumenta a produtividade geralmente tem uma desvantagem. O pré-carregamento tem muitas nuances, que darei a seguir. Todos eles precisam ser levados em consideração, mas apenas um (primeiro) pode ser fundamental.
Alterar - reiniciar
Como todos os arquivos de pré-carregamento são compilados apenas na inicialização, marcados como imutáveis e não recompilados no futuro, a única maneira de aplicar alterações nesses arquivos é reiniciar (recarregar ou reiniciar) PHP-FPM / Apache / etc.
No caso de recarregar, o PHP tenta reiniciar com a maior precisão possível: as solicitações do usuário não serão interrompidas, mas, mesmo assim, enquanto a fase de pré-carregamento estiver em andamento, todas as novas solicitações aguardarão a conclusão. Se não houver muito código no pré-carregamento, isso pode não causar problemas, mas se você tentar fazer o download de todo o aplicativo, haverá um aumento significativo no tempo de resposta durante uma reinicialização.
Além disso, uma reinicialização (independentemente de ser recarregada ou reiniciada) possui um recurso importante - como resultado dessa ação, o OPcache é limpo. Ou seja, todas as solicitações depois funcionarão com um cache de código de operação frio, o que pode aumentar ainda mais o tempo de resposta.
Caracteres indefinidos
Para o pré-carregamento carregar uma classe, tudo o que depende deve ser definido até este ponto. Para a classe abaixo, isso significa que todas as outras classes (B, C, D, E, F, G), a variável
$someGlobalVar
e a constante SOME_CONST devem estar disponíveis antes da compilação desta classe. Como o script de pré-carregamento é apenas um código PHP comum, podemos definir um carregador automático. Nesse caso, tudo o que estiver conectado a outras classes será carregado automaticamente. Mas isso não funciona com variáveis e constantes: nós mesmos devemos garantir que eles sejam definidos no momento em que essa classe é declarada.
class A extends \B implements \C { use \D; const SOME_CONST = \E::E1; private static $someVar = \F::F1; private $anotherVar = \G::G1; private $varLink = $someGlobalVar; private $constLink = SOME_CONST; }
Felizmente, a pré-carga contém ferramentas suficientes para entender se você tira algo do caminho ou não. Em primeiro lugar, estas são mensagens de aviso com informações sobre o que não foi carregado e por que:
PHP Warning: Can't preload class MyTestClass with unresolved initializer for constant RAND in /local/preload-internal.php on line 6 PHP Warning: Can't preload unlinked class MyTestClass: Unknown parent AnotherClass in /local/preload-internal.php on line 5
Em segundo lugar, o preload adiciona uma seção separada ao resultado da função opcache_get_status (), que mostra o que foi carregado com sucesso na fase de pré-carregamento:

Campo de classe / otimização constante
Como escrevi acima, o pré-carregamento resolve os valores dos campos / constantes da classe e os salva. Isso permite otimizar o código: durante o processamento da solicitação, os dados estão prontos e não precisam ser derivados de outros dados. Mas isso pode levar a resultados não óbvios, que o exemplo a seguir demonstra:
const.php: <?php define('MYTESTCONST', mt_rand(1, 1000));
preload.php: <?php include 'const.php'; class MyTestClass { const RAND = MYTESTCONST; }
script.php: <?php include 'const.php'; echo MYTESTCONST, ', ', MyTestClass::RAND;
O resultado é uma situação contra-intuitiva: parece que as constantes devem ser iguais, já que uma delas recebeu o valor da outra, mas, na realidade, não é assim. Isso ocorre porque as constantes globais, em contraste com as constantes / campos da classe, são limpas à força após o término da fase de pré-carregamento, enquanto as constantes / campos da classe são resolvidas e salvas. Isso leva ao fato de que, durante a execução da solicitação, precisamos definir a constante global novamente, como resultado da qual ela pode obter um valor diferente.
Não é possível redeclarar someFunc ()
No caso de classes, a situação é simples: geralmente não as conectamos explicitamente, mas usamos um carregador automático. Isso significa que se uma classe for definida na fase de pré-carregamento, o carregador automático simplesmente não será executado durante a solicitação e não tentaremos conectar essa classe uma segunda vez.
A situação é diferente com as funções: devemos conectá-las explicitamente. Isso pode levar a uma situação em que, no script de pré-carregamento, conectaremos todos os arquivos necessários às funções e, durante a solicitação, tentaremos fazê-lo novamente (um exemplo típico é o gerenciador de inicialização do Composer: ele sempre tentará conectar todos os arquivos com as funções). Nesse caso, obtemos um erro: a função já foi definida e não pode ser redefinida.
Este problema pode ser resolvido de diferentes maneiras. No caso do Composer, você pode, por exemplo, conectar tudo na fase de pré-carregamento e não conectar nada relacionado ao Composer durante solicitações. Outra solução não é conectar arquivos com funções diretamente, mas fazer isso por meio de um arquivo proxy com uma verificação de function_exists (), como, por exemplo, o Guzzle HTTP.

O PHP 7.4 ainda não foi lançado oficialmente (ainda)
Essa nuance se tornará irrelevante após algum tempo, mas até agora a versão do PHP 7.4 ainda não foi lançada oficialmente e a equipe do PHP nas notas de versão
escreve explicitamente: "Por favor, NÃO use esta versão em produção, é uma versão de teste inicial". Durante nossos experimentos com pré-carregamento, encontramos vários bugs, corrigimos eles mesmos e até
enviamos algo para o upstream. Para evitar surpresas, é melhor aguardar o lançamento oficial.
Roadrunner
O RoadRunner é um daemon escrito em Go, que, por um lado, cria trabalhadores PHP e os monitora (inicia / termina / reinicia conforme necessário) e, por outro lado, aceita solicitações e as transmite a esses trabalhadores. Nesse sentido, seu trabalho não é diferente do trabalho do PHP-FPM (onde também há um processo mestre que monitora os trabalhadores). Mas ainda existem diferenças. A chave é que o RoadRunner não redefine o estado do script após a conclusão da consulta.
Assim, se recordarmos nossa lista de quais recursos são gastos no caso do PHP "clássico", o RoadRunner permitirá que você lide com todos os pontos (a pré-carga, como lembramos, é apenas a primeira):
- conexão de arquivo (incluir, exigir, etc.);
- inicialização (estrutura, bibliotecas, contêiner DI, etc.);
- solicitar dados do armazenamento externo (em vez de armazenar na memória).
O exemplo do Hello World RoadRunner é mais ou menos assim:
$relay = new Spiral\Goridge\StreamRelay(STDIN, STDOUT); $psr7 = new Spiral\RoadRunner\PSR7Client(new Spiral\RoadRunner\Worker($relay)); while ($req = $psr7->acceptRequest()) { $resp = new \Zend\Diactoros\Response(); $resp->getBody()->write("hello world"); $psr7->respond($resp); }
Vamos tentar nosso ponto de extremidade atual, que testamos com pré-carregamento, para rodar no RoadRunner sem modificações, carregá-lo e medir o desempenho. Sem modificações - porque, caso contrário, o benchmark não será completamente honesto.
Vamos tentar adaptar o exemplo do Hello World para isso.
Em primeiro lugar, como escrevi acima, não queremos que o trabalhador caia no caso de um erro. Para fazer isso, precisamos agrupar tudo em uma tentativa global .. captura. Em segundo lugar, como nosso script não sabe nada sobre o Zend Diactoros, para obter a resposta, precisaremos converter seus resultados. Para isso, usamos funções ob_. Terceiro, nosso script não sabe nada sobre a natureza da solicitação PSR-7. A solução é preencher o ambiente PHP padrão dessas entidades. E quarto, nosso script espera que a solicitação morra e todo o estado seja limpo. Portanto, com o RoadRunner, precisaremos fazer essa limpeza sozinhos.
Assim, a versão inicial do Hello World se transforma em algo assim:
while ($req = $psr7->acceptRequest()) { try { $uri = $req->getUri(); $_COOKIE = $req->getCookieParams(); $_POST = $req->getParsedBody(); $_SERVER = [ 'REQUEST_METHOD' => $req->getMethod(), 'HTTP_HOST' => $uri->getHost(), 'DOCUMENT_URI' => $uri->getPath(), 'SERVER_NAME' => $uri->getHost(), 'QUERY_STRING' => $uri->getQuery(),
Pontos de Referência RoadRunner
Bem, é hora de lançar benchmarks.

Os resultados não atendem às expectativas: o RoadRunner permite nivelar mais fatores que causam perdas de desempenho do que a pré-carga, mas os resultados são piores. Vamos descobrir por que isso acontece, como sempre, executando o perf para isso.

Nos resultados do perf, vemos phar_compile_file. Isso ocorre porque incluímos alguns arquivos durante a execução do script e, como o OPcache não está ativado (o RoadRunner executa scripts como a CLI, onde o OPcache está desativado por padrão), esses arquivos são compilados novamente com cada solicitação.
Edite a configuração do RoadRunner - ative o OPcache:


Esses resultados já são mais parecidos com o que esperávamos: o RoadRunner começou a mostrar mais desempenho do que o pré-carregamento. Mas talvez possamos conseguir ainda mais!
Parece não haver nada mais incomum com o perf - vejamos o código PHP. A maneira mais fácil de criar um perfil é usar o
phpspy : ele não requer nenhuma modificação do código PHP - você só precisa executá-lo no console. Vamos fazer isso e criar um gráfico de chama:

Como concordamos em não modificar a lógica de nosso aplicativo para a pureza do experimento, estamos interessados no ramo de pilha associado ao trabalho do RoadRunner:

A parte principal se resume a chamar fread (), quase nada pode ser feito com isso. Mas vemos alguns outros ramos em
\ Spiral \ RoadRunner \ PSR7Client :: acceptRequest () , exceto o próprio medo. Você pode entender o significado deles olhando para o código-fonte:
public function acceptRequest() { $rawRequest = $this->httpClient->acceptRequest(); if ($rawRequest === null) { return null; } $_SERVER = $this->configureServer($rawRequest['ctx']); $request = $this->requestFactory->createServerRequest( $rawRequest['ctx']['method'], $rawRequest['ctx']['uri'], $_SERVER ); parse_str($rawRequest['ctx']['rawQuery'], $query); $request = $request ->withProtocolVersion(static::fetchProtocolVersion($rawRequest['ctx']['protocol'])) ->withCookieParams($rawRequest['ctx']['cookies']) ->withQueryParams($query) ->withUploadedFiles($this->wrapUploads($rawRequest['ctx']['uploads']));
Torna-se claro que o RoadRunner está tentando criar um objeto de solicitação compatível com PSR-7 usando uma matriz serializada. Se sua estrutura trabalha diretamente com objetos de consulta PSR-7 (por exemplo, o Symfony
não funciona ), isso é completamente justificado. Em outros casos, o PSR-7 se torna um link extra antes que a solicitação seja convertida no que seu aplicativo pode trabalhar. Vamos remover esse link intermediário e examinar os resultados novamente:

O script de teste foi bastante fácil, então consegui extrair uma parte significativa do desempenho - + 17% em comparação com o PHP puro (lembro que o pré-carregamento fornece + 10% no mesmo script).
Nuances do RoadRunner
Em geral, o uso do RoadRunner é uma mudança mais séria do que apenas a inclusão da pré-carga; portanto, as nuances aqui são ainda mais significativas.
-, RoadRunner, , PHP- , , , : , , .
-, RoadRunner , «» — . / RoadRunner ; , , , , - .
-, endpoint', , , RoadRunner. .
Conclusão
, «» PHP, , preload RoadRunner.
PHP «» (PHP-FPM, Apache mod_php ) . - , . , , preload JIT.
, , , RoadRunner, .
, (: ):
- PHP 7.2 — 845 RPS;
- PHP 7.4 — 931 RPS;
- RoadRunner — 987 RPS;
- PHP 7.4 + preload — 1030 RPS;
- RoadRunner — 1089 RPS.
Badoo PHP 7.4 , ( ).
RoadRunner , , , , .
Obrigado pela atenção!