Pièges des lambdas asynchrones

Les méthodes lambda asynchrones et anonymes sont des fonctionnalités puissantes qui vous permettent de créer des délégués représentant des opérations asynchrones. Utilisez-les avec des API conçues pour les délégués asynchrones. Cet article présente d’abord les modèles appropriés, puis explique ce qui se passe mal lorsque vous passez des lambda asynchrones aux API qui attendent des délégués synchrones.

Lambdas asynchrones associées aux délégués Action

Créez une surcharge qui accepte Func<Task> et attendez le résultat :

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

Chaque fois que vous passez une lambda asynchrone à une méthode, vérifiez le type de délégué du paramètre. Si le paramètre est Action, Action<T>, ou tout autre délégué retournant void, passez à un délégué retournant une tâche pour les opérations asynchrones.

Une lambda asynchrone peut correspondre à un type de délégué retournant un void comme Action, en plus de Func<Task>. Lorsque le paramètre cible est un Action, le compilateur mappe l’lambda asynchrone à une méthode async void. L’appelant n’a aucun moyen de suivre l’achèvement.

Considérez un assistant de synchronisation qui accepte un 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

Lorsque vous passez une lambda synchrone, le temps mesuré est précis. Avec une lambda asynchrone, le Action délégué retourne dès que le premier await produit, de sorte que le minuteur capture uniquement la partie synchrone au lieu de l’opération complète.

Parallel.ForEach avec des lambdas asynchrones

Dans .NET 6 et versions ultérieures, utilisez ForEachAsync, qui accepte 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

Vous pouvez également projeter les éléments en tâches et utiliser 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 accepte un paramètre 'body' Action<T>. Le passage d’une expression lambda asynchrone crée un délégué async void. Parallel.ForEach retourne dès que chaque délégué atteint son premier rendement 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

La boucle se termine en quelques millisecondes au lieu de la durée attendue, car les fonctions lambda asynchrones se transforment en opérations sans retour.

Task.Factory.StartNew avec des lambdas asynchrones

Run démasque automatiquement les lambdas asynchrones. Il accepte les surcharges Func<Task> et Func<Task<TResult>>, et retourne la tâche interne.

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

Si vous avez besoin StartNewd’options spécifiques (par exemple LongRunning), appelez Unwrap le résultat :

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

Lorsque vous passez une lambda asynchrone à StartNew, le type de retour est Task<Task> (ou Task<Task<TResult>>). La tâche externe représente uniquement la partie synchrone du délégué ; elle se termine au premier point de suspension await. La tâche interne représente l’opération asynchrone complète :

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

Si vous traitez la tâche externe comme l’ensemble de l’opération, vous observerez l’achèvement avant que le travail asynchrone se termine réellement.

Résumé

Lorsque vous passez une lambda asynchrone à n’importe quelle méthode, vérifiez le type de délégué du paramètre cible :

Type de délégué Comportement asynchrone Risque
Func<Task>, Func<Task<T>> L’appelant reçoit une tâche qui représente l’achèvement Sûr
Action, Action<T> Devient async void : l’appelant ne peut pas observer l’achèvement Élevé
Func<TResult>TResult est Task Renvoie Task<Task> ; la tâche externe ne représente pas le travail complet Moyenne

Voir également