Flussi asincroni
Nota
Questo articolo è una specifica delle funzionalità. La specifica funge da documento di progettazione per la funzionalità. Include le modifiche specifiche proposte, insieme alle informazioni necessarie durante la progettazione e lo sviluppo della funzionalità. Questi articoli vengono pubblicati fino a quando le modifiche specifiche proposte non vengono completate e incorporate nella specifica ECMA corrente.
Potrebbero verificarsi alcune discrepanze tra la specifica di funzionalità e l'implementazione completata. Tali differenze vengono acquisite nelle note
Ulteriori dettagli sul processo di adozione delle specifiche di funzionalità nello standard del linguaggio C# sono disponibili nell'articolo sulle specifiche .
Problema del campione: https://github.com/dotnet/csharplang/issues/43
Sommario
C# include il supporto per i metodi iteratori e i metodi asincroni, ma non è supportato per un metodo sia iteratore che asincrono. È consigliabile correggere questo problema consentendo l'uso di await
in una nuova forma di iteratore async
, che restituisce un IAsyncEnumerable<T>
o un IAsyncEnumerator<T>
anziché un IEnumerable<T>
o IEnumerator<T>
, con IAsyncEnumerable<T>
utilizzabile in un nuovo await foreach
. Viene usata anche un'interfaccia IAsyncDisposable
per abilitare la pulizia asincrona.
Discussione correlata
Progettazione dettagliata
Interfacce
IAsyncDisposable
C'è stata molta discussione su IAsyncDisposable
(ad esempio, https://github.com/dotnet/roslyn/issues/114) e se è una buona idea. Tuttavia, è un concetto necessario per aggiungere il supporto di iteratori asincroni. Poiché i blocchi finally
possono contenere await
e poiché è necessario eseguire i blocchi finally
come parte dell'eliminazione di iteratori, è necessaria l'eliminazione asincrona. È utile in generale ogni volta che la pulizia delle risorse potrebbe richiedere un certo periodo di tempo, ad esempio la chiusura dei file (che richiedono svuotamenti), l'annullamento della registrazione dei callback e fornire un modo per sapere quando l'annullamento della registrazione è stato completato, e così via.
L'interfaccia seguente viene aggiunta alle librerie .NET principali, ad esempio System.Private.CoreLib/ System.Runtime:
namespace System
{
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
}
Come per Dispose
, richiamare DisposeAsync
più volte è accettabile e le chiamate successive dopo la prima devono essere considerate no-ops, restituendo un'attività riuscita completata in modo sincrono (DisposeAsync
non deve essere thread-safe, e non è necessario supportare chiamate simultanee). Inoltre, i tipi possono implementare sia IDisposable
che IAsyncDisposable
e, se lo fanno, è parimenti accettabile richiamare Dispose
e quindi DisposeAsync
, o viceversa, ma solo la prima invocazione dovrebbe essere significante e le invocazioni successive di entrambi dovrebbero essere un nop. Di conseguenza, se un tipo implementa entrambi, i consumatori sono invitati a chiamare una volta sola il metodo più pertinente in base al contesto, Dispose
in contesti sincroni e DisposeAsync
in quelli asincroni.
Il modo in cui IAsyncDisposable
interagisce con using
è una discussione separata. E la copertura del modo in cui interagisce con foreach
viene gestita più avanti in questa proposta.
Le alternative considerate
-
DisposeAsync
accettare unCancellationToken
: mentre in teoria ha senso che qualsiasi cosa asincrona può essere annullata, lo smaltimento riguarda la pulizia, la chiusura delle cose, le risorse gratuite e così via, che in genere non è qualcosa che deve essere annullato; la pulizia è ancora importante per il lavoro annullato. Lo stessoCancellationToken
che ha causato l'annullamento del lavoro effettivo sarebbe in genere lo stesso token passato aDisposeAsync
, rendendoDisposeAsync
senza valore perché l'annullamento del lavoro causerebbe cheDisposeAsync
diventi un no-op. Se qualcuno vuole evitare di essere bloccato in attesa di smaltimento, può evitare di attendere il risultatoValueTask
oppure attendere solo per un certo periodo di tempo. -
DisposeAsync
restituendo unTask
: ora che esiste unValueTask
non generico e può essere costruito da unIValueTaskSource
, restituendoValueTask
daDisposeAsync
consente di riutilizzare un oggetto esistente come promessa che rappresenta il completamento finale asincrono diDisposeAsync
, salvando un'allocazioneTask
nel caso in cuiDisposeAsync
sia completato in modo asincrono. -
Configurare
DisposeAsync
con unbool continueOnCapturedContext
(ConfigureAwait
): Anche se potrebbero esserci problemi relativi a come tale concetto viene esposto ausing
,foreach
e altri costrutti linguistici che lo utilizzano, da una prospettiva d'interfaccia, in realtà non sta facendo nessunawait
e non c'è nulla da configurare... i consumatori delValueTask
possono utilizzarlo come desiderano. -
IAsyncDisposable
che ereditaIDisposable
: poiché deve essere usato solo uno o l'altro, non ha senso forzare i tipi a implementare entrambi. -
IDisposableAsync
invece diIAsyncDisposable
: abbiamo seguito la denominazione che gli elementi o i tipi sono un "elemento asincrono", mentre le operazioni sono "eseguite asincrone", quindi i tipi hanno "Async" come prefisso e i metodi hanno "Async" come suffisso.
IAsyncEnumerable/IAsyncEnumerator
Due interfacce vengono aggiunte alle librerie .NET principali:
namespace System.Collections.Generic
{
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
ValueTask<bool> MoveNextAsync();
T Current { get; }
}
}
Il consumo tipico (senza funzionalità aggiuntive del linguaggio) si presenterebbe come:
IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
while (await enumerator.MoveNextAsync())
{
Use(enumerator.Current);
}
}
finally { await enumerator.DisposeAsync(); }
Opzioni considerate e scartate
-
Task<bool> MoveNextAsync(); T current { get; }
: L'utilizzo diTask<bool>
supporterebbe l'uso di un oggetto attività memorizzato nella cache per rappresentare chiamate sincrone e riuscite diMoveNextAsync
, ma sarebbe comunque necessaria un'allocazione per il completamento asincrono. RestituendoValueTask<bool>
, abilitiamo l'oggetto enumeratore a implementareIValueTaskSource<bool>
e a essere usato come supporto per ilValueTask<bool>
restituito daMoveNextAsync
, il che a sua volta consente di ridurre notevolmente i sovraccarichi. -
ValueTask<(bool, T)> MoveNextAsync();
: Non è solo più difficile da comprendere, ma significa cheT
non può più essere covariante. -
ValueTask<T?> TryMoveNextAsync();
: non covariante. -
Task<T?> TryMoveNextAsync();
: non covariante, allocazioni su ogni chiamata e così via. -
ITask<T?> TryMoveNextAsync();
: non covariante, allocazioni su ogni chiamata e così via. -
ITask<(bool,T)> TryMoveNextAsync();
: non covariante, allocazioni su ogni chiamata e così via. -
Task<bool> TryMoveNextAsync(out T result);
: Il risultatoout
deve essere impostato quando l'operazione restituisce in modo sincrono, non quando completa in modo asincrono l'attività potenzialmente in un momento futuro indefinito, a quel momento non sarebbe possibile comunicare il risultato. -
IAsyncEnumerator<T>
non implementaIAsyncDisposable
: è possibile scegliere di separare questi elementi. Tuttavia, ciò complica alcune altre aree della proposta, poiché il codice deve quindi essere in grado di gestire la possibilità che un enumeratore non fornisca lo smaltimento, il che rende difficile scrivere helper basati su criteri. Inoltre, è comune che gli enumeratori dispongano di una necessità di eliminazione (ad esempio, qualsiasi iteratore asincrono C# con un blocco finally, la maggior parte degli elementi che enumerano i dati da una connessione di rete e così via) e, in caso contrario, è semplice implementare il metodo esclusivamente comepublic ValueTask DisposeAsync() => default(ValueTask);
con un sovraccarico aggiuntivo minimo. - _
IAsyncEnumerator<T> GetAsyncEnumerator()
: nessun parametro del token di annullamento.
La sottosezione seguente illustra le alternative che non sono state scelte.
Alternativa praticabile:
namespace System.Collections.Generic
{
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator();
}
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
ValueTask<bool> WaitForNextAsync();
T TryGetNext(out bool success);
}
}
TryGetNext
viene utilizzato in un ciclo interno per elaborare gli elementi con una singola chiamata di interfaccia, purché siano disponibili in modo sincrono. Quando l'elemento successivo non può essere recuperato in modo sincrono, restituisce false e ogni volta che restituisce false, un chiamante deve successivamente richiamare WaitForNextAsync
per attendere che l'elemento successivo sia disponibile o per determinare che non ci sarà mai un altro elemento. Il consumo tipico (senza funzionalità aggiuntive del linguaggio) si presenterebbe come:
IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
while (await enumerator.WaitForNextAsync())
{
while (true)
{
int item = enumerator.TryGetNext(out bool success);
if (!success) break;
Use(item);
}
}
}
finally { await enumerator.DisposeAsync(); }
Il vantaggio di questo è duplice: uno minore e uno maggiore.
-
Minore: consente a un enumeratore di supportare più consumatori. Possono esserci scenari in cui è utile per un enumeratore supportare più consumatori concorrenti. Ciò non può essere raggiunto quando
MoveNextAsync
eCurrent
sono separati, impedendo a un'implementazione di rendere atomico l'utilizzo. Al contrario, questo approccio fornisce un singolo metodoTryGetNext
che supporta lo spostamento dell'enumeratore in avanti e l'ottenimento dell'elemento successivo, in modo che l'enumeratore possa abilitare l'atomicità, se desiderato. Tuttavia, è probabile che tali scenari possano anche essere abilitati assegnando a ogni consumer il proprio enumeratore da un'enumerabile condivisa. Inoltre, non si vuole imporre che ogni enumeratore supporti l'utilizzo simultaneo, in quanto aggiungerebbe un sovraccarico non banale al caso di maggioranza che non lo richiede generalmente, il che significa che un consumatore dell'interfaccia in genere non può basarsi su questo in ogni caso. -
principale: prestazioni. L'approccio
MoveNextAsync
/Current
richiede due chiamate di interfaccia per operazione, mentre il caso migliore perWaitForNextAsync
/TryGetNext
è che la maggior parte delle iterazioni è completa in modo sincrono, consentendo un ciclo interno stretto conTryGetNext
, in modo che sia disponibile una sola chiamata di interfaccia per operazione. Ciò può avere un impatto misurabile nelle situazioni in cui le chiamate di interfaccia dominano il calcolo.
Tuttavia, ci sono svantaggi non banali, tra cui una complessità significativamente maggiore nel consumo manuale e una maggiore probabilità di introdurre bug quando vengono usati. E mentre i vantaggi delle prestazioni si fanno notare nei microbenchmark, non crediamo che avranno un impatto significativo nella maggior parte dell'utilizzo reale. Se si scopre che lo sono, possiamo introdurre un secondo set di interfacce in modo illuminato.
Opzioni considerate e scartate
-
ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result);
: i parametriout
non possono essere covarianti. C'è anche un piccolo impatto qui (una questione con il modello try in generale) che potrebbe comportare una barriera di scrittura durante l'esecuzione per i risultati dei tipi di riferimento.
Cancellazione
Esistono diversi approcci possibili per supportare l'annullamento:
-
IAsyncEnumerable<T>
/IAsyncEnumerator<T>
sono insensibili all'annullamento:CancellationToken
non appare da nessuna parte. L'annullamento viene ottenuto integrando logicamente ilCancellationToken
nell'enumerabile e/o nell'enumeratore in qualsiasi modo appropriato, ad esempio quando si chiama un iteratore, passando ilCancellationToken
come argomento al metodo dell'iteratore e usandolo nel corpo dell'iteratore, come avviene con qualsiasi altro parametro. -
IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: si passa unCancellationToken
aGetAsyncEnumerator
e le successive operazioni diMoveNextAsync
lo rispettano per quanto possibile. -
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: si passa unCancellationToken
a ogni singola chiamataMoveNextAsync
. - 1 && 2: Incorporate entrambi i
CancellationToken
nel vostro enumerabile/enumeratore e passate iCancellationToken
inGetAsyncEnumerator
. - 1 && 3: sia l'incorporamento di
CancellationToken
nell'enumeratore/enumerabile che il passaggio diCancellationToken
inMoveNextAsync
.
Dal punto di vista puramente teorico, (5) è il più robusto, in quanto (a) MoveNextAsync
accettando un CancellationToken
consente il controllo più granulare su ciò che viene annullato, e (b) CancellationToken
è solo qualsiasi altro tipo che può essere passato come argomento in iteratori, incorporati in tipi arbitrari e così via.
Tuttavia, esistono più problemi con questo approccio:
- In che modo un
CancellationToken
passato aGetAsyncEnumerator
arriva nel corpo dell'iteratore? Potremmo esporre una nuova parola chiaveiterator
a cui potresti accedere per ottenere l'accesso alCancellationToken
passato aGetEnumerator
, ma a) richiede molte funzionalità aggiuntive, b) lo stiamo trattando come una funzionalità di primo livello, e c) il caso 99% sembrerebbe essere lo stesso codice sia per chiamare un iteratore che per chiamareGetAsyncEnumerator
su di esso, nel qual caso può semplicemente passare ilCancellationToken
come argomento al metodo. - In che modo un
CancellationToken
passato aMoveNextAsync
entra nel corpo del metodo? Questo è ancora peggio, come se fosse esposto da un oggetto localeiterator
, il relativo valore potrebbe cambiare durante gli await, il che significa che qualsiasi codice registrato con il token avrebbe bisogno di deregistrarsi da esso prima degli await e quindi ri-registrarsi dopo; è anche potenzialmente piuttosto costoso dover eseguire tale registrazione e deregistrazione in ogni chiamataMoveNextAsync
, indipendentemente dal fatto che sia implementato dal compilatore in un iteratore o manualmente da uno sviluppatore. - In che modo uno sviluppatore annulla un ciclo di
foreach
? Se viene eseguita assegnando unCancellationToken
a un enumerabile/enumeratore, allora a) dobbiamo supportare l'utilizzo diforeach
sugli enumeratori, elevandoli a cittadini di prima classe, e ora è necessario iniziare a pensare a un ecosistema costruito intorno agli enumeratori (ad esempio metodi LINQ) oppure b) dobbiamo incorporare comunque ilCancellationToken
nell'enumerabile avendo un qualche metodo di estensioneWithCancellation
daIAsyncEnumerable<T>
che conservi il token fornito e quindi lo passi nelGetAsyncEnumerator
dell'enumerabile incapsulato quando viene richiamato ilGetAsyncEnumerator
sulla struct restituita (ignorando tale token). In alternativa, puoi semplicemente usare ilCancellationToken
che hai nel corpo del foreach. - Se e quando sono supportate le comprensioni delle query, in che modo le
CancellationToken
fornite aGetEnumerator
oMoveNextAsync
verranno passate in ogni clausola? Il modo più semplice sarebbe semplicemente che la clausola lo acquisisca e a quel punto qualsiasi token sia passato aGetAsyncEnumerator
/MoveNextAsync
viene ignorato.
Una versione precedente di questo documento consigliava (1), ma abbiamo poi cambiato a (4).
I due problemi principali con (1):
- i produttori di enumerabili annullabili devono implementare alcuni boilerplate e possono sfruttare solo il supporto del compilatore per gli iteratori asincroni per implementare un metodo
IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken)
. - è probabile che molti produttori siano tentati di aggiungere invece un parametro
CancellationToken
alla firma asincrona enumerabile, il che impedirà ai consumatori di passare il token di annullamento desiderato quando viene fornito un tipoIAsyncEnumerable
.
Esistono due scenari di consumo principali:
-
await foreach (var i in GetData(token)) ...
in cui il consumer chiama il metodo async-iterator, -
await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
in cui il consumatore gestisce un'istanza specifica diIAsyncEnumerable
.
Si ritiene che un compromesso ragionevole per supportare entrambi gli scenari in modo pratico, sia per i produttori sia per i consumatori di flussi asincroni, consista nell'usare un parametro con annotazioni speciali nel metodo async-iterator. L'attributo [EnumeratorCancellation]
viene usato a questo scopo. L'inserimento di questo attributo su un parametro indica al compilatore che, se un token viene passato al metodo GetAsyncEnumerator
, tale token deve essere usato invece del valore originariamente passato per il parametro .
Prendere in considerazione IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default)
.
L'implementatore di questo metodo può semplicemente usare il parametro nel corpo del metodo.
Il consumer può usare uno dei modelli di consumo precedenti:
- se usi
GetData(token)
, il token viene salvato nell'enumerabile asincrono e verrà usato nell'iterazione. - se si usa
givenIAsyncEnumerable.WithCancellation(token)
, il token passato aGetAsyncEnumerator
sostituisce qualsiasi token salvato nell'enumerabile asincrono.
foreach
foreach
verrà ampliato per supportare IAsyncEnumerable<T>
oltre al supporto esistente per IEnumerable<T>
. E supporterà l'equivalente di IAsyncEnumerable<T>
come modello se i membri pertinenti sono esposti pubblicamente; in caso contrario, ricadrà sull'uso diretto dell'interfaccia, al fine di abilitare estensioni basate su struct che evitano l'allocazione, oltre a utilizzare awaitable alternativi come tipo di ritorno di MoveNextAsync
e DisposeAsync
.
Sintassi
Uso della sintassi:
foreach (var i in enumerable)
C# continuerà a trattare enumerable
come enumerabile sincrona, in modo che anche se espone le API pertinenti per enumerabili asincrone (esponendo il modello o implementando l'interfaccia), considererà solo le API sincrone.
Per costringere foreach
a considerare solo le API asincrone, await
viene inserito come segue:
await foreach (var i in enumerable)
Non verrebbe fornita alcuna sintassi che supportasse l'uso delle API asincrone o di sincronizzazione; lo sviluppatore deve scegliere in base alla sintassi usata.
Semantica
L'elaborazione in fase di compilazione di un'istruzione await foreach
determina innanzitutto il tipo di raccolta , il tipo di enumeratore e il tipo di iterazione dell'espressione (molto simile a https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement). Questa determinazione procede come segue:
- Se il tipo
X
dell'espressione èdynamic
o un tipo di matrice, viene generato un errore e non vengono eseguiti altri passaggi. - In caso contrario, determinare se il tipo
X
dispone di un metodo diGetAsyncEnumerator
appropriato:- Eseguire la ricerca dei membri nel tipo
X
con identificatoreGetAsyncEnumerator
e senza argomenti di tipo. Nel caso in cui la ricerca del membro non produca una corrispondenza, produca un'ambiguità o produca una corrispondenza che non sia un gruppo di metodi, verificare la presenza di un'interfaccia enumerabile come descritto di seguito. - Eseguire la risoluzione dell'overload usando il gruppo di metodi risultante e un elenco di argomenti vuoto. Se la risoluzione dell'overload non produce metodi applicabili, genera un'ambiguità o restituisce un singolo metodo migliore, ma tale metodo è statico o non pubblico, verificare la presenza di un'interfaccia enumerabile come descritto di seguito.
- Se il tipo restituito
E
del metodoGetAsyncEnumerator
non è una classe, uno struct o un tipo di interfaccia, viene generato un errore e non vengono eseguiti altri passaggi. - La ricerca dei membri viene eseguita su
E
con l'identificatoreCurrent
e senza parametri di tipo. Se la ricerca del membro non produce corrispondenze, il risultato è un errore o il risultato è qualsiasi elemento tranne una proprietà dell'istanza pubblica che consente la lettura, viene generato un errore e non vengono eseguiti altri passaggi. - La ricerca dei membri viene eseguita su
E
con l'identificatoreMoveNextAsync
e senza parametri di tipo. Se la ricerca del membro non produce alcuna corrispondenza, il risultato è un errore o il risultato è qualsiasi elemento ad eccezione di un gruppo di metodi, viene generato un errore e non vengono eseguiti altri passaggi. - La risoluzione dell'overload viene eseguita nel gruppo di metodi con un elenco di argomenti vuoto. Se la risoluzione dell'overload non risulta in metodi applicabili, comporta un'ambiguità o risulta in un unico miglior metodo, ma tale metodo è statico o non pubblico oppure il tipo di ritorno non può essere atteso in
bool
, viene generato un errore e nessun ulteriore passaggio viene intrapreso. - Il tipo di raccolta è
X
, il tipo di enumeratore èE
e il tipo di iterazione è il tipo della proprietàCurrent
.
- Eseguire la ricerca dei membri nel tipo
- In caso contrario, verificare la presenza di un'interfaccia enumerabile:
- Se tra tutti i tipi
Tᵢ
per cui è presente una conversione implicita daX
aIAsyncEnumerable<ᵢ>
, esiste un tipo univocoT
in modo cheT
non sia dinamico e per tutte le altreTᵢ
sia presente una conversione implicita daIAsyncEnumerable<T>
aIAsyncEnumerable<Tᵢ>
, il tipo di raccolta è l'interfacciaIAsyncEnumerable<T>
, il tipo di enumeratore è l'interfacciaIAsyncEnumerator<T>
, e il tipo di iterazione èT
. - In caso contrario, se è presente più di un tipo di questo tipo
T
, viene generato un errore e non vengono eseguiti altri passaggi.
- Se tra tutti i tipi
- In caso contrario, viene generato un errore e non vengono eseguiti altri passaggi.
I passaggi precedenti, se hanno esito positivo, producono in modo non ambiguo un tipo di raccolta C
, il tipo di enumeratore E
e il tipo di iterazione T
.
await foreach (V v in x) «embedded_statement»
viene quindi espanso in:
{
E e = ((C)(x)).GetAsyncEnumerator();
try {
while (await e.MoveNextAsync()) {
V v = (V)(T)e.Current;
«embedded_statement»
}
}
finally {
... // Dispose e
}
}
Il corpo del blocco finally
viene costruito in base ai passaggi seguenti:
- Se il tipo
E
ha un metodoDisposeAsync
appropriato:- Eseguire la ricerca dei membri nel tipo
E
con identificatoreDisposeAsync
e senza argomenti di tipo. Se la ricerca del membro non produce una corrispondenza o produce un'ambiguità o produce una corrispondenza che non è un gruppo di metodi, verificare l'interfaccia di eliminazione come descritto di seguito. - Eseguire la risoluzione dell'overload usando il gruppo di metodi risultante e un elenco di argomenti vuoto. Se la risoluzione dell'overload non produce metodi applicabili, genera un'ambiguità o restituisce un singolo metodo migliore, ma tale metodo è statico o non pubblico, verificare la presenza dell'interfaccia di eliminazione come descritto di seguito.
- Se il tipo restituito del metodo
DisposeAsync
non è awaitable, viene generato un errore e non vengono eseguiti altri passaggi. - La clausola
finally
viene espansa fino all'equivalente semantico di:
finally { await e.DisposeAsync(); }
- Eseguire la ricerca dei membri nel tipo
- In caso contrario, se è presente una conversione implicita da
E
all'interfaccia diSystem.IAsyncDisposable
,- Se
E
è un tipo valore non nullable, la clausolafinally
viene espansa fino all'equivalente semantico di:
finally { await ((System.IAsyncDisposable)e).DisposeAsync(); }
- In caso contrario, la clausola
finally
viene espansa fino all'equivalente semantico di:
Tranne che sefinally { System.IAsyncDisposable d = e as System.IAsyncDisposable; if (d != null) await d.DisposeAsync(); }
E
è un tipo valore, o un parametro di tipo istanziato in un tipo valore, la conversione die
inSystem.IAsyncDisposable
non deve causare il boxing.
- Se
- In caso contrario, la clausola
finally
viene espansa in un blocco vuoto:finally { }
ConfigureAwait
Questa compilazione basata su pattern consentirà di usare ConfigureAwait
su tutti i await, tramite un metodo di estensione ConfigureAwait
:
await foreach (T item in enumerable.ConfigureAwait(false))
{
...
}
Questo si baserà sui tipi che aggiungeremo anche a .NET, probabilmente a System.Threading.Tasks.Extensions.dll:
// Approximate implementation, omitting arg validation and the like
namespace System.Threading.Tasks
{
public static class AsyncEnumerableExtensions
{
public static ConfiguredAsyncEnumerable<T> ConfigureAwait<T>(this IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext) =>
new ConfiguredAsyncEnumerable<T>(enumerable, continueOnCapturedContext);
public struct ConfiguredAsyncEnumerable<T>
{
private readonly IAsyncEnumerable<T> _enumerable;
private readonly bool _continueOnCapturedContext;
internal ConfiguredAsyncEnumerable(IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext)
{
_enumerable = enumerable;
_continueOnCapturedContext = continueOnCapturedContext;
}
public ConfiguredAsyncEnumerator<T> GetAsyncEnumerator() =>
new ConfiguredAsyncEnumerator<T>(_enumerable.GetAsyncEnumerator(), _continueOnCapturedContext);
public struct ConfiguredAsyncEnumerator<T>
{
private readonly IAsyncEnumerator<T> _enumerator;
private readonly bool _continueOnCapturedContext;
internal ConfiguredAsyncEnumerator(IAsyncEnumerator<T> enumerator, bool continueOnCapturedContext)
{
_enumerator = enumerator;
_continueOnCapturedContext = continueOnCapturedContext;
}
public ConfiguredValueTaskAwaitable<bool> MoveNextAsync() =>
_enumerator.MoveNextAsync().ConfigureAwait(_continueOnCapturedContext);
public T Current => _enumerator.Current;
public ConfiguredValueTaskAwaitable DisposeAsync() =>
_enumerator.DisposeAsync().ConfigureAwait(_continueOnCapturedContext);
}
}
}
}
Si noti che questo approccio non consentirà l'uso di ConfigureAwait
con enumerabili basate su schemi, tuttavia, è già così che il ConfigureAwait
è esposto solo come estensione per Task
/Task<T>
/ValueTask
/ValueTask<T>
e non può essere applicato a elementi awaitable arbitrari, perché ha senso solo quando viene applicato alle Task (controlla un comportamento implementato nel supporto di continuazione di Task), e quindi non ha senso quando si utilizza un modello in cui gli elementi awaitable potrebbero non essere Task. Chiunque restituisca elementi in attesa può fornire il proprio comportamento personalizzato in scenari avanzati.
Se è possibile trovare un modo per supportare una soluzione a livello di ambito o di assembly ConfigureAwait
, non sarà necessario.
Iteratori asincroni
Il linguaggio/compilatore supporterà la produzione di IAsyncEnumerable<T>
s e IAsyncEnumerator<T>
s oltre a consumarli. Oggi il linguaggio supporta la scrittura di un iteratore come:
static IEnumerable<int> MyIterator()
{
try
{
for (int i = 0; i < 100; i++)
{
Thread.Sleep(1000);
yield return i;
}
}
finally
{
Thread.Sleep(200);
Console.WriteLine("finally");
}
}
ma await
non può essere usato nel corpo di questi iteratori. Questo supporto verrà aggiunto.
Sintassi
Il supporto del linguaggio esistente per gli iteratori deduce la natura iteratore del metodo in base al fatto che contenga yield
s. Lo stesso vale per gli iteratori asincroni. Tali iteratori asincroni verranno demarcati e differenziati dagli iteratori sincroni tramite l'aggiunta di async
alla firma e devono quindi avere anche IAsyncEnumerable<T>
o IAsyncEnumerator<T>
come tipo restituito. Ad esempio, l'esempio precedente può essere scritto come iteratore asincrono come segue:
static async IAsyncEnumerable<int> MyIterator()
{
try
{
for (int i = 0; i < 100; i++)
{
await Task.Delay(1000);
yield return i;
}
}
finally
{
await Task.Delay(200);
Console.WriteLine("finally");
}
}
Le alternative considerate
-
Non usare
async
nella firma: l'uso diasync
è probabilmente richiesto dal compilatore, perché lo usa per determinare seawait
è valido in tale contesto. Tuttavia, anche se non è necessario, è stato stabilito cheawait
può essere usato solo nei metodi contrassegnati comeasync
e sembra importante mantenere la coerenza. -
Abilitare costruttori personalizzati per
IAsyncEnumerable<T>
: Questo è qualcosa che potremmo considerare per il futuro, ma il meccanismo è complicato e non forniamo supporto per le versioni sincrone. -
la presenza di una parola chiave
iterator
nella firma: gli iteratori asincroni userebberoasync iterator
nella firma eyield
potrebbe essere usato solo nei metodiasync
che includonoiterator
;iterator
sarebbe quindi reso facoltativo sugli iteratori sincroni. A seconda della prospettiva, questo ha il vantaggio di rendere molto chiaro dalla firma del metodo seyield
è consentito e se il metodo è effettivamente destinato a restituire istanze di tipoIAsyncEnumerable<T>
, anziché lasciare che sia il compilatore a crearne una basandosi sul fatto che il codice utilizzi o menoyield
. Ma è diverso dagli iteratori sincroni, che non e non possono essere fatti per richiederne uno. Inoltre, alcuni sviluppatori non amano la sintassi aggiuntiva. Se lo stessimo progettando da zero, probabilmente renderemmo questo obbligatorio, ma a questo punto c'è molto più valore nel mantenere gli iteratori asincroni vicini agli iteratori sincroni.
LINQ
Sono presenti più di 200 sovraccarichi di metodi nella classe System.Linq.Enumerable
, tutti operanti in termini di IEnumerable<T>
; alcuni di questi accettano IEnumerable<T>
, alcuni producono IEnumerable<T>
e molti fanno entrambi. L'aggiunta del supporto LINQ per IAsyncEnumerable<T>
comporterebbe probabilmente la duplicazione di tutti questi overload, portandoli a circa 200 in totale. Poiché IAsyncEnumerator<T>
è probabilmente più comune come entità autonoma nel mondo asincrono rispetto a IEnumerator<T>
nel mondo sincrono, potrebbe essere necessario avere circa 200 overload che funzionano con IAsyncEnumerator<T>
. Inoltre, un gran numero di overload riguarda i predicati (ad esempio, Where
che accetta un Func<T, bool>
). Potrebbe essere desiderabile avere overload basati su IAsyncEnumerable<T>
che gestiscono predicati sia sincroni che asincroni (ad esempio, Func<T, ValueTask<bool>>
oltre a Func<T, bool>
). Anche se questo non è applicabile a tutti i circa 400 nuovi overloads, un calcolo approssimativo indica che sarebbe applicabile a metà di essi, ovvero altri circa 200 overloads, per un totale di circa 600 nuovi metodi.
Si tratta di un numero di API davvero impressionante, con il potenziale di aumentare ulteriormente quando si considerano librerie di estensioni come le estensioni interattive (Ix). Ma Ix ha già un'implementazione di molti di questi, e non sembra essere una grande ragione per duplicare tale lavoro; è consigliabile invece aiutare la community a migliorare Ix e consigliarla quando gli sviluppatori vogliono usare LINQ con IAsyncEnumerable<T>
.
Esiste anche il problema della sintassi di comprensione delle query. La natura basata su schemi delle comprensioni delle query consentirebbe loro di "funzionare immediatamente" con alcuni operatori, ad esempio se Ix fornisce i metodi seguenti:
public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, TResult> func);
public static IAsyncEnumerable<T> Where(this IAsyncEnumerable<T> source, Func<T, bool> func);
quindi questo codice C# funzionerà semplicemente:then this C# code will "just work":
IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select item * 2;
Tuttavia, non esiste una sintassi per l'interpretazione delle query che supporti l'uso di await
nelle clausole, quindi se Ix venisse aggiunto, ad esempio:
public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);
quindi questo funzionerebbe senza problemi
IAsyncEnumerable<string> result = from url in urls
where item % 2 == 0
select SomeAsyncMethod(item);
async ValueTask<int> SomeAsyncMethod(int item)
{
await Task.Yield();
return item * 2;
}
ma non sarebbe possibile scriverlo con il await
inline nella clausola select
. Come sforzo separato, potremmo esaminare l'aggiunta di espressioni async { ... }
al linguaggio, a quel punto potremmo consentire di usarle nelle comprensioni delle query e quanto sopra potrebbe invece essere scritto come:
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select async
{
await Task.Yield();
return item * 2;
};
o per abilitare await
per essere usato direttamente nelle espressioni, ad esempio supportando async from
. Tuttavia, è improbabile che un design in questo caso influisca sul resto del set di funzionalità in un senso o nell'altro, e questo non è un'area di investimento particolarmente importante in questo momento, quindi la proposta è di non fare nulla di aggiuntivo qui per il momento.
Integrazione con altri framework asincroni
L'integrazione con IObservable<T>
e altri framework asincroni (ad esempio flussi reattivi) verrebbe eseguita a livello di libreria anziché a livello di linguaggio. Ad esempio, tutti i dati di un IAsyncEnumerator<T>
possono essere pubblicati in un IObserver<T>
semplicemente await foreach
'ing over the enumerator and OnNext
'ing the data to the observer, so an AsObservable<T>
extension method is possible. L'utilizzo di un IObservable<T>
in un await foreach
richiede il buffering dei dati (nel caso in cui venga eseguito il push di un altro elemento mentre l'elemento precedente è ancora in elaborazione), ma tale adattatore push-pull può essere facilmente implementato per consentire di tirare un IObservable<T>
da un IAsyncEnumerator<T>
. Ecc. Rx/Ix fornisce già prototipi di tali implementazioni e librerie come https://github.com/dotnet/corefx/tree/master/src/System.Threading.Channels forniscono vari tipi di strutture di dati di buffering. La lingua non deve essere coinvolta in questa fase.
C# feature specifications