Por que std :: future é diferente retornado de std :: packaged_task e std :: async?

Sep 11 2020

Fiquei sabendo o motivo pelo qual futurevoltou de std::asynctem algum estado compartilhado especial pelo qual wait on returned futureaconteceu no destruidor do futuro. Mas quando usamos std::pakaged_task, seu futuro não exibe o mesmo comportamento. Para completar uma tarefa embalados, você tem que chamar explicitamente get()no futureobjeto packaged_task.

Agora minhas perguntas são:

  1. Qual poderia ser a implementação interna do futuro (pensando std::asyncvs std::packaged_task)?
  2. Por que o mesmo comportamento não foi aplicado ao futureretornado std::packaged_task? Ou, em outras palavras, como o mesmo comportamento é interrompido std::packaged_task future?

Para ver o contexto, consulte o código abaixo:

Não espera para terminar a countdowntarefa. No entanto, se eu descomentar // int value = ret.get();, ele terminaria countdowne é óbvio porque estamos literalmente bloqueando o futuro retornado.

    // packaged_task example
#include <iostream>     // std::cout
#include <future>       // std::packaged_task, std::future
#include <chrono>       // std::chrono::seconds
#include <thread>       // std::thread, std::this_thread::sleep_for

// count down taking a second for each value:
int countdown (int from, int to) {
  for (int i=from; i!=to; --i) {
    std::cout << i << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));
  }
  std::cout << "Lift off!" <<std::endl;
  return from-to;
}

int main ()
{
   std::cout << "Start " << std::endl;
  std::packaged_task<int(int,int)> tsk (countdown);   // set up packaged_task
  std::future<int> ret = tsk.get_future();            // get future

  std::thread th (std::move(tsk),10,0);   // spawn thread to count down from 10 to 0

//   int value = ret.get();                  // wait for the task to finish and get result

  std::cout << "The countdown lasted for " << std::endl;//<< value << " seconds.\n";

  th.detach();   

  return 0;
}

Se eu usar std::asyncpara executar tarefas countdownem outro thread, não importa se eu uso get()no futureobjeto retornado ou não , sempre terminará a tarefa.

// packaged_task example
#include <iostream>     // std::cout
#include <future>       // std::packaged_task, std::future
#include <chrono>       // std::chrono::seconds
#include <thread>       // std::thread, std::this_thread::sleep_for

    // count down taking a second for each value:
    int countdown (int from, int to) {
      for (int i=from; i!=to; --i) {
        std::cout << i << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
      }
      std::cout << "Lift off!" <<std::endl;
      return from-to;
    }
    
    int main ()
    {
       std::cout << "Start " << std::endl;
      std::packaged_task<int(int,int)> tsk (countdown);   // set up packaged_task
      std::future<int> ret = tsk.get_future();            // get future
    
      auto fut = std::async(std::move(tsk), 10, 0);   

    
    //   int value = fut.get();                  // wait for the task to finish and get result
    
      std::cout << "The countdown lasted for " << std::endl;//<< value << " seconds.\n";

      return 0;
    }

Respostas

5 NicolBolas Oct 15 2020 at 08:43

std::asynctem conhecimento definido de como e onde a tarefa que lhe é atribuída é executada. Essa é a sua função: executar a tarefa. Para fazer isso, ele tem que realmente colocá-lo em algum lugar. Esse lugar pode ser um pool de threads, uma thread recém-criada ou um local a ser executado por quem quer que destrua o future.

Por asyncsaber como a função será executada, ela tem 100% das informações de que precisa para construir um mecanismo que possa se comunicar quando a execução potencialmente assíncrona for concluída, bem como garantir que se você destruir o future, então qualquer mecanismo que vá executar essa função acabará por realmente executá-la. Afinal, ele sabe o que é esse mecanismo.

Mas packaged_task não faz . Tudo o que packaged_taskfaz é armazenar um objeto chamável que pode ser chamado com os argumentos fornecidos, criar a promisecom o tipo do valor de retorno da função e fornecer um meio para obter a futuree executar a função que gera o valor.

Quando e onde a tarefa realmente é executada não é da packaged_taskconta da. Sem esse conhecimento, a sincronização necessária para fazer futureo destrutor de sincronizar com a tarefa simplesmente não pode ser construída.

Digamos que você queira executar a tarefa em um thread recém-criado. OK, então para sincronizar sua execução com a futuredestruição de, você precisaria de um mutex que o destruidor irá bloquear até que o thread da tarefa termine.

Mas e se você quiser executar a tarefa no mesmo thread que o chamador do futuredestruidor do? Bem, então você não pode usar um mutex para sincronizar isso, pois está tudo no mesmo segmento. Em vez disso, você precisa fazer com que o destruidor invoque a tarefa. Esse é um mecanismo completamente diferente e depende de como você planeja executar.

Porque packaged_tasknão sabe como você pretende executá-lo, ele não pode fazer nada disso.

Observe que isso não é exclusivo para packaged_task. Todos future s criado a partir de um criado pelo usuário promiseobjeto não terá a propriedade especial de async's futures.

Portanto, a questão realmente deveria ser por que asyncfunciona assim, não por que todo mundo não funciona .

Se você quer saber disso, é por causa de duas necessidades concorrentes: asyncprecisava ser uma maneira simples de alto nível e com morte cerebral para obter execução assíncrona (para a qual a sincronização na destruição faz sentido) e ninguém queria criar um novo futuretipo que era idêntico ao existente, exceto pelo comportamento de seu destruidor. Então eles decidiram sobrecarregar o futurefuncionamento, complicando sua implementação e uso.

5 Nawaz Nov 09 2020 at 01:21

@Nicol Bolas já respondeu a essa pergunta de forma bastante satisfatória. Portanto, tentarei responder um pouco à questão de uma perspectiva diferente, elaborando os pontos já mencionados por @Nicol Bolas.

O design de coisas relacionadas e seus objetivos

Considere esta função simples que queremos executar, de várias maneiras:

int add(int a, int b) {
    std::cout << "adding: " << a << ", "<< b << std::endl;
    return a + b;
}

Esqueça std::packaged_task, std ::futuree std::asyncpor enquanto, vamos dar um passo para trás e revisitar como std::functionfunciona e quais são os problemas que isso causa.

caso 1 - std::functionnão é bom o suficiente para executar coisas em diferentes threads

std::function<int(int,int)> f { add };

Assim que tivermos f, podemos executá-lo, no mesmo thread, como:

int result = f(1, 2); //note we can get the result here

Ou, em um tópico diferente, como este:

std::thread t { std::move(f), 3, 4 };
t.join(); 

Se observarmos com atenção, perceberemos que a execução fem uma thread diferente cria um novo problema: como obtemos o resultado da função? Executar fna mesma thread não tem esse problema - obtemos o resultado como valor retornado, mas quando executado em uma thread diferente, não temos como obter o resultado. Isso é exatamente o que é resolvido std::packaged_task.

caso 2 - std::packaged_taskresolve o problema que std::functionnão resolve

Em particular, ele cria um canal entre os encadeamentos para enviar o resultado ao outro encadeamento. Tirando isso, é mais ou menos igual a std::function.

std::packaged_task<int(int,int)> f { add }; // almost same as before

std::future<int> channel = f.get_future();  // get the channel
    
std::thread t{ std::move(f), 30, 40 }; // same as before
t.join();  // same as before
    
int result = channel.get(); // problem solved: get the result from the channel

Agora você vê como std::packaged_taskresolve o problema criado por std::function. Isso, entretanto, não significa que std::packaged_taskdeva ser executado em uma thread diferente. Você também pode executá-lo no mesmo thread, assim como std::function, embora você ainda obtenha o resultado do canal.

std::packaged_task<int(int,int)> f { add }; // same as before
std::future<int> channel = f.get_future(); // same as before
    
f(10, 20); // execute it in the current thread !!

int result = channel.get(); // same as before

Então, fundamentalmente std::functione std::packaged_tasksão um tipo de coisa semelhante: eles simplesmente envolvem entidades que podem ser chamadas, com uma diferença: std::packaged_taské compatível com multithreading, porque fornece um canal através do qual pode passar o resultado para outros threads. Ambos NÃO executam a entidade chamável agrupada por si próprios. É necessário invocá- los, seja no mesmo encadeamento ou em outro encadeamento, para executar a entidade chamável encapsulada. Então, basicamente, existem dois tipos de coisas neste espaço:

  • o que é executado ou seja regular funções, std::function, std::packaged_task, etc.
  • como / onde é executado, ou seja, threads, pools de threads, executores, etc.

caso 3: std::asyncé uma coisa totalmente diferente

É diferente porque combina o que é executado com como / onde é executado .

std::future<int> fut = std::async(add, 100, 200);
int result = fut.get();

Observe que, neste caso, o futuro criado tem um executor associado, o que significa que o futuro será concluído em algum ponto, pois há alguém executando coisas nos bastidores. No entanto, no caso do futuro criado por std::packaged_task, não há necessariamente um executor e esse futuro pode nunca ser concluído se a tarefa criada nunca for dada a nenhum executor.

Espero que isso ajude você a entender como as coisas funcionam nos bastidores. Veja a demonstração online .

A diferença entre dois tipos de std::future

Bem, neste ponto, fica bem claro que existem dois tipos de std::futureque podem ser criados:

  • Um tipo pode ser criado por std::async. Esse futuro tem um executor associado e, portanto, pode ser concluído.
  • Outro tipo pode ser criado por std::packaged_taskou coisas assim. Esse futuro não tem necessariamente um executor associado e, portanto, pode ou não ser concluído.

Visto que, no segundo caso, o futuro não tem necessariamente um executor associado, seu destruidor não foi projetado para sua conclusão / espera porque pode nunca ser concluído:

 {
   std::packaged_task<int(int,int)> f { add };
 
   std::future<int> fut = f.get_future(); 

 } // fut goes out of scope, but there is no point 
   // in waiting in its destructor, as it cannot complete 
   // because as `f` is not given to any executor.

Espero que esta resposta ajude você a entender as coisas de uma perspectiva diferente.

1 M.A Oct 11 2020 at 16:55

A mudança de comportamento se deve à diferença entre std::threade std::async.

No primeiro exemplo, você criou um encadeamento daemon desanexando. Onde você imprime std::cout << "The countdown lasted for " << std::endl;em seu thread principal, pode ocorrer antes, durante ou após as instruções de impressão dentro da countdownfunção de thread. Como o thread principal não aguarda o thread gerado, você provavelmente nem verá todas as impressões.

No segundo exemplo, você inicia a função thread com a std::launch::deferredpolítica. O comportamento de std :: async é:

Se a política assíncrona for escolhida, a conclusão do thread associado será sincronizada com o retorno bem-sucedido da primeira função que está aguardando o estado compartilhado ou com o retorno da última função que libera o estado compartilhado , o que ocorrer primeiro.

Neste exemplo, você tem dois futuros para o mesmo estado compartilhado. Antes que seus dtors sejam chamados ao sair do principal, a tarefa assíncrona deve ser concluída. Mesmo se você não tiver definido explicitamente nenhum futuro, o futuro temporário que é criado e destruído (retornado da chamada para std::async) significará que a tarefa será concluída antes que o thread principal saia.


Aqui está uma ótima postagem no blog de Scott Meyers, esclarecendo o comportamento de std::future& std::async.

Postagem SO relacionada .