Async (launch :: async) in C ++ 11 rende i pool di thread obsoleti per evitare la creazione di thread costosi?


117

È vagamente correlato a questa domanda: std :: thread è in pool in C ++ 11? . Sebbene la domanda sia diversa, l'intenzione è la stessa:

Domanda 1: ha ancora senso utilizzare pool di thread propri (o di una libreria di terze parti) per evitare la creazione di thread costosi?

La conclusione nell'altra domanda era che non puoi fare affidamento std::threadper essere messo in comune (potrebbe o potrebbe non esserlo). Tuttavia, std::async(launch::async)sembra avere una probabilità molto più alta di essere raggruppati.

Non penso che sia forzato dallo standard, ma IMHO mi aspetterei che tutte le buone implementazioni C ++ 11 utilizzerebbero il pool di thread se la creazione del thread è lenta. Solo su piattaforme in cui è poco costoso creare un nuovo thread, mi aspetto che generino sempre un nuovo thread.

Domanda 2: Questo è proprio quello che penso, ma non ho fatti per dimostrarlo. Potrei benissimo sbagliarmi. È un'ipotesi plausibile?

Infine, qui ho fornito un codice di esempio che mostra prima come penso che la creazione di thread possa essere espressa da async(launch::async):

Esempio 1:

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

diventa

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

Esempio 2: spara e dimentica il thread

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

diventa

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

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

Domanda 3: Preferiresti le asyncversioni alle threadversioni?


Il resto non fa più parte della domanda, ma solo per chiarimenti:

Perché il valore restituito deve essere assegnato a una variabile fittizia?

Sfortunatamente, l'attuale standard C ++ 11 impone di catturare il valore di ritorno std::async, altrimenti viene eseguito il distruttore, che si blocca fino al termine dell'azione. È da alcuni considerato un errore nello standard (ad esempio, da Herb Sutter).

Questo esempio tratto da cppreference.com lo illustra bene:

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

Un'altra precisazione:

So che i pool di thread possono avere altri usi legittimi, ma in questa domanda sono interessato solo all'aspetto di evitare costosi costi di creazione di thread .

Penso che ci siano ancora situazioni in cui i pool di thread sono molto utili, soprattutto se è necessario un maggiore controllo sulle risorse. Ad esempio, un server potrebbe decidere di gestire solo un numero fisso di richieste contemporaneamente per garantire tempi di risposta rapidi e aumentare la prevedibilità dell'utilizzo della memoria. I pool di thread dovrebbero andare bene, qui.

Le variabili locali del thread possono anche essere un argomento per i tuoi pool di thread, ma non sono sicuro che sia rilevante nella pratica:

  • Creazione di un nuovo thread con std::threadinizi senza variabili locali del thread inizializzate. Forse questo non è quello che vuoi.
  • Nei thread generati da async, non è chiaro per me perché il thread avrebbe potuto essere riutilizzato. Dalla mia comprensione, non è garantito che le variabili locali del thread vengano reimpostate, ma potrei sbagliarmi.
  • L'utilizzo di pool di thread (a dimensione fissa), d'altra parte, ti dà il pieno controllo se ne hai davvero bisogno.

8
"Tuttavia, std::async(launch::async)sembra avere una probabilità molto più alta di essere raggruppati". No, credo std::async(launch::async | launch::deferred)che possa essere messo insieme. Con solo launch::asyncl'attività dovrebbe essere avviata su un nuovo thread indipendentemente dalle altre attività in esecuzione. Con la politica, launch::async | launch::deferredl'implementazione può scegliere quale politica, ma soprattutto può ritardare la scelta di quale politica. Ovvero, può attendere fino a quando un thread in un pool di thread non diventa disponibile e quindi scegliere il criterio asincrono.
bames53

2
Per quanto ne so solo VC ++ utilizza un pool di thread con std::async(). Sono ancora curioso di vedere come supportano distruttori thread_local non banali in un pool di thread.
bames53

2
@ bames53 Ho esaminato libstdc ++ fornito con gcc 4.7.2 e ho scoperto che se la politica di avvio non è esattamente launch::async , la tratta come se fosse solo launch::deferrede non la esegue mai in modo asincrono, quindi in effetti, quella versione di libstdc ++ "sceglie" utilizzare sempre differito, a meno che non sia costretto diversamente.
doug65536

3
@ doug65536 Il mio punto sui distruttori thread_local era che la distruzione all'uscita dal thread non è del tutto corretta quando si usano i pool di thread. Quando un'attività viene eseguita in modo asincrono, viene eseguita "come su un nuovo thread", in base alle specifiche, il che significa che ogni attività asincrona ottiene i propri oggetti thread_local. Un'implementazione basata su pool di thread deve prestare particolare attenzione per garantire che le attività che condividono lo stesso thread di supporto si comportino ancora come se avessero i propri oggetti thread_local. Considera questo programma: pastebin.com/9nWUT40h
bames53

2
@ bames53 Usare "come su un nuovo thread" nelle specifiche è stato un grosso errore secondo me. std::asyncavrebbe potuto essere una cosa meravigliosa per le prestazioni: avrebbe potuto essere il sistema di esecuzione di attività a esecuzione breve standard, naturalmente supportato da un pool di thread. In questo momento, è solo un std::threadcon qualche schifezza appiccicata per rendere la funzione thread in grado di restituire un valore. Oh, e hanno aggiunto funzionalità ridondanti "differite" che si sovrappongono std::functioncompletamente al lavoro di .
doug65536

Risposte:


55

Domanda 1 :

L'ho cambiato dall'originale perché l'originale era sbagliato. Avevo l'impressione che la creazione di thread Linux fosse molto economica e dopo il test ho determinato che il sovraccarico della chiamata di funzione in un nuovo thread rispetto a uno normale è enorme. L'overhead per la creazione di un thread per gestire una chiamata di funzione è qualcosa come 10000 o più volte più lento di una semplice chiamata di funzione. Quindi, se stai emettendo molte piccole chiamate di funzione, un pool di thread potrebbe essere una buona idea.

È abbastanza evidente che la libreria C ++ standard fornita con g ++ non ha pool di thread. Ma posso sicuramente vedere un caso per loro. Anche con l'overhead di dover spingere la chiamata attraverso una sorta di coda inter-thread, sarebbe probabilmente più economico che avviare un nuovo thread. E lo standard lo consente.

IMHO, le persone del kernel Linux dovrebbero lavorare per rendere la creazione di thread più economica di quanto non sia attualmente. Tuttavia, la libreria C ++ standard dovrebbe anche considerare l'utilizzo del pool per l'implementazione launch::async | launch::deferred.

E l'OP è corretto, usare ::std::threadper lanciare un thread ovviamente forza la creazione di un nuovo thread invece di usarne uno da un pool. Quindi ::std::async(::std::launch::async, ...)è preferito.

Domanda 2 :

Sì, fondamentalmente questo "implicitamente" avvia un thread. Ma in realtà, è ancora abbastanza ovvio cosa sta succedendo. Quindi non credo che la parola implicitamente sia una parola particolarmente buona.

Inoltre, non sono convinto che costringerti ad aspettare un ritorno prima della distruzione sia necessariamente un errore. Non so se dovresti usare la asyncchiamata per creare thread "daemon" che non dovrebbero tornare. E se ci si aspetta che tornino, non va bene ignorare le eccezioni.

Domanda 3 :

Personalmente, mi piace che i lanci dei thread siano espliciti. Attribuisco molto valore alle isole in cui puoi garantire l'accesso seriale. Altrimenti ti ritroverai con uno stato mutabile che devi sempre avvolgere un mutex da qualche parte e ricordarti di usarlo.

Mi è piaciuto molto il modello della coda di lavoro rispetto al modello "futuro" perché ci sono "isole di seriale" in giro in modo da poter gestire in modo più efficace lo stato mutevole.

Ma in realtà, dipende esattamente da cosa stai facendo.

Test della prestazione

Quindi, ho testato le prestazioni di vari metodi di chiamata e ho trovato questi numeri su un sistema a 8 core (AMD Ryzen 7 2700X) che esegue Fedora 29 compilato con clang versione 7.0.1 e libc ++ (non 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, sul mio MacBook Pro 15 "(CPU Intel (R) Core (TM) i7-7820HQ a 2,90 GHz) con Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6, ottengo questo:

   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

Per il thread di lavoro, ho avviato un thread, quindi ho utilizzato una coda senza blocco per inviare richieste a un altro thread e quindi ho aspettato una risposta "È fatto" da inviare indietro.

Il "Non fare nulla" serve solo a testare il sovraccarico del cablaggio di prova.

È chiaro che il sovraccarico dell'avvio di un thread è enorme. E anche il thread di lavoro con la coda inter-thread rallenta le cose di circa 20 volte su Fedora 25 in una VM e di circa 8 su OS X nativo.

Ho creato un progetto Bitbucket contenente il codice che ho usato per il test delle prestazioni. Può essere trovato qui: https://bitbucket.org/omnifarious/launch_thread_performance


3
Concordo sul modello della coda di lavoro, tuttavia questo richiede un modello "pipeline" che potrebbe non essere applicabile a ogni utilizzo di accesso simultaneo.
Matthieu M.

1
Mi sembra che i modelli di espressione (per operatori) possano essere usati per comporre i risultati, per le chiamate di funzione avresti bisogno di un metodo di chiamata immagino, ma a causa dei sovraccarichi potrebbe essere leggermente più difficile.
Matthieu M.

3
"molto economico" è relativo alla tua esperienza. Trovo che il sovraccarico della creazione di thread Linux sia sostanziale per il mio utilizzo.
Jeff

1
@ Jeff - Ho pensato che fosse molto più economico di quello che è. Ho aggiornato la mia risposta qualche tempo fa per riflettere un test che ho fatto per scoprire il costo effettivo.
Omnifarious

4
Nella prima parte, stai in qualche modo sottovalutando quanto deve essere fatto per creare una minaccia e quanto poco deve essere fatto per chiamare una funzione. Una chiamata di funzione e un ritorno sono poche istruzioni della CPU che manipolano alcuni byte in cima allo stack. La creazione di una minaccia significa: 1. allocare uno stack, 2. eseguire una syscall, 3. creare strutture di dati nel kernel e collegarle, agganciare blocchi lungo il percorso, 4. attendere che lo scheduler esegua il thread, 5. cambiare contesto al thread. Ciascuno di questi passaggi richiede di per sé molto più tempo delle chiamate di funzioni più complesse.
cmaster - ripristina monica il
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.