同步方法的异步包装器

当您在库中有一个同步方法时,可能会考虑通过 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 的真正异步实现。

这些异常有效,因为:

  • 该模式旨在实现多态性。 调用方与基类型交互。
  • 派生类型提供真正的异步替代。

Guideline

仅当实现对其同步对应项提供真正的可伸缩性优势时,才公开库中的异步方法。 不要纯粹公开异步方法进行卸载。 将该选择留给使用者。

另见