일반적인 비동기/대기 버그

비동기/await는 비동기 프로그래밍을 간소화하지만 특정 실수는 반복적으로 나타납니다. 이 문서에서는 비동기 코드에서 가장 일반적인 5가지 버그를 설명하고 각 버그를 수정하는 방법을 보여 줍니다.

비동기 메서드가 동기적으로 실행됨

메서드에 async 키워드를 추가해도 백그라운드 스레드에서 메서드가 실행되지는 않습니다. 메서드 본문 내에서 await를 허용하고 반환 값을 Task로 래핑하라는 컴파일러에 지시합니다. 비동기 메서드를 호출하면 완료되지 않은 대기 가능한 첫 번째 await 메서드에 도달할 때까지 동기적으로 실행됩니다. 메서드에 await 표현식이 없거나, 대기 중인 모든 비동기 작업이 이미 완료된 경우, 메서드는 호출 스레드에서 완전히 완료됩니다.

public static class SyncExecutionExample
{
    public static Task<int> ComputeAsync()
    {
        // No await in this method — it runs entirely synchronously.
        return Task.FromResult(42);
    }
}
Public Module SyncExecutionExample
    Public Function ComputeAsync() As Task(Of Integer)
        ' No Await in this method — it runs entirely synchronously.
        Return Task.FromResult(42)
    End Function
End Module

여기서 메서드는 생성되지 않으므로 완료된 작업을 즉시 반환합니다. 비동기 메서드에 await 표현식이 없을 경우 컴파일러가 경고를 발생시킵니다.

CPU 바인딩된 작업을 스레드 풀 스레드로 오프로드하는 것이 목표인 경우 다음 대신 Run을 사용합니다: async.

public static class OffloadExample
{
    public static int ComputeIntensive()
    {
        int sum = 0;
        for (int i = 0; i < 1_000; i++)
            sum += i;
        return sum;
    }

    public static Task<int> ComputeOnThreadPoolAsync()
    {
        return Task.Run(() => ComputeIntensive());
    }
}
Public Module OffloadExample
    Public Function ComputeIntensive() As Integer
        Dim sum As Integer = 0
        For i As Integer = 0 To 999
            sum += i
        Next
        Return sum
    End Function

    Public Function ComputeOnThreadPoolAsync() As Task(Of Integer)
        Return Task.Run(Function() ComputeIntensive())
    End Function
End Module

사용 Task.Run의 적절한 시기를 알기 위한 추가 지침은 동기 메서드를 위한 비동기 래퍼를 참조하세요.

비동기 void 메서드를 기다릴 수 없습니다.

동기 void-returning 메서드를 비동기로 변환하는 경우 반환 형식 Task을 .로 변경합니다. 반환 형식을 그대로 void두면 메서드는 대기할 수 없는 "비동기 void"가 됩니다.

public static class AsyncVoidExample
{
    // BAD: async void — can't be awaited.
    public static async void DoWorkBadAsync()
    {
        await Task.Delay(100);
    }

    // GOOD: async Task — callers can await this.
    public static async Task DoWorkGoodAsync()
    {
        await Task.Delay(100);
    }
}
Public Module AsyncVoidExample
    ' BAD: Async Sub — can't be awaited.
    Public Async Sub DoWorkBadAsync()
        Await Task.Delay(100)
    End Sub

    ' GOOD: Async Function returning Task — callers can await this.
    Public Async Function DoWorkGoodAsync() As Task
        Await Task.Delay(100)
    End Function
End Module

비동기 void 메서드는 UI 프레임워크의 최상위 이벤트 처리기라는 특정 용도로 사용됩니다. 이벤트 처리기 외부에서는 비동기 메서드에서 항상 Task 또는 Task<T>을 반환합니다. 비동기 void 메서드에는 다음과 같은 단점이 있습니다.

  • 예외는 관찰되지 않습니다. 비동기 void 메서드에서 throw된 예외는 메서드가 시작될 때 활성 상태였던 SynchronizationContext로 전파됩니다. 호출자는 이러한 예외를 catch하지 못합니다.
  • 호출자는 완료를 추적할 수 없습니다. Task없으면 작업이 완료되는 시기를 알 수 있는 메커니즘이 없습니다.
  • 테스트는 어렵습니다. 테스트에서 메서드의 동작을 확인하기 위해 대기할 수 없습니다.

비동기 코드에서의 차단으로 인한 교착 상태

이 버그는 "완료되지 않는" 비동기 코드의 가장 일반적인 원인입니다. 단일 스레드가 있는 스레드에서 동기적으로 차단(호출 Wait또는 Task<TResult>.ResultGetAwaiter.GetResult)할 때 발생합니다SynchronizationContext.

교착 상태를 유발하는 시퀀스:

  1. UI 스레드(또는 이전 ASP.NET ASP.NET 요청 스레드)의 코드는 비동기 메서드를 호출하고 반환된 작업을 차단합니다.
  2. 비동기 메서드는 ConfigureAwait(false)를 사용하지 않고 완료되지 않은 작업을 대기합니다.
  3. 대기 중인 작업이 완료되면 연속 작업이 원래 SynchronizationContext로 다시 게시하려고 시도합니다.
  4. 해당 컨텍스트의 스레드는 태스크 완료를 기다리면서 일시 정지되어 교착 상태에 빠집니다.
public static class DeadlockExample
{
    public static async Task<string> GetDataAsync()
    {
        // Without ConfigureAwait(false), this continuation
        // posts back to the original SynchronizationContext.
        await Task.Delay(100);
        return "data";
    }

    public static void CallerThatDeadlocks()
    {
        // On a single-threaded SynchronizationContext (e.g. UI thread),
        // the following line deadlocks because the continuation needs
        // the same thread that .Result is blocking.
        string result = GetDataAsync().Result;
    }
}
Public Module DeadlockExample
    Public Async Function GetDataAsync() As Task(Of String)
        ' Without ConfigureAwait(False), this continuation
        ' posts back to the original SynchronizationContext.
        Await Task.Delay(100)
        Return "data"
    End Function

    Public Sub CallerThatDeadlocks()
        ' On a single-threaded SynchronizationContext (e.g. UI thread),
        ' the following line deadlocks because the continuation needs
        ' the same thread that .Result is blocking.
        Dim result As String = GetDataAsync().Result
    End Sub
End Module

교착 상태를 방지하는 방법

다음 전략 중 하나 이상을 사용합니다.

  • 차단하지 마세요. await을(를) .Result 또는 .Wait() 대신 사용하세요.

    public static class DeadlockFix1
    {
        public static async Task CallerFixedAsync()
        {
            // Use await instead of .Result
            string result = await DeadlockExample.GetDataAsync();
            Console.WriteLine(result);
        }
    }
    
    Public Module DeadlockFix1
        Public Async Function CallerFixedAsync() As Task
            ' Use Await instead of .Result
            Dim result As String = Await DeadlockExample.GetDataAsync()
            Console.WriteLine(result)
        End Function
    End Module
    
  • 라이브러리 코드에서 ConfigureAwait(false)를 사용합니다. 라이브러리 메서드가 호출자의 컨텍스트 ConfigureAwait(false)에서 다시 시작할 필요가 없는 경우, await를 지정하십시오.

    public static class DeadlockFix2
    {
        public static async Task<string> GetDataSafeAsync()
        {
            await Task.Delay(100).ConfigureAwait(false);
            return "data";
        }
    }
    
    Public Module DeadlockFix2
        Public Async Function GetDataSafeAsync() As Task(Of String)
            Await Task.Delay(100).ConfigureAwait(False)
            Return "data"
        End Function
    End Module
    

    ConfigureAwait(false)를 사용하면 런타임에 계속 작업을 원래 SynchronizationContext로 다시 마샬링하지 않도록 지시합니다. 이 방법은 차단하는 호출자를 보호하고 불필요한 스레드 홉을 방지하여 성능을 향상시킵니다.

경고

정적 생성자 교착 상태. CLR은 정적 생성자를cctor 실행하는 동안 잠금을 유지합니다. 정적 생성자가 작업을 차단하고, 그 작업의 연속이 동일한 형식(또는 생성 체인에 관여하는 형식)에서 코드를 실행해야 하는 경우, cctor 잠금이 유지되고 있기 때문에 연속 작업은 진행할 수 없습니다. 정적 생성자 내에서 호출을 완전히 차단하지 않습니다.

작업<태스크> 래핑 해제

같은 StartNew 메서드에 비동기 람다를 전달하면, 반환된 객체는 단순한 Task<Task>이 아닌 Task<Task<TResult>> (또는 Task)입니다. 비동기 람다가 첫 번째 생성에 도달하면 외부 작업이 완료됩니다 await. 내부 작업이 완료되기를 기다리지 않습니다.

public static class TaskTaskBugExample
{
    public static async Task DemoAsync()
    {
        var sw = Stopwatch.StartNew();
        // StartNew returns Task<Task>, not Task.
        // The outer task completes immediately when the lambda yields.
        await Task.Factory.StartNew(async () =>
        {
            await Task.Delay(1000);
        });
        // Elapsed shows ~0 seconds, not ~1 second.
        Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module TaskTaskBugExample
    Public Async Function DemoAsync() As Task
        Dim sw = Stopwatch.StartNew()
        ' StartNew returns Task(Of Task), not Task.
        ' The outer task completes immediately when the lambda yields.
        Await Task.Factory.StartNew(Async Function()
                                        Await Task.Delay(1000)
                                    End Function)
        ' Elapsed shows ~0 seconds, not ~1 second.
        Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

다음 세 가지 방법 중 하나로 이 문제를 해결합니다.

  • Run를 대신 사용하세요. Task.Run 자동으로 래프 해제 Task<Task>:

    public static class TaskTaskFix1
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            await Task.Run(async () =>
            {
                await Task.Delay(1000);
            });
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix1
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Await Task.Run(Async Function()
                               Await Task.Delay(1000)
                           End Function)
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    
  • 결과에서 Unwrap 를 호출합니다.

    public static class TaskTaskFix2
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            await Task.Factory.StartNew(async () =>
            {
                await Task.Delay(1000);
            }).Unwrap();
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix2
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Await Task.Factory.StartNew(Async Function()
                                            Await Task.Delay(1000)
                                        End Function).Unwrap()
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    
  • 두 번 기다립니다 (첫 번째 외부 작업, 내부 작업):

    public static class TaskTaskFix3
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            Task<Task> outerTask = Task.Factory.StartNew(async () =>
            {
                await Task.Delay(1000);
            });
            Task innerTask = await outerTask;
            await innerTask;
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix3
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Dim outerTask As Task(Of Task) = Task.Factory.StartNew(Async Function()
                                                                       Await Task.Delay(1000)
                                                                   End Function)
            Dim innerTask As Task = Await outerTask
            Await innerTask
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    

작업 반환 호출에서 await 누락

메서드에서 대기 없이 async 작업을 반환하는 메서드를 호출하면 메서드는 비동기 작업을 시작하지만 완료될 때까지 기다리지 않습니다. 컴파일러는 C#에서 CS4014 및 Visual Basic에서 BC42358의 경우에 대해 경고합니다:

public static class MissingAwaitExample
{
    // BAD: Task.Delay is started but never awaited.
    public static async Task PauseOneSecondBuggyAsync()
    {
        Task.Delay(1000); // CS4014 warning
    }

    // GOOD: await the task.
    public static async Task PauseOneSecondAsync()
    {
        await Task.Delay(1000);
    }
}
Public Module MissingAwaitExample
    ' BAD: Task.Delay is started but never awaited.
    Public Async Function PauseOneSecondBuggyAsync() As Task
        Task.Delay(1000) ' Warning BC42358
    End Function

    ' GOOD: Await the task.
    Public Async Function PauseOneSecondAsync() As Task
        Await Task.Delay(1000)
    End Function
End Module

결과를 변수에 저장하면 경고가 표시되지 않지만 기본 버그가 수정되지는 않습니다. 의도적으로 "fire-and-forget" 동작을 원하지 않는 한 항상 await 작업을 수행하십시오.

참고하십시오