동기 메서드를 위한 비동기 래퍼

라이브러리에 동기 메서드가 있을 때, Task.Run로 해당 메서드를 래핑하는 비동기 메서드를 만들고 싶어질 수 있습니다.

public T Foo() { /* synchronous work */ }

// Don't do this in a library:
public Task<T> FooAsync()
{
    return Task.Run(() => Foo());
}

이 문서에서는 라이브러리에 대한 접근 방식이 거의 항상 잘못된 이유와 장단분에 대해 생각하는 방법을 설명합니다.

확장성 및 오프로드

비동기 프로그래밍은 다음과 같은 두 가지 고유한 이점을 제공합니다.

  • 확장성 - I/O 대기 중에 스레드를 해제하여 리소스 사용량을 줄입니다.
  • 오프로드 - 작업을 다른 스레드로 이동하여 응답성을 유지하거나(예: UI 스레드를 자유롭게 유지) 병렬 처리를 수행합니다.

이러한 이점에는 다른 접근 방식이 필요합니다. 중요한 차이점: 동기 메서드를 래핑하면 부하 분산에 Task.Run 도움이 되지만, 확장성에는 전혀 도움이 되지 않습니다.

확장성을 향상하지 않는 이유 Task.Run

진정한 비동기 구현은 장기 실행 작업 중에 사용되는 스레드 수를 줄입니다. 래퍼는 Task.Run 여전히 스레드를 블록합니다. 즉, 차단을 한 스레드에서 다른 스레드로 전환합니다.

public static class TimerExampleWrong
{
    public static Task SleepAsync(int millisecondsTimeout)
    {
        return Task.Run(() => Thread.Sleep(millisecondsTimeout));
    }
}
Public Module TimerExampleWrong
    Public Function SleepAsync(millisecondsTimeout As Integer) As Task
        Return Task.Run(Sub() Thread.Sleep(millisecondsTimeout))
    End Function
End Module

이 접근 방식을 대기하는 동안 스레드를 사용하지 않는 진정한 비동기 구현과 비교합니다.

public static class TimerExampleRight
{
    public static Task SleepAsync(int millisecondsTimeout)
    {
        var tcs = new TaskCompletionSource<bool>();
        var timer = new Timer(
            _ => tcs.TrySetResult(true), null, millisecondsTimeout, Timeout.Infinite);

        tcs.Task.ContinueWith(
            _ => timer.Dispose(), TaskScheduler.Default);

        return tcs.Task;
    }
}
Public Module TimerExampleRight
    Public Function SleepAsync(millisecondsTimeout As Integer) As Task
        Dim tcs As New TaskCompletionSource(Of Boolean)()
        Dim tmr As New Timer(
            Sub(state) tcs.TrySetResult(True), Nothing, millisecondsTimeout, Timeout.Infinite)

        tcs.Task.ContinueWith(
            Sub(t) tmr.Dispose(), TaskScheduler.Default)

        Return tcs.Task
    End Function
End Module

두 구현 모두 지정된 지연 후에 완료되지만 두 번째 구현은 대기하는 동안 스레드를 차단하지 않습니다. 많은 동시 요청을 처리하는 서버 애플리케이션의 경우 이러한 차이는 서버가 동시에 처리할 수 있는 요청 수에 직접적인 영향을 줍니다.

오프로드는 소비자의 책임입니다.

동기 호출을 Task.Run로 래핑하는 것은 UI 스레드에서 작업을 오프로드하는 데 유용합니다. 그러나 라이브러리가 아닌 소비자는 이 래핑을 처리해야 합니다.

public static class UIOffloadExample
{
    public static int ComputeIntensive(int input)
    {
        int result = 0;
        for (int i = 0; i < input; i++)
        {
            result += i;
        }
        return result;
    }

    public static async Task ConsumeFromUIThreadAsync()
    {
        int result = await Task.Run(() => ComputeIntensive(10_000));
        Console.WriteLine($"Result: {result}");
    }
}
Public Module UIOffloadExample
    Public Function ComputeIntensive(input As Integer) As Integer
        Dim result As Integer = 0
        For i As Integer = 0 To input - 1
            result += i
        Next
        Return result
    End Function

    Public Async Function ConsumeFromUIThreadAsync() As Task
        Dim result As Integer = Await Task.Run(Function() ComputeIntensive(10_000))
        Console.WriteLine($"Result: {result}")
    End Function
End Module

소비자는 UI 스레드에 있는지 여부, 필요한 세분성 및 오프로딩이 가치를 더하는지 여부와 같은 컨텍스트를 알고 있습니다. 라이브러리는 그렇지 않습니다.

라이브러리가 비동기-오버-동기화 래퍼를 노출하지 않아야 하는 이유

라이브러리가 비동기 래퍼가 아닌 동기 메서드만 노출하는 경우 소비자는 다음과 같은 여러 가지 방법으로 이점을 누릴 수 있습니다.

  • API 노출 영역 감소: 학습, 테스트 및 유지 관리 방법이 줄어듭니다.
  • 오해의 소지가 있는 확장성 기대 없음: 사용자는 비동기식으로 노출된 방법만 실제로 확장성 이점을 제공한다는 것을 알고 있습니다.
  • 소비자 제어: 호출자는 적절한 수준의 세분성에서 오프로드 여부방법을 선택합니다. 처리량이 높은 서버 애플리케이션은 동기 메서드를 직접 호출하여 불필요한 오버헤드 Task.Run를 방지할 수 있습니다.
  • 성능 향상: 비동기 래퍼는 할당, 컨텍스트 스위치 및 스레드 풀 예약을 통해 오버헤드를 추가합니다. 세분화된 작업의 경우 오버헤드가 클 수 있습니다.

규칙에 대한 예외

일부 기본 클래스는 파생 클래스가 진정한 비동기 구현으로 재정의할 수 있도록 비동기 메서드를 노출합니다. 기본 클래스는 비동기 대비 동기 기본값을 제공합니다.

예를 들어 Stream를 공개하고 ReadAsyncWriteAsync를 공개합니다. 기본 구현은 동기 ReadWrite 메서드를 래핑합니다. 파생 클래스는 FileStreamNetworkStream과 같은 클래스들이 진정한 확장성 이점을 제공하는 비동기 I/O 구현을 사용하여 이러한 메서드를 재정의합니다.

마찬가지로, TextReader는 기본 클래스에 대해 ReadToEndAsync를 래퍼로 제공하며, StreamReader는 내부적으로 ReadAsync를 호출하는 진정한 비동기 구현으로 이를 재정의합니다.

이러한 예외는 다음과 같은 이유로 유효합니다.

  • 이 패턴은 다형성을 위해 설계되었습니다. 호출자는 기본 형식과 상호 작용합니다.
  • 파생 타입은 진정으로 비동기적인 재정의를 제공합니다.

지침

구현이 동기에 비해 실제 확장성 이점을 제공하는 경우에만 라이브러리에서 비동기 메서드를 노출합니다. 비동기 메서드는 부하를 줄이기 위해서만 노출하지 마세요. 그 선택은 소비자에게 맡깁니다.

참고하십시오