비동기 메서드에 대한 동기 래퍼

라이브러리가 비동기 API만 노출하는 경우 소비자는 동기 인터페이스 또는 계약을 충족하기 위해 동기 호출로 래핑하는 경우가 있습니다. 이 "sync-over-async" 패턴은 간단해 보일 수 있지만 교착 상태 및 성능 문제의 일반적인 원인입니다.

기본 래핑 패턴

TAP(작업 기반 비동기 패턴) 메서드를 둘러싼 동기 래퍼는 호출 스레드를 차단하는 태스크의 Result 속성에 액세스합니다.

public class TapWrapper
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }
}
Public Module TapWrapper
    Public Function Foo(fooAsync As Func(Of Task(Of Integer))) As Integer
        Return fooAsync().Result
    End Function
End Module

이 방법은 간단해 보이지만 실행되는 환경에 따라 심각한 문제가 발생할 수 있습니다.

단일 스레드 컨텍스트에서 발생하는 교착 상태

가장 위험한 시나리오는 단일 스레드가 있는 스레드에서 동기 래퍼를 호출할 때 발생합니다 SynchronizationContext. 이 시나리오는 일반적으로 WPF, Windows Forms 또는 MAUI 애플리케이션의 UI 스레드입니다.

public static class DeadlockExample
{
    private static void Delay(int milliseconds)
    {
        DelayAsync(milliseconds).Wait();
    }

    private static async Task DelayAsync(int milliseconds)
    {
        await Task.Delay(milliseconds);
    }
}
Public Module DeadlockExample
    Private Sub Delay(milliseconds As Integer)
        DelayAsync(milliseconds).Wait()
    End Sub

    Private Async Function DelayAsync(milliseconds As Integer) As Task
        Await Task.Delay(milliseconds)
    End Function
End Module

단계별로 수행되는 작업은 다음과 같습니다.

  1. UI 스레드는 Delay를 호출하고, 이는 DelayAsync(milliseconds).Wait()를 호출합니다.
  2. DelayAsyncawait Task.Delay(milliseconds)에 도달할 때까지 동기적으로 실행됩니다.
  3. 지연이 아직 완료되지 않았으므로 await 현재 SynchronizationContext을 캡처하고 일시 중단합니다. DelayAsync은 호출자에게 Task을 반환합니다.
  4. UI 스레드는 해당 태스크가 완료될 때까지 막히게 됩니다 .Wait().
  5. 지연이 완료되면 UI 스레드인 원래 SynchronizationContext 스레드에서 연속 작업을 실행해야 합니다.
  6. UI 스레드는 .Wait() 에서 차단되어 계속 작업을 처리할 수 없습니다.
  7. 교착 상태.

중요합니다

동기화 비동기 코드의 성공 또는 실패는 코드가 실행되는 환경에 따라 달라집니다. 콘솔 앱에서 작동하는 코드는 UI 스레드 또는 ASP.NET(.NET Framework)에서 교착 상태에 빠질 수 있습니다. 이러한 환경 종속성은 동기식 래퍼를 노출하지 않는 것이 중요한 이유입니다.

스레드 풀 고갈

교착 상태는 UI 스레드로 제한되지 않습니다. 예를 들어 비동기 메서드가 작업을 완료하기 위해 스레드 풀에 의존하는 경우(예: 최종 처리 단계를 큐에 대기) 동기 래퍼가 있는 많은 풀 스레드를 차단하면 풀이 굶어 죽을 수 있습니다.

public static class ThreadPoolDeadlockExample
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }

    public static async Task DemonstrateDeadlockRiskAsync()
    {
        var tasks = Enumerable.Range(0, 25)
            .Select(_ => Task.Run(() => Foo(() => SomeIOOperationAsync())));
        await Task.WhenAll(tasks);
    }

    private static async Task<int> SomeIOOperationAsync()
    {
        await Task.Delay(100);
        return 42;
    }
}

이 시나리오에서는

  1. 많은 스레드 풀 스레드가 Foo을 호출하며, .Result에서 차단됩니다.
  2. 각 비동기 작업은 I/O를 완료하고 완료 콜백을 실행하려면 스레드 풀 스레드가 필요합니다.
  3. 차단된 호출은 사용 가능한 작업자 스레드를 차지하므로 완료는 스레드를 사용할 수 있을 때까지 오래 기다릴 수 있습니다.
  4. 최신 .NET은 시간이 지나면서 더 많은 스레드 풀 스레드를 추가할 수 있지만, 애플리케이션은 여전히 심각한 스레드 풀 고갈 현상, 낮은 처리량, 긴 지연 또는 명백한 정지를 겪을 수 있습니다.

이 패턴은 .NET Framework 1.x에서 HttpWebRequest.GetResponse에 영향을 주었으며, 여기서 동기 메서드는 비동기 BeginGetResponse/EndGetResponse 주위에 래퍼로 구현되었습니다.

지침: 동기화 래퍼의 노출을 피하십시오

비동기 구현을 래핑하는 동기 메서드를 노출하지 마세요. 대신, 소비자에게 차단할지 여부를 결정합니다. 소비자는 스레딩 환경을 알고 있으며 정보에 입각한 선택을 할 수 있습니다.

비동기 메서드를 동기적으로 호출해야 하는 경우 먼저 코드를 "끝까지 비동기화"로 재구성할 수 있는지 여부를 고려합니다. 리팩터링이 더 나은 장기 솔루션인 경우가 많습니다.

비동기 동기화를 피할 수 없는 경우 완화 전략

경우에 따라 비동기 방식 대신 동기화를 사용하는 것이 정말 불가피할 때가 있습니다. 예를 들어 동기 메서드가 필요한 인터페이스를 구현할 때는 피할 수 없으며 사용 가능한 유일한 구현은 비동기입니다. 이러한 경우 다음 전략을 적용하여 위험을 줄입니다.

비동기 구현에서 사용 ConfigureAwait(false)

비동기 메서드를 제어하는 경우 각 Task.ConfigureAwait와 함께 false를 사용하여 await 이어짐이 초기 SynchronizationContext으로 다시 마샬링되지 않게 하세요.

public static class ConfigureAwaitMitigation
{
    public static async Task<int> LibraryMethodAsync()
    {
        await Task.Delay(100).ConfigureAwait(false);
        return 42;
    }

    public static int Sync()
    {
        return LibraryMethodAsync().GetAwaiter().GetResult();
    }
}
Public Module ConfigureAwaitMitigation
    Public Async Function LibraryMethodAsync() As Task(Of Integer)
        Await Task.Delay(100).ConfigureAwait(False)
        Return 42
    End Function

    Public Function Sync() As Integer
        Return LibraryMethodAsync().Result
    End Function
End Module

라이브러리 작성자로서, 코드를 캡처된 컨텍스트에서 다시 시작해야 하는 경우가 아니면 모든 await에서 ConfigureAwait(false)를 사용합니다. 사용 ConfigureAwait(false) 은 성능에 대한 모범 사례이며 소비자가 차단할 때 교착 상태를 방지하는 데 도움이 됩니다.

스레드 풀로 오프로드

비동기 구현을 제어하지 못하고 ConfigureAwait(false)을(를) 사용하지 않는 경우, 호출을 스레드 풀로 오프로드하십시오. 스레드 풀에는 SynchronizationContext이(가) 없으므로, await는 차단된 스레드로 다시 마샬링하려고 시도하지 않습니다.

public int Sync()
{
    return Task.Run(() => Library.FooAsync()).Result;
}
Public Function Sync() As Integer
    Return Task.Run(Function() Library.FooAsync()).Result
End Function

여러 환경에서 테스트

동기 래퍼를 제공해야 하는 경우, 다음 위치에서 테스트합니다.

  • UI 스레드(WPF, Windows Forms)입니다.
  • 로드 중인 스레드 풀입니다.
  • 최대 스레드 수가 낮은 스레드 풀입니다.
  • 콘솔 애플리케이션입니다.

한 환경에서 작동하는 동작은 다른 환경에서 교착 상태에 빠질 수 있습니다.

참고하십시오