首先是剧透。这篇文章所讲的东西,其实就是golang和erlang里的并行精髓。文中的问题在golang里可以这样解决:
ch := make(chan int); go fun(ch chan int) { DoSomething(); ch <- result; }(ch); OtherWork(); MoreOtherWork(); result := <-ch; Herb Sutter
当设计并发APIs的时候,要分离“要做啥”和“如何做”。
Herb Sutter写过不少畅销书,也是软件开发方面的顾问,同时还在Microsoft任软件架构师一职。可以通过www.gotw.ca联系到他。
介绍
让我们从一个已有的同步API函数说起:
例子1:原始的同步API,可能执行时间很长(需要计算,需要等待磁盘或者网络,等等)
RetType DoSomething(InParameters ins, OutParameters outs );
因为DoSomething可能会花很长时间执行(不管CPU是不是因此很忙),而且可能与调用者的其他工作互相独立,自然的,调用者就希望能异步执行DoSomething。比如说,像这样调用代码:
例子1,后续:需要调用的样例代码
void CallerMethod() { result = DoSomething( this, that, outTheOther ); // 这些可能也很花时间 OtherWork(); MoreOtherWork(); // 现在可以查看使用result和outOther(应该是outTheOther) }
如果OtherWork和MoreOtherWork并不依赖返回值result或者其他DoSomething产生的副作用,并且他们并不与DoSomething使用相同的数据或资源,因此可以正确的并行运转,那么让DoSomething与OtherWork/MoreOtherWork并行执行会有很有用。
问题是,我们如何让他们并行化?其实有个简单正确的答案。但是因为很多接口已经选择了更复杂的答案,我们还是先来看看这些复杂的做法。
选择1:明确指定开始/结束(不好)
对API来说,一种选择是提供异步版本的函数。这种做法太流行了,已经形成了许多固定的模式。每个模式都需要将相关变量改造了多次。
一种在.NET里常用的模式(这种改造同样用在其他地方,比如Microsoft Office的C++代码)被称作框架异步模式(Frameworks Async Pattern)。这个模式的思想是,把原始方法分离成一组方法,一个用来开始(启动)工作,另一个用来结束(结合)工作。我们一会儿将看到这个模式的细节,包括引入中间结构和需要明确注册回调函数。先从简单的版本说起:
例子2:应用最基本的“异步模式” “开始”部分接受输入参数。
IAsyncResult BeginDoSomething( InParameters ins ); // “结束”部分产生返回值和输出参数。注意:调用者需要显式调用EndDoSomething。 // RetType EndDoSomething( IAsyncResult asyncResult, OutParameters outs );
这里展示的是调用方的代码,使用这种BeginXxx/EndXxx(开始/结束)模式来异步调用DoSomething:
例子2,后续:调用示例代码:
void CallerMethod() { // 启动,传入输入参数 IAsyncResult ar = BeginDoSomething( this, that ); result = DoSomething( this, that, outTheOther ); // 这些可能也很花时间 // 但是会和DoSomething并行执行 OtherWork(); MoreOtherWork(); // 结合,然后调用End得到返回值和输出参数 ar.AsyncWaitHandle.WaitOne(); result = EndDoSomething( ar, outTheOther ); // 现在可以使用result和outTheOther }
这种方法确实能工作,但是,有些明显的缺点。
第一,这种模式侵入了原有API,并增加了API数量。API接口数量为了实现异步,变成了原来的三倍,原来的一个函数变成了需要互相维持同步关系的三个函数。这里还有一致性的问题:由于每个API手册上的约束不同,API作者每次在应用这个模式时会有很大变化(应该是指函数接口上的变化)。而且,API设计者必须提前知道哪些方法需要支持异步调用。
我们可以提供不需要关联特定API的,通用形式的开始/结束模式,来消除上面提到的问题。比如,.NET框架提供泛型的BeginInvoke/EndInvoke来异步调用任意方法。[1]可惜,这个方法并不能解决下面的缺点。
第二,这种方法对调用者来说比最早的同步方案复杂很多,原来只用调一个函数,而现在必须调用三个:一个用来启动,一个用来等待,一个用来结束。这也增加了潜在失败的调用点,这些点上调用代码可能返回错误。比如说,由于你取消了工作或者只是试探性的调用或者其他什么原因,你并不关心结束后的结果那你还需要调用EndXxx方法么?通常来说,是的(对.NET来说,永远是的),因为BeginXxx调用将会分配资源来启动和跟踪工作,而EndXxx调用会释放这些资源。因此,.NET设计指导文档这样写道:“当操作已经结束后,必须保证总是调用EndMethodName方法。 这样可以接受到在异步操作中产生的异常,并释放异步操作相关的资源。”即便对有经验的程序员来说,这也是常见的会引起错误的地方。而有些大师写的书,甚至错误的认为EndXxx只是个可选的调用,或者只是忘了在所有的路径里调用EndXxx方法。
第三,沿着这个方向,这个方法会变得更加复杂和“华丽”。特别是,如果你看看当前的开始/结束模式的实现,你会发现已经变成了这样:
例子3:更完整的“异步模式”
// // “开始”部分接受输入参数 // 并且可以接受一个回调函数在任务结束后被调用, // 和一个标识这次调用的cookie(就是指这次调用需要用到的特殊变量) // IAsyncResult BeginDoSomething( InParameters ins, // + 调用者可以提供一个用于通知的回调函数 AsnycCallback callback, // + 用来标识这次调用的额外状态 Object cookie } // “结束”和例子2里一样 RetType EndDoSomething( IAsyncResult asyncResult, OutParameters outs );
需要我们这样做的原因是,调用者可以提供一种“最终,根据结果做X”的代理器,这样万一调用者不需要/不想等待异步调用完成的时候,调用者可以有方法返回。比如说:
例子3,后续:另一种不需要等待的调用代码
// void CallerMethod() { // // 启动,传入输入参数 // 但是不等待,只是在最后直接使用result。 IAsyncResult ar = BeginDoSomething( this, that, (Object myExtraState) => { result = EndDoSomething( ar, outTheOther ); // 需要根据result来处理的事情 // (写磁盘,更新GUI文字框) }, new MyExtraState( /**/ ) ); result = DoSomething( this, that, outTheOther); OtherWork(); MoreOtherWork(); // 不等待DoSomething的完成,直接返回 }
最后第四点,开始/结束方法用一堆变量来连接这种特定的异步调用方法。调用者可以选择让工作异步执行,但不能选择如何执行——是让工作跑在一个线程池的线程?在一个全新的线程?一个运行时自动负载均衡的任务?一个特定的处理器核心上?还是其他什么的。
我们确实想把“让这个工作异步执行”的想法(“如何执行”调用)和某个具体的API本身(调用“做什么”)解耦。我们通过将开始/结束模式泛型化,部分的实现了这个想法。但是我们想做的更好:我们想把调用代码变得更简单健壮,并让调用者可以灵活选择用哪种方式启动工作。
好消息是,我们确实可以做得更好。
选择2:将“做什么”和“怎么做”解耦
通过抽象来解决:
使用一个单独的更加通用的任务启动器,来启动一个工作。根据你的使用环境,你可能已经有很多可用的选择了,比如可以用pool.run( /任务/ )在Java或者.NET线程池里运行任务,或者在C++ 0x里调用async( /任务/ )。
使用future来管理异步结果。future是一个异步值——把它想成类似“在未来可赎回一个值的票据”。[2]这个抽象在Java中已经存在Future<T>,在即将到来的C++ 0x里则叫做future<T>,在下一个.NET发布版本中则叫做Task<T>。
举个例子,这里有个简单的同步调用CallSomeFunc:
// 同步调用(会阻塞直到执行完成) int result = CallSomeFunc(x, y, z); // 直到上面的调用完成,result已经返回,才能执行到这段代码 // 使用已经返回好的result DoSomethingWith( result ); 这是对应的异步调用(混合了C++ 0x和C#的语法): // 异步调用 future<int> result = async( ()=<{ return CallSomeFunc(x, y, z); } ); // 这里的代码与CallSomeFunc并发执行 // 当result准备好后使用它(这里可能会阻塞) DoSomethingWith( result.value() );
future允许我们把调用(启动)和接收结果(结合)解耦。这让我们可以灵活指定如何启动工作,即不用入侵修改任何已存在的同步API,也不需要入侵表述起来很简单健壮的future任务句柄。同时,我们也不用必须记住要显式调用EndXxx方法来做收尾工作,这些收尾工作已经封装到future抽象里了(一般来说,是future对象的析构函数dtor或者清扫方法disposer method)。
下面是对文章最早那个例子的改进。注意这里并没有修改原始的API:
例子4:依旧是原始的同步API
RetType DoSomething( InParameters ins, OutParameters outs ); // 异步调用代码的例子 // void CallerMethod() { // 异步启动工作(随便什么流行的启动方式;比如用线程池) // 注意result和outTheOther现在是future类型 result = pool.run( ()=>{ DoSomething( this, that, outTheOther ) } ); // 这些可能也会花很长时间执行 // 但是现在和DoSomething是并发执行的 OtherWork(); MoreOtherWork(); // 现在可以使用result.wait()(可能会阻塞)和outTheOther }
注意:我用C#的lambda语法来展示代码只是为了写起来方便。如果你的环境无法提供C++ 0x或者C#的lambda语法,你依旧可以这样做:将“()=>”改成不同的Runnable对象(Java),delegate(C#)或者仿函数(C++)。lambda只是一个语法糖,可以更方便的写runnable或者仿函数。
如果我们不想等待result但当工作结束还需要做些收尾工作,就像例子3那样,应该怎么做?简单:只要完成异步调用工作那块就行,不需要回调函数:
例子5(与例子3对比):另一种不需等待的调用代码
void CallerMethod() { // // 启动,传入输入参数。 // 但是不等待,只是最终使用result async( ()=>{ DoSomething( this, that, outTheOther ); // 处理那些需要result的工作 // (写磁盘,更新GUI文字框) } ); OtherWork(); MoreOtherWork(); // 现在直接返回,不需要等待DoSomething }
总结
我们如何提供异步版本的API?通常最好的答案是什么也不要做。因为调用者可以使用future特性结合“async”或者任务启动特性,从API外面将一个调用变成异步执行调用。
如果你需要提供框架或者程序库或者其他什么API接口,那么你会更喜欢将异步启动(“如何做”)与完成任务(“做什么”)分离。这条路会得到更简单的API,更简单且更健壮的调用代码,和更灵活的指定在何时何地执行工作的能力。
注释
- 异步调用同步方法
- 在过去,人们有时用“future”这个词同时指代异步工作和工作的结果,但这其实将两个不同的事情混为一谈。总是将future堪称异步的值或者对象。
有疑问加站长微信联系(非本文作者)