Wrapper sincroni per i metodi asincroni

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:

  1. Il thread dell'interfaccia utente chiama Delay, che chiama DelayAsync(milliseconds).Wait().
  2. DelayAsync viene eseguito in modo sincrono fino a raggiungere await Task.Delay(milliseconds).
  3. Poiché il ritardo non è ancora completo, await cattura lo stato corrente SynchronizationContext e sospende. DelayAsync restituisce un Task al chiamante.
  4. Il thread dell'interfaccia utente si blocca su .Wait(), aspettando il completamento di quell'attività.
  5. Al termine del ritardo, la continuazione deve essere eseguita sul thread originale SynchronizationContext, ovvero il thread dell'interfaccia utente.
  6. Il thread dell'interfaccia utente non può elaborare la continuazione perché è bloccato in .Wait().
  7. 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:

  1. Molti thread del pool di thread chiamano Foo, che blocca in .Result.
  2. 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.
  3. Poiché le chiamate bloccate occupano thread di lavoro disponibili, i completamenti potrebbero attendere molto tempo prima che un thread diventi disponibile.
  4. .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.

Vedere anche