Nota
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare ad accedere o modificare le directory.
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare a modificare le directory.
Quando una libreria espone solo API asincrone, i consumatori talvolta le incapsulano in chiamate sincrone per soddisfare un'interfaccia o un contratto sincrono. Questo modello "sync-over-async" può sembrare semplice, ma è una fonte comune di deadlock e problemi di prestazioni.
Modelli di formattazione di base
Un wrapper sincrono intorno a un metodo TAP (Task-based Asynchronous Pattern) accede alla proprietà del task Result, che blocca il thread chiamante.
public class TapWrapper
{
public static int Foo(Func<Task<int>> fooAsync)
{
return fooAsync().Result;
}
}
Public Module TapWrapper
Public Function Foo(fooAsync As Func(Of Task(Of Integer))) As Integer
Return fooAsync().Result
End Function
End Module
Questo approccio è semplice, ma può causare gravi problemi a seconda dell'ambiente in cui viene eseguito.
Deadlock con contesti a thread singolo
Lo scenario più pericoloso si verifica quando si chiama un wrapper sincrono da un thread che opera in modalità a singolo thread SynchronizationContext. Questo scenario è in genere un thread dell'interfaccia utente in macchine virtuali Windows, Windows Forms o applicazioni MAUI.
public static class DeadlockExample
{
private static void Delay(int milliseconds)
{
DelayAsync(milliseconds).Wait();
}
private static async Task DelayAsync(int milliseconds)
{
await Task.Delay(milliseconds);
}
}
Public Module DeadlockExample
Private Sub Delay(milliseconds As Integer)
DelayAsync(milliseconds).Wait()
End Sub
Private Async Function DelayAsync(milliseconds As Integer) As Task
Await Task.Delay(milliseconds)
End Function
End Module
Ecco cosa accade passo dopo passo:
- Il thread dell'interfaccia utente chiama
Delay, che chiamaDelayAsync(milliseconds).Wait(). -
DelayAsyncviene eseguito in modo sincrono fino a raggiungereawait Task.Delay(milliseconds). - Poiché il ritardo non è ancora completo,
awaitcattura lo stato corrente SynchronizationContext e sospende.DelayAsyncrestituisce un Task al chiamante. - Il thread dell'interfaccia utente si blocca su
.Wait(), aspettando il completamento di quell'attività. - Al termine del ritardo, la continuazione deve essere eseguita sul thread originale
SynchronizationContext, ovvero il thread dell'interfaccia utente. - Il thread dell'interfaccia utente non può elaborare la continuazione perché è bloccato in
.Wait(). - Deadlock.
Importante
L'esito del successo o fallimento del codice di sincronizzazione su asincrono dipende dall'ambiente in cui viene eseguito. Il codice che funziona in un'app console potrebbe causare un deadlock in un thread dell'interfaccia utente o in ASP.NET (in .NET Framework). Questa dipendenza ambientale è una ragione fondamentale per evitare di esporre wrapper sincroni.
Esaurimento del pool di thread
I deadlock non sono limitati ai thread dell'interfaccia utente. Se un metodo asincrono dipende dal pool di thread per completarne il lavoro, ad esempio accodando un passaggio di elaborazione finale, il blocco di molti thread del pool con wrapper sincroni può affamare il pool.
public static class ThreadPoolDeadlockExample
{
public static int Foo(Func<Task<int>> fooAsync)
{
return fooAsync().Result;
}
public static async Task DemonstrateDeadlockRiskAsync()
{
var tasks = Enumerable.Range(0, 25)
.Select(_ => Task.Run(() => Foo(() => SomeIOOperationAsync())));
await Task.WhenAll(tasks);
}
private static async Task<int> SomeIOOperationAsync()
{
await Task.Delay(100);
return 42;
}
}
In questo scenario:
- Molti thread del pool di thread chiamano
Foo, che blocca in.Result. - Ogni operazione asincrona completa la sua operazione di I/O e richiede un thread del pool di thread per eseguire il suo callback di completamento.
- Poiché le chiamate bloccate occupano thread di lavoro disponibili, i completamenti potrebbero attendere molto tempo prima che un thread diventi disponibile.
- .NET moderni possono aggiungere più thread al pool di thread nel tempo, ma l'applicazione può comunque subire gravi problemi di saturazione del pool di thread, bassa efficienza, lunghi ritardi o un apparente deadlock.
Questo modello ha interessato HttpWebRequest.GetResponse in .NET Framework 1.x, in cui il metodo sincrono è stato implementato come un wrapper attorno al BeginGetResponse/EndGetResponse.
Linee guida: evitare di esporre wrapper sincroni
Non esporre un metodo sincrono che esegue il wrapping di un'implementazione asincrona. Lasciare invece al consumatore la decisione se bloccare. Il consumatore conosce il suo ambiente di elaborazione parallela e può effettuare una scelta informata.
Se è necessario chiamare un metodo asincrono in modo sincrono, valutare innanzitutto se è possibile ristrutturare il codice in modo che sia "asincrono fino in fondo". Il refactoring è spesso la soluzione migliore a lungo termine.
Strategie di mitigazione quando la sincronizzazione su asincrona è inevitabile
A volte "sync-over-async" è veramente inevitabile. Ad esempio, è inevitabile quando si implementa un'interfaccia che richiede un metodo sincrono e l'unica implementazione disponibile è asincrona. In questi casi, applicare le strategie seguenti per ridurre il rischio.
Usare ConfigureAwait(false) nell'implementazione asincrona
Se si controlla il metodo asincrono, usare Task.ConfigureAwait con false su ogni await per impedire che la continuazione esegua il marshalling all'originale SynchronizationContext.
public static class ConfigureAwaitMitigation
{
public static async Task<int> LibraryMethodAsync()
{
await Task.Delay(100).ConfigureAwait(false);
return 42;
}
public static int Sync()
{
return LibraryMethodAsync().GetAwaiter().GetResult();
}
}
Public Module ConfigureAwaitMitigation
Public Async Function LibraryMethodAsync() As Task(Of Integer)
Await Task.Delay(100).ConfigureAwait(False)
Return 42
End Function
Public Function Sync() As Integer
Return LibraryMethodAsync().Result
End Function
End Module
In qualità di autore di libreria, usare ConfigureAwait(false) per tutti gli await, a meno che il codice non debba riprendere in modo specifico nel contesto acquisito. L'uso di ConfigureAwait(false) è una procedura consigliata per le prestazioni e consente di evitare deadlock quando i consumatori si bloccano.
Delegare al pool di thread
Se non si controlla l'implementazione asincrona (e potrebbe non usare ConfigureAwait(false)), eseguire l'offload della chiamata al pool di thread. Il pool di thread non ha un SynchronizationContext, quindi l'await non tenterà di eseguire il marshalling su un thread bloccato.
public int Sync()
{
return Task.Run(() => Library.FooAsync()).Result;
}
Public Function Sync() As Integer
Return Task.Run(Function() Library.FooAsync()).Result
End Function
Testare in più ambienti
Se è necessario spedire un wrapper sincrono, testarlo da:
- Un thread dell'interfaccia utente (macchine virtuali Windows, Windows Forms).
- Pool di thread in fase di caricamento.
- Pool di thread con un numero di thread massimo basso.
- Un'applicazione console.
Il comportamento che funziona in un ambiente potrebbe causare un deadlock in un altro.