Perché tutte le funzioni non dovrebbero essere asincrone per impostazione predefinita?


107

Il modello di attesa asincrono di .net 4.5 sta cambiando paradigma. È quasi troppo bello per essere vero.

Ho portato un po 'di codice IO-heavy in Async-Wait perché il blocco è un ricordo del passato.

Molte persone stanno confrontando l'attesa asincrona con un'infestazione di zombi e ho trovato che sia piuttosto preciso. Il codice asincrono piace altro codice asincrono (è necessaria una funzione asincrona per attendere su una funzione asincrona). Quindi sempre più funzioni diventano asincrone e questo continua a crescere nella tua base di codice.

Cambiare le funzioni in asincrono è un lavoro in qualche modo ripetitivo e privo di fantasia. Lancia una asyncparola chiave nella dichiarazione, racchiudi il valore restituito Task<>e il gioco è fatto. È piuttosto sconvolgente quanto sia facile l'intero processo, e molto presto uno script di sostituzione del testo automatizzerà la maggior parte del "porting" per me.

E ora la domanda .. Se tutto il mio codice sta lentamente diventando asincrono, perché non renderlo completamente asincrono per impostazione predefinita?

L'ovvia ragione per cui presumo sia la performance. Async-await ha un overhead e un codice che non deve essere asincrono, preferibilmente non dovrebbe. Ma se le prestazioni sono l'unico problema, sicuramente alcune ottimizzazioni intelligenti possono rimuovere automaticamente l'overhead quando non è necessario. Ho letto dell'ottimizzazione del "percorso veloce" e mi sembra che da sola dovrebbe occuparsene per la maggior parte.

Forse questo è paragonabile al cambio di paradigma portato dai netturbini. Nei primi giorni di GC, liberare la propria memoria era decisamente più efficiente. Ma le masse hanno comunque scelto la raccolta automatica a favore di un codice più sicuro e più semplice che potrebbe essere meno efficiente (e anche questo probabilmente non è più vero). Forse dovrebbe essere così qui? Perché tutte le funzioni non dovrebbero essere asincrone?


8
Dai al team C # il merito di aver contrassegnato la mappa. Come è stato fatto centinaia di anni fa, "i draghi giacciono qui". Potresti equipaggiare una nave e andarci, molto probabilmente sopravviverai con il sole che splende e il vento alle spalle. E a volte no, non sono mai tornati. Proprio come async / await, SO è pieno di domande da parte di utenti che non hanno capito come sono usciti dalla mappa. Anche se hanno ricevuto un buon avvertimento. Ora è il loro problema, non il problema del team di C #. Hanno segnato i draghi.
Hans Passant

1
@Sayse anche se rimuovi la distinzione tra funzioni di sincronizzazione e asincrone, le funzioni di chiamata implementate in modo sincrono saranno comunque sincrone (come il tuo esempio WriteLine)
talkol

2
"Racchiudi il valore restituito da Task <>" A meno che il tuo metodo non debba essere async(per adempiere a qualche contratto), questa è probabilmente una cattiva idea. Stai ottenendo gli svantaggi dell'asincronia (aumento del costo delle chiamate al metodo; la necessità di utilizzare awaitnel codice chiamante), ma nessuno dei vantaggi.
svick

1
Questa è una domanda davvero interessante ma forse non si adatta perfettamente a SO. Potremmo considerare di migrarlo su Programmers.
Eric Lippert

1
Forse mi manca qualcosa, ma ho esattamente la stessa domanda. Se async / await vengono sempre in coppia e il codice deve ancora attendere che finisca l'esecuzione, quando perché non aggiornare semplicemente il framework .NET esistente e rendere quei metodi che devono essere asincroni - asincroni per impostazione predefinita SENZA creare parole chiave aggiuntive? Il linguaggio sta già diventando quello per cui è stato progettato per sfuggire: parola chiave spaghetti. Penso che avrebbero dovuto smettere di farlo dopo che è stato suggerito il "var". Ora abbiamo "dinamico", asyn / await ... ecc ... Perché non usate solo .NET-ify javascript? ;))
monstro

Risposte:


127

Prima di tutto, grazie per le tue gentili parole. È davvero una caratteristica fantastica e sono felice di averne fatto una piccola parte.

Se tutto il mio codice sta lentamente diventando asincrono, perché non renderlo completamente asincrono per impostazione predefinita?

Beh, stai esagerando; tutto il tuo codice non sta diventando asincrono. Quando aggiungi due interi "semplici", non stai aspettando il risultato. Quando aggiungi due numeri interi futuri insieme per ottenere un terzo intero futuro - perché è quello che Task<int>è, è un numero intero a cui avrai accesso in futuro - ovviamente probabilmente starai aspettando il risultato.

Il motivo principale per non rendere tutto asincrono è perché lo scopo di async / await è rendere più semplice la scrittura di codice in un mondo con molte operazioni ad alta latenza . La stragrande maggioranza delle tue operazioni non è ad alta latenza, quindi non ha alcun senso subire il calo delle prestazioni che mitiga tale latenza. Piuttosto, alcune delle tue operazioni chiave sono l'alta latenza e quelle operazioni stanno causando l'infestazione da zombi dell'asincronia in tutto il codice.

se le prestazioni sono l'unico problema, sicuramente alcune ottimizzazioni intelligenti possono rimuovere automaticamente l'overhead quando non è necessario.

In teoria, teoria e pratica sono simili. In pratica, non lo sono mai.

Consentitemi di darvi tre punti contro questo tipo di trasformazione seguita da un passaggio di ottimizzazione.

Il primo punto è ancora: async in C # / VB / F # è essenzialmente una forma limitata di passaggio di continuazione . Un'enorme quantità di ricerche nella comunità dei linguaggi funzionali è stata dedicata alla ricerca di modi per identificare come ottimizzare il codice che fa un uso massiccio dello stile di passaggio di continuazione. Il team del compilatore avrebbe probabilmente dovuto risolvere problemi molto simili in un mondo in cui "asincrono" era l'impostazione predefinita e i metodi non asincroni dovevano essere identificati e de-asincroni. Il team di C # non è realmente interessato ad affrontare i problemi di ricerca aperta, quindi questo è un grosso problema proprio lì.

Un secondo punto contro è che C # non ha il livello di "trasparenza referenziale" che rende questo tipo di ottimizzazioni più trattabili. Per "trasparenza referenziale" intendo la proprietà dalla quale il valore di un'espressione non dipende quando viene valutata . Espressioni come 2 + 2sono referenzialmente trasparenti; puoi fare la valutazione in fase di compilazione, se vuoi, o rimandarla al runtime e ottenere la stessa risposta. Ma un'espressione come x+ynon può essere spostata nel tempo perché x e y potrebbero cambiare nel tempo .

Async rende molto più difficile ragionare su quando si verificherà un effetto collaterale. Prima dell'asincronia, se hai detto:

M();
N();

ed M()era void M() { Q(); R(); }, ed N()era void N() { S(); T(); }, e Re Sproduce effetti collaterali, allora sai che l'effetto collaterale di R si verifica prima dell'effetto collaterale di S. Ma se hai async void M() { await Q(); R(); }poi improvvisamente che va fuori dalla finestra. Non hai alcuna garanzia se R()accadrà prima o dopo S()(a meno che ovviamente non M()sia atteso; ma ovviamente Tasknon è necessario attendere fino a dopo N()).

Ora immagina che questa proprietà di non sapere più in quale ordine si verificano gli effetti collaterali si applica a ogni pezzo di codice nel tuo programma tranne quelli che l'ottimizzatore riesce a de-asincronizzare-ify. Fondamentalmente non hai più idea di quali espressioni verranno valutate in quale ordine, il che significa che tutte le espressioni devono essere referenzialmente trasparenti, il che è difficile in un linguaggio come C #.

Un terzo punto contro è che devi chiedere "perché async è così speciale?" Se hai intenzione di sostenere che ogni operazione dovrebbe effettivamente essere Task<T>eseguita, devi essere in grado di rispondere alla domanda "perché no Lazy<T>?" o "perché no Nullable<T>?" o "perché no IEnumerable<T>?" Perché potremmo farlo altrettanto facilmente. Perché non dovrebbe essere il caso che ogni operazione venga elevata a nullable ? Oppure ogni operazione viene calcolata pigramente e il risultato viene memorizzato nella cache per dopo , oppure il risultato di ogni operazione è una sequenza di valori anziché un singolo valore . Devi quindi cercare di ottimizzare quelle situazioni in cui sai "oh, questo non deve mai essere nullo, così posso generare codice migliore", e così via.

Il punto è: non è chiaro per me che in Task<T>realtà sia così speciale da giustificare così tanto lavoro.

Se questo genere di cose ti interessa, ti consiglio di indagare su linguaggi funzionali come Haskell, che hanno una trasparenza referenziale molto più forte e consentono tutti i tipi di valutazione fuori ordine e fanno la cache automatica. Haskell ha anche un supporto molto più forte nel suo sistema di tipi per i tipi di "elevazioni monadiche" a cui ho accennato.


Avere funzioni asincrone chiamate senza await non ha senso per me (nel caso comune). Se dovessimo rimuovere questa funzionalità, il compilatore potrebbe decidere da solo se una funzione è asincrona o meno (chiama await?). Quindi potremmo avere una sintassi identica per entrambi i casi (async e sync) e utilizzare solo await nelle chiamate come differenziatore. Risolto l'infestazione di zombi :)
talkol

Ho continuato la discussione sui programmatori secondo la tua richiesta: programmers.stackexchange.com/questions/209872/…
talkol

@EricLippert - Risposta molto bella come sempre :) Ero curioso se potessi chiarire "alta latenza"? C'è un intervallo generale in millisecondi qui? Sto solo cercando di capire dove si trova la linea di confine inferiore per l'utilizzo di async perché non voglio abusarne.
Travis J

7
@TravisJ: La guida è: non bloccare il thread dell'interfaccia utente per più di 30 ms. Più di questo corri il rischio che la pausa venga notata dall'utente.
Eric Lippert

1
Ciò che mi sfida è che se qualcosa viene fatto in modo sincrono o asincrono è un dettaglio di implementazione che può cambiare. Ma il cambiamento in quell'implementazione impone un effetto a catena attraverso il codice che lo chiama, il codice che lo chiama e così via. Finiamo per cambiare il codice a causa del codice da cui dipende, che è qualcosa che normalmente facciamo di tutto per evitare. Oppure usiamo async/awaitperché qualcosa nascosto sotto strati di astrazione potrebbe essere asincrono.
Scott Hannen

23

Perché tutte le funzioni non dovrebbero essere asincrone?

Le prestazioni sono una delle ragioni, come hai detto. Nota che l'opzione "percorso veloce" a cui ti sei collegato migliora le prestazioni nel caso di un'attività completata, ma richiede ancora molte più istruzioni e overhead rispetto a una singola chiamata al metodo. In quanto tale, anche con il "percorso veloce" in atto, si aggiunge molta complessità e sovraccarico a ciascuna chiamata di metodo asincrona.

Anche la compatibilità con le versioni precedenti, così come la compatibilità con altri linguaggi (inclusi gli scenari di interoperabilità), diventerebbe problematica.

L'altro è una questione di complessità e intenti. Le operazioni asincrone aggiungono complessità: in molti casi, le funzionalità del linguaggio lo nascondono, ma ci sono molti casi in cui la creazione di metodi asyncaggiunge sicuramente complessità al loro utilizzo. Ciò è particolarmente vero se non si dispone di un contesto di sincronizzazione, poiché i metodi asincroni possono facilmente finire per causare problemi di threading imprevisti.

Inoltre, ci sono molte routine che, per loro natura, non sono asincrone. Quelle hanno più senso come operazioni sincrone. Costringere Math.Sqrtad essere Task<double> Math.SqrtAsyncsarebbe ridicolo, ad esempio, poiché non vi è alcun motivo per essere asincrono. Invece di asyncspingere attraverso la tua applicazione, awaitfiniresti per diffondere ovunque .

Ciò inoltre romperebbe completamente il paradigma corrente, oltre a causare problemi con le proprietà (che in effetti sono solo coppie di metodi .. diventerebbero anche asincroni?), E avrebbe altre ripercussioni durante la progettazione del framework e del linguaggio.

Se stai facendo un sacco di lavoro legato all'IO, tenderai a scoprire che l'uso asyncpervasivo è un'ottima aggiunta, molte delle tue routine lo saranno async. Tuttavia, quando si inizia a lavorare con la CPU, in generale, fare le cose in asyncrealtà non è buono: sta nascondendo il fatto che stai usando i cicli della CPU sotto un'API che sembra essere asincrona, ma in realtà non è necessariamente veramente asincrona.


Esattamente quello che stavo per scrivere (prestazioni), la compatibilità con le versioni precedenti potrebbe essere un'altra cosa, dll deve essere utilizzato con lingue meno
recenti

Rendere sqrt async non è ridicolo se rimuoviamo semplicemente la distinzione tra funzioni di sincronizzazione e asincrono
talkol

@talkol Immagino che cambierei questa situazione: perché ogni chiamata di funzione dovrebbe assumere la complessità dell'asincronia?
Reed Copsey

2
@talkol Direi che non è necessariamente vero - l'asincronia può aggiungere bug che sono peggiori del blocco ...
Reed Copsey

1
@talkol Come è await FooAsync()più semplice di Foo()? E invece di un piccolo effetto domino qualche volta, hai sempre un enorme effetto domino e lo chiami un miglioramento?
svick

4

Prestazioni a parte: l'asincronia può avere un costo di produttività. Sul client (WinForms, WPF, Windows Phone) è un vantaggio per la produttività. Ma sul server, o in altri scenari non UI, paghi la produttività. Certamente non vuoi diventare asincrono per impostazione predefinita lì. Usalo quando hai bisogno dei vantaggi della scalabilità.

Usalo quando sei nel punto giusto. In altri casi, no.


2
+1 - Il semplice tentativo di avere una mappa mentale del codice che esegue 5 operazioni asincrone in parallelo con una sequenza casuale di completamento, per la maggior parte delle persone fornirà abbastanza dolore per un giorno. Ragionare sul comportamento del codice asincrono (e quindi intrinsecamente parallelo) è molto più difficile del buon vecchio codice sincrono ...
Alexei Levenkov

2

Credo che ci sia una buona ragione per rendere tutti i metodi asincroni se non sono necessari: estensibilità. La creazione selettiva dei metodi asincroni funziona solo se il codice non si evolve mai e sai che il metodo A () è sempre legato alla CPU (lo mantieni sincronizzato) e il metodo B () è sempre vincolato all'I / O (lo contrassegni come asincrono).

Ma cosa succede se le cose cambiano? Sì, A () sta eseguendo calcoli, ma a un certo punto in futuro è stato necessario aggiungere lì la registrazione, o il reporting, o il callback definito dall'utente con un'implementazione che non può prevedere, oppure l'algoritmo è stato esteso e ora include non solo i calcoli della CPU ma anche qualche I / O? Dovrai convertire il metodo in asincrono, ma ciò interromperà l'API e sarà necessario aggiornare anche tutti i chiamanti nello stack (e possono anche essere app diverse di fornitori diversi). Oppure dovrai aggiungere la versione asincrona insieme alla versione di sincronizzazione, ma questo non fa molta differenza: l'uso della versione di sincronizzazione si bloccherebbe e quindi è difficilmente accettabile.

Sarebbe fantastico se fosse possibile rendere il metodo di sincronizzazione esistente asincrono senza modificare l'API. Ma nella realtà non abbiamo questa opzione, credo, e l'utilizzo della versione asincrona anche se non è attualmente necessaria è l'unico modo per garantire che non si verifichino problemi di compatibilità in futuro.


Anche se questo sembra un commento estremo, c'è molta verità in esso. Primo, a causa di questo problema: "questo interromperà l'API e tutti i chiamanti nello stack" async / await rende un'applicazione più strettamente accoppiata. Potrebbe facilmente infrangere il principio di sostituzione di Liskov se una sottoclasse desidera utilizzare async / await, ad esempio. Inoltre, è difficile immaginare un'architettura di microservizi in cui la maggior parte dei metodi non necessitava di asincronia / attesa.
ItsAllABadJoke
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.