Após o lançamento do PHP7, tornou-se possível escrever aplicativos de longa duração a um custo relativamente baixo. Para programadores, projetos como prooph
, broadway
, prooph
, messenger
foram disponibilizados, cujos autores assumem a solução dos problemas mais comuns. Mas e se você der um pequeno passo adiante, investigando a questão?
Vamos tentar descobrir o destino de outra bicicleta, que permite implementar o aplicativo Publicar / Assinar.
Para começar, tentaremos revisar brevemente as tendências atuais no mundo do PHP, bem como uma breve visão da operação assíncrona.
PHP criado para morrer
Durante muito tempo, o PHP foi usado principalmente no fluxo de trabalho de solicitação / resposta. Do ponto de vista dos desenvolvedores, isso é bastante conveniente, porque não há necessidade de se preocupar com vazamentos de memória, monitorar conexões.
Todas as consultas serão executadas isoladamente, os recursos utilizados serão liberados e as conexões, por exemplo, com o banco de dados serão fechadas quando o processo for concluído.
Como exemplo, você pode usar um aplicativo CRUD regular, escrito com base na estrutura do Symfony. Para ler o banco de dados e retornar o JSON, é necessário executar várias etapas (para economizar espaço e tempo, exclua as etapas para gerar / executar opcodes):
- Análise da configuração;
- Compilação de contêineres;
- Solicitar roteamento
- Cumprimento;
- Renderizando o resultado.
Como no caso do PHP (usando aceleradores), a estrutura usa ativamente o armazenamento em cache (algumas tarefas não serão concluídas na próxima solicitação), bem como a inicialização atrasada. A partir da versão 7.4, o pré - carregamento estará disponível, o que otimizará ainda mais a inicialização do aplicativo.
No entanto, não é possível remover completamente todos os custos indiretos da inicialização.
Vamos ajudar o PHP a sobreviver
A solução para o problema parece bastante simples: se você executar o aplicativo sempre que for muito caro, precisará inicializá-lo uma vez e depois passar solicitações para ele, controlando sua execução.
Existem projetos no ecossistema PHP, como php-pm e RoadRunner . Ambos conceitualmente fazem a mesma coisa:
- É criado um processo pai que atua como um supervisor;
- Um conjunto de processos filhos é criado;
- Quando uma solicitação é recebida, o mestre recupera o processo do pool e passa a solicitação para ele. O cliente está pendente neste momento;
- Depois que a tarefa é concluída, o mestre retorna o resultado ao cliente e o processo filho é enviado de volta ao pool.
Se algum processo filho morrer, o supervisor o cria novamente e o adiciona ao pool. Criamos um daemon de nosso aplicativo com um único objetivo: remover a sobrecarga de inicialização, aumentando significativamente a velocidade das solicitações de processamento. Essa é a maneira mais indolor de aumentar a produtividade, mas não a única.
Nota:
muitos exemplos da série “pegue o ReactPHP e acelere o Laravel N times” andam na rede. É importante entender a diferença entre demonizar (e, como resultado, economizar tempo ao inicializar o aplicativo) e multitarefa.
Ao usar php-pm ou roadrunner, seu código não fica bloqueado. Você economiza tempo na inicialização.
Comparando php-pm, roadrunner e ReactPHP / Amp / Swoole é incorreto por definição.
PHP e E / S
A interação com E / S no PHP é executada por padrão no modo de bloqueio. Isso significa que, se executarmos uma solicitação para atualizar as informações na tabela, o fluxo de execução será interrompido, aguardando uma resposta do banco de dados. Quanto mais essas chamadas estiverem no processo de processamento da solicitação, mais tempo os recursos do servidor estarão inativos. De fato, no processo de processamento da solicitação, precisamos acessar o banco de dados várias vezes, escrever algo no log e retornar o resultado ao cliente, no final - também uma operação de bloqueio.
Imagine que você é um operador de call center e precisa ligar para 50 clientes em uma hora.
Você disca o primeiro número e lá está ocupado (o assinante discute por telefone a última série de Game of Thrones e o que a série trouxe).
E agora você está sentado e tentando alcançá-lo antes da vitória. O tempo passa, a mudança está chegando ao fim. Tendo perdido 40 minutos tentando alcançar o primeiro assinante, você perdeu a oportunidade de entrar em contato com outras pessoas e naturalmente recebida do chefe.
Mas você pode fazer o contrário: não espere até que o primeiro assinante esteja livre e assim que ouvir um sinal sonoro, desligue e comece a discar o próximo número. Você pode retornar ao primeiro um pouco mais tarde.
Com essa abordagem, as chances de telefonar para o número máximo de pessoas aumentam bastante, e a velocidade do seu trabalho não depende da tarefa mais lenta.
O código que não bloqueia o encadeamento de execução (não usa chamadas de E / S de bloqueio, além de funções como sleep()
) é chamado de assíncrono.
Vamos voltar ao nosso aplicativo Symfony CRUD. É quase impossível fazê-lo funcionar no modo assíncrono devido à abundância do uso de funções de bloqueio: todos trabalham com configurações, caches, log, renderização da resposta, interação com o banco de dados.
Mas essas são todas as convenções, vamos tentar lançar o Symfony e usar o Amp , que fornece uma implementação do Event Loop (incluindo vários binders), Promises e Coroutines, como uma cereja no bolo para resolver nosso problema.
Promessa é uma maneira de organizar código assíncrono. Por exemplo, precisamos acessar algum recurso http.
Criamos um objeto de solicitação e o passamos para o transporte, que o Promise retorna para nós contendo o estado atual. Existem três estados possíveis:
- Sucesso: nossa solicitação foi concluída com sucesso;
- Erro: durante a execução da solicitação, algo deu errado (por exemplo, o servidor retornou uma resposta de 500);
- Aguardando: o processamento da solicitação ainda não foi iniciado.
Cada promessa tem um método (no exemplo, promessa é analisada por Amp ) - onResolve()
, no qual uma função de retorno de chamada com dois argumentos é passada
$promise->onResolve( static function(?/Throwable $throwable, $result): void { if(null !== $throwable) { return; } } );
Depois que recebemos o Promise, surge a pergunta: quem irá monitorar seu status e nos notificar sobre a alteração de status?
Para isso, o Loop de Eventos é usado.
Em essência, um loop de eventos é um planejador que monitora a execução. Assim que a tarefa for concluída (não importa como), a chamada que passamos para o Promise será chamada.
Quanto às nuances, eu recomendaria a leitura de um artigo de Nikita Popov: multitarefa cooperativa usando corotinas . Isso ajudará a esclarecer o que está acontecendo e onde estão os geradores.
Armado com novos conhecimentos, vamos tentar retornar à nossa tarefa de renderização JSON.
Um exemplo de processamento de uma solicitação http recebida usando o servidor anfph / http .
Assim que recebemos a solicitação, é executada uma leitura assíncrona do banco de dados (obtemos Promise) e, após a conclusão, o usuário receberá o cobiçado JSON, formado com base nos dados recebidos.
Se precisarmos ouvir uma porta de vários processos, podemos olhar para o anfph / cluster
A principal diferença é que um único processo pode atender a várias solicitações por vez, devido ao fato de o encadeamento de execução não estar bloqueado. O cliente receberá sua resposta quando a leitura do banco de dados estiver concluída e, enquanto não houver resposta, você poderá começar a atender a próxima solicitação.
O maravilhoso mundo do PHP assíncrono
Isenção de responsabilidade
PHP assíncrono é considerado no contexto de exóticos e não é considerado algo saudável / normal. Basicamente, eles vão esperar risadas no estilo de "pegue GO / Kotlin, um tolo", etc. Eu não diria que essas pessoas estão erradas, mas ...
Existem vários projetos que ajudam a escrever código PHP sem bloqueio. Na estrutura do artigo, não analisarei completamente todos os prós e contras, mas tentarei apenas examinar cada um deles superficialmente.
Uma estrutura assíncrona escrita em contraste com as outras em C e entregue como uma extensão do PHP. Possui talvez os melhores indicadores de desempenho no momento.
Há uma implementação de canais, corutin e outras coisas saborosas, mas ele tem 1 grande menos - a documentação. Embora seja parcialmente em inglês, na minha opinião, não é muito detalhado, e a API em si não é muito óbvia.
Quanto à comunidade, também não é tudo simples e inequívoco. Pessoalmente, não conheço uma única pessoa viva que use Swoole em batalha. Talvez eu supere meus medos e migrei para ele, mas isso não acontecerá no futuro próximo.
Às desvantagens, você também pode adicionar que contribuir para o projeto (usando solicitação de recebimento) com quaisquer alterações também é difícil se você não conhece C no nível adequado.
Se perder em velocidade para o seu concorrente (falando sobre Swoole), isso não é muito perceptível e a diferença em vários cenários pode ser negligenciada.
Possui integração com o ReactPHP, que, por sua vez, expande o número de implementações de problemas de infraestrutura. Para economizar espaço, descreverei os contras, juntamente com o ReactPHP.
As vantagens incluem uma comunidade bastante grande e um grande número de exemplos. Os contras começam a aparecer no processo de uso - esse é o conceito da Promise.
Se você precisar executar várias operações assíncronas, o código se transformará em um lixo interminável de chamadas (aqui está um exemplo de uma conexão simples com o RabbiqMQ sem criar troca / fila e seus ligantes).
Com algum refinamento em um arquivo (considerado a norma), você pode obter uma implementação da corotina, que ajudará a se livrar do inferno da Promise.
Sem o projeto recoilphp / recoil, o uso do ReactPHP, na minha opinião, não é possível em um aplicativo sensato.
Além disso, além de tudo o mais, sente-se que seu desenvolvimento desacelerou muito. Não basta, por exemplo, trabalho normal com o PostgreSQL.
Na minha opinião, a melhor das opções que existem no momento atual.
Além da promessa usual, há uma implementação da Coroutine, que facilita muito o processo de desenvolvimento e o código parece mais familiar aos programadores de PHP.
Os desenvolvedores constantemente complementam e melhoram o projeto, com feedback também não há problemas.
Infelizmente, com todas as vantagens da estrutura, a comunidade é relativamente pequena, mas ao mesmo tempo há implementações, por exemplo, trabalhando com o PostgreSQL, além de todas as coisas básicas (sistema de arquivos, cliente http, DNS, etc).
Ainda não entendo bem o destino do projeto ext-async, mas os caras o acompanham. O que virá disso na 3ª versão, o tempo dirá.
Introdução
Então, resolvemos um pouco a parte teórica, é hora de praticar e preencher os inchaços.
Primeiro, formalizamos um pouco os requisitos:
- Mensagens assíncronas (o conceito de
message
si pode ser dividido em 2 tipos)
command
: indica a necessidade de concluir a tarefa. Não retorna um resultado (pelo menos no caso de comunicação assíncrona);event
: relata qualquer alteração de estado (por exemplo, como resultado de um comando).
- Formato sem bloqueio para trabalhar com E / S;
- A capacidade de aumentar facilmente o número de processadores;
- Capacidade de escrever manipuladores de mensagens em qualquer idioma.
Qualquer mensagem é inerentemente uma estrutura simples e compartilhada apenas pela semântica. A nomeação de mensagens é extremamente importante do ponto de vista da compreensão do tipo e objetivo (embora este ponto seja ignorado no exemplo).
Para uma lista de requisitos, é mais adequada uma implementação simples do padrão Publicar / Assinar .
Para garantir a execução distribuída, usaremos o RabbitMQ como um intermediário de mensagens.
O protótipo foi escrito usando ReactPHP , Bunny e DoctrineDBAL .
Um leitor atento pode perceber que o Dbal usa chamadas de bloqueio de pdo / mysqli internamente, mas no estágio atual isso não era particularmente importante, pois você tinha que entender o que deveria acontecer no final.
Um dos problemas foi a falta de bibliotecas para trabalhar com o PostgreSQL. Existem alguns rascunhos, mas isso não é suficiente para o trabalho completo (mais sobre isso abaixo).
Após uma breve pesquisa, o ReactPHP foi removido em favor do Amp, pois é relativamente simples e se desenvolve ativamente.
RabbitMQ transport
Mas com todas as vantagens do Amp, houve um problema: O Amp não possui um driver para o RabbitMQ (o Bunny suporta apenas o ReactPHP).
Em teoria, o Amp permite usar o Promise de um concorrente. Parece que tudo deve ser simples, mas o ReactPHP usa o Event Loop para trabalhar com soquetes na biblioteca.
Em um determinado momento, obviamente, dois Loops de Eventos diferentes não puderam ser iniciados, portanto não pude usar a função adap () .
Infelizmente, a qualidade do código no bunny deixou muito a desejar e não foi possível substituir adequadamente uma implementação por outra. Para não interromper o trabalho, foi decidido reescrever a biblioteca um pouco para que funcione com o Amp e não leve ao bloqueio do fluxo de execução.
Essa adaptação parecia muito assustadora, o tempo todo eu tinha muita vergonha, mas o mais importante era que funcionava. Bem, como não há nada mais permanente do que temporário, o adaptador permaneceu na expectativa de uma pessoa que não tem preguiça de lidar com a implementação do driver.
E esse homem foi encontrado. O projeto PHPinnacle , entre outras coisas, fornece uma implementação de um adaptador personalizado para o Amp.
O nome do autor é Anton Shabovta, que falará sobre php assíncrono dentro da estrutura do PHP Russia e sobre o desenvolvimento de drivers para PHP nos dias úteis .
PostgreSQL
A segunda característica do trabalho é a interação com o banco de dados. Nas condições do PHP “tradicional”, tudo é simples: temos uma conexão e todos os pedidos são executados sequencialmente.
No caso de execução assíncrona, precisamos executar simultaneamente várias solicitações (por exemplo, 3 transações). Para poder fazer isso, é necessária uma implementação do conjunto de conexões.
O mecanismo do trabalho é bastante simples:
- abrimos N conexões na inicialização (ou inicialização atrasada, não é o ponto);
- se necessário, retiramos a conexão do pool, garantindo que ninguém mais possa usá-lo;
- Executamos a solicitação e destruímos a conexão ou a devolvemos ao pool (preferencial).
Primeiro, ele permite iniciar várias transações ao mesmo tempo e, em segundo lugar, acelera o trabalho devido à presença de conexões já abertas. O Amp possui um componente amphp / postgres . Ele cuida das conexões: monitora o número, a vida útil e tudo isso sem bloquear o fluxo de execução.
A propósito, ao usar, por exemplo, o ReactPHP, você precisará implementar isso sozinho se quiser trabalhar com um banco de dados.
Mutex
Para uma operação eficaz e, mais importante, adequada do aplicativo, é necessário implementar algo semelhante aos mutexes. Podemos distinguir três cenários para seu uso:
- Dentro da estrutura de um processo, um mecanismo simples de memória é adequado sem excedentes;
- Se queremos fornecer bloqueio em vários processos, podemos usar o sistema de arquivos (é claro, no modo sem bloqueio);
- Se no contexto de vários servidores, você já precisa pensar em algo como o Zookeeper.
Mutexes são necessários para resolver problemas de condição de corrida . Afinal, não sabemos (e não sabemos) em que ordem nossas tarefas serão executadas, mas, no entanto, precisamos garantir a integridade dos dados.
Log / Contextos
Para o registro, o Monolog já se tornou padrão, mas com algumas ressalvas: não podemos usar os manipuladores internos, pois eles levarão a bloqueios.
Para gravar no stdOut, você pode usar o anfph / log ou escrever uma mensagem simples enviando para algum Graylog.
Como em um momento, podemos processar muitas tarefas e, ao registrar registros, você precisa entender em que contexto os dados são gravados. Durante os experimentos, foi decidido fazer trace_id
( rastreamento distribuído ). A linha inferior é que toda a cadeia de chamadas deve ser acompanhada por um identificador de passagem que possa ser rastreado. Além disso, no momento do recebimento da mensagem, package_id
gerado, o que indica exatamente a mensagem recebida.
Assim, usando os dois identificadores, podemos rastrear facilmente a que determinado registro se refere. O fato é que, no PHP tradicional, todos os registros que obtemos no log estão principalmente na ordem em que foram gravados. No caso de execução assíncrona, não há padrão na ordem das entradas.
Terminando
Outra das nuances do desenvolvimento assíncrono é controlar o desligamento do nosso daemon. Se você simplesmente finalizar o processo, todas as tarefas em andamento não serão concluídas e os dados serão perdidos.Na abordagem usual, existe um problema, mas não é tão grande, porque apenas uma tarefa é executada por vez.
Para concluir a execução corretamente, precisamos:
- Cancele a inscrição na fila. Em outras palavras, torne impossível receber novas mensagens;
- Conclua todas as tarefas restantes (aguarde a resolução de promessas);
- E somente depois disso termine o script.
Vazamentos, depuração
Ao contrário da crença popular, no PHP moderno não é tão simples enfrentar situações nas quais ocorre um vazamento de memória. É necessário fazer algo absolutamente errado.
No entanto, uma vez confrontado com isso, mas por causa do descuido banal. Durante a implementação da pulsação, um novo timer foi adicionado a cada 40 segundos para consultar a conexão. Não é difícil adivinhar que, depois de algum tempo, o uso da memória começou a surgir rapidamente.
Além disso, ele escreveu um observador simples que iniciará opcionalmente a cada 10 minutos e chamará gc_collect_cycles () e gc_mem_caches () .
Mas o início forçado do coletor de lixo não é algo necessário e fundamental.
Para ver constantemente o uso da memória, um MemoryUsageProcessor padrão foi adicionado ao log .
Se surgir o pensamento de que o Loop de Eventos está bloqueando algo, isso também pode ser verificado com facilidade: basta conectar o LoopBlockWatcher .
Mas você precisa garantir que esse observador não inicie no ambiente de produção. Esse recurso é usado exclusivamente durante o desenvolvimento.
Resultados
: php-service-bus , Message Based .
, :
composer create-project php-service-bus/skeleton pub-sub-example cd pub-sub-example docker-compose up --build -d
, , .
/bin/consumer
, .
/src
3 : Ping
; Pong
: ; PingService
: , .
PingService
, 2 :
public function handle(Ping $command, KernelContext $context): Promise { return $context->delivery(new Pong()); } public function whenPong(Pong $event, KernelContext $context): void { $context->logContextMessage('Pong message received'); }
handle
( 1 ). @CommandHandler
;
- Promise , RabbitMQ (
delivery()
). , RabbitMQ .
whenPong
— Pong
. . @EventListener
;
, — . , , , . php-service-bus , , .
2 : , ( ) . , , (, ).
Ping
, Pong
. .
, RabbitMQ:
tools/ping
, php-service-bus , Message based .
Ping\Pong, — , , Hello, world
.
, .
- , , , Saga pattern (Process manager) .
, symfony/messenger .
, , .