Insidie delle lambda asincrone

Le espressioni lambda asincrone e i metodi anonimi sono potenti funzionalità che consentono di creare delegati che rappresentano operazioni asincrone. Usarli con le API progettate per i delegati asincroni. Questo articolo illustra innanzitutto i modelli corretti e quindi spiega cosa va storto quando si passano espressioni lambda asincrone alle API che prevedono delegati sincroni.

Espressioni lambda asincrone assegnate ai Action delegati

Creare un overload che accetti Func<Task> e attenda il risultato:

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

Ogni volta che si passa un'espressione lambda asincrona a un metodo, verificare il tipo delegato del parametro. Se il parametro è Action, Action<T>, o qualsiasi altro delegato che restituisce void, passare a un delegato che restituisce un task per le operazioni asincrone.

Un'espressione lambda asincrona può corrispondere a un tipo delegato che restituisce void, come Action oltre a Func<Task>. Quando il parametro di destinazione è un oggetto Action, il compilatore esegue il mapping dell'espressione lambda asincrona a un metodo asincrono void. Il chiamante non è in grado di tenere traccia del completamento.

Si consideri un helper di intervallo che accetta Action:

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

Quando si passa un'espressione lambda sincrona, il tempo misurato è accurato. Con un'espressione lambda asincrona, il Action delegato restituisce non appena viene restituito il primo await risultato, quindi il timer acquisisce solo la parte sincrona anziché l'operazione completa.

Parallel.ForEach con espressioni lambda asincrone

In .NET 6 e versioni successive usare ForEachAsync, che accetta un Func<TSource, CancellationToken, ValueTask>:

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

In alternativa, trasformare gli elementi in compiti e usare 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 accetta un oggetto Action<T> per il relativo parametro body. Il passaggio di un'espressione lambda asincrona crea un delegato void asincrono. Parallel.ForEach restituisce non appena ogni delegato raggiunge il primo risultato await:

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

Il ciclo viene completato in millisecondi anziché della durata prevista perché le espressioni lambda asincrone diventano operazioni senza attesa di risposta.

Task.Factory.StartNew con espressioni lambda asincrone

Run elimina automaticamente il wrapping dei lambdas asincroni. Accetta i sovraccarichi Func<Task> e Func<Task<TResult>> e restituisce il compito interno:

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

Se hai bisogno di opzioni specifiche StartNew (ad esempio LongRunning), chiama Unwrap sul risultato:

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

Quando si passa un'espressione lambda asincrona a StartNew, il tipo restituito è Task<Task> (o Task<Task<TResult>>). L'attività esterna rappresenta solo la parte sincrona del delegato, che viene completata al primo risultato.await L'attività interna rappresenta l'operazione asincrona completa:

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

Se si considera l'attività esterna come l'intera operazione, si osserverà il completamento prima che il lavoro asincrono sia effettivamente terminato.

Sommario

Quando si passa un'espressione lambda asincrona a qualsiasi metodo, verificare il tipo delegato del parametro di destinazione:

Tipo delegato Comportamento asincrono Rischio
Func<Task>, Func<Task<T>> Il chiamante riceve un'attività che rappresenta il completamento Sicuro
Action, Action<T> Diventa un void asincrono: il chiamante non può osservare il completamento Alto
Func<TResult> dove TResult è Task Restituisce Task<Task>—l'attività esterna non rappresenta il lavoro completo Medium

Vedere anche