Versão em texto do relatório "Atores vs CSP x Tarefas ..." com C ++ CoreHard Outono de 2018

No início de novembro, Minsk organizou a próxima conferência C ++ CoreHard Outono de 2018. Ele entregou um relatório do capitão "Atores vs CSP x Tarefas ..." , que falou sobre como aplicativos de nível superior do que "podem parecer em C ++" multithreading bare ”, modelos de programação competitivos. Sob a versão recortada deste relatório, transformada em artigo. Penteado, aparado em alguns lugares, suplementado em alguns lugares.

Gostaria de aproveitar esta oportunidade para agradecer à comunidade CoreHard por organizar a próxima grande conferência em Minsk e pela oportunidade de falar. E também para a pronta publicação de relatórios em vídeo de relatórios no YouTube .

Então, vamos ao tópico principal da conversa. Ou seja, quais abordagens podemos usar para simplificar a programação multithread em C ++, como algumas dessas abordagens aparecerão no código, quais recursos são inerentes a abordagens específicas, o que é comum entre elas etc.

Nota: foram encontrados erros e erros de digitação na apresentação original do relatório, portanto, o artigo usará slides da versão atualizada e editada, que podem ser encontrados no Google Slides ou no SlideShare .

Multithreading nu é mau!


Você precisa começar com a repetida banalidade, que, no entanto, ainda permanece relevante:
A programação C ++ multithread através de threads simples, mutex e variáveis ​​de condição é suor , dor e sangue .

Um bom exemplo foi recentemente descrito aqui neste artigo aqui no Habré: " Arquitetura do meta-servidor do Tacticool, jogo para dispositivos móveis online ". Nele, os caras falaram sobre como eles conseguiram coletar, aparentemente, uma gama completa de rakes relacionados ao desenvolvimento de código multiencadeado em C e C ++. Houve “passes de memória” como resultado das corridas e baixo desempenho devido à paralelização malsucedida.

Como resultado, tudo terminou naturalmente:
Depois de algumas semanas procurando e corrigindo os bugs mais críticos, decidimos que era mais fácil reescrever tudo do zero do que tentar corrigir todas as deficiências da solução atual.

As pessoas comiam C / C ++ enquanto trabalhavam na primeira versão do servidor e reescreviam o servidor em outro idioma.

Uma excelente demonstração de como, no mundo real, fora da nossa aconchegante comunidade C ++, os desenvolvedores se recusam a usar C ++ mesmo quando o uso de C ++ ainda é apropriado e justificado.

Mas porque?


Mas por que, se é dito repetidamente que “multithreading simples” em C ++ é mau, as pessoas continuam a usá-lo com perseverança digna de uma aplicação melhor? O que tem a culpa:

  • ignorância?
  • preguiça?
  • Síndrome do NIH?

Afinal, há longe de uma abordagem testada pelo tempo e muitos projetos. Em particular:

  • atores
  • processos sequenciais de comunicação (CSP)
  • tarefas (assíncronas, promessas, futuros, ...)
  • fluxos de dados
  • programação reativa
  • ...

Espera-se que o principal motivo ainda seja a ignorância. É improvável que isso seja ensinado nas universidades. Então, os jovens profissionais, entrando na profissão, usam o pouco que eles já sabem. E se o repositório de conhecimento não for reabastecido, as pessoas continuarão usando threads simples, mutexes e variáveis ​​de condição.

Hoje falaremos sobre as três primeiras abordagens desta lista. E não falaremos abstratamente, mas no exemplo de uma tarefa simples. Vamos tentar mostrar como será o código que resolve esse problema usando o Actor, processos e canais de CSP, além de usar a Tarefa.

Desafio para experimentos


É necessário implementar um servidor HTTP que:

  • aceita a solicitação (ID da imagem, ID do usuário);
  • dá uma imagem com "marcas d'água" exclusivas para esse usuário.

Por exemplo, esse servidor pode ser exigido por algum serviço pago que distribui conteúdo por assinatura. Se a imagem deste serviço "aparecer" em algum lugar, pelas "marcas d'água" nele será possível entender quem precisa "bloquear o oxigênio".

A tarefa é abstrata, foi formulada especificamente para este relatório sob a influência do nosso projeto de demonstração Camarão (já falamos sobre isso: nº 1 , nº 2 , nº 3 ).

Este nosso servidor HTTP funcionará da seguinte maneira:

Após receber uma solicitação de um cliente, passamos a dois serviços externos:

  • o primeiro nos retorna informações do usuário. Inclusive a partir daí, obtemos uma imagem com "marcas d'água";
  • o segundo nos retorna a imagem original

Ambos os serviços funcionam de forma independente e podemos acessá-los simultaneamente.

Como o processamento de solicitações pode ser feito independentemente um do outro, e mesmo algumas ações ao processar uma única solicitação podem ser realizadas em paralelo, o uso da competitividade se sugere. A coisa mais simples que vem à mente é criar um encadeamento separado para cada solicitação recebida:

Mas o modelo de solicitação única = fluxo de trabalho é muito caro e não tem uma escala adequada. Nós não precisamos disso.

Mesmo se abordarmos o número de fluxos de trabalho com desperdício, ainda precisaremos de um pequeno número deles:

Aqui, precisamos de um fluxo separado para receber solicitações HTTP recebidas, um fluxo separado para nossas próprias solicitações HTTP de saída, um fluxo separado para coordenar o processamento de solicitações HTTP recebidas. Além de um conjunto de fluxos de trabalho para executar operações nas imagens (como as manipulações nas imagens são bem paralelas, o processamento de uma imagem por vários fluxos ao mesmo tempo reduz o tempo de processamento).

Portanto, nosso objetivo é lidar com um grande número de solicitações de entrada simultâneas em um pequeno número de threads de trabalho. Vamos ver como conseguimos isso através de várias abordagens.

Algumas isenções de responsabilidade importantes


Antes de passar para a história principal e analisar exemplos de código, é necessário fazer algumas anotações.

Primeiro, todos os exemplos a seguir não estão vinculados a nenhuma estrutura ou biblioteca específica. Quaisquer correspondências nos nomes das chamadas da API são aleatórias e não intencionais.

Em segundo lugar, não há tratamento de erros nos exemplos abaixo. Isso é feito deliberadamente, para que os slides sejam compactos e visíveis. E também para que o material caiba no tempo alocado para o relatório.

Em terceiro lugar, os exemplos usam uma certa entidade entity_context, que contém informações sobre o que mais existe dentro do programa. O preenchimento dessa entidade depende da abordagem. No caso de atores, Execution_context terá links para outros atores. No caso de CSP, em Execution_context, haverá canais de CSP para comunicação com outros processos de CSP. Etc.

Abordagem # 1: Atores


Modelo de atores em poucas palavras


Ao usar o modelo de atores, a solução será construída com objetos-atores separados, cada um com seu próprio estado privado e esse estado é inacessível a qualquer pessoa, exceto o próprio ator.

Os atores interagem entre si através de mensagens assíncronas. Cada ator possui sua própria caixa de correio exclusiva (fila de mensagens), na qual as mensagens enviadas ao ator são salvas e de onde são recuperadas para processamento adicional.

Os atores trabalham com princípios muito simples:

  • um ator é uma entidade com comportamento;
  • atores respondem a mensagens recebidas;
  • Após receber a mensagem, o ator pode:
    • envie um número (final) de mensagens para outros atores;
    • criar um número (final) de novos atores;
    • Defina um novo comportamento para o processamento de mensagens subseqüentes.

Dentro de um aplicativo, os atores podem ser implementados de diferentes maneiras:

  • cada ator pode ser representado como um fluxo de SO separado (isso acontece, por exemplo, na biblioteca C :: Just :: Thread Pro Actor Edition);
  • cada ator pode ser representado como uma rotineira empilhável;
  • cada ator pode ser representado como um objeto no qual alguém chama métodos de retorno de chamada.

Em nossa decisão, usaremos atores na forma de objetos com retornos de chamada e deixaremos rotinas para a abordagem CSP.

Esquema de decisão baseado no modelo de atores


Com base nos atores, o esquema geral para resolver nosso problema terá a seguinte aparência:

Teremos atores criados no início do servidor HTTP e que existem o tempo todo enquanto o servidor HTTP está funcionando. São atores como: HttpSrv, UserChecker, ImageDownloader, ImageMixer.

Após o recebimento de uma nova solicitação HTTP de entrada, criamos uma nova instância do ator RequestHandler, que será destruída após emitir uma resposta à solicitação HTTP de entrada.

Código do ator RequestHandler


A implementação do ator request_handler, que coordena o processamento de uma solicitação HTTP recebida, pode ser assim:
class request_handler final : public some_basic_type { const execution_context context_; const request request_; optional<user_info> user_info_; optional<image_loaded> image_; void on_start(); void on_user_info(user_info info); void on_image_loaded(image_loaded image); void on_mixed_image(mixed_image image); void send_mix_images_request(); ... //     . }; void request_handler::on_start() { send(context_.user_checker(), check_user{request_.user_id(), self()}); send(context_.image_downloader(), download_image{request_.image_id(), self()}); } void request_handler::on_user_info(user_info info) { user_info_ = std::move(info); if(image_) send_mix_images_request(); } void request_handler::on_image_loaded(image_loaded image) { image_ = std::move(image); if(user_info_) send_mix_images_request(); } void request_handler::send_mix_images_request() { send(context_.image_mixer(), mix_images{user_info->watermark_image(), *image_, self()}); } void request_handler::on_mixed_image(mixed_image image) { send(context_.http_srv(), reply{..., std::move(image), ...}); } 

Vamos analisar esse código.

Temos uma classe nos atributos dos quais armazenamos ou vamos armazenar o que precisamos para processar a solicitação. Também nesta classe, há um conjunto de retornos de chamada que serão chamados uma vez ou outra.

Primeiro, quando um ator acaba de ser criado, o retorno de chamada on_start () é chamado. Nele, enviamos duas mensagens para outros atores. Primeiro, esta é uma mensagem check_user para verificar o ID do cliente. Em segundo lugar, esta é uma mensagem de download_image para baixar a imagem original.

Em cada uma das mensagens enviadas, passamos um link para nós mesmos (uma chamada para o método self () retorna um link para o ator para o qual self () foi chamado). Isso é necessário para que nosso ator possa enviar uma mensagem em resposta. Se não enviarmos um link para o nosso ator, por exemplo, na mensagem check_user, o ator do UserChecker não saberá para quem enviar as informações do usuário.

Quando uma mensagem user_info com informações do usuário é enviada para nós em resposta, o retorno de chamada on_user_info () é chamado. E quando a mensagem image_loaded é enviada para nós, o retorno de chamada on_image_loaded () é chamado ao nosso ator. E agora, dentro desses dois retornos de chamada, vemos um recurso inerente ao Modelo de Atores: não sabemos exatamente em que ordem receberemos as mensagens de resposta. Portanto, devemos escrever nosso código para que não dependa da ordem em que as mensagens chegam. Portanto, em cada um dos processadores, primeiro armazenamos as informações recebidas no atributo correspondente e depois verificamos se já coletamos todas as informações necessárias. Se assim for, então podemos seguir em frente. Caso contrário, esperaremos mais.

É por isso que temos ifs on_user_info () e on_image_loaded () executados quando o send_mix_images_request () é chamado.

Em princípio, nas implementações do Modelo de Atores, pode haver mecanismos como o recebimento seletivo de Erlang ou a ocultação de Akka, através dos quais você pode manipular a ordem de processamento das mensagens recebidas, mas não falaremos sobre isso hoje, para não investigar detalhes de várias implementações do Modelo. Atores.

Portanto, se todas as informações que precisamos do UserChecker e ImageDownloader forem recebidas, o método send_mix_images_request () será chamado, no qual a mensagem mix_images será enviada ao ator do ImageMixer. O retorno de chamada on_mixed_image () é chamado quando recebemos uma mensagem de resposta com a imagem resultante. Aqui, enviamos esta imagem ao ator HttpSrv e aguardamos até que o HttpSrv forme uma resposta HTTP e destrua o RequestHandler que se tornou desnecessário (embora, em princípio, nada impeça o ator RequestHandler de se autodestruir no retorno de chamada on_mixed_image ()).

Só isso.

A implementação do ator RequestHandler acabou sendo bastante volumosa. Mas isso se deve ao fato de precisarmos descrever uma classe com atributos e retornos de chamada e também implementar retornos de chamada. Mas a lógica do trabalho do RequestHandler é muito trivial e é fácil entendê-lo, apesar da quantidade de código na classe request_handler.

Recursos inerentes aos atores


Agora podemos dizer algumas palavras sobre os recursos do Modelo de Atores.

Reatores


Como regra, os atores respondem apenas às mensagens recebidas. Há mensagens - o ator as processa. Sem mensagens - o ator não faz nada.

Isso é especialmente verdade para as implementações do Modelo de atores nos quais os atores são representados como objetos com retornos de chamada. A estrutura puxa o retorno de chamada do ator e, se o ator não retornar o controle do retorno de chamada, a estrutura não poderá atender outros atores no mesmo contexto.

Os atores estão sobrecarregados


Sobre os atores, podemos facilmente fazer ator-produtor gerar mensagens para consumidor-ator em um ritmo muito mais rápido do que o ator-consumidor será capaz de processar.

Isso levará ao fato de que a fila de mensagens recebidas para o ator-consumidor crescerá constantemente. Crescimento da fila, ou seja, o aumento do consumo de memória no aplicativo reduzirá a velocidade do aplicativo. Isso levará a um crescimento ainda mais rápido da fila e, como resultado, o aplicativo poderá se degradar para concluir a inoperabilidade.

Tudo isso é uma conseqüência direta da interação assíncrona dos atores. Como a operação de envio geralmente é sem bloqueio. E fazê-lo bloquear não é fácil, porque um ator pode enviar para si mesmo. E se a fila do ator estiver cheia, no envio para si mesmo, o ator será bloqueado e isso interromperá o trabalho.

Portanto, ao trabalhar com atores, deve-se prestar muita atenção ao problema da sobrecarga.

Muitos atores nem sempre são a solução.


Como regra, os atores são entidades leves e existe uma tentação de criá-los em sua aplicação em grandes números. Você pode criar dez mil atores, cem mil e um milhão. E até cem milhões de atores, se o ferro permitir.

Mas o problema é que é difícil rastrear o comportamento de um número muito grande de atores. I.e. você pode ter alguns atores que claramente funcionam corretamente. Alguns atores que obviamente trabalham incorretamente ou não trabalham, e você tem certeza. Mas pode haver um grande número de atores sobre os quais você não sabe nada: eles funcionam de alguma forma, funcionam corretamente ou incorretamente. E tudo porque quando você tem cem milhões de entidades autônomas com sua própria lógica de comportamento em seu programa, monitorar isso é muito difícil para todos.

Portanto, pode ser que, ao criar um grande número de atores no aplicativo, não resolvamos nosso problema aplicado, mas tenhamos outro problema. E, portanto, pode ser benéfico para nós abandonarmos atores simples que resolvem uma única tarefa, em favor de atores mais complexos e pesados ​​que realizam várias tarefas. Mas haverá menos atores "pesados" no aplicativo e será mais fácil segui-los.

Onde procurar, o que levar?


Se alguém quiser tentar trabalhar com atores em C ++, não há sentido em criar suas próprias bicicletas, existem várias soluções prontas, em particular:


Essas três opções são animadas, em evolução, multiplataforma, documentadas. Você também pode experimentá-los gratuitamente. Além disso, várias outras opções de graus variados de [não] frescor podem ser encontradas na lista da Wikipedia .

O SObjectizer e o CAF foram projetados para uso em tarefas de alto nível, nas quais exceções e memória dinâmica podem ser aplicadas. E a estrutura QP / C ++ pode ser de interesse para os envolvidos no desenvolvimento incorporado, como é nesse nicho que ele é "preso".

Abordagem # 2: CSP (comunicando processos sequenciais)


CSP nos dedos e sem matan


O modelo CSP é muito semelhante ao modelo de atores. Também construímos nossa solução a partir de um conjunto de entidades autônomas, cada uma com seu próprio estado privado e interage com outras entidades apenas através de mensagens assíncronas.

Somente essas entidades no modelo CSP são chamadas de "processos".

Os processos no CSP são leves, sem paralelismo de seu trabalho interno. Se precisarmos paralelizar algo, simplesmente iniciaremos vários processos de CSP, dentro dos quais não há mais paralelização.

Os processos CSP interagem entre si por meio de mensagens assíncronas, mas as mensagens são enviadas não para caixas de correio, como no Modelo de atores, mas para canais. Os canais podem ser vistos como filas de mensagens, geralmente de tamanho fixo.

Diferentemente do Modelo de atores, em que uma caixa de correio é criada automaticamente para cada ator, os canais no CSP devem ser criados explicitamente. E se precisamos que os dois processos interajam, devemos criar o canal por nós mesmos e, em seguida, informar o primeiro processo "você escreverá aqui" e o segundo processo deverá dizer: "você lerá aqui daqui".

Ao mesmo tempo, os canais têm pelo menos duas operações que devem ser chamadas explicitamente. A primeira é a operação de gravação (envio) para gravar uma mensagem no canal.

Em segundo lugar, é uma operação de leitura (recebimento) para ler uma mensagem de um canal. E a necessidade de chamar explicitamente leitura / recebimento distingue CSP do Modelo de Atores, porque no caso de atores, a operação de leitura / recebimento geralmente pode estar oculta do ator. I.e. A estrutura do ator pode recuperar mensagens da fila de atores e chamar um manipulador (retorno de chamada) para a mensagem recuperada.

Enquanto o próprio processo CSP deve escolher o momento para a chamada de leitura / recebimento, o processo CSP deve determinar qual mensagem recebeu e processar a mensagem extraída.

Dentro de nosso aplicativo "grande", os processos de CSP podem ser implementados de diferentes maneiras:

  • O processo CSP-shny pode ser implementado como um SO de thread separado. É uma solução cara, mas com multitarefa preventiva;
  • O processo de CSP pode ser implementado por corotina (corotina empilhada, fibra, fio verde, ...). É muito mais barato, mas a multitarefa é apenas cooperativa.

Além disso, assumimos que os processos CSP são apresentados na forma de corotinas empilháveis ​​(embora o código mostrado abaixo possa muito bem ser implementado em threads do SO).

Diagrama da solução baseada em CSP


O esquema de solução baseado no modelo CSP será muito parecido com um esquema semelhante para o Modelo de Atores (e isso não é por acaso):

Também haverá entidades que serão iniciadas quando o servidor HTTP iniciar e funcionar o tempo todo - esses são os processos CSP HttpSrv, UserChecker, ImageDownloader e ImageMixer. Para cada nova solicitação de entrada, um novo processo de RequestHandler CSP será criado. Esse processo envia e recebe as mesmas mensagens que ao usar o modelo de atores.

Código do Processo RequestHandler CSP


Pode parecer o código de uma função que implementa o processo tímido de CSP do RequestHandler:
 void request_handler(const execution_context ctx, const request req) { auto user_info_ch = make_chain<user_info>(); auto image_loaded_ch = make_chain<image_loaded>(); ctx.user_checker_ch().write(check_user{req.user_id(), user_info_ch}); ctx.image_downloader_ch().write(download_image{req.image_id(), image_loaded_ch}); auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); auto image_mix_ch = make_chain<mixed_image>(); ctx.image_mixer_ch().write( mix_image{user.watermark_image(), std::move(original_image), image_mix_ch}); auto result_image = image_mix_ch.read(); ctx.http_srv_ch().write(reply{..., std::move(result_image), ...}); } 

Aqui tudo é bastante trivial e repete regularmente o mesmo padrão:

  • Primeiro, criamos um canal para receber mensagens de resposta. Isso é necessário porque o processo CSP não possui sua própria caixa de correio padrão, como atores. Portanto, se o processo CSP-shny quiser receber algo, ele deve ficar confuso com a criação do canal onde esse "algo" será escrito;
  • então enviamos nossa mensagem para o processo mestre do CSP. E nesta mensagem, indicamos o canal para a mensagem de resposta;
  • então, realizamos a operação de leitura no canal para o qual devemos receber uma mensagem de resposta.

Isso é visto com muita clareza no exemplo de comunicação com o processo do ImageSPixer CSP:
 auto image_mix_ch = make_chain<mixed_image>(); //  . ctx.image_mixer_ch().write( //  . mix_image{..., image_mix_ch}); //     . auto result_image = image_mix_ch.read(); //  . 

Mas separadamente vale a pena focar neste fragmento:
  auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); 

Aqui vemos outra diferença séria em relação ao modelo de atores. No caso do CSP, podemos receber mensagens de resposta na ordem que mais nos convém.

Deseja esperar primeiro por user_info? Não tem problema, vá dormir na leitura até que user_info apareça. Se image_loaded já tiver sido enviado para nós nesse momento, ele simplesmente esperará no canal até lermos.

Isso, de fato, é tudo o que pode acompanhar o código mostrado acima. O código baseado em CSP era mais compacto que seu equivalente baseado em ator. O que não é surpreendente, já que aqui não precisamos descrever uma classe separada com métodos de retorno de chamada. E parte do estado de nosso processo tímido no CSP, RequestHandler, está presente implicitamente na forma dos argumentos ctx e req.

Recursos CSP


Reatividade e proatividade de processos CSP


Diferentemente dos atores, os processos de CSP podem ser reativos, proativos ou ambos. Digamos que o processo CSP verificou suas mensagens recebidas e, se houver alguma, processou-as. E então, vendo que não havia mensagens recebidas, ele se comprometeu a multiplicar as matrizes.

Depois de algum tempo, o processo CSP da matriz estava cansado de se multiplicar, e ele mais uma vez verificou as mensagens recebidas. Não há novos? Bem, ok, vamos multiplicar ainda mais as matrizes.

E essa capacidade dos processos CSP de realizar algum trabalho, mesmo na ausência de mensagens recebidas, torna o modelo CSP muito diferente do modelo de atores.

Mecanismos nativos de proteção contra sobrecarga


Como, via de regra, os canais são filas de mensagens de tamanho limitado e a tentativa de gravar uma mensagem em um canal cheio interrompe o remetente; então, no CSP, temos um mecanismo interno de proteção contra sobrecarga.

De fato, se tivermos um processo de produção ágil e um processo de consumo lento, o processo de produção preencherá rapidamente o canal e será suspenso para a próxima operação de envio. E o processo produtor ficará suspenso até que o processo consumidor libere espaço no canal para novas mensagens. Assim que o local aparece, o processo do produtor é ativado e lança novas mensagens no canal.

Assim, ao usar o CSP, podemos nos preocupar menos com o problema de sobrecarga do que no caso do Modelo de Atores. É verdade que há uma armadilha aqui, sobre a qual falaremos um pouco mais tarde.

Como os processos de CSP são implementados


Precisamos decidir como nossos processos de CSP serão implementados.

Isso pode ser feito para que cada processo CSP-shny seja representado por um thread do SO separado. Acontece uma solução cara e não escalável. Mas, por outro lado, temos multitarefa preemptiva: se nosso processo CSP começar a multiplicar matrizes ou fazer algum tipo de chamada de bloqueio, o SO acabará empurrando-o para fora do núcleo computacional e possibilitando o funcionamento de outros processos CSP.

É possível fazer com que cada processo CSP seja representado por uma corotina (corotina empilhável). Esta é uma solução muito mais barata e escalável. Mas aqui teremos apenas multitarefa cooperativa. Portanto, se de repente o processo CSP ocupar a multiplicação de matrizes, o encadeamento de trabalho com esse processo CSP e outros processos CSP anexados a ele será bloqueado.

Pode haver outro truque. Suponha que usamos uma biblioteca de terceiros, na qual não podemos influenciar. E dentro da biblioteca, as variáveis ​​TLS são usadas (ou seja, thread-local-storage). Fazemos uma chamada para a função de biblioteca e a biblioteca define o valor de alguma variável TLS. Então a nossa rotina "move" para outro segmento de trabalho, e isso é possível, porque em princípio, as corotinas podem migrar de um segmento de trabalho para outro. Fazemos a seguinte chamada para a função de biblioteca e a biblioteca tenta ler o valor da variável TLS. Mas já pode haver um significado diferente! E procurar esse bug será muito difícil.

Portanto, você precisa considerar cuidadosamente a escolha do método para implementar os processos CSP-shnyh. Cada uma das opções tem seus próprios pontos fortes e fracos.

Muitos processos nem sempre são a solução.


Assim como os atores, a capacidade de criar muitos processos de CSP em seu programa nem sempre é uma solução para um problema aplicado, mas cria problemas adicionais para si mesmo.

Além disso, a baixa visibilidade do que está acontecendo dentro do programa é apenas uma parte do problema. Eu gostaria de me concentrar em outra armadilha.

O fato é que, nos canais CSP-shnyh, você pode facilmente obter um análogo de impasse. O processo A tenta gravar uma mensagem no canal C1 completo e o processo A está em pausa. Do canal C1, o processo B, que tentou gravar no canal C2, que está cheio, deve ser lido e, portanto, o processo B foi suspenso. E no canal C2, o processo A. era para ler, só isso, temos um impasse.

Se tivermos apenas dois processos CSP, podemos encontrar esse conflito durante a depuração ou mesmo com o procedimento de revisão de código. Mas se tivermos milhões de processos no programa, eles se comunicam ativamente, a probabilidade de tais impasses aumenta significativamente.

Onde procurar, o que levar?


Se alguém quiser trabalhar com CSP em C ++, a escolha aqui, infelizmente, não é tão grande quanto para os atores. Bem, ou não sei para onde procurar e como procurar. Nesse caso, espero que os comentários compartilhem outros links.

Mas, se queremos usar o CSP, primeiro precisamos olhar para o Boost.Fiber . Existem fibras (ou seja, corotinas) e canais, e até mesmo primitivos de baixo nível como barreira mutex, variável_da_avaliação. Tudo isso pode ser tomado e usado.

Se você estiver satisfeito com os processos CSP na forma de threads, poderá ver o SObjectizer . Também existem análogos de canais CSP e aplicativos complexos de vários segmentos no SObjectizer podem ser escritos sem nenhum ator.

Actors vs CSP


Atores e CSPs são muito parecidos entre si. Repetidamente, deparei-me com a afirmação de que esses dois modelos são equivalentes entre si. I.e. o que pode ser feito nos atores pode ser quase 1 em 1 repetido nos processos de CSP e vice-versa. Eles dizem que isso é provado matematicamente. Mas aqui eu não entendo nada, então não posso dizer nada. Mas, a partir de meus próprios pensamentos em algum lugar no nível do senso comum cotidiano, tudo isso parece bastante plausível. Em alguns casos, de fato, os atores podem ser substituídos por processos de CSP, e os processos de CSP por atores.

No entanto, existem várias diferenças entre atores e CSPs que podem ajudar a determinar onde cada um desses modelos é benéfico ou desvantajoso.

Canais vs caixa de correio


Um ator possui um único "canal" para receber mensagens recebidas - essa é a caixa de correio dele, criada automaticamente para cada ator. E o ator recupera as mensagens de lá sequencialmente, exatamente na ordem em que as mensagens estavam na caixa de correio.

E esta é uma pergunta bastante séria. Digamos que haja três mensagens na caixa de correio do ator: M1, M2 e M3. Atualmente, o ator está interessado apenas em M3.Mas antes de chegar ao M3, o ator extrairá primeiro M1, depois M2. E o que ele fará com eles?

Novamente, como parte dessa conversa, não abordaremos os mecanismos de recebimento seletivo de Erlang e os escondidos de Akka.

Enquanto o processo CSP-shny tem a capacidade de selecionar o canal do qual atualmente deseja ler as mensagens. Portanto, um processo CSP pode ter três canais: C1, C2 e C3. Atualmente, o processo CSP está interessado apenas em mensagens do C3. É esse canal que o processo lê. E ele retornará ao conteúdo dos canais C1 e C2 quando estiver interessado nisso.

Reatividade e Proatividade


Como regra, os atores são reativos e só funcionam quando recebem mensagens.

Enquanto os processos CSP podem fazer algum trabalho, mesmo na ausência de mensagens recebidas. Em alguns cenários, essa diferença pode desempenhar um papel importante.

Máquinas de estado


De fato, os atores são máquinas de estados finitos (KA). Portanto, se houver muitas máquinas de estados finitos em sua área de assunto e, mesmo que sejam complexas, máquinas hierárquicas de estados finitos, será muito mais fácil implementá-las com base no modelo de ator do que adicionar uma implementação de espaçonave a um processo CSP.

No C ++, ainda não há suporte nativo ao CSP.


A experiência da linguagem Go mostra como é fácil e conveniente usar o modelo CSP quando seu suporte é implementado no nível de uma linguagem de programação e de sua biblioteca padrão.

No Go, é fácil criar "processos CSP" (também conhecidos como goroutines), é fácil criar e trabalhar com canais, existe uma sintaxe interna para trabalhar com vários canais ao mesmo tempo (Go-shny select, que funciona não apenas para leitura, mas também para escrita), a biblioteca padrão conhece as goroutins e pode trocá-las quando a goroutin faz uma chamada de bloqueio do stdlib.

No C ++, até o momento não há suporte para corotinas empilhadas (no nível da linguagem). Portanto, trabalhar com CSP em C ++ pode parecer, em alguns lugares, se não uma muleta, então ... Isso certamente exige muito mais atenção a si mesmo do que no caso do mesmo Go.

Abordagem nº 3: tarefas (assíncrono, futuro, wait_all, ...)


Sobre a abordagem baseada em tarefas nas palavras mais comuns


O significado da abordagem baseada em tarefas é que, se tivermos uma operação complexa, dividimos essa operação em etapas de tarefa separadas, onde cada tarefa (é uma tarefa) executa uma única suboperação.

Iniciamos essas tarefas com a operação especial assíncrona. A operação assíncrona retorna um objeto futuro no qual, após a conclusão da tarefa, o valor retornado pela tarefa será colocado.

Depois que lançamos N tarefas e recebemos N objetos-futuro, precisamos de alguma forma tricotar tudo isso em uma cadeia. Parece que, quando as tarefas 1 e 2 são concluídas, os valores retornados por elas devem cair na tarefa 3. E quando a tarefa nº 3 for concluída, o valor retornado deverá ser transferido para as tarefas nº 4, nº 5 e nº 6. Etc., etc.

Para tal "empate", são usados ​​meios especiais. Como, por exemplo, o método .then () de um objeto futuro, bem como as funções wait_all (), wait_any ().

Essa explicação "nos dedos" pode não ser muito clara, então vamos ao código. Talvez em uma conversa sobre um código específico, a situação se torne mais clara (mas não um fato).

Código Request_handler para abordagem baseada em tarefas


O código para processar uma solicitação HTTP recebida com base em tarefas pode ser assim:
 void handle_request(const execution_context & ctx, request req) { auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); when_all(user_info_ft, original_image_ft).then( [&ctx, req](tuple<future<user_info>, future<image_loaded>> data) { async(ctx.image_mixer_ctx(), [&ctx, req, d=std::move(data)] { return mix_image(get<0>(d).get().watermark_image(), get<1>(d).get()); }) .then([req](future<mixed_image> mixed) { async(ctx.http_srv_ctx(), [req, im=std::move(mixed)] { make_reply(...); }); }); }); } 

Vamos tentar descobrir o que está acontecendo aqui.

Primeiro, criamos uma tarefa que deve ser iniciada no contexto de nosso próprio cliente HTTP e que solicita informações sobre o usuário. O objeto futuro retornado é armazenado na variável user_info_ft.

Em seguida, criamos uma tarefa semelhante, que também deve ser executada no contexto de nosso próprio cliente HTTP e que carrega a imagem original. O objeto futuro retornado é armazenado na variável original_image_ft.

Em seguida, precisamos aguardar a conclusão das duas primeiras tarefas. O que escrevemos diretamente: when_all (user_info_ft, original_image_ft). Quando os dois objetos futuros obtiverem seus valores, executaremos outra tarefa. Esta tarefa pegará o bitmap com a marca d'água e a imagem original e executará outra tarefa no contexto do ImageMixer. Essa tarefa mescla imagens e, quando concluída, outra tarefa será iniciada no contexto do servidor HTTP, o que gerará uma resposta HTTP.

Talvez essa explicação do que está acontecendo no código não esteja muito esclarecida. Portanto, vamos numerar nossas tarefas:

E vejamos as dependências entre elas (a partir das quais a ordem das tarefas flui):

E se agora sobrepormos esta imagem ao nosso código-fonte, espero que fique mais claro:


Recursos da abordagem baseada em tarefas


Visibilidade


O primeiro recurso que já deveria ser óbvio é a visibilidade do código na tarefa. Nem tudo está bem com ela.

Aqui você pode mencionar o inferno de retorno de chamada. Os programadores do Node.js estão muito familiarizados com isso. Mas apelidos em C ++ que trabalham em estreita colaboração com a Task também mergulham nesse inferno de retorno de chamada.

Tratamento de erros


Outro recurso interessante é o tratamento de erros.

Por um lado, no caso de uso assíncrono e futuro com a entrega de informações de erro à parte interessada, pode ser ainda mais fácil do que no caso de atores ou CSP. Afinal, se no processo CSP A enviar uma solicitação para processar B e aguardar uma mensagem de resposta, quando B encontrar um erro ao executar a solicitação, precisaremos decidir como enviar o erro ao processo A:

  • ou criaremos um tipo separado de mensagem e um canal para recebê-la;
  • ou retornamos o resultado com uma única mensagem, que será std :: variant para um resultado normal e incorreto.

E no caso do futuro, tudo é mais simples: extraímos do futuro um resultado normal ou uma exceção é lançada para nós.

Mas, por outro lado, podemos facilmente encontrar uma cascata de erros. Por exemplo, ocorreu uma exceção na tarefa nº 1; essa exceção caiu no objeto futuro, que foi passado para a tarefa nº 2. Na tarefa nº 2, tentamos tirar o valor do futuro, mas recebemos uma exceção. E, provavelmente, lançaremos a mesma exceção. Consequentemente, ele cairá no futuro próximo, que passará à tarefa nº 3. Também haverá uma exceção, que, possivelmente, também será lançada. Etc.

Se nossas exceções forem registradas, no log, podemos ver a repetição repetida da mesma exceção, que passa de uma tarefa na cadeia para outra.

Cancelar tarefas e temporizadores / tempos limite


E outra característica muito interessante da campanha baseada em tarefas é o cancelamento de tarefas se algo der errado. De fato, digamos que criamos 150 tarefas, concluímos as 10 primeiras e percebemos que não havia sentido em continuar o trabalho. Como cancelamos os 140 restantes? Essa é uma pergunta muito, muito boa :)

Outra pergunta semelhante é como fazer tarefas de amigos com temporizadores e tempos limite. Suponha que estamos acessando algum sistema externo e desejamos limitar o tempo de espera para 50 milissegundos. Como podemos definir o cronômetro, como reagir à expiração do tempo limite, como interromper a cadeia de tarefas se o tempo limite expirou? Mais uma vez, perguntar é mais fácil do que responder :)

Trapaça


Bem, e para falar sobre os recursos da abordagem baseada em tarefas. No exemplo mostrado, um pouco de trapaça foi aplicada:
  auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); 

Aqui, enviei duas tarefas para o contexto de nosso próprio servidor HTTP, cada uma das quais executa uma operação de bloqueio no interior. De fato, para poder processar duas solicitações para serviços de terceiros em paralelo, aqui você tinha que criar suas próprias cadeias de tarefas assíncronas. Mas não fiz isso para tornar a solução mais ou menos visível e ajustada ao slide da apresentação.

Atores / CSP vs Tarefas


Examinamos três abordagens e vimos que, se os atores e os processos de CSP são semelhantes entre si, a abordagem baseada em tarefas não é como nenhuma delas. E pode parecer que os atores / CSP devam ser contrastados com a tarefa.

Mas, pessoalmente, eu gosto de um ponto de vista diferente.

Quando falamos sobre o Modelo de Atores e CSP, estamos falando sobre a decomposição de nossa tarefa. Em nossa tarefa, destacamos entidades independentes separadas e descrevemos as interfaces dessas entidades: quais mensagens eles enviam, quais eles recebem, por quais canais as mensagens passam.

I.e.trabalhando com atores e CSP, estamos falando de interfaces.

Mas suponha que dividamos a tarefa em atores separados e processos de CSP. Como exatamente eles fazem seu trabalho?

Quando adotamos a abordagem baseada em tarefas, começamos a falar sobre implementação. Sobre como um trabalho específico é executado, quais suboperações são executadas, em que ordem, como essas suboperações são conectadas de acordo com dados, etc.

I.e.trabalhando com a tarefa, estamos falando sobre implementação.

Portanto, atores / CSP e tarefas não se opõem muito, mas se complementam. Atores / CSPs podem ser usados ​​para decompor tarefas e definir interfaces entre componentes. E as tarefas podem ser usadas para implementar componentes específicos.

Por exemplo, ao usar o Actor, temos uma entidade como ImageMixer, que precisa ser manipulada com imagens no pool de threads. Em geral, nada nos impede de usar o ator ImageMixer para usar a abordagem baseada em tarefas.

Onde procurar, o que levar?


Se você deseja trabalhar com Tarefas em C ++, pode olhar para a biblioteca padrão do próximo C ++ 20. Eles já adicionaram o método .then () ao futuro, bem como as funções livres wait_all () e wait_any. Veja cppreference para detalhes .

Também já está longe de uma nova biblioteca async ++ . Em que, em princípio, há tudo o que você precisa, um pouco com um molho diferente.

E existe uma biblioteca Microsoft PPL ainda mais antiga . O que também dá tudo o que você precisa, mas com seu próprio molho.

Adição separada sobre a biblioteca Intel TBB. Não foi mencionado na história sobre a abordagem baseada em tarefas porque, na minha opinião, os gráficos de tarefas do TBB já são uma abordagem de fluxo de dados. E, se este relatório continuar, a conversa sobre o Intel TBB certamente chegará, mas no contexto da história sobre o fluxo de dados.

Mais interessante


Recentemente, aqui em Habré, houve um artigo de Anton Polukhin: "Estamos nos preparando para o C ++ 20. Coroutines TS usando um exemplo real ".

Ele fala sobre como combinar uma abordagem baseada em tarefas com corotinas sem pilha do C ++ 20. E descobriu-se que o código com base na legibilidade da tarefa se aproximava da legibilidade do código nos processos CSP.

Portanto, se alguém estiver interessado na abordagem baseada em tarefas, faz sentido ler este artigo.

Conclusão


Bem, é hora de avançar para os resultados, já que não existem muitos deles.

A principal coisa que quero dizer é que no mundo moderno você pode precisar de multithreading apenas se estiver desenvolvendo algum tipo de estrutura ou resolvendo alguma tarefa específica e de baixo nível.

E se você estiver escrevendo o código do aplicativo, dificilmente precisará de threads nus, primitivas de sincronização de baixo nível ou algum tipo de algoritmo sem bloqueios junto com contêineres sem bloqueios. Por um longo tempo, existem abordagens testadas pelo tempo e que se provaram bem:

  • atores
  • processos sequenciais de comunicação (CSP)
  • tarefas (assíncronas, promessas, futuros, ...)
  • fluxos de dados
  • programação reativa
  • ...

E o mais importante, existem ferramentas prontas para eles em C ++. Você não precisa pedalar nada, pode pegar, experimentar e, se quiser, colocá-lo em operação.

Tão simples: pegue, tente e coloque em operação.

Source: https://habr.com/ru/post/pt430672/


All Articles