Mais sobre Coroutines em C ++

Olá colegas.

Como parte do desenvolvimento do tema C ++ 20, encontramos um artigo bastante antigo (setembro de 2018) do hublog do Yandex, chamado " Preparando-se para C ++ 20. Coroutines TS com um exemplo real ". Termina com a seguinte votação muito expressiva:



“Por que não?”, Decidimos e traduzimos um artigo de David Pilarski sob o título “Introdução às corotinas”. O artigo foi publicado há pouco mais de um ano, mas, com sorte, você o achará muito interessante.

Então aconteceu. Após muita dúvida, controvérsia e preparação desse recurso, o WG21 chegou a uma opinião comum sobre como as corotinas deveriam parecer no C ++ - e é muito provável que elas sejam incluídas no C ++ 20. Como esse é um recurso importante, acho que é hora de prepará-lo e estudá-lo. agora (como você se lembra, ainda existem mais módulos, conceitos, intervalos para aprender ...)

Muitos ainda se opõem à corotina. Freqüentemente eles reclamam da complexidade de seu desenvolvimento, de muitos pontos de customização e, possivelmente, de desempenho abaixo do ideal devido à alocação sub-otimizada de memória dinâmica (talvez;)).

Paralelamente ao desenvolvimento de especificações técnicas (TS) aprovadas (publicadas oficialmente), até tentativas foram feitas para paralelizar o desenvolvimento de outro mecanismo de corutina. Aqui, falaremos sobre as rotinas descritas em TS ( especificação técnica ). Uma abordagem alternativa, por sua vez, pertence ao Google. Como resultado, a abordagem do Google sofre de numerosos problemas, cuja solução geralmente requer recursos adicionais estranhos do C ++.

No final, foi decidido adotar uma versão do Corutin desenvolvida pela Microsoft (patrocinada pela TS). É sobre essas rotinas que serão discutidas neste artigo. Então, vamos começar com a questão de ...

O que são corotinas?


As corotinas já existem em muitas linguagens de programação, por exemplo, em Python ou C #. As corotinas são outra maneira de criar código assíncrono. Como eles diferem dos fluxos, por que as corotinas devem ser implementadas como um recurso de linguagem dedicado e, finalmente, qual é o seu uso será explicado nesta seção.

Existe um sério mal-entendido sobre o que são corotinas. Dependendo do ambiente em que são usados, eles podem ser chamados:

  • Corotinas sem pilha
  • Corotinas de pilha
  • Fluxos verdes
  • Fibras
  • Gorutins

A boa notícia: empilhar corutinas, correntes verdes, fibras e gorutinas são a mesma coisa (mas às vezes são usadas de maneiras diferentes). Falaremos sobre eles mais adiante neste artigo e os chamaremos de fibras ou empilharemos corotinas. Mas a corotina sem pilha possui alguns recursos que precisam ser discutidos separadamente.

Para entender as corotinas, inclusive em um nível intuitivo, vamos conhecer brevemente as funções e (vamos colocar dessa maneira) "sua API". A maneira padrão de trabalhar com eles é ligar e aguardar até que termine:

void foo(){
     return; //     
}	
foo(); //   / 

Depois de chamar a função, já é impossível pausar ou retomar seu trabalho. Você pode executar apenas duas operações nas funções: starte finish. Quando a função é iniciada, você deve esperar até que seja concluída. Se a função for chamada novamente, sua execução será iniciada desde o início.

Com as corotinas, a situação é diferente. Você não pode apenas iniciá-los e pará-los, mas também pausar e retomar. Eles ainda são diferentes dos fluxos principais, porque as próprias corotinas não estão se aglomerando (por outro lado, as corotinas geralmente se referem ao fluxo e o fluxo está se aglomerando). Para entender isso, considere um gerador definido em Python. Que tal coisa seja chamada de gerador em Python, em C ++ seria chamada de corotina. Um exemplo é retirado deste site :

def generate_nums():
     num = 0
     while True:
          yield num
          num = num + 1	

nums = generate_nums()
	
for x in nums:
     print(x)
	
     if x > 9:
          break

Eis como este código funciona: uma chamada de função generate_numsleva à criação de um objeto de corotina. Em cada etapa da enumeração de um objeto de rotina, a própria rotina retoma o trabalho e o pausa somente após uma palavra-chave yieldno código; então, o próximo número inteiro da sequência é retornado (o loop for é um açúcar sintático para chamar uma função next()que retoma a corotina). O código termina o loop encontrando uma declaração de interrupção. Nesse caso, o corutin nunca termina, mas é fácil imaginar uma situação em que o corutin chegue ao fim e ao fim. Como podemos ver, a um korutine operações aplicáveis start, suspend, resumee, finalmente,finish. [Nota: C ++ também fornece operações de criação e destruição, mas elas não são importantes no contexto de um entendimento intuitivo da corotina].

Corotinas como uma biblioteca


Então, agora está aproximadamente claro o que são corotinas. Você pode saber que existem bibliotecas para criar objetos de fibra. A questão é: por que precisamos de corotinas na forma de um recurso de linguagem dedicado, e não apenas uma biblioteca que funcione com corotinas.

Aqui, estamos tentando responder a essa pergunta e demonstrar a diferença entre corotinas empilhadas e sem pilha. Essa diferença é fundamental para entender o corutin como parte do idioma.

Corotinas de pilha


Portanto, vamos discutir primeiro o que são as corotinas de pilha, como elas funcionam e por que elas podem ser implementadas como uma biblioteca. Explicá-los é relativamente simples, porque eles se assemelham a fluxos em termos de design.

A fibra ou pilha corutin possui uma pilha separada que pode ser usada para lidar com chamadas de função. Para entender exatamente como as corotinas desse tipo funcionam, examinamos brevemente os quadros de funções e as chamadas de funções de um ponto de vista de baixo nível. Mas primeiro, vamos falar sobre as propriedades das fibras.

  • Eles têm sua própria pilha,
  • O tempo de vida das fibras não depende do código que as chama (geralmente elas possuem um agendador definido pelo usuário),
  • As fibras podem ser destacadas de uma linha e conectadas a outra,
  • Planejamento cooperativo (a fibra deve decidir mudar para outra fibra / agendador),
  • Não é possível trabalhar simultaneamente no mesmo segmento.

Os seguintes efeitos resultam das propriedades acima:

  • a alternância do contexto das fibras deve ser realizada pelo usuário das fibras, e não o SO (além disso, o SO pode liberar a fibra, liberando o fio no qual trabalha),
  • Não há dados reais entre as duas fibras, pois a qualquer momento, apenas uma delas pode estar ativa,
  • O projetista de fibra deve poder escolher o local e a hora certos, onde e quando é apropriado devolver a energia da computação a um possível agendador ou chamador.
  • As operações de entrada / saída na fibra devem ser assíncronas, para que outras fibras possam executar suas tarefas sem bloquear uma à outra.

Agora vamos dar uma olhada mais de perto na operação das fibras e primeiro explicar como a pilha participa de chamadas de função.

Portanto, a pilha é um bloco contínuo de memória necessário para armazenar variáveis ​​locais e argumentos de função. Mas, mais importante, após cada chamada de função (com algumas exceções), informações adicionais são enviadas para a pilha que informa à função chamada como retornar ao chamador e restaurar os registros do processador.

Alguns desses registradores têm atribuições especiais e, ao chamar funções, eles são armazenados na pilha. Estes são os registradores (no caso da arquitetura ARM):

SP - ponteiro de pilha
LR - registro de comunicação
PC - contador de programa

ponteiro de pilha(SP) é um registro que contém o endereço do início da pilha relacionado à chamada de função atual. Graças ao valor existente, você pode consultar facilmente argumentos e variáveis ​​locais armazenadas na pilha.

O registro de comunicação (LR) é muito importante ao chamar funções. Ele armazena o endereço de retorno (o endereço da parte que chama), onde o código será executado após a conclusão da função atual. Quando a função é chamada, o PC é salvo no LR. Quando a função retorna, o PC é restaurado usando LR.

Program Counter (PC) é o endereço da instrução em execução no momento.
Sempre que uma função é chamada, a lista de links é salva, para que a função saiba para onde o programa deve retornar após a conclusão.



O comportamento do PC e do LR é registrado ao chamar e retornar uma função

Ao executar uma rotina de pilha, as funções chamadas usam a pilha alocada anteriormente para armazenar seus argumentos e variáveis ​​locais. Como todas as informações sobre cada função chamada na pilha corutin são armazenadas na pilha, a fibra pode suspender qualquer função dentro dessa corutina.



Vamos ver o que acontece nesta imagem. Em primeiro lugar, cada fibra e segmento tem sua própria pilha separada. A cor verde indica os números de série, indicando a sequência de ações.

  1. Uma chamada de função regular dentro de um encadeamento. A memória está alocada na pilha.
  2. . . , . . , .
  3. .
  4. . .
  5. .
  6. .
  7. . , , , .
  8. .
  9. .
  10. – , .
  11. , .
  12. . .
  13. . : , . , ( ) .
  14. , .
  15. .
  16. . . . , .
  17. .
  18. , , .

Ao trabalhar com corotinas de pilha, não há necessidade de um recurso de idioma dedicado que garanta seu uso. O core inteiro da pilha pode ser implementado usando bibliotecas, e já existem bibliotecas projetadas especificamente para esta finalidade:

swtch.com/libtask
code.google.com/archive/p/libconcurrency
www.boost.org Boost.Fiber
www.boost.org the Boost .Corotina

De todas essas bibliotecas, apenas o Boost é C ++ e o restante é C.
Para obter uma descrição detalhada de como essas bibliotecas funcionam, consulte a documentação. Mas, em geral, todas essas bibliotecas permitem que você crie uma pilha separada para fibra e forneça a oportunidade de retomar a corotina (por iniciativa do chamador) e pausá-la (por dentro).

Considere um exemplo Boost.Fiber:

#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
	
#include <boost/intrusive_ptr.hpp>
	
#include <boost/fiber/all.hpp>
	
inline
void fn( std::string const& str, int n) {
     for ( int i = 0; i < n; ++i) {
          std::cout << i << ": " << str << std::endl;
               boost::this_fiber::yield();
     }
}
	
int main() {
     try {
          boost::fibers::fiber f1( fn, "abc", 5);
          std::cerr << "f1 : " << f1.get_id() << std::endl;
          f1.join();
          std::cout << "done." << std::endl;
	
          return EXIT_SUCCESS;
     } catch ( std::exception const& e) {
          std::cerr << "exception: " << e.what() << std::endl;
     } catch (...) {
          std::cerr << "unhandled exception" << std::endl;
     }
     return EXIT_FAILURE;
}

No caso do Boost.Fiber , a biblioteca possui um agendador interno para corotina. Todas as fibras correm no mesmo fio. Como o planejamento da corutina é cooperativo, a fibra deve primeiro decidir quando devolver o controle ao planejador. Neste exemplo, isso acontece quando a função yield é chamada, o que pausa a corotina.

Como não há outra fibra, o planejador de fibra sempre decide retomar a corotina.

Corotinas sem pilha


As corotinas sem pilha diferem ligeiramente nas propriedades das de pilha. No entanto, eles têm as mesmas características básicas, pois as corotinas não empilhadas também podem ser iniciadas e após a suspensão da retomada. Correções deste tipo provavelmente encontraremos em C ++ 20.

Se falamos sobre as propriedades semelhantes de corutin - coroutines podem:

  • Corutin está intimamente ligada ao seu interlocutor: quando é chamada uma corotina, a execução é transferida para ela e o resultado da corotina é transferido de volta ao interlocutor.
  • A vida útil de uma pilha corutin é igual à vida útil de sua pilha. A vida útil de uma rotina sem pilha é igual à vida do seu objeto.

No entanto, no caso de corotinas sem pilha, não há necessidade de alocar uma pilha inteira. Eles consomem muito menos memória do que os da pilha, mas isso é precisamente devido a algumas de suas limitações.

Para começar, se eles não alocam memória para a pilha, como eles funcionam? Onde, no caso deles, estão todos os dados que devem ser armazenados na pilha ao trabalhar com corotinas da pilha. Resposta: na pilha do chamador.

O segredo das corotinas sem pilha é que elas só podem se suspender da função superior. Para todas as outras funções, seus dados estão localizados na pilha do lado chamado, portanto, todas as funções chamadas de corutin devem ser concluídas antes que o trabalho da corutin seja suspenso. Todos os dados necessários pela corotina para manter seu estado são alocados dinamicamente no heap. Isso geralmente requer algumas variáveis ​​e argumentos locais, que são muito mais compactos do que uma pilha inteira que precisaria ser alocada com antecedência.

Veja como funcionam as corutinas sem pilha:



Desafiando uma corutina sem pilha

Como você pode ver, agora existe apenas uma pilha - esta é a pilha principal do encadeamento. Vamos dar uma olhada passo a passo no que é mostrado nesta imagem (o quadro de ativação da corotina aqui é de duas cores - preto mostra o que está armazenado na pilha e azul - o que está armazenado no heap).

  1. Uma chamada de função regular cujo quadro está armazenado na pilha
  2. A função cria uma corotina . Ou seja, ele aloca um quadro de ativação para ele em algum lugar na pilha.
  3. Chamada de função normal.
  4. Ligue para Corutin . O corpo de Corutin se destaca em uma pilha regular. O programa é executado da mesma maneira que no caso de uma função regular.
  5. Uma chamada de função regular da corotina. Novamente, tudo ainda acontece na pilha [Nota: você não pode pausar a corotina a partir deste ponto, pois essa não é a função mais alta na corotina]
  6. [: .]
  7. – , , .
  8. – , + .
  9. 5.
  10. 6.
  11. . .

Portanto, é óbvio que, no segundo caso, é necessário lembrar muito menos dados de todas as operações de suspensão e retomada do trabalho da corutina; no entanto, a corotina pode retomar e suspender apenas a si mesma e apenas a partir da função superior. Todas as chamadas de funções e corotina ocorrem da mesma maneira, no entanto, alguns dados adicionais devem ser salvos entre as chamadas, e a função deve poder pular para o ponto de suspensão e restaurar o estado das variáveis ​​locais. Não há outras diferenças entre o quadro da corotina e o quadro da função.

Corutin também pode causar outras corotinas (não mostradas neste exemplo). No caso de corotinas sem pilha, cada chamada resulta na alocação de um novo espaço para novos dados de corutina (com uma chamada repetida de corotina, a memória dinâmica também pode ser alocada várias vezes).

A razão pela qual as corotinas precisam fornecer um recurso de linguagem dedicado é porque o compilador precisa decidir quais variáveis ​​descrevem o estado da corotina e criar código estereotipado para ir para os pontos de suspensão.

Uso prático de corutin


As corotinas em C ++ podem ser usadas da mesma maneira que em outros idiomas. As corotinas simplificarão a ortografia:

  • geradores
  • código de entrada / saída assíncrono
  • computação preguiçosa
  • aplicativos orientados a eventos

Sumário


Espero que, lendo este artigo, você descubra:

  • por que em C ++ você precisa implementar corotinas como um recurso de linguagem dedicado
  • Qual é a diferença entre corotinas empilhadas e sem pilha?
  • por que as corotinas são necessárias

All Articles