代码改变世界

.NET中的异步编程(三)- Continuation passing style以及使用yield实现异步

2011-02-24 07:45  横刀天笑  阅读(8026)  评论(26编辑  收藏  举报

在上一篇文章中我们围观了传统的异步编程,感受到了异步编程不是简单的事情。传统的异步方式将本来紧凑的代码都分成两部分,不仅仅降低了代码的可读性,还让一些基本的程序构造无法使用,所以大部分开发人员在遇到应该使用异步的地方都忍痛割爱。本来我在本篇文章中想讨论一下.NET世界中已有的几个辅助异步开发的类库,但是经过思考后觉得在这之前介绍一下一些理论知识也许对理解后面的类库以及更新的内容有所帮助。今天我们要讨论的是Continuation Passing Style,简称CPS。

CPS

首先,我们看看下面这个方法:

   1: public int Add(int a, int b)
   2: {
   3:     return a + b;
   4: }

我们一般这样调用它:

   1: Print(Add(5, 6))
   2:  
   3: public void Print(int result)
   4: {
   5:     Console.WriteLine(result);
   6: }

如果我们以CPS的方式编写上面的代码则是这个样子:

   1: public void Add(int a, int b, Action<int> continueWith)
   2: {
   3:     continueWith(a+b);
   4: }
   5:  
   6: Add(5, 6, (ret) => Print(ret));

就好像我们将方法倒过来,我们不再是直接返回方法的结果;我们现在做的是接受一个委托,这个委托表示我这个方法运算完后要干什么,就是传说的continue。对于这里来说,Add的continue就是Print。

不仅是上面这样的代码示例。在一个方法中,在本语句后面执行的语句都可以称之为本语句的continue。

 

CPS 与 Async

那么可能有人要问,你说这么多跟异步有什么关系么?对,跟异步有很大的关系。回想上一篇文章,经典的异步模式都是一个以Begin开头的方法发起异步请求,并且向这个方法传入一个回调(callback),当异步执行完毕后该回调会被执行,那么我们可以称该回调为这个异步请求的continue:

   1: stream.BeginRead(buffer, 0, 1024, continueWith, null)

这又有什么用呢?那先来看看我们期望写出什么样子的异步代码吧(注意,这是伪代码,不要没有看文章就直接粘贴代码到vs运行):

   1: var request = HttpWebRequest.Create("http://www.google.com");
   2: var asyncResult1 = request.BeginGetResponse(...);
   3: var response = request.EndGetResponse(asyncResult1);
   4: using(stream = response.GetResponseStream())
   5: {
   6:     var asyncResult2 = stream.BeginRead(buffer, 0, 1024, ...);
   7:     var actualRead = stream.EndRead(asyncResult2);
   8: }

对,我们想要像同步的方式一样编写异步代码,我讨厌那么多回调,特别是一环嵌套一环的回调。

参照前面对CPS的讨论,在request.BeginGetResponse之后的代码,都是它的continue,如果我能够有一种机制获得我的continue,然后在我执行完毕之后调用continue该多好啊。可惜,C#没有像Scheme那样的控制操作符call/cc获取continue。

思路貌似到这儿断了。但是我们是否可以换个角度想想,如果我们能给上面这段代码加上标识:在每个异步请求发起的地方都加一个标识,而标识之后的部分就是continue。

var request = HttpWebRequest.Create("http://www.google.com");
标识1 var asyncResult1 = request.BeginGetResponse(...);
var response = request.EndGetResponse(asyncResult1);
using(stream = response.GetResponseStream())
{
    标识2 var asyncResult2 = stream.BeginRead(buffer, 0, 1024, ...);
    var actualRead = stream.EndRead(asyncResult2);
}

当执行到 标识1 时,立即返回,并且记住本次执行只执行到了 标识1,当异步请求完毕后,它知道上次执行到了 标识1,那么这个时候就从标识1的下一行开始执行,当执行到标识2时,又遇到一个异步请求,立即返回并记住本次执行到了标识2,然后请求完毕后从标识2的下一行恢复执行。那么现在的任务就是如果打标识以及在异步请求完毕后如何从标识位置开始恢复执行。

yield 与 异步

如果你熟悉C# 2.0加入的迭代器特性,你就会发现yield就是我们可以用来打标识的东西。看下面的代码:

   1: public IEnumerator<int> Demo()
   2: {
   3:     //code 1
   4:     yield return 1;
   5:     //code 2
   6:     yield return 2;
   7:     //code 3
   8:     yield return 3;
   9: }

 

经过编译会生成类似下面的代码(伪代码,相差很远,只是意义相近,想要了解详情的同学可以自行打开Reflector观看):

   1: public IEnumerator<int> Demo()
   2: {
   3:    return new GeneratedEnumerator();
   4: }
   5:  
   6: public class GeneratedEnumerator
   7: {
   8:     private int state = 0;
   9:  
  10:     private int currentValue = 0;
  11:     
  12:     public bool MoveNext()
  13:     {
  14:         switch(state)
  15:         {
  16:             case 0:
  17:                 //code 1
  18:                 currentValue = 1;
  19:                 state = 1;
  20:                 return true;
  21:             case 1:
  22:                 //code 2
  23:                 currentValue = 2;
  24:                 state = 2;
  25:                 return true;
  26:             case 2:
  27:                 //code 3
  28:                 currentValue = 3;
  29:                 state = 3;
  30:                 return true;
  31:             default:return false;
  32:         }
  33:     }
  34:     
  35:     public int Current{get{return currentValue;}}
  36: }

 

 

对,C#编译器将其翻译成了一个状态机。yield return就好像做了很多标记,MoveNext每调用一次,它就执行下个yield return之前的代码,然后立即返回。

好,现在打标记的功能有了,我们如何在异步请求执行完毕后恢复调用呢?通过上面的代码,你可能已经想到了,我们这里恢复调用只需要再次调用一下MoveNext就行了,那个状态机会帮我们处理一切。

那我们改造我们的异步代码:

   1: public IEnumerator<int> Download()
   2: {
   3:     var request = HttpWebRequest.Create("http://www.google.com");
   4:     var asyncResult1 = request.BeginGetResponse(...);
   5:     yield return 1;
   6:     var response = request.EndGetResponse(asyncResult1);
   7:     using(stream = response.GetResponseStream())
   8:     {
   9:         var asyncResult2 = stream.BeginRead(buffer, 0, 1024, ...);
  10:         yield return 1;
  11:         var actualRead = stream.EndRead(asyncResult2);
  12:     }
  13: }

标记打好了,考虑如何在异步调用完执行一下MoveNext吧。

呵呵,你还记得异步调用的那个AsyncCallback回调么?也就是异步请求执行完会调用的那个。如果我们向发起异步请求的BeginXXX方法传入一个AsyncCallback,而这个回调里会调用MoveNext怎么样?

   1: public IEnumerator<int> Download(Context context)
   2: {
   3:     var request = HttpWebRequest.Create("http://www.google.com");
   4:     var asyncResult1 = request.BeginGetResponse(context.Continue(),null);
   5:     yield return 1;
   6:     var response = request.EndGetResponse(asyncResult1);
   7:     using(stream = response.GetResponseStream())
   8:     {
   9:         var asyncResult2 = stream.BeginRead(buffer, 0, 1024, context.Continue(),null);
  10:         yield return 1;
  11:         var actualRead = stream.EndRead(asyncResult2);
  12:     }
  13: }

Continue方法的定义是:

   1: public class Context
   2: {
   3:     //...
   4:     private IEnumerator enumerator;
   5:     
   6:     public AsyncCallback Continue()
   7:     {
   8:         return (ar) => enumerator.MoveNext();
   9:     }
  10: }

 

在调用Continue方法之前,Context类还必须保存有Download方法返回的IEnumerator,所以:

   1: public class Context
   2: {
   3:     //...
   4:     private IEnumerator enumerator;
   5:     
   6:     public AsyncCallback Continue()
   7:     {
   8:         return (ar) => enumerator.MoveNext();
   9:     }
  10:  
  11:     public void Run(IEnumerator enumerator)
  12:     {
  13:         this.enumerator = enumerator;
  14:         enumerator.MoveNext();
  15:     }
  16: }

那调用Download的方法就可以写成:

   1: public void Main()
   2: {
   3:     Program p = new Program();
   4:     
   5:     Context context = new Context();
   6:     context.Run(p.Download(context));
   7: }

除了执行方式的不同外,我们几乎就可以像同步的方式那样编写异步的代码了。

完整的代码如下(为了更好的演示,我将下面代码改为Winform版本):

   1: public class Context
   2: {
   3:     private IEnumerator enumerator;
   4:     
   5:     public AsyncCallback Continue()
   6:     {
   7:         return (ar) => enumerator.MoveNext();
   8:     }
   9:  
  10:     public void Run(IEnumerator enumerator)
  11:     {
  12:         this.enumerator = enumerator;
  13:         enumerator.MoveNext();
  14:     }
  15: }
  16:  
  17: private void btnDownload_click(object sender,EventArgs e)
  18: {
  19:     Context context = new Context();
  20:     context.Run(Download(context));
  21: }
  22:  
  23: private IEnumerator<int> Download(Context context)
  24: {
  25:     var request = HttpWebRequest.Create("http://www.google.com");
  26:     var asyncResult1 = request.BeginGetResponse(context.Continue(),null);
  27:     yield return 1;
  28:     var response = request.EndGetResponse(asyncResult1);
  29:     using(stream = response.GetResponseStream())
  30:     {
  31:         var asyncResult2 = stream.BeginRead(buffer, 0, 1024, context.Continue(),null);
  32:         yield return 1;
  33:         var actualRead = stream.EndRead(asyncResult2);
  34:     }
  35: }

不知道你注意到没有,我们不仅可以顺序的编写异步代码,连using这样的构造也可以使用了。如果你想更深入的理解这段代码,推荐你使用Reflector查看迭代器最后生成的代码。我在这里做一下简短的描述:

1、Context的Run调用时会调用Dowload方法,得到一个IEnumerator对象,我们将该对象保存在Context的实例字段中,以备后用

2、调用该IEnumerator对象的MoveNext方法,该方法会执行到第一个yield return位置,然后返回,这个时候request.BeginGetResponse已经调用,这个时候线程可以干其他的事情了。

3、在BeginGetResponse调用时我们通过Context的Continue方法传入了一个回调,该回调里会执行刚才保存的IEnumerator对象的MoveNext方法。也就是在BeginGetResponse这个异步请求执行完毕后,会调用MoveNext方法,控制流又回到Download方法,执行到下一个yield return…… 以此类推。

总结

总结本文,我们发现我们要的东西就是怎样将顺序风格的代码转换为CPS方式,如何去寻找发起异步请求这行代码的continue。由于C#提供了yield这种机制,C#编译器会为其生产一个状态机,能够将控制权在调用代码和被调用代码之间交换。

要注意的是本文最后实现的异步执行方式是非常简陋的,绝对不能应用在产品代码上。这里仅仅是为了演示目的。在这方面微软社区的大牛Jeffrey Ritcher早以为我们开发了Power Threading这个类库,里面提供了AsyncEnumerator类,是一种更可靠的实现。

而微软自己为机器人开发提供的CCR也提供了相类似的实现。我们会在下一篇文章来学习这两个类库。

后记

(2011年3月23日更新)

这两天为了补充相关知识,我又拿起了未曾学习过的《操作系统原理》。在学习线程一章的时候书中提到一个线程原语就是thread_yield。其意思即线程主动放弃对CPU的控制,让其他线程可以运行。回想其C#中yield关键字,其作用不也正是如此么:放弃对当前线程的控制,让其立即返回,让其他方法代码得以运行。

在这之前,我只理解yield具体的行为表现,但是不理解为什么命名为yield,对其也是不明不白的。这次看书之后有一种醒悟的感觉。由此可见实践和理论是需要相互印证的,很多时候如果我们知道理论然后再实践会起到事半功倍的效果。

注:.NET 4.0里增添了Thread.Yield()方法,其意思和thread_yield原语是一样的。

参考资料

Continuation Passing Style wiki page

Continuation Passing Style Revisited Part Two: Handwaving about control flow
Continuation-Passing Style