Preparando-se para C ++ 20. Estudo de caso real da Coroutines TS

No C ++ 20, a oportunidade de trabalhar com corotinas prontas para o uso está prestes a aparecer. Este tópico é próximo e interessante para nós no Yandex.Taxi (para nossas próprias necessidades, estamos desenvolvendo uma estrutura assíncrona). Portanto, hoje mostraremos aos leitores do Habr usando um exemplo real de como trabalhar com corotinas sem pilha C ++.

Como exemplo, vamos usar algo simples: sem trabalhar com interfaces de rede assíncronas, temporizadores assíncronos, consistindo em uma função. Por exemplo, vamos tentar perceber e reescrever esse "macarrão" a partir de retornos de chamada:


void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto finally = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetworkThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(finally); }); } else { writerQueue.PushTask(finally); } }); } else { finally(); } }); } 


1. Introdução


Corotinas ou corotinas são a capacidade de impedir a execução de uma função em um local predeterminado; passar em algum lugar todo o estado da função parada junto com variáveis ​​locais; execute a função no mesmo local em que a interrompemos.
Existem vários sabores de corotinas: sem pilha e com pilha. Falaremos sobre isso mais tarde.

Declaração do problema


Temos várias filas de tarefas. Cada tarefa contém determinadas tarefas: há uma fila para desenhar gráficos, há uma fila para interações de rede e há uma fila para trabalhar com um disco. Todas as filas são instâncias da classe WorkQueue que possuem o método PushTask nulo (std :: function <void ()> task); As filas duram mais do que todas as tarefas colocadas nelas (a situação em que destruímos uma fila quando há tarefas pendentes nela não deve acontecer).

A função FuncToDealWith () do exemplo executa alguma lógica em diferentes filas e, dependendo dos resultados da execução, coloca uma nova tarefa na fila.

Reescrevemos o "macarrão" dos retornos de chamada na forma de um pseudocódigo linear, marcando em qual fila o código subjacente deve ser executado:

 void CoroToDealWith() { InCurrentThread(); // =>   writerQueue InWriterThread1(); if (NeedNetwork()) { // =>   networkQueue auto v = InNetworkThread(); if (v) { // =>   UIQueue InUIThread(); } } // =>   writerQueue InWriterThread2(); ShutdownAll(); } 

Aproximadamente esse resultado eu quero alcançar.

Existem limitações:

  • As interfaces da fila não podem ser alteradas - elas são usadas em outras partes do aplicativo por desenvolvedores de terceiros. Você não pode quebrar o código do desenvolvedor ou adicionar novas instâncias da fila.
  • Você não pode alterar a maneira como você usa a função FuncToDealWith. Você só pode alterar seu nome, mas não pode fazê-lo retornar nenhum objeto que o usuário precise manter em casa.
  • O código resultante deve ser tão produtivo quanto o original (ou ainda mais produtivo).

Solução


Reescrever a função FuncToDealWith


No Coroutines TS, o ajuste da coroutina é feito definindo o tipo do valor de retorno da função. Se o tipo atender a determinados requisitos, dentro do corpo da função, você poderá usar as novas palavras-chave co_await / co_return / co_yield. Neste exemplo, para alternar entre filas, usaremos co_yield:

 CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetworkThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } 

Foi muito semelhante ao pseudocódigo da última seção. Toda a "mágica" para trabalhar com corotinas está oculta na classe CoroTask.

CoroTask


No caso mais simples (no nosso), o conteúdo da classe "tuner" da corotina consiste em apenas um alias:

 #include <experimental/coroutine> struct CoroTask { using promise_type = PromiseType; }; 


promessa_tipo é um tipo de dados que devemos escrever por nós mesmos. Ele contém uma lógica que descreve:

  • o que fazer ao sair da corotina
  • o que fazer quando você entra pela primeira vez em corutin
  • quem libera recursos
  • o que fazer com exceções voando fora da corotina
  • Como criar um objeto CoroTask
  • o que fazer se dentro de corutinas chamado co_yield

O alias promessa_tipo deve ser chamado dessa maneira. Se você alterar o nome alternativo para outra coisa, o compilador jurará e dirá que você digitou o CoroTask incorretamente. O nome CoroTask pode ser alterado da maneira que você desejar.

Mas por que esse CoroTask é necessário se tudo estiver descrito no tipo_de_ promessa?
Em casos mais complexos, você pode criar o CoroTask que permitirá que você se comunique com uma rotina interrompida, envie e receba dados dela, desperte e destrua.

PromiseType


Chegando à parte divertida. Nós descrevemos o comportamento de corutin:

 class WorkQueue; // forward declaration class PromiseType { public: //      `co_return;`     , ... void return_void() const { /* ...    :) */ } //        ,  CoroTask, ... auto initial_suspend() const { // ...       . return std::experimental::suspend_never{}; } //      - , ... auto final_suspend() const { // ...        //      . return std::experimental::suspend_never{}; } //     , ... void unhandled_exception() const { // ...   (  ). std::terminate(); } //    CoroTask,    , ... auto get_return_object() const { // ...  CoroTask. return CoroTask{}; } //     co_yield, ... auto yield_value(WorkQueue& wq) const; // ... <  > }; 

No código acima, você pode observar o tipo de dados std :: experimental :: suspend_never. Este é um tipo de dados especial que diz que a corutina não precisa ser interrompida. Há também o seu oposto - o tipo std :: experimental :: suspend_always, que diz para você parar o corutin. Esses tipos são os chamados aguardáveis. Se você estiver interessado em sua estrutura interna, não se preocupe, escreveremos nossos Aguardáveis ​​em breve.

O lugar mais não trivial no código acima é final_suspend (). A função tem efeitos inesperados. Portanto, se não pararmos a execução nessa função, os recursos alocados à corotina pelo compilador limparão o compilador para nós. Mas se nesta função pararmos a execução da corotina (por exemplo, retornando std :: experimental :: suspend_always {}), você precisará liberar recursos manualmente de algum lugar externo: será necessário salvar um ponteiro inteligente na corotina em algum lugar e chamá-lo explicitamente destruir (). Felizmente, isso não é necessário para o nosso exemplo.

PromiseType :: yield_value INCORRETO


Parece que escrever PromiseType :: yield_value é bastante simples. Nós temos uma linha; rotina, que deve ser suspensa e, por sua vez, colocar:

 auto PromiseType::yield_value(WorkQueue& wq) { //        std::experimental::coroutine_handle<> this_coro = std::experimental::coroutine_handle<>::from_promise(*this); //    .  this_coro  operator(),    // wq      .   , //     ,  operator(),  //   . wq.PushTask(this_coro); //     . return std::experimental::suspend_always{}; } 

E aqui estamos diante de um problema muito grande e difícil de detectar. O fato é que primeiro colocamos a corotina na fila e só depois a suspendemos. Pode acontecer que a corotina seja removida da fila e comece a ser executada antes mesmo de a suspendermos no encadeamento atual. Isso levará a uma condição de corrida, comportamento indefinido e erros de tempo de execução completamente insanos.

PromiseType correto :: yield_value


Portanto, precisamos primeiro parar o corutin e depois adicioná-lo à fila. Para fazer isso, escreveremos nosso Awaitable e chamaremos de schedule_for_execution:

 auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { WorkQueue& wq; constexpr bool await_ready() const noexcept { return false; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; return schedule_for_execution{wq}; } 

As classes std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution e outros Awaitables devem conter 3 funções. O waitit_ready é chamado para verificar se a corotina deve ser parada. waitit_suspend é chamado depois que o programa é parado, o identificador da corotina parada é passado para ele. waitit_resume é chamado quando a execução da rotina é retomada.
E o que pode ser escrito em skrabs triangulares std :: experimental :: coroutine_handle <>?
Você pode especificar o tipo PromiseType lá e o exemplo funcionará exatamente da mesma maneira :)

std :: experimental :: coroutine_handle <> (também conhecido como std :: experimental :: coroutine_handle <void>) é o tipo de base para todos os std :: experimental :: coroutine_handle <DataType>, em que o DataType deve ser o tipo de promessa da corotina atual. Se você não precisar acessar o conteúdo interno do DataType, poderá escrever std :: experimental :: coroutine_handle <>. Isso pode ser útil em locais onde você deseja abstrair de um tipo específico de tipo_promessa e usa apagamento de tipo.

Concluído


Você pode compilar, executar o exemplo online e experimentar de todas as formas .

E se eu não gostar do co_yield, posso substituí-lo por algo?
Pode ser substituído por co_await. Para fazer isso, adicione a seguinte função ao PromiseType:

 auto await_transform(WorkQueue& wq) { return yield_value(wq); } 

Mas e se eu não gostar de co_await?
A coisa está ruim. Nada para mudar.


Folha de dicas


CoroTask é uma classe que ajusta o comportamento de uma corotina. Em casos mais complexos, ele permite que você se comunique com uma corotina parada e retire quaisquer dados dela.

CoroTask :: promessa_tipo descreve como e quando as corotinas param, como liberar recursos e como construir o CoroTask.

Oswaitables (std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution e outros) informam ao compilador o que fazer com a corotina em um ponto específico (se é necessário parar a corutina, o que fazer com a corutina parada e o que fazer quando a corutina acorda) .

Otimizações


Existe uma falha no nosso PromiseType. Mesmo se atualmente estivermos executando na fila de tarefas correta, a chamada de co_yield ainda suspenderá a corotina e a substituirá na mesma fila de tarefas. Seria muito melhor não parar a execução das corotinas, mas continuar a execução imediatamente.

Vamos consertar essa falha. Para fazer isso, adicione um campo particular ao PromiseType:

 WorkQueue* current_queue_ = nullptr; 

Nele, manteremos um ponteiro para a fila na qual estamos executando atualmente.

Em seguida, ajuste PromiseType :: yield_value:

 auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { const bool do_resume; WorkQueue& wq; constexpr bool await_ready() const noexcept { return do_resume; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; const bool do_not_suspend = (current_queue_ == &wq); current_queue_ = &wq; return schedule_for_execution{do_not_suspend, wq}; } 

Aqui ajustamos o schedule_for_execution :: waitit_ready (). Agora, essa função informa ao compilador que a corotina não precisa ser suspensa se a fila de tarefas atual corresponder àquela em que estamos tentando iniciar.

Feito. Você pode experimentar de todas as maneiras .

Sobre desempenho


No exemplo original, com todas as chamadas para WorkQueue :: PushTask (std :: function <void ()> f), criamos uma instância da classe std :: function <void ()> a partir do lambda. No código real, essas lambdas geralmente têm tamanho bastante grande, e é por isso que std :: function <void ()> é forçado a alocar dinamicamente memória para armazenar lambdas.

No exemplo da corotina, criamos instâncias de std :: function <void ()> a partir de std :: experimental :: coroutine_handle <>. O tamanho de std :: experimental :: coroutine_handle <> depende da implementação, mas a maioria das implementações tenta manter seu tamanho no mínimo. Portanto, no clang, seu tamanho é igual a sizeof (vazio *). Ao construir std :: function <void ()>, a alocação dinâmica não ocorre em objetos pequenos.
Total - com as Coroutines, nos livramos de várias alocações dinâmicas desnecessárias.

Mas! O compilador geralmente não pode apenas salvar toda a rotina na pilha. Por isso, é possível uma alocação dinâmica adicional ao inserir CoroToDealWith.

Stackless vs stackful


Acabamos de trabalhar com corotinas Stackless, que requerem suporte do compilador para trabalhar. Também existem Coroutines Stackful que podem ser implementadas inteiramente no nível da biblioteca.

Os primeiros permitem alocação mais econômica de memória, potencialmente são melhor otimizados pelo compilador. Os segundos são mais fáceis de implementar em projetos existentes, pois exigem menos modificações no código. No entanto, neste exemplo, você não sente a diferença, são necessários exemplos mais complicados.

Sumário


Examinamos o exemplo básico e obtivemos uma classe universal CoroTask, que pode ser usada para criar outras corotinas.

O código com ele se torna mais legível e um pouco mais produtivo do que com a abordagem ingênua:
WasCom corotinas
 void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto fin = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(fin); }); } else { writerQueue.PushTask(fin); } }); } else { fin(); } }); } 
 CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } 

No mar, houve momentos:

  • como chamar outra corotina de corutin e aguardar sua conclusão
  • que material útil você pode usar no CoroTask
  • um exemplo que faz a diferença entre Stackless e Stackful

Outros


Se você quiser aprender sobre outras novidades da linguagem C ++ ou se comunicar pessoalmente com seus colegas sobre as vantagens, consulte a conferência C ++ Russia. O próximo será realizado em 6 de outubro em Nizhny Novgorod .

Se você tem problemas associados ao C ++ e deseja melhorar algo na linguagem ou apenas deseja discutir possíveis inovações, seja bem-vindo ao https://stdcpp.ru/ .

Bem, se surpreende que o Yandex.Taxi tenha um grande número de tarefas que não estão relacionadas aos gráficos, espero que isso tenha sido uma surpresa agradável para você :) Venha nos visitar em 11 de outubro , falaremos sobre C ++ e muito mais.

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


All Articles