Compartilhar via



Outubro de 2015

Volume 30 - Número 10

Windows com C++: Co-rotinas no Visual C++ 2015

Por Kenny Kerr | Outubro de 2015

Eu entrei em contato com co-rotinas em C++ no ano de 2012, e escrevi sobre as ideias em uma série de artigos aqui na MSDN Magazine. Eu explorei uma forma leve de multitarefas cooperativas que emulavam co-rotinas aplicando alguns truques espertos com instruções switch. Então, expliquei os esforços para melhorar a eficiência e a capacidade de realizar combinações de sistemas assíncronos com extensões propostas, promessas e futuros. Por fim, cobri alguns dos desafios que existem mesmo com uma visão futurista de futuros, assim como uma proposta para algo chamado funções retomáveis. Se possui interesse em alguns dos desafios e histórico relacionado à simultaneidade elegante em C++, sugiro que você leia esses textos:

Uma grande parte desses textos é teórica porque eu não tinha um compilador que implementava essas ideias, e precisava usar emuladores para elas. E então o Visual Studio 2015 foi lançado no começo do ano. Essa versão do C++ inclui uma opção do compilador experimental chamada /await, que desbloqueia a implementação de co-rotinas diretamente suportadas pelo compilador. Acabou-se o uso de hacks, macros ou outras mágicas. Agora é de verdade, mesmo que ainda experimental e não sancionado pelo comitê de C++. E não é apenas açúcar sintático no front-end do compilador, como com os métodos yield keyword do C# e async. A implementação de C++ inclui um profundo investimento de engenharia no back-end do compilador, oferecendo uma incrível implementação escalonável. De fato, vai muito além do que poderia ser se o front-end do compilador simplesmente fornecesse uma sintaxe mais conveniente para trabalhar com promessas e futuros, ou mesmo com a classe de tarefa Tempo de Execução de Simultaneidade. Então vamos voltar ao assunto e ver como está hoje. Desde 2012, muito mudou, então começarei com uma breve recapitulação para ilustrar de onde viemos e onde estamos, antes de olharmos alguns exemplos mais específicos e usos práticos.

Como concluí a série que mencionei com um exemplo muito interessante de funções retomáveis, começarei com elas. Imagine um par de recursos para leitura a partir de um arquivo e escrita para uma conexão de rede:

struct File
{
  unsigned Read(void * buffer, unsigned size);
};
struct Network
{
  void Write(void const * buffer, unsigned size);
};

Usando a sua imaginação, você pode preencher o restante, mas essa é uma boa representação de como a E/S síncrona tradicional pode ser. O método Read do arquivo tentará fazer a leitura de dados da posição atual do arquivo no buffer até um tamanho máximo, e retornará o número real de bytes copiados. Se o valor retornado for menos do que o tamanho solicitado, geralmente significa que o término do arquivo foi alcançado. A classe Rede modela um protocolo típico orientado para conexão como TCP ou um pipe nomeado do Windows. O método Write copia um número específico de bytes na pilha da rede. É muito fácil imaginar uma operação de cópia síncrona, mas ajudarei com a Figura 1 para termos um modelo de referência.

Figura 1 Operação de cópia síncrona

File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
while (unsigned const actual = file.Read(buffer, sizeof(buffer)))
{
  network.Write(buffer, actual);
}

Enquanto o método Read retornar valores maior do que zero, os bytes resultantes são copiados do buffer intermediário para a rede usando o método Write. Esse é o tipo de código que qualquer programador razoável pode compreender facilmente, independentemente do seu histórico. Naturalmente, o Windows fornece serviços que podem descarregar esse tipo de operação no kernel para evitar as transições, mas esses serviços são limitados a cenários específicos, e isso representa os tipos de operações de bloqueio que geralmente prende os aplicativos.

A Biblioteca Padrão de C++ oferece futuros e promessas em uma tentativa de oferecer suporte a operações assíncronas, mas vem sendo criticada pelo seu design primário. Expliquei esses problemas em 2012. Mesmo ignorando essas problemas, reescrever o exemplo de cópia de arquivo para a rede na Figura 1 não é nada óbvio. A translação mais direta do loop while síncrono (e simples) requer um algoritmo de iteração cuidadosamente preparado que possa atravessar uma cadeia de futuros:

template <typename F>
future<void> do_while(F body)
{
  shared_ptr<promise<void>> done = make_shared<promise<void>>();
  iteration(body, done);
  return done->get_future();
}

O algoritmo realmente adquire vida na função de iteração:

template <typename F>
void iteration(F body, shared_ptr<promise<void>> const & done)
{
  body().then([=](future<bool> const & previous)
  {
    if (previous.get()) { iteration(body, done); }
    else { done->set_value(); }
  });
}

O lambda deve capturar o promise compartilhado por valor, porque ele é mesmo iterativo e não recursivo. Mas isso apresenta problemas, por significar um par de operações sincronizadas para cada iteração. Além disso, futuros ainda não possuem um método "then" para continuações de cadeia, apesar de você poder simular isso hoje com a classe de tarefa Tempo de Execução de Simultaneidade. Ainda, considerando que algoritmos futuristas e continuações assim existem, eu poderia reescrever a operação de cópia síncrona da Figura 1 de uma maneira assíncrona. Primeiro, eu teria que adicionar sobrecargas async às classes Arquivo e Rede. Provavelmente assim:

struct File
{
  unsigned Read(void * buffer, unsigned const size);
  future<unsigned> ReadAsync(void * buffer, unsigned const size);
};
struct Network
{
  void Write(void const * buffer, unsigned const size);
  future<unsigned> WriteAsync(void const * buffer, unsigned const size)
};

O futuro do método WriteAsync deve ecoar o número de bytes copiados, por ser tudo o que qualquer continuação pode ter para decidir entre terminar a iteração ou não. Outra opção pode ser a classe Arquivo fornecer um método EndOfFile. Em todo caso, dados esses novos primitivos, a operação de cópia pode ser expressada de uma maneira compreensível se você tiver ingerido quantidades suficientes de cafeína. A Figura 2 ilustra essa abordagem.

Figure 2 Operação de cópia com futuros

File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
future<void> operation = do_while([&]
{
  return file.ReadAsync(buffer, sizeof(buffer))
    .then([&](task<unsigned> const & read)
    {
      return network.WriteAsync(buffer, read.get());
    })
    .then([&](task<unsigned> const & write)
    {
      return write.get() == sizeof(buffer);
    });
});
operation.get();

O algoritmo do_while facilita o encadeamento de continuações enquando o "body" do loop returnar true. Então ReadAsync é chamado, o resultado do qual é usado por WriteAsync, e o resultado do qual é testado na condição do loop. Não é ciência de foguetes, mas não tenho o menor desejo de escrever código assim. É forçado e rapidamente se torna muito complexo para a compreensão. É aí que entram as funções retomáveis.

Adicionar a opção do compilador /await habilita o suporte do compilador para funções retomáveis, uma implementação de co-rotinas para C++. São chamadas de funções retomáveis e não simplesmente co-rotinas porque devem se comportar o máximo possível como funções C++ tradicionais. De fato, e ao contrário do que disse em 2012, um consumidor de alguma função não precisa saber de maneira alguma se ela está implementada como uma co-rotina.

Enquanto escrevo isso a opção do compilador /await também necessita da opção /Zi ao invés da opção padrão /ZI para desabilitar o recurso editar e continuar do depurador. Você também precisa desabilitar verificações SDL com a opção /sdl- e evitar as opções /RTC, uma vez que as verificações de tempo de execução do compilador não são compatíveis com co-rotinas. Todas essas limitações são temporárias e se devem à natureza experimental da implementação, e espero que sejam retiradas nas próximas atualizações do compilador. Mas ainda vale a pena, como você pode ver na Figura 3. É francamente e sem dúvida muito mais simples de escrever e de compreender do que o necessário para a operação de cópia implementada com futuros. De fato, se parece muito com o exemplo síncrono original da Figura 1. E também não há necessidade, nesse caso, para o futuro WriteAsync retornar um valor específico.

Figura 3 Operação de cópia dentro da função retomável

future<void> Copy()
{
  File file = // Open file
  Network network = // Open connection
  uint8_t buffer[4096];
  while (unsigned copied = await file.ReadAsync(buffer, sizeof(buffer)))
  {
    await network.WriteAsync(buffer, copied);
  }
}

A palavra-chave await usada na Figura 3, assim como as outras palavras-chaves novas fornecidas pela opção do compilador /await, pode aparecer apenas dentro de uma função retomável, e, então, a função Copiar ao redor retorna um futuro. Estou usando os mesmos métodos ReadAsync e WriteAsync do exemplo anterior de futuros, mas é importante perceber que o compilador não sabe nada sobre futuros. E então, não precisam nem mesmo existir futuros. Então, como funciona? Bem, não funcionará ao menos que certas funções de adaptador sejam escritas para fornecer as associações necessárias ao compilador. Isso é parecido com a maneira na qual o compilador descobre como conectar um intervalo com base em instrução procurando funções de início e término adequadas. No caso de uma expressão await, ao invés de procurar por funções de início e término, o compilador procura funções adequadas chamadas await_ready, await_suspend e await_resume. Like como início e término, essas novas funções podem ser funções de membro ou livres. A capacidade de escrever funções não-membro ajuda muito, por você poder escrever adaptadores para tipos existentes que fornecem a semântica necessária, como é o caso com os futuros futuristas que explorei até aqui. A Figura 4 fornece um conjunto de adaptadores que poderiam satisfazer a intepretação do compilador da função retomável na Figura 3.

Figure 4 Adaptadores await para um futuro hipotético

namespace std
{
  template <typename T>
  bool await_ready(future<T> const & t)
  {
    return t.is_done();
  }
  template <typename T, typename F>
  void await_suspend(future<T> const & t, F resume)
  {
    t.then([=](future<T> const &)
    {
      resume();
    });
  }
  template <typename T>
  T await_resume(future<T> const & t)
  {
    return t.get();
  }
}

Novamente, lembre-se que o modelo de classe futura da Biblioteca Padrão de C++ ainda não fornece um método "then" para adicionar uma continuação, mas isso seria todo o necessário para que esse exemplo funcione com o compilador de hoje. A palavra-chave await dentro de uma função retomável efetivamente configura um ponto de suspensão potencial em que a execução pode deixar a função se a operação ainda não estiver completa. Se await_ready retornar true, a execução não será suspensa, e await_resume será chamado imediatamente para obter o resultado. Se, por outro lado, await_ready retornar false, await_suspend será chamado, permitindo que a operação registre uma função continuar fornecida pelo compilador, a ser chamada em uma conclusão eventual. Assim que essa função continuar for chamada, a co-rotina continuará no ponto de suspensão anterior, e a execução continuará até a próxima expressão await, ou o término da função.

Lembre-se que a continuação ocorre em qualquer thread que tenha chamado a função continuar do compilador. Isso significa que é totalmente possível uma função retomável iniciar em um thread, e depois continuar a execução em outro thread. Do ponto de vista do desempenho isso é desejável, já que a alternativa seria enviar a continuação para outro thread, o que normalmente seria custoso e desnecessário. Por outro lado, podem haver casos em que isso é desejável e até mesmo obrigatório, se um código subsequente tiver afinidade de thread, como é o caso na maior parte dos códigos de gráficos. Infelizmente, a palavra-chave await ainda não tem uma maneira para deixar o autor de uma expressão await fornecer uma dica assim ao compilador. E isso não é inédito. O Tempo de Execução de Simultaneidade possui uma opção assim, mas o interessante é que a própria linguagem C++ fornece um padrão que você pode seguir:

int * p = new int(1);
// Or
int * p = new (nothrow) int(1);

Da mesma maneira, a expressão await necessita de um mecanismo para fornecer uma dica para a função await_suspend, para afetar o contexto do thread em que a continuação ocorre:

await network.WriteAsync(buffer, copied);
// Or
await (same_thread) network.WriteAsync(buffer, copied);

Como padrão, a continuação ocorre da maneira mais eficiente para a operação. A constante same_thread de um tipo hipotético std::same_thread_t removeriam a ambiguidade entre sobrecargas da função await_suspend. A await_suspend na Figura 3 seria a opção padrão e mais eficiente, porque presume-se que continuaria em um thread de trabalho e completar sem uma maior opção de contexto. A sobrecarga same_thread ilustrada na Figura 5 seria solicitada quando o consumidor solicitar afinidade de thread.

Figure 5 Sobrecarga hipotética de await_suspend

template <typename T, typename F>
void await_suspend(future<T> const & t, F resume, same_thread_t const &)
{
  ComPtr<IContextCallback> context;
  check(CoGetObjectContext(__uuidof(context),
    reinterpret_cast<void **>(set(context))));
  t.then([=](future<T> const &)
  {
    ComCallData data = {};
    data.pUserDefined = resume.to_address();
    check(context->ContextCallback([](ComCallData * data)
    {
      F::from_address(data->pUserDefined)();
      return S_OK;
    },
    &data,
    IID_ICallbackWithNoReentrancyToApplicationSTA,
    5,
    nullptr));
  });
}

Essa sobrecarga recupera a interface IContextCallback para o thread de chamada (ou apartamento). A continuação então eventualmente chama a função continuar do compilador do mesmo contexto. Se isso acontecer dentro do STA do aplicativo, o mesmo poderia continuar interagindo com outros serviços com afinidade de thread tranquilamente. O modelo de classe ComPtr e a função auxiliar de verificação são partes da biblioteca Moderna que você pode baixar em github.com/kennykerr/modern, mas você também pode usar o que tiver disponível.

Eu cobri muitos tópicos, alguns que continuam de certa forma teóricos, mas o compilador C++ já fornece toda a força para que isso seja possível. É um momento muito animador para desenvolvedores C++ interessados em simultaneidade, e espero que você se junte a mim novamente no mês que vem para mergulharmos ainda mais fundo nas funções retomáveis com o Visual C++.


Kenny Kerr é programador de computador, autor da Pluralsight e Microsoft MVP, e mora no Canadá. Ele mantém um blog em kennykerr.ca e pode ser seguido no Twitter @kennykerr.

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Gor Nishanov