O async (launch :: async) em C ++ 11 torna os pools de threads obsoletos para evitar a criação de threads cara?

117

Ele está vagamente relacionado a esta questão: std :: thread é agrupado em C ++ 11? . Embora a pergunta seja diferente, a intenção é a mesma:

Pergunta 1: ainda faz sentido usar seus próprios pools de thread (ou biblioteca de terceiros) para evitar a criação de thread cara?

A conclusão na outra pergunta foi que você não pode confiar std::threadpara ser agrupado (pode ou não ser). No entanto, std::async(launch::async)parece ter uma chance muito maior de ser agrupado.

Não acho que seja forçado pelo padrão, mas IMHO eu esperaria que todas as boas implementações do C ++ 11 usassem o pool de threads se a criação de threads fosse lenta. Apenas em plataformas em que é barato criar um novo thread, eu esperaria que eles sempre gerassem um novo thread.

Pergunta 2: Isso é exatamente o que eu penso, mas não tenho fatos para provar isso. Posso muito bem estar enganado. É um palpite?

Finalmente, forneci aqui alguns códigos de amostra que mostram primeiro como acho que a criação de threads pode ser expressa por async(launch::async):

Exemplo 1:

 thread t([]{ f(); });
 // ...
 t.join();

torna-se

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

Exemplo 2: disparar e esquecer o tópico

 thread([]{ f(); }).detach();

torna-se

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

Pergunta 3: Você prefere as asyncversões às threadversões?


O resto já não faz parte da questão, mas apenas para esclarecimentos:

Por que o valor de retorno deve ser atribuído a uma variável fictícia?

Infelizmente, o padrão C ++ 11 atual força que você capture o valor de retorno std::async, caso contrário, o destruidor é executado, o que bloqueia até que a ação termine. É considerado por alguns um erro no padrão (por exemplo, por Herb Sutter).

Este exemplo de cppreference.com ilustra bem:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

Outro esclarecimento:

Eu sei que os pools de thread podem ter outros usos legítimos, mas nesta questão estou interessado apenas no aspecto de evitar custos de criação de thread caros .

Acho que ainda existem situações em que os pools de threads são muito úteis, especialmente se você precisar de mais controle sobre os recursos. Por exemplo, um servidor pode decidir lidar com apenas um número fixo de solicitações simultaneamente para garantir tempos de resposta rápidos e aumentar a previsibilidade do uso de memória. Pools de threads devem estar bem, aqui.

Variáveis ​​de thread local também podem ser um argumento para seus próprios pools de thread, mas não tenho certeza se isso é relevante na prática:

  • A criação de um novo encadeamento std::threadcomeça sem variáveis ​​locais de encadeamento inicializadas. Talvez não seja isso que você deseja.
  • Em tópicos gerados por async, não está claro para mim porque o tópico poderia ter sido reutilizado. Do meu entendimento, as variáveis ​​locais de thread não têm garantia de serem redefinidas, mas posso estar enganado.
  • Usar seus próprios pools de threads (de tamanho fixo), por outro lado, oferece controle total se você realmente precisar dele.
Philipp Claßen
fonte
8
"No entanto, std::async(launch::async)parece ter uma chance muito maior de ser agrupado." Não, eu acredito std::async(launch::async | launch::deferred)que pode ser agrupado. Com apenas launch::asynca tarefa deve ser iniciada em um novo thread, independentemente de quais outras tarefas estão em execução. Com a política launch::async | launch::deferred, a implementação pode escolher qual política, mas o mais importante, pode atrasar a escolha de qual política. Ou seja, ele pode esperar até que um thread em um pool de threads se torne disponível e, em seguida, escolher a política assíncrona.
bames53
2
Até onde eu sei, apenas o VC ++ usa um pool de threads com std::async(). Ainda estou curioso para ver como eles oferecem suporte a destruidores thread_local não triviais em um pool de threads.
bames53
2
@ bames53 Passei pela libstdc ++ que vem com o gcc 4.7.2 e descobri que se a política de inicialização não for exatamente launch::async , ela a trata como se fosse única launch::deferrede nunca a executa de forma assíncrona - então, na verdade, essa versão de libstdc ++ "escolhe" sempre usar diferido, a menos que forçado de outra forma.
doug65536
3
@ doug65536 Meu ponto sobre os destruidores thread_local é que a destruição na saída do thread não é totalmente correta ao usar pools de threads. Quando uma tarefa é executada de forma assíncrona, ela é executada "como se estivesse em um novo thread", de acordo com a especificação, o que significa que cada tarefa assíncrona obtém seus próprios objetos thread_local. Uma implementação baseada em pool de encadeamentos deve ter cuidado especial para assegurar que as tarefas que compartilham o mesmo encadeamento de apoio ainda se comportem como se tivessem seus próprios objetos thread_local. Considere este programa: pastebin.com/9nWUT40h
bames53
2
@ bames53 Usar "como se fosse um novo tópico" nas especificações foi um grande erro na minha opinião. std::asyncpoderia ter sido uma coisa bonita para o desempenho - poderia ter sido o sistema padrão de execução de tarefas curtas, naturalmente apoiado por um pool de threads. No momento, é apenas um std::threadcom alguma porcaria acrescentada para tornar a função thread ser capaz de retornar um valor. Ah, e eles adicionaram funcionalidade "adiada" redundante que se sobrepõe std::functioncompletamente ao trabalho .
doug65536 de

Respostas:

54

Questão 1 :

Eu mudei isso do original porque o original estava errado. Fiquei com a impressão de que a criação de thread no Linux era muito barata e, depois de testar, determinei que a sobrecarga de chamada de função em uma nova thread em relação a uma normal é enorme. A sobrecarga para criar um thread para lidar com uma chamada de função é algo como 10.000 ou mais vezes mais lenta do que uma chamada de função simples. Portanto, se você estiver emitindo muitas chamadas de funções pequenas, um pool de threads pode ser uma boa ideia.

É bastante evidente que a biblioteca C ++ padrão fornecida com g ++ não tem pools de threads. Mas posso definitivamente ver um caso para eles. Mesmo com a sobrecarga de ter que empurrar a chamada por algum tipo de fila entre threads, provavelmente seria mais barato do que iniciar um novo thread. E o padrão permite isso.

IMHO, o pessoal do kernel Linux deve trabalhar para tornar a criação de threads mais barata do que é atualmente. Mas, a biblioteca C ++ padrão também deve considerar o uso de pool para implementação launch::async | launch::deferred.

E o OP está correto, usar ::std::threadpara lançar um thread obviamente força a criação de um novo thread em vez de usar um de um pool. Então ::std::async(::std::launch::async, ...)é o preferido.

Questão 2 :

Sim, basicamente, isso inicia um tópico 'implicitamente'. Mas, realmente, ainda é bastante óbvio o que está acontecendo. Portanto, não acho que a palavra implicitamente seja uma palavra particularmente boa.

Também não estou convencido de que forçar você a esperar por um retorno antes da destruição seja necessariamente um erro. Não sei se você deve usar a asyncchamada para criar threads de 'daemon' que não devem retornar. E se eles devem retornar, não é normal ignorar exceções.

Questão 3 :

Pessoalmente, gosto que os lançamentos de threads sejam explícitos. Eu valorizo ​​muito as ilhas onde você pode garantir acesso serial. Caso contrário, você acabará com o estado mutável, de modo que sempre terá que envolver um mutex em algum lugar e se lembrar de usá-lo.

Gostei muito mais do modelo de fila de trabalho do que do modelo 'futuro' porque há 'ilhas de série' espalhadas para que você possa lidar com o estado mutável de maneira mais eficaz.

Mas, na verdade, depende exatamente do que você está fazendo.

Teste de performance

Portanto, testei o desempenho de vários métodos de chamada de coisas e cheguei a esses números em um sistema de 8 núcleos (AMD Ryzen 7 2700X) executando o Fedora 29 compilado com clang versão 7.0.1 e libc ++ (não libstdc ++):

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

E nativo, no meu MacBook Pro 15 "(Intel (R) Core (TM) i7-7820HQ CPU @ 2,90 GHz) com Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6, eu obtenho o seguinte:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

Para o thread de trabalho, iniciei um thread, usei uma fila sem bloqueio para enviar solicitações a outro thread e esperei que uma resposta "Pronto" fosse enviada de volta.

O "Não fazer nada" serve apenas para testar a sobrecarga do equipamento de teste.

É claro que a sobrecarga de lançar um thread é enorme. E mesmo o thread de trabalho com a fila entre threads diminui as coisas em um fator de 20 ou mais no Fedora 25 em uma VM, e cerca de 8 no OS X nativo.

Criei um projeto Bitbucket contendo o código que usei para o teste de desempenho. Ele pode ser encontrado aqui: https://bitbucket.org/omnifarious/launch_thread_performance

Omniforme
fonte
3
Concordo com o modelo de fila de trabalho, no entanto, isso requer um modelo de "pipeline" que pode não ser aplicável a todos os usos de acesso simultâneo.
Matthieu M.
1
Parece-me que modelos de expressão (para operadores) poderiam ser usados ​​para compor os resultados, para chamadas de função, você precisaria de um método de chamada , eu acho, mas por causa de sobrecargas, pode ser um pouco mais difícil.
Matthieu M.
3
"muito barato" é relativo à sua experiência. Acho que a sobrecarga de criação de thread do Linux é substancial para o meu uso.
Jeff
1
@Jeff - Achei muito mais barato do que é. Atualizei minha resposta há algum tempo para refletir um teste que fiz para descobrir o custo real.
Onifário,
4
Na primeira parte, você está subestimando um pouco o quanto deve ser feito para criar uma ameaça e o quão pouco deve ser feito para chamar uma função. Uma chamada e retorno de função são algumas instruções da CPU que manipulam alguns bytes no topo da pilha. A criação de uma ameaça significa: 1. alocar uma pilha, 2. executar uma syscall, 3. criar estruturas de dados no kernel e vinculá-las, travar travas ao longo do caminho, 4. aguardar que o planejador execute o thread, 5. alternar contexto para o segmento. Cada uma dessas etapas em si leva muito mais tempo do que as chamadas de função mais complexas.
cmaster - restabelecer monica