Asynkrona omslutningar för synkrona metoder

När du har en synkron metod i ett bibliotek kan du vara frestad att exponera en asynkron motsvarighet som omsluter den i Task.Run:

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

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

Den här artikeln förklarar varför den metoden nästan alltid är fel för bibliotek och hur man tänker på kompromisserna.

Skalbarhet jämfört med avlastning

Asynkron programmering ger två distinkta fördelar:

  • Skalbarhet – Minska resursförbrukningen genom att frigöra trådar under I/O-väntetider.
  • Avlastning – Flytta arbetet till en annan tråd för att bibehålla svarstiden (till exempel att hålla en användargränssnittstråd fri) eller uppnå parallellitet.

Dessa fördelar kräver olika metoder. Den kritiska skillnaden: att omsluta en synkron metod i Task.Run hjälper till med avlastning men gör ingenting för skalbarhet.

Varför Task.Run förbättrar inte skalbarheten

En verkligt asynkron implementering minskar antalet trådar som förbrukas under en långvarig åtgärd. En Task.Run omslutning blockerar fortfarande en tråd – den flyttar bara blockeringen från en tråd till en annan:

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

Jämför den metoden med en verkligt asynkron implementering som inte förbrukar några trådar i väntan:

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

Båda implementeringarna slutförs efter den angivna fördröjningen, men den andra implementeringen blockerar inte någon tråd i väntan. För serverprogram som hanterar många samtidiga begäranden påverkar den skillnaden direkt hur många begäranden en server kan bearbeta samtidigt.

Avlastning är konsumentens ansvar

Att omsluta synkrona anrop i Task.Run är användbart för att avlasta arbete från en användargränssnittstråd. Konsumenten, inte biblioteket, bör dock hantera den här inpackningen:

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

Användaren känner till sin kontext: om de är på en UI-tråd, hur mycket detaljnivå de behöver och om offloading tillför värde. Biblioteket gör det inte.

Varför bibliotek inte ska exponera asynkron-över-synkrona omslutningar

När ett bibliotek endast exponerar den synkrona metoden (och inte en asynkron omslutning) drar konsumenterna nytta av det på flera sätt:

  • Minskad API-yta: Färre metoder för att lära sig, testa och underhålla.
  • Inga missvisande skalbarhetsförväntningar: Användarna vet att endast de metoder som exponeras som asynkrona faktiskt ger skalbarhetsfördelar.
  • Konsumentkontroll: Uppringare väljer om och hur de ska hantera avlastning, på rätt nivå av detaljering. Ett serverprogram med högt dataflöde kan anropa den synkrona metoden direkt, vilket undviker onödiga omkostnader från Task.Run.
  • Bättre prestanda: Asynkrona omslutningar lägger till omkostnader via allokeringar, kontextväxlar och schemaläggning av trådpooler. För detaljerade åtgärder kan dessa omkostnader vara betydande.

Undantag till regeln

Vissa basklasser exponerar asynkrona metoder så att härledda klasser kan åsidosätta dem med verkligt asynkrona implementeringar. Basklassen tillhandahåller en standardinställning för async-over-sync.

Till exempel Stream exponerar ReadAsync och WriteAsync. Basimplementeringarna omsluter synkrona Read metoder och Write metoder. Härledda klasser som FileStream och NetworkStream åsidosätter dessa metoder med asynkrona I/O-implementeringar som ger verkliga skalbarhetsfördelar.

TextReader På samma sätt tillhandahåller ReadToEndAsync basklassen som omslutning och StreamReader åsidosätter den med en verkligt asynkron implementering som anropar ReadAsync internt.

Dessa undantag är giltiga eftersom:

  • Mönstret är utformat för polymorfism. Anropare interagerar med den basiska typen.
  • Härledda typer medför verkligt asynkrona överskrivningar.

Riktlinjer

Exponera asynkrona metoder från ett bibliotek endast när implementeringen ger verkliga skalbarhetsfördelar jämfört med dess synkrona motsvarighet. Exponera inte asynkrona metoder enbart för avlastning. Lämna det valet till konsumenten.

Se även