비동기 람다 문제

비동기 람다 및 익명 메서드는 비동기 작업을 나타내는 대리자를 만들 수 있는 강력한 기능입니다. 비동기 대리자를 위해 설계된 API와 함께 사용합니다. 이 문서에서는 먼저 올바른 패턴을 보여 줍니다. 그런 다음 동기 대리자를 예상하는 API에 비동기 람다를 전달할 때 무엇이 잘못되는지 설명합니다.

대리자로 할당된 비동기 Action 람다

Func<Task> 매개변수를 받아들이고 결과를 기다리는 오버로드를 생성합니다.

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

메서드에 비동기 람다를 전달할 때마다 매개 변수의 대리자 형식을 확인합니다. 매개 변수가 Action, Action<T> 또는 다른 void 반환 대리자인 경우 비동기 작업을 위해 task 반환 대리자로 전환합니다.

비동기 람다는 Func<Task> 외에도 void를 반환하는 Action 대리자 형식과 일치할 수 있습니다. 대상 매개 변수가 있으면 Action컴파일러는 비동기 람다를 비동기 void 메서드에 매핑합니다. 호출자는 완료를 추적할 방법이 없습니다.

타이밍 도우미를 고려합니다. 이는 다음을 허용합니다: 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

동기 람다를 통과하면 측정된 시간이 정확합니다. 비동기 람다 Action 를 사용하면 대리자가 첫 번째 await 가 생성되는 즉시 반환되므로 타이머는 전체 작업 대신 동기 부분만 캡처합니다.

Parallel.ForEach 비동기 람다와 함께

.NET 6 이상에서는 ForEachAsync을/를 사용하고, 이는 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

또는 항목을 작업으로 변환하고 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 은 해당 본문 매개변수로 Action<T>을/를 허용합니다. 비동기 람다를 전달하면 비동기 void 대리자가 생성됩니다. Parallel.ForEach는 각 대리자가 첫 번째 양보 지점에 도달하면 즉시 반환됩니다.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

비동기 람다 함수가 실행 후 신경 쓰지 않는 작업이 되어, 루프는 예상된 시간 대신 밀리초 단위로 완료됩니다.

Task.Factory.StartNew 비동기 람다를 사용하여

Run 는 비동기 람다를 자동으로 해제합니다. Func<Task>Func<Task<TResult>> 오버로드를 받아들이고 내부 작업을 반환합니다.

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

StartNew-특정 옵션(예: LongRunning)이 필요한 경우 결과에서 Unwrap를 호출합니다.

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

비동기 람다를 StartNew에 전달하면 반환 형식은 Task<Task> (또는 Task<Task<TResult>>)입니다. 외부 작업은 대리자의 동기식 부분만 나타내며, 첫 양보 await에서 완료됩니다. 내부 작업은 전체 비동기 작업을 나타냅니다.

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

외부 작업을 전체 작업으로 처리하면 비동기 작업이 실제로 완료되기 전에 완료가 관찰됩니다.

요약

메서드에 비동기 람다를 전달하는 경우 대상 매개 변수의 대리자 형식을 확인합니다.

대리자 형식 비동기 동작 위험
Func<Task>, Func<Task<T>> 호출자가 완료를 나타내는 작업을 받습니다. 안전
Action, Action<T> 비동기 함수가 되어 호출자가 완료 여부를 알 수 없습니다. 높음
TaskTResult의 위치는 Func<TResult> 반환 Task<Task>- 외부 작업이 전체 작업을 나타내지 않음 중간

참고하십시오