.NET中的異步編程-Continuation passing style以及使用yield實現異步
傳統的異步方式將本來緊湊的代碼都分成兩部分,不僅僅降低了代碼的可讀性,還讓一些基本的程序構造無法使用,所以大部分開發人員在遇到應該使用異步的地方都忍痛割愛。本來我在本篇文章中想討論一下.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也提供了相類似的實現。我們會在下一篇文章來學習這兩個類庫。