È vero che async non dovrebbe essere usato per compiti con CPU elevate?

Mi chiedevo se fosse vero che asyncawait non dovrebbe essere usato per compiti “high-CPU”. Ho visto questo reclamato in una presentazione.

Quindi immagino che significherebbe qualcosa del genere

 Task calculateMillionthPrimeNumber = CalculateMillionthPrimeNumberAsync(); DoIndependentWork(); int p = await calculateMillionthPrimeNumber; 

La mia domanda è se quanto sopra può essere giustificato, o se no, c’è qualche altro esempio di fare un task asincrono con CPU alta?

Mi chiedevo se fosse vero che async-await non dovrebbe essere usato per compiti “high-CPU”.

Sì è vero.

La mia domanda è se quanto sopra può essere giustificato

Direi che non è giustificato. Nel caso generale, evitare di utilizzare Task.Run per implementare metodi con firme asincrone. Non esporre involucri asincroni per i metodi sincroni . Questo per evitare confusione da parte dei consumatori, in particolare su ASP.NET.

Tuttavia, non c’è nulla di sbagliato nell’usare Task.Run per chiamare un metodo sincrono, ad esempio in un’app UI. In questo modo, puoi utilizzare il multithreading ( Task.Run ) per mantenere il thread dell’interfaccia utente libero e consumarlo elegantemente in await :

 var task = Task.Run(() => CalculateMillionthPrimeNumber()); DoIndependentWork(); var prime = await task; 

Esistono, infatti, due principali usi di asincrono / attesa. Uno (e la mia comprensione è che questo è uno dei motivi principali per cui è stato inserito nel framework) è quello di consentire al thread chiamante di fare altro lavoro mentre è in attesa di un risultato. Questo è principalmente per attività con I / O (cioè attività in cui il “holdup” principale è una sorta di I / O – in attesa di un disco rigido, server, stampante, ecc. Per rispondere o completare il suo compito).

Come nota a margine, se stai usando async / attendi in questo modo, è importante assicurarsi di averlo implementato in modo tale che il thread chiamante possa effettivamente fare altro lavoro mentre è in attesa del risultato; Ho visto un sacco di casi in cui le persone fanno cose come “A attende B, che aspetta C”; questo può non funzionare meglio di se A ha appena chiamato B in modo sincrono e B ha appena chiamato C sincrono (perché il thread chiamante non ha mai permesso di fare altro lavoro mentre è in attesa dei risultati di B e C).

Nel caso di attività legate all’I / O, non ha molto senso creare un thread aggiuntivo solo per aspettare un risultato. La mia solita analogia qui è di pensare di ordinare in un ristorante con 10 persone in un gruppo. Se la prima persona che il cameriere chiede di ordinare non è ancora pronta, il cameriere non aspetta solo che sia pronto prima di prendere l’ordine di qualcun altro, né porta un secondo cameriere solo per aspettare il primo. La cosa migliore da fare in questo caso è chiedere agli altri 9 membri del gruppo i loro ordini; speriamo che, nel momento in cui avranno ordinato, il primo ragazzo sarà pronto. Altrimenti, almeno il cameriere ha ancora risparmiato un po ‘di tempo perché passa meno tempo rimanendo inattivo.

È anche ansible utilizzare cose come Task.Run per eseguire attività legate alla CPU (e questo è il secondo utilizzo per questo). Per seguire la nostra analogia sopra, questo è un caso in cui sarebbe generalmente utile avere più camerieri – ad esempio se ci fossero troppe tabelle per un singolo cameriere al servizio. In realtà, tutto ciò che effettivamente fa “dietro le quinte” è utilizzare il Pool di thread; è uno dei tanti possibili costrutti per eseguire il lavoro legato alla CPU (ad esempio, semplicemente inserendolo “direttamente” nel Pool di thread, creando esplicitamente un nuovo thread o utilizzando un Background Worker ), quindi è una domanda di progettazione sul meccanismo che si utilizza.

Un vantaggio async/await qui è che può (date le giuste circostanze) ridurre la quantità di logica di blocco / sincronizzazione esplicita che si deve scrivere manualmente. Ecco una sorta di esempio stupido:

 private static async Task SomeCPUBoundTask() { // Insert actual CPU-bound task here using Task.Run await Task.Delay(100); } public static async Task QueueCPUBoundTasks() { List tasks = new List(); // Queue up however many CPU-bound tasks you want for (int i = 0; i < 10; i++) { // We could just call Task.Run(...) directly here Task task = SomeCPUBoundTask(); tasks.Add(task); } // Wait for all of them to complete // Note that I don't have to write any explicit locking logic here, // I just tell the framework to wait for all of them to complete await Task.WhenAll(tasks); } 

Ovviamente, presumo qui che i compiti siano completamente parallelizzabili. Nota anche che avresti potuto usare il Thread Pool da solo, ma sarebbe un po 'meno conveniente perché avresti bisogno di un modo per capire da solo se tutti loro hanno completato (piuttosto che lasciare che il quadro sia fuori per te). Potresti anche essere stato in grado di utilizzare un ciclo Parallel.For qui.

Supponiamo che il tuo CalculateMillionthPrimeNumber sia simile al seguente (non molto efficiente o ideale nel suo uso del goto ma molto semplice da sottovalutare):

 public int CalculateMillionthPrimeNumber() { List primes = new List(1000000){2}; int num = 3; while(primes.Count < 1000000) { foreach(int div in primes) { if ((num / div) * div == num) goto next; } primes.Add(num); next: ++num; } return primes.Last(); } 

Ora, non c'è un punto utile in cui questo possa fare qualcosa in modo asincrono. Facciamo diventare un metodo di ritorno delle attività usando async :

 public async Task CalculateMillionthPrimeNumberAsync() { List primes = new List(1000000){2}; int num = 3; while(primes.Count < 1000000) { foreach(int div in primes) { if ((num / div) * div == num) goto next; } primes.Add(num); next: ++num; } return primes.Last(); } 

Il compilatore ci avviserà di questo, perché non c'è nulla per noi da await qualcosa di utile. Task.FromResult(CalculateMillionthPrimeNumber()) versione leggermente più complicata della chiamata Task.FromResult(CalculateMillionthPrimeNumber()) . Vale a dire, equivale a fare il calcolo e quindi a creare un'attività già completata che ha come risultato il numero calcolato.

Ora, le attività già completate non sono sempre inutili. Ad esempio, considera:

 public async Task GetInterestingStringAsync() { if (_cachedInterestingString == null) _cachedInterestingString = await GetStringFromWeb(); return _cachedInterestingString; } 

Ciò restituisce un'attività già completata quando la stringa è nella cache e non altrimenti, e in tal caso tornerà abbastanza velocemente. Altri casi sono se esiste più di un'implementazione della stessa interfaccia e non tutte le implementazioni possono utilizzare l'I / O asincrono.

E allo stesso modo un metodo async che await questo metodo restituirà un'attività già completata o meno a seconda di questo. In realtà è un ottimo modo per restare sullo stesso thread e fare ciò che è necessario quando è ansible.

Ma se è sempre ansible, l'unico effetto è un po 'eccessivo di creare l'object Task e lo stato macchina che async usa per implementarlo.

Quindi, piuttosto inutile. Se era così che è stata implementata la versione nella tua domanda, allora calculateMillionthPrimeNumber avrebbe avuto IsCompleted restituendo true vero dall'inizio. Dovresti aver appena chiamato la versione non asincrona.

Ok, come implementatori di CalculateMillionthPrimeNumberAsync() vogliamo fare qualcosa di più utile per i nostri utenti. Quindi facciamo:

 public Task CalculateMillionthPrimeNumberAsync() { return Task.Factory.StartNew(CalculateMillionthPrimeNumber, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); } 

Ok, non stiamo perdendo tempo per i nostri utenti. DoIndependentWork() eseguirà tutto nello stesso momento di CalculateMillionthPrimeNumberAsync() , e se finisce prima l' await rilascerà quel thread.

Grande!

Solo, non abbiamo davvero spostato l'ago tanto dalla posizione sincrona. In effetti, specialmente se DoIndependentWork() non è arduo, forse lo abbiamo reso molto peggio. Il modo sincrono farebbe tutto su un thread, chiamiamolo Thread A Il nuovo metodo esegue il calcolo sul Thread B quindi rilascia il Thread A , quindi si sincronizza nuovamente in alcuni modi possibili. È un sacco di lavoro, ha guadagnato qualcosa?

Beh, forse, ma l'autore di CalculateMillionthPrimeNumberAsync() non può saperlo, perché i fattori che influenzano sono tutti nel codice chiamante. Il codice chiamante avrebbe potuto essere StartNew stesso ed essere stato in grado di adattarsi meglio alle opzioni di sincronizzazione quando necessario.

Quindi, mentre le attività possono essere un modo conveniente per chiamare il codice associato alla cpu in parallelo a un'altra attività, i metodi che lo fanno non sono utili. Peggio ancora stanno ingannando quando qualcuno che vede CalculateMillionthPrimeNumberAsync potrebbe essere perdonato per aver creduto che chiamarlo non fosse inutile.

A meno che CalculateMillionthPrimeNumberAsync utilizzi costantemente async/await da solo, non vi è alcun motivo per non consentire all’attività di eseguire un lavoro con CPU pesanti, in quanto consente di debind il proprio metodo alla discussione di ThreadPool.

Che thread ThreadPool è e in che modo è diverso da un thread normale è scritto qui .

In breve, prende il thread threadpool in custodia per un po ‘di tempo (e il numero di thread del threadpool è limitato), quindi, a meno che non ne prendiate troppi, non c’è nulla di cui preoccuparsi.