.NET中的異步編程(二)- 傳統的異步編程
在上一篇文章中,我們從構建響應靈敏的界面以及構建高可伸縮性的服務應用來討論我們為什么需要異步編程,異步編程能給我們帶來哪些好處。那么知道了好處,我們就開始吧,但是在異步編程這個方面,說總是比做簡單。套用那句不是名言的名言:編寫異步程序是困難的,編寫可靠的異步程序尤其困難。因為異步程序非常難以編寫,而且非常容易出錯,很多基本的構造元素在異步編程中都無法使用,這讓我們這些開發人員更愿意編寫同步的代碼,雖然我們知道有些地方真的應該使用異步。
如何實現異步
對于很多人來說,異步就是使用后臺線程運行耗時的操作。在有些時候這是對的,而在我們日常大部分場景中卻不對。
比如現在我們有這么一個需求:使用HttpWebRequest請求某個指定URI的內容,然后輸出在界面上的文本域中。同步代碼很容易編寫:
{
var request = HttpWebRequest.Create("http://www.sina.com.cn");
var response = request.GetResponse();
var stream = response.GetResponseStream();
using(StreamReader reader = new StreamReader(stream))
{
var content = reader.ReadToEnd();
this.txtContent.Text = content;
}
}
是吧,很簡單。但是正如上一篇文章所說,這個簡短的程序體驗會非常差。特別是在URI所指向的資源非常大,網絡非常慢的情況下,在點擊下載按鈕到獲得結果這段時間界面會假死。
哦,這個時候你想起了異步。回憶上篇文章的示意圖。我們發現只要我們將耗時的操作放到另外一個線程上執行就可以了,這樣我們的UI線程可以繼續響應用戶的操作。
使用獨立的線程實現異步
如是你寫下了下面的代碼:
{
var downloadThread = new Thread(Download);
downloadThread.Start();
}
private void Download()
{
var request = HttpWebRequest.Create("http://www.sina.com.cn");
var response = request.GetResponse();
var stream = response.GetResponseStream();
using(StreamReader reader = new StreamReader(stream))
{
var content = reader.ReadToEnd();
this.txtContent.Text = content;
}
}
然后,F5運行。很不幸,這里出現了異常:我們不能在一個非UI線程上更新UI的屬性(更詳細的討論參見我的這篇文章:WinForm二三事(三)Control.Invoke&Control.BeginInvoke)。我們暫時忽略這個異常(在release模式下是不會出現的,但這是不推薦的做法)。
哦,你寫完上面的代碼后發現UI不再阻塞了。心里想,異步也不過如此嘛。過了一會兒你突然想起,你好像在哪本書里看到過說盡量不要自己聲明Thread,而應用使用線程池。如是你搜索了一下MSDN,將上面的代碼改成下面這個樣子:
{
ThreadPool.QueueUserWorkItem((state) => {Download();});
}
private void Download()
{
var request = HttpWebRequest.Create("http://www.sina.com.cn");
var response = request.GetResponse();
var stream = response.GetResponseStream();
using(StreamReader reader = new StreamReader(stream))
{
var content = reader.ReadToEnd();
this.txtContent.Text = content;
}
}
嗯,很容易完成了。你都有點佩服自己了,這么短的時間居然連線程池這么“高級的技術”都給使用上了。就在你沾沾自喜的時候,你的一個同事走過來說:你這種實現方式是非常低效的,這里要進行的耗時操作屬于IO操作,不是計算密集型,可以不分配線程給它(雖然不算準確,但如果不深究的話就這么認為吧)。
你的同事說的是對的。對于IO操作(比如讀寫磁盤,網絡傳輸,數據庫查詢等),我們是不需要占用一個thread來執行的。現代的磁盤等設備,都可以與CPU同時工作,在磁盤尋道讀取這段時間CPU可以干其他的事情,當讀取完畢之后通過中斷再讓CPU參與進來。所以上面的代碼,雖然構建了響應靈敏的界面,但是卻創建了一個什么也不干的線程(當進行網絡請求這段時間內,該線程會被一直阻塞)。所以,如果你要進行異步時首先要考慮,耗時的操作屬于計算密集型還是IO密集型,不同的操作需要采用不同的策略。對于計算密集型的操作你是可以采用上面的方法的:比如你要進行很復雜的方程的求解。是采用專門的線程還是使用線程池,也要看你的操作的關鍵程度。
這個時候你又在思考,不讓我使用線程,又要讓我實現異步。這該怎么辦呢?微軟早就幫你想到了這點,在.NET Framework中,幾乎所有進行IO操作的方法幾乎都提供了同步版本和異步版本,而且微軟為了簡化異步的使用難度還定義了兩種異步編程模式:
Classic Async Pattern
這種方式就是提供兩個方法實現異步編程:比如System.IO.Stream的Read方法:
它還提供了兩個方法實現異步讀取:
public int EndRead(IAsyncResult asyncResult);
以Begin開頭的方法發起異步操作,Begin開頭的方法里還會接收一個AsyncCallback類型的回調,該方法會在異步操作完成后執行。然后我們可以通過調用EndRead獲得異步操作的結果。關于這種模式更詳細的細節我不在這里多闡述,感興趣的同學可以閱讀《CLR via C#》26、27章,以及《.NET設計規范》里對異步模式的描述。在這里我會使用這種模式重新實現上面的代碼片段:
private void btnDownload_Click(object sender,EventArgs e)
{
var request = HttpWebRequest.Create("http://www.sina.com.cn");
request.BeginGetResponse((ar) => {
var response = request.EndRequest(ar);
var stream = response.GetResponseStream();
ReadHelper(stream,0);
},null);
}
private void ReadHelper(Stream stream,int offset)
{
var buffer = new byte[BUFFER_LENGTH];
stream.BeginRead(buffer,offset,BUFFER_LENGTH,(ar) =>{
var actualRead = stream.EndRead(ar);
if(actualRead == BUFFER_LENGTH)
{
var partialContent = Encoding.Default.GetString(buffer);
Update(partialContent);
ReadHelper(stream,offset+BUFFER_LENGTH);
}
else
{
var latestContent = Encoding.Default.GetString(buffer,0,actualRead);
Update(latestContent);
stream.Close();
}
},null);
}
private void Update(string content)
{
this.BeginInvoke(new Action(()=>{this.txtContent.Text += content;}));
}
感謝lambda表達式,讓我少些了很多方法聲明,也少引入了很多實例成員。不過上面的代碼還是非常難以讀懂,原本簡簡單單的同步代碼被改寫成了分段式的,而且我們再也無法使用using了,所以需要顯示的寫stream.Close()。哦,我的代碼還沒有進行異常處理,這令我非常頭痛。實際上要寫出一個健壯的異步代碼是非常困難的,而且非常難以調試。但是,上面的代碼不僅僅能創建響應靈敏的界面,還能更高效的利用線程。在這種異步模式中,BeginXXX方法會返回一個IAsyncResult對象,在進行異步編程時也非常有效,關于它的更詳細信息你可以閱讀我的這篇文章:WinForm二三事(二)異步操作。
除此之外,因為我們在這里不能使用while等循環,我們想要從stream里讀取完整的內容并不是一件容易事兒,我們必須將很好的循環結果替換成遞歸調用:ReadHelper。
Event-based Async Pattern(EAP)
.NET Framework除了提供上面這種編程模式外,還提供了基于事件的異步編程模式。比如WebClient的很多方法就提供了異步版本,比如DownloadString方法。
同步版本:
異步版本:
public event DownloadStringCompleteEventHandler DownloadStringComplete;
(在這里請注意,這兩種異步編程模式以及未來要介紹的Async CTP中的TAP方法的命名,參數的傳遞都是有一定規則的,弄清楚這些規則在進行異步編程時會事半功倍)
基于事件的異步模式我也不作過多闡述,同樣可以參考《CLR via C#》以及MSDN。基于事件的異步編程模式點相比上一種的優點是實現了該模式的類一般從Component派生,所以可以獲得更好的設計器支持,但如此一來也會在性能上稍微差一點點。
尷尬
雖然微軟費盡心思,提出兩種異步編程的模式,讓我們編寫異步代碼能稍微輕松那么一點點;但不管是使用回調還是基于事件的異步模式,都會將順序的同步方式的代碼拆成兩個部分:一個部分發起異步操作,而另外一個部分獲得結果。當有多個異步操作要進行時(比如上面的代碼首先使用異步的方式獲得response,然后又使用異步的方式讀取stream中的內容)就會回調里嵌套著另外一個異步調用,代碼更加混亂。而且方法打散之后,像using、for、while、常規的異常處理都變得難以進行。代碼的可讀性也急劇降低,代碼又容易出錯,如是我們舍爾求其次,轉而去使用低效的同步版本。
不過作為.NET程序員我們是幸運的,因為.NET提供的一些特性讓我們可以開發一些類庫輔助異步開發,比如Jeffrey Richter的AsyncEnumerator,以及微軟的CCR。我們會在接下來的文章里討論這些第三方類庫的使用以及背后的原理。
最后還是套用Async CTP的程序經理Lucian Wischik的那句話:異步并不意味著后臺線程結束本文。
參考文獻
《CLR via C#》
關于IO部分,如果想更深入了解,可以使用IO完成端口(或對應英文IO Completion Port)進行搜索