Wrapper asincroni per metodi sincroni

Quando avete un metodo sincrono in una libreria, potreste essere tentati di esporre una controparte asincrona che lo incapsula in Task.Run:

public T Foo() { /* synchronous work */ }

// Don't do this in a library:
public Task<T> FooAsync()
{
    return Task.Run(() => Foo());
}

Questo articolo spiega perché questo approccio è quasi sempre errato per le librerie e come considerare i compromessi.

Scalabilità contro scaricamento

La programmazione asincrona offre due vantaggi distinti:

  • Scalabilità : ridurre il consumo di risorse liberando thread durante le attese di I/O.
  • Offload: spostare il lavoro in un thread diverso per mantenere la velocità di risposta (ad esempio, mantenere libero un thread dell'interfaccia utente) o ottenere il parallelismo.

Questi vantaggi richiedono approcci diversi. La distinzione critica: il wrapping di un metodo sincrono in Task.Run aiuta ad alleggerire il carico, ma non influisce sulla scalabilità.

Perché Task.Run non migliora la scalabilità

Un'implementazione veramente asincrona riduce il numero di thread utilizzati durante un'operazione a esecuzione prolungata. Un Task.Run wrapper blocca ancora un thread: sposta semplicemente il blocco da un thread a un altro:

public static class TimerExampleWrong
{
    public static Task SleepAsync(int millisecondsTimeout)
    {
        return Task.Run(() => Thread.Sleep(millisecondsTimeout));
    }
}
Public Module TimerExampleWrong
    Public Function SleepAsync(millisecondsTimeout As Integer) As Task
        Return Task.Run(Sub() Thread.Sleep(millisecondsTimeout))
    End Function
End Module

Confrontare questo approccio con un'implementazione veramente asincrona che non utilizza thread durante l'attesa:

public static class TimerExampleRight
{
    public static Task SleepAsync(int millisecondsTimeout)
    {
        var tcs = new TaskCompletionSource<bool>();
        var timer = new Timer(
            _ => tcs.TrySetResult(true), null, millisecondsTimeout, Timeout.Infinite);

        tcs.Task.ContinueWith(
            _ => timer.Dispose(), TaskScheduler.Default);

        return tcs.Task;
    }
}
Public Module TimerExampleRight
    Public Function SleepAsync(millisecondsTimeout As Integer) As Task
        Dim tcs As New TaskCompletionSource(Of Boolean)()
        Dim tmr As New Timer(
            Sub(state) tcs.TrySetResult(True), Nothing, millisecondsTimeout, Timeout.Infinite)

        tcs.Task.ContinueWith(
            Sub(t) tmr.Dispose(), TaskScheduler.Default)

        Return tcs.Task
    End Function
End Module

Entrambe le implementazioni vengono completate dopo il ritardo specificato, ma la seconda implementazione non blocca alcun thread durante l'attesa. Per le applicazioni server che gestiscono molte richieste simultanee, tale differenza influisce direttamente sul numero di richieste che un server può elaborare contemporaneamente.

Il trasferimento è responsabilità del consumatore

L'incapsulamento delle chiamate sincrone in Task.Run è utile per scaricare il lavoro da un thread dell'interfaccia utente. Tuttavia, l'utente, non la libreria, deve gestire questo incapsulamento.

public static class UIOffloadExample
{
    public static int ComputeIntensive(int input)
    {
        int result = 0;
        for (int i = 0; i < input; i++)
        {
            result += i;
        }
        return result;
    }

    public static async Task ConsumeFromUIThreadAsync()
    {
        int result = await Task.Run(() => ComputeIntensive(10_000));
        Console.WriteLine($"Result: {result}");
    }
}
Public Module UIOffloadExample
    Public Function ComputeIntensive(input As Integer) As Integer
        Dim result As Integer = 0
        For i As Integer = 0 To input - 1
            result += i
        Next
        Return result
    End Function

    Public Async Function ConsumeFromUIThreadAsync() As Task
        Dim result As Integer = Await Task.Run(Function() ComputeIntensive(10_000))
        Console.WriteLine($"Result: {result}")
    End Function
End Module

Il consumer conosce il contesto: se si trovano in un thread dell'interfaccia utente, la granularità necessaria e se l'offload aggiunge valore. La libreria non lo fa.

Perché le librerie non dovrebbero esporre i wrapper asincroni su sincroni

Quando una libreria espone solo il metodo sincrono (e non un wrapper asincrono), i consumer traggono vantaggio in diversi modi:

  • Superficie di attacco API ridotta: meno metodi per apprendere, testare e gestire.
  • Nessuna aspettativa di scalabilità fuorviante: gli utenti sanno che solo i metodi esposti come asincroni offrono effettivamente vantaggi di scalabilità.
  • Controllo del consumatore: i chiamanti scelgono se e come scaricare, al livello di granularità corretto. Un'applicazione server ad alta larghezza di banda può chiamare direttamente il metodo sincrono, evitando un sovraccarico non necessario da Task.Run.
  • Prestazioni migliori: i wrapper asincroni aggiungono sovraccarico tramite allocazioni, commutatori di contesto e pianificazione del pool di thread. Per le operazioni con granularità fine, questo sovraccarico può essere significativo.

Eccezioni alla regola

Alcune classi di base espongono metodi asincroni in modo che le classi derivate possano eseguirne l'override con implementazioni veramente asincrone. La classe base fornisce una modalità asincrona su sincronizzazione come impostazione predefinita.

Ad esempio, Stream espone ReadAsync e WriteAsync. Le implementazioni di base eseguono il wrapping dei metodi sincroni Read e Write. Classi derivate come FileStream e NetworkStream eseguono l'override di questi metodi con implementazioni di I/O asincrone che offrono vantaggi reali di scalabilità.

Analogamente, TextReader fornisce ReadToEndAsync sulla classe base come wrapper e StreamReader esegue l'override con un'implementazione autenticamente asincrona che chiama ReadAsync internamente.

Queste eccezioni sono valide perché:

  • Il modello è progettato per il polimorfismo. I chiamanti interagiscono con il tipo base.
  • I tipi derivati forniscono sostituzioni veramente asincrone.

Linee guida

Esporre metodi asincroni da una libreria solo quando l'implementazione offre vantaggi reali di scalabilità rispetto alla controparte sincrona. Non implementare metodi asincroni esclusivamente per lo scaricamento. Lasciare tale scelta al consumer.

Vedere anche