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 + 2
sono 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+y
non 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 R
e S
produce 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 Task
non è 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.