【突然想多了解一点】可以用 Task.Run() 将同步方法包装为异步方法吗?
本文翻译自《Should I expose asynchronous wrappers for synchronous methods? - Stephen Toub》,原文地址:Should I expose asynchronous wrappers for synchronous methods?(microsoft.com)
注:我会对照原文进行逐句翻译,但是考虑到中西方表达方式以及中英文语法的差异,我会适当的修改语句的顺序和陈述方式。此外,限于自身英文和技术水平,有些词或者句子的翻译并不能表达原文的意思,对于这些词语我会同时标注原文用词。个人水平有限,有不对的地方请多批评指教。文章中会添加我自己对原文的一些理解,有不对的地方也请多批评指教。
概述
本文将会介绍 为什么不推荐对外公开那些使用 Task.Run
将同步方法包装为异步的方法。
引言
如果各位学习过或者接触过 C# 中基于任务的异步编程,那么肯定对 Task.Run()
方法不陌生。Task.Run()
方法用于在线程池中运行指定的操作。Task.Run
再结合 C# 中的 async
和 await
两个关键字,会让编写异步代码变的“很简单”,就像写同步代码一样。
初次尝到异步编程的甜头再加上对异步编程浅尝辄止,就可能会想产生一个很普遍的想法:我要把原有的同步方法都包装成异步方法。
例如,
//原有的同步方法
public T Foo()
{
//一些代码
}
//设想如下
public Task FooAsync()
{
return Task.Run(() => Foo());
}
注:我就这么干过。
那是否推荐这种做法呢?作者 Stephen Toub 说:别这样er
至于原因,下面的内容都是原因。
1. 为什么要异步?
在使用一种新的技术之前我们通常会考虑一个问题,为什么要使用这种技术,它对我的程序有帮助吗?
在我看来异步有两个主要的好处:可扩展性(scalability) 和 负载转移(offloading,例如响应性、并行性)。
那这两个哪一个更重要呢?这个问题一般与应用程序的类型相关。大多数客户端应用出于负载转移的原因而关心异步,例如要保持 UI 线程的响应性。而如果应用中有较多技术运算(technical computing,例如科学领域的数据计算)或者基于代理的仿真工作负荷(agent-based simulation workloads)时,可扩展性对客户端应用也很重要。大多数服务器应用(例如 ASP.NET 应用)更多的是出于可扩展性的考虑而关心异步,当然如果需要在后端服务器中实现并行的时候,负载转移也重要。
以下内容是我自己加的,仅供娱乐,有不对的地方请指教批评。
关于 scalability 和 offloading:不太知道应该怎么翻译,查阅了英文释义也没能准确地表达出来,我做的了解如下:
- 可扩展性(scalability):指应用程序处理增加的工作量的能力。比如用一台服务器能满足一些要求,当添加了第二台服务器之后,完成同样的工作只需要一半的时间,或者说每分钟可以处理原来两倍数量的工作,那就表示应用的可扩展性强。可以参考 What does "scalability" mean? - Stack Overflow 和 What Is Scalability?, by Chris Shiflett。
- 负载转移(offloading):把工作转移到其它资源进行处理。可以参考 Computation offloading - Wikipedia。
关于 technical computing 和 agent-based simulation workload:我不太明白这两个词所对应的工作领域,目前理解就是有大量计算的工作。
2. 可扩展性(scalability)
异步调用同步方法的方式对可扩展性没有任何帮助,因为这种方式通常还是会消耗和同步调用这个方法时相同数量的资源(实际上,异步调用同步方法使用的资源更多一点,因为需要有开销安排一些事情),你只是使用不同的资源来做这件事,例如这种方式只是使用来自线程池的线程执行操作而不是当前正在执行的那个线程。
异步带来的可扩展性这个好处是通过减少使用的资源量来实现的,这需要从异步方法的具体实现上来体现,这不是简单的通过在同步方法的外部包装一个异步调用来实现的。
以下内容是我自己加的,仅供娱乐,有不对的地方请指教批评。
真正的异步操作是很难自己去实现的,.NET 库中提供的异步方法都是使用”标准P/Invoke异步I/O系统“实现的,这种真正的异步操作不会有其它线程的参与。所以自己基于.NET中提供的同步方法包装的异步方法是不会有助于可扩展性的。可以参考 Stephen Cleary 的文章:There Is No Thread (stephencleary.com),这篇文章后续可能会进行翻译,方便自己快速回顾。
举个例子,有一个同步方法 Sleep()
,该方法在 N 毫秒后才会结束执行:
public void Sleep(int millisecondsTimeout)
{
Thread.Sleep(millisecondsTimeout);
}
接下来,需要为 Sleep()
方法创建一个异步版本。下面是第一种实现方式,使用 Task.Run()
方法将原有的 Sleep()
方法包裹起来:
public Task SleepAsync(int millisecondsTimeout)
{
return Task.Run(() => Sleep(millisecondsTimeout));
}
然后看第二种实现方式,这种实现方式没有使用原有的 Sleep()
方法,而是重写内部实现以消耗更少的资源:
public Task SleepAsync(int millisecondsTimeout)
{
TaskCompletionSource<bool> tcs = null;
var t = new Timer(delegate { tcs.TrySetResult(true); }, null, –1, -1);
tcs = new TaskCompletionSource<bool>(t);
t.Change(millisecondsTimeout, -1);
return tcs.Task;
}
以上两种异步的实现方式都实现了相同的操作,都在指定时间后才结束任务并返回。但是,从可扩展性的角度来说,第二种方式更具有可扩展性。第一种方式在等待期间消耗了线程池中的一个线程,而第二种方式仅仅依赖于一个有效的计时器在持续时间到期后向任务发出完成的信号。
以下内容是我自己加的,仅供参考,有不对的地方请指教批评。
第一中方式没有减少资源消耗,只是把阻塞的线程从调用它的线程转到了线程池中的另一个线程,这对扩展性来说没有提升,但它确实可以避免阻塞调用它的线程,这对 UI 应用来说是有用的,但是在异步代码中一般会使用
Task.Delay()
而不是Thread.Sleep()
。两者的区别可以参考: c# - When to use Task.Delay, when to use Thread.Sleep? - Stack Overflow。第二种方式使用了
Timer
来实现相同的操作,文章中提到这可以消耗更少的资源,原因是这种方法仅依赖于一个计时器的回调。其实Timer
也是使用了线程池中的线程,只不过所有的Timer
实例只会使用同一个线程,而且Task.Delay
方法内部也使用了Timer
,可以查看源码:runtime/Task.cs at main · dotnet/runtime (github.com)。
3. 负载转移(offloading)
异步调用同步方法的方式对于响应性非常有用,因为它允许将长时间运行的操作转移到一个不同的线程中。重点不在于消耗了多少资源,而是在于消耗了哪些资源。
例如,在 Winform 应用程序中,主线程除了会执行运算操作之外还会处理 UI 消息循环。如果主线程上执行长时间的操作就会阻塞主线程从而导致应用程序失去响应。所以主线程相比其他线程(例如 ThreadPool 中的线程)来说,它对用户体验“更有价值”。所以,将方法的调用从 UI 线程转移到 ThreadPool 的线程就能让应用程序使用对用户体验来说“价值较低”的资源。这种负载转移不需要修改原有方法的实现,它可以通过包装原有方法来实现响应性的优势。
异步调用同步方法的方式不仅对更改线程非常有用,而且也很有助于脱离当前上下文(escaping the current context)。
例如,有时我们需要调用一些第三方的代码,但我们不适合或者不确定是否适合这样做。比如在调用栈的更高位置存在锁,而我们不想在持有锁的同时调用第三方代码。再比如我们的代码也可能继续被其它用户调用,而这些用户并不希望我们的代码花费很长时间。那我们就可以异步调用第三方的代码,而不是作为调用栈上更高层的一部分去同步调用它。
以下内容是我自己加的,仅供参考,有不对的地方请指教批评。
这部分没有太明白,翻译也就会不准确,建议可以阅读原文。我大概理解就是通过异步调用把某部分代码和异步方法外的执行环境分隔开。
异步调用同步方法的方式对于并行也很重要。并行编程就是把一个问题分解成可以同时处理的子问题。
如果我们把一个问题拆分为多个子问题,然后依次处理每个子问题,那就不存在任何并行,因为整个问题会在单个线程上进行处理。相反,如果我们通过异步调用将子问题转移到另一个线程,那就可以同时处理多个子问题。与响应性一样,这种负载转移不需要修改原有方法的实现,可以通过包装实现并行的优势。
4. 上面说的一大堆和文章标题有什么关系?
回到核心问题:是否应该为实际上是同步的方法公开一个异步入口点? 我们在 .NET 4.5 中基于任务的异步模式的立场下应该坚定的说:不。
请注意,上述关于可扩展性和负载转移的讨论中,我们了解到真正实现可扩展性优势的方法是通过修改同步方法的具体实现,而负载转移则可以通过包装同步方法来实现,它并不需要修改同步方法的具体实现。这就是关键。用简单的异步外观包装同步方法不会产生任何可扩展性优势。而仅公开同步方法,就可以获得一些不错的好处,例如:
- 库更加简洁。这意味着这个库的成本更低,包括开发、测试、维护、文档等等。这同时简化了这个库的用户的选择。虽然有更多选择通常是一件好事,但过多的选择往往会导致生产力下降。如果我作为用户面对同一个操作的同步和异步方法,我经常需要评估哪一种方法是适合我在不同情况下使用的。
- 库的用户将会明白这个库所公开的异步的 API 是否真正具有可扩展性优势。因为根据共识,只有真正有助于可扩展性的 API 才会以异步方式公开。
- 是否异步调用同步方法的选择由开发人员决定。围绕同步方法的异步包装器具有开销(例如,分配内存、上下文切换等)。例如,如果您的客户正在编写一个高吞吐量的服务器应用程序,他们不想将精力花费在实际上对他们没有任何好处的开销上,因此他们可以调用同步方法。如果同步方法和基于它的异步包装方法都对公开了,那么开发人员就可能会出于可扩展性的考虑而调用异步版本的方法,但实际上这种简单包装的异步方法不存在可扩展性的优势,这会引起额外的开销而有损于他们的吞吐量。
如果开发人员需要获得更好的可扩展性,他们就可以使用任何公开的异步 API,并且他们不必为调用虚假异步 API(指用异步包装的同步方法) 承担额外的开销。而如果他们需要通过同步 API 实现响应性或并行性,他们可以简单地使用 Task.Run
之类的方法包装然后再调用。
在你的代码库中公开“基于同步的异步方法(async over sync)”的这种想法是很糟糕的,极端情况下每个方法都会同时公开它的同步和异步形式。有很多人问过我这种做法,他们想为长时间运行的 CPU 密集型的操作通过异步包装器公开为异步方法,这种想法的意图是好的:提升响应性。但就像前面所说,API 的使用者自己就可以轻松实现响应性,并且他们更加能知道应该在哪个层面上去做到这一点,而不需要针对每个调用进行单独操作。另外,定义哪些操作可能是长时间运行是非常困难的,许多方法的时间复杂度通常变化很大。
以下内容是我自己加的,仅供参考,有不对的地方请指教批评。
基于同步的异步方法:
这句话的原文是 “async over sync”,按照我的理解这句话是指那些使用
Task.Run
这种方法把原有的同步方法包装成为的异步方法。或者也可以翻译成”同步之上的异步“,大概意思就是这样吧。
例如,Dictionary
,这是一个非常快速的方法,但请记住 Dictonary
类是如何工作的:它需要对 Key 进行哈希处理才能找到正确的用来保存它的 bucket,并且它需要检查该 Key 与 bucket 中已存在的其他项是否相等。这一系列哈希处理和相等性检查可能会导致调用用户代码,而这些操作具体做什么或需要多长时间是不知道的。那 Dictionary
类上的每个方法都应该公开一个异步包装器吗?这显然是一个极端的例子,但也有简单点儿的例子,比如 Regex
,提供给 Regex
的正则表达式模式的复杂性以及输入字符串的性质和大小可能会对 Regex
匹配的运行时间产生较大影响,以至于 Regex
现在支持可选超时。Regex
上的每个方法都应该有等价的异步方法吗?我真的希望不会那样。
5. 总结
我认为应该公开的异步方法只有那些比它自己的同步方法更具有可扩展性优势的方法。不应该仅仅只为了实现负载转移的目的而公开对应的异步方法。同步方法的调用者可以很容易地通过使用专门针对异步处理同步方法的功能来实现这些好处,例如 Task.Run
。
当然,这也有例外,在 .NET 4.5 中就存在一些这样的例外。例如,抽象基类 Stream
提供了 ReadAsync
和 WriteAsync
方法。在大多数情况下,Stream
的派生类使用不在内存中的数据源,因此派生类一般会涉及某种磁盘 I/O 或网络 I/O。而派生类很可能能够提供利用异步 I/O 而不是阻塞线程的同步 I/O 的 ReadAsync
和 WriteAsync
的实现,因此派生类的拥有的 ReadAsync
和 WriteAsync
方法使其具有了可扩展性优势。
此外,我们希望能够多态地使用这些方法,而不考虑具体的 Stream
类型,因此我们希望将这两个方法作为基类上的虚拟方法。但是,基类不知道如何使用异步 I/O 来完成这些方法的基本实现,因此它所能做的最好的事情是为同步的 Read
和 Write
方法提供异步包装器(实际上,ReadAsync
和 WriteAsync
实际上包装了 BeginRead/EndRead
和 BeginWrite/EndWrite
,而它们如果没有被重写,则将依次用等效的 Task.Run
包装同步的 Read
和 Write
方法)。
另一个类似的例子是 TextReader
,它提供了 ReadToEndAsync
之类的方法,它在基类的实现中只是使用一个 Task
来包装对 TextReader.ReadToEnd
的调用。但是,它期望开发人员实际使用的派生类会重写 ReadToEndAsync
以提供有利于可扩展性的实现,例如使用了 Stream.ReadAsync
方法的 StreamReader
的 ReadToEndAsync
方法。
以下内容是我自己加的,仅供参考,有不对的地方请指教批评。
听君一席话,如听一席话。
读完整篇文章之后,可能会觉得好像看半天,但好像又没有学到什么。不过我还是想说一下我自己从这篇文章看到的东西。
在我们使用异步的时候,首先要想清楚使用异步的目的是什么。如果只是因为微软推荐使用异步或者大家都说异步好,所以就把原有的同步方法或者准备创建的新的同步方法通过
Task.Run
改成异步方法,那这样的想法是错误的。因为文章中已经提到,如果是为了提升性能而这么做的话是没有意义的,它不会提升程序性能,反而可能会引起性能问题。但是如果是为了实现类似不阻塞 Winform 主线程的效果的话也是可以这么做的。
原文的标题是 Should I expose asynchronous wrappers for synchronous methods,是指如果我们写的代码是需要提供给其他人使用的,是否应该对外公开这种假异步方法。当然读完文章后我们自然明白这种做法是不应该的。
其次,当我们想好使用异步的目的后,就要考虑如何实现异步了。文章中提到自己实现一个真正的异步是很难的,所以在自己编写 .NET 没有提供的异步方法时就要慎重了。
最后,我翻译文章主要是为了方便自己以后能快速回顾(毕竟看英文需要在脑子中先翻译成中文才能开始消化),另外把自己看到的内容输出出去也是一种吸收。英文和技术水平都有限,有不对的地方请指教批评,感谢!
__EOF__