Fallstricke bei asynchronen Lambda-Ausdrücken

Async Lambdas und anonyme Methoden sind leistungsstarke Features, mit denen Sie Stellvertretungen erstellen können, die asynchrone Vorgänge darstellen. Verwenden Sie sie mit APIs, die für asynchrone Stellvertretungen entwickelt wurden. In diesem Artikel werden zuerst die richtigen Muster erläutert und dann erläutert, was schief geht, wenn Sie asynchrone Lambdas an APIs übergeben, die synchrone Stellvertretungen erwarten.

Asynchrone Lambda-Ausdrücke, die Action-Delegaten zugewiesen sind

Erstellen Sie eine Überladung, die Func<Task> akzeptiert und auf das Ergebnis wartet:

public static class TimingHelperFixed
{
    public static double Time(Action action, int iterations = 10)
    {
        var sw = Stopwatch.StartNew();
        for (int i = 0; i < iterations; i++)
            action();
        return sw.Elapsed.TotalSeconds / iterations;
    }

    public static async Task<double> TimeAsync(Func<Task> func, int iterations = 10)
    {
        var sw = Stopwatch.StartNew();
        for (int i = 0; i < iterations; i++)
            await func();
        return sw.Elapsed.TotalSeconds / iterations;
    }
}

public static class ActionFixDemo
{
    public static async Task Run()
    {
        // Now the async lambda maps to Func<Task>, and
        // the timer awaits each iteration to complete.
        double seconds = await TimingHelperFixed.TimeAsync(async () =>
        {
            await Task.Delay(100);
        }, iterations: 3);
        Console.WriteLine($"Async (fixed): {seconds:F4}s per iteration");
    }
}
Public Module TimingHelperFixed
    Public Function Time(action As Action, Optional iterations As Integer = 10) As Double
        Dim sw = Stopwatch.StartNew()
        For i As Integer = 0 To iterations - 1
            action()
        Next
        Return sw.Elapsed.TotalSeconds / iterations
    End Function

    Public Async Function Time(func As Func(Of Task), Optional iterations As Integer = 10) As Task(Of Double)
        Dim sw = Stopwatch.StartNew()
        For i As Integer = 0 To iterations - 1
            Await func()
        Next
        Return sw.Elapsed.TotalSeconds / iterations
    End Function
End Module

Public Module ActionFixDemo
    Public Async Function Run() As Task
        ' Now the async lambda maps to Func(Of Task), and
        ' the timer waits for each iteration to complete.
        Dim seconds As Double = Await TimingHelperFixed.Time(
            Async Function()
                Await Task.Delay(100)
            End Function, iterations:=3)
        Console.WriteLine($"Async (fixed): {seconds:F4}s per iteration")
    End Function
End Module

Wenn Sie eine asynchrone Lambda-Funktion an eine Methode übergeben, überprüfen Sie den Delegatentyp des Parameters. Wenn der Parameter Action, Action<T> oder ein anderer void-Rückgabedelegat ist, wechseln Sie für asynchrone Vorgänge zu einem Aufgabenrückgabedelegat.

Ein asynchroner Lambda-Ausdruck kann neben Func<Task> auch mit einem Delegatentyp übereinstimmen, der void zurückgibt, wie Action. Wenn der Zielparameter ein Actionist, ordnet der Compiler die asynchrone Lambda-Funktion einer asynchronen Void-Methode zu. Der Anrufer hat keine Möglichkeit, den Abschluss nachzuverfolgen.

Betrachten Sie ein Timing-Hilfsprogramm, das ein Action akzeptiert.

public static class TimingHelper
{
    public static double Time(Action action, int iterations = 10)
    {
        var sw = Stopwatch.StartNew();
        for (int i = 0; i < iterations; i++)
            action();
        return sw.Elapsed.TotalSeconds / iterations;
    }
}

public static class ActionPitfallDemo
{
    public static void Run()
    {
        // Synchronous lambda — timing is accurate.
        double syncSeconds = TimingHelper.Time(() =>
        {
            Thread.Sleep(100);
        }, iterations: 3);
        Console.WriteLine($"Sync: {syncSeconds:F4}s per iteration");

        // Async lambda — becomes async void, returns immediately.
        double asyncSeconds = TimingHelper.Time(async () =>
        {
            await Task.Delay(100);
        }, iterations: 3);
        Console.WriteLine($"Async (buggy): {asyncSeconds:F4}s per iteration");
    }
}
Public Module TimingHelper
    Public Function Time(action As Action, Optional iterations As Integer = 10) As Double
        Dim sw = Stopwatch.StartNew()
        For i As Integer = 0 To iterations - 1
            action()
        Next
        Return sw.Elapsed.TotalSeconds / iterations
    End Function
End Module

Public Module ActionPitfallDemo
    Public Sub Run()
        ' Synchronous lambda — timing is accurate.
        Dim syncSeconds As Double = TimingHelper.Time(
            Sub() Thread.Sleep(100), iterations:=3)
        Console.WriteLine($"Sync: {syncSeconds:F4}s per iteration")

        ' Async lambda — becomes Async Sub, returns immediately.
        Dim asyncSeconds As Double = TimingHelper.Time(
            Async Sub() Await Task.Delay(100), iterations:=3)
        Console.WriteLine($"Async (buggy): {asyncSeconds:F4}s per iteration")
    End Sub
End Module

Wenn Sie ein synchrones Lambda übergeben, ist die gemessene Zeit korrekt. Bei einem asynchronen Lambda-Ausdruck gibt der Action Delegat zurück, sobald der erste await das Ergebnis liefert, so dass der Timer nur den synchronen Teil und nicht den gesamten Vorgang erfasst.

Parallel.ForEach mit asynchronen Lambdas

Verwenden Sie in .NET 6 und höher ForEachAsync, die eine Func<TSource, CancellationToken, ValueTask> akzeptiert:

public static class ParallelForEachFixDemo
{
    public static async Task RunAsync()
    {
        var sw = Stopwatch.StartNew();
        await Parallel.ForEachAsync(
            Enumerable.Range(0, 10),
            new ParallelOptions { MaxDegreeOfParallelism = 4 },
            async (i, ct) =>
            {
                await Task.Delay(200, ct);
            });
        Console.WriteLine($"Parallel.ForEachAsync (fixed): {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module ParallelForEachFixDemo
    Private Function ProcessItemAsync(i As Integer, ct As CancellationToken) As ValueTask
        Return New ValueTask(Task.Delay(200, ct))
    End Function

    Public Async Function RunAsync() As Task
        Dim sw = Stopwatch.StartNew()
        Await Parallel.ForEachAsync(
            Enumerable.Range(0, 10),
            New ParallelOptions With {.MaxDegreeOfParallelism = 4},
            AddressOf ProcessItemAsync)
        Console.WriteLine($"Parallel.ForEachAsync (fixed): {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

Alternativ können Sie die Elemente in Vorgänge projizieren und folgendes verwenden WhenAll:

public static class WhenAllAlternativeDemo
{
    public static async Task RunAsync()
    {
        var sw = Stopwatch.StartNew();
        var tasks = Enumerable.Range(0, 10)
            .Select(async i =>
            {
                await Task.Delay(200);
            });
        await Task.WhenAll(tasks);
        Console.WriteLine($"Task.WhenAll: {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module WhenAllAlternativeDemo
    Public Async Function RunAsync() As Task
        Dim sw = Stopwatch.StartNew()
        Dim tasks = Enumerable.Range(0, 10).
            Select(Async Function(i)
                       Await Task.Delay(200)
                   End Function)
        Await Task.WhenAll(tasks)
        Console.WriteLine($"Task.WhenAll: {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

ForEach akzeptiert einen Body-Parameter als Action<T>. Durch die Übergabe eines asynchronen Lambda wird ein asynchroner void-Delegat erstellt. Parallel.ForEach gibt zurück, sobald jeder Delegat sein erstes yielding await erreicht:

public static class ParallelForEachBugDemo
{
    public static void Run()
    {
        var sw = Stopwatch.StartNew();
        Parallel.ForEach(Enumerable.Range(0, 10), async i =>
        {
            await Task.Delay(200);
        });
        // Completes almost immediately — the async lambdas are fire-and-forget.
        Console.WriteLine($"Parallel.ForEach (buggy): {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module ParallelForEachBugDemo
    Public Sub Run()
        Dim sw = Stopwatch.StartNew()
        Parallel.ForEach(Enumerable.Range(0, 10),
            Async Sub(i As Integer)
                Await Task.Delay(200)
            End Sub)
        ' Completes almost immediately — the async lambdas are fire-and-forget.
        Console.WriteLine($"Parallel.ForEach (buggy): {sw.Elapsed.TotalSeconds:F2}s")
    End Sub
End Module

Die Schleife wird in Millisekunden statt in der erwarteten Dauer abgeschlossen, da die asynchronen Lambda-Ausdrücke zu Fire-and-Forget-Operationen werden.

Task.Factory.StartNew mit asynchronen Lambdas

Run entpackt automatisch asynchrone Lambda-Ausdrücke. Es akzeptiert Func<Task> und Func<Task<TResult>>-Überladungen und gibt die innere Aufgabe zurück:

public static class StartNewFix1Demo
{
    public static async Task RunAsync()
    {
        var sw = Stopwatch.StartNew();
        await Task.Run(async () =>
        {
            await Task.Delay(1000);
        });
        Console.WriteLine($"Task.Run (fixed): {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module StartNewFix1Demo
    Public Async Function RunAsync() As Task
        Dim sw = Stopwatch.StartNew()
        Await Task.Run(Async Function()
                           Await Task.Delay(1000)
                       End Function)
        Console.WriteLine($"Task.Run (fixed): {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

Wenn Sie StartNew-spezifische Optionen (wie z. B. LongRunning) benötigen, rufen Sie Unwrap auf dem Ergebnis auf.

public static class StartNewFix2Demo
{
    public static async Task RunAsync()
    {
        var sw = Stopwatch.StartNew();
        await Task.Factory.StartNew(async () =>
        {
            await Task.Delay(1000);
        }).Unwrap();
        Console.WriteLine($"StartNew + Unwrap (fixed): {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module StartNewFix2Demo
    Public Async Function RunAsync() As Task
        Dim sw = Stopwatch.StartNew()
        Await Task.Factory.StartNew(Async Function()
                                        Await Task.Delay(1000)
                                    End Function).Unwrap()
        Console.WriteLine($"StartNew + Unwrap (fixed): {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

Wenn Sie eine asynchrone Lambda-Funktion StartNewübergeben, lautet Task<Task> der Rückgabetyp (oder Task<Task<TResult>>). Die äußere Aufgabe stellt nur den synchronen Teil des Delegaten dar - sie wird beim ersten Yielding await abgeschlossen. Die innere Aufgabe stellt den vollständigen asynchronen Vorgang dar:

public static class StartNewBugDemo
{
    public static async Task RunAsync()
    {
        var sw = Stopwatch.StartNew();
        // t is Task<Task> — the outer task completes at the first yielding await.
        Task<Task> t = Task.Factory.StartNew(async () =>
        {
            await Task.Delay(1000);
        });
        await t; // Awaits only the outer task.
        Console.WriteLine($"StartNew (buggy): {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module StartNewBugDemo
    Public Async Function RunAsync() As Task
        Dim sw = Stopwatch.StartNew()
        ' t is Task(Of Task) — the outer task completes at the first yielding Await.
        Dim t As Task(Of Task) = Task.Factory.StartNew(Async Function()
                                                           Await Task.Delay(1000)
                                                       End Function)
        Await t ' Awaits only the outer task.
        Console.WriteLine($"StartNew (buggy): {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

Wenn Sie die äußere Aufgabe als den gesamten Vorgang betrachten, wird dieser als abgeschlossen angezeigt, noch bevor die asynchrone Verarbeitung tatsächlich beendet ist.

Zusammenfassung

Wenn Sie eine asynchrone Lambda-Funktion an eine beliebige Methode übergeben, überprüfen Sie den Delegattyp des Zielparameters:

Delegattyp Asynchrones Verhalten Risiko
Func<Task>, Func<Task<T>> Der Anrufer empfängt eine Aufgabe, die den Abschluss darstellt. Sicher
Action, Action<T> Wird zu „async void“ – der Aufrufer kann den Abschluss nicht erkennen Hoch
Func<TResult> wo TResult ist Task Gibt Task<Task> zurück - die äußere Aufgabe repräsentiert nicht die gesamte Arbeit Mittelstufe

Siehe auch