在上一篇文章中,我们从构建响应灵敏的界面以及构建高可伸缩性的服务应用来讨论我们为什么需要异步编程,异步编程能给我们带来哪些好处。那么知道了好处,我们就开始吧,但是在异步编程这个方面,说总是比做简单。套用那句不是名言的名言:编写异步程序是困难的,编写可靠的异步程序尤其困难。因为异步程序非常难以编写,而且非常容易出错,很多基本的构造元素在异步编程中都无法使用,这让我们这些开发人员更愿意编写同步的代码,虽然我们知道有些地方真的应该使用异步。
如何实现异步
对于很多人来说,异步就是使用后台线程运行耗时的操作。在有些时候这是对的,而在我们日常大部分场景中却不对。
比如现在我们有这么一个需求:使用HttpWebRequest请求某个指定URI的内容,然后输出在界面上的文本域中。同步代码很容易编写:
private void btnDownload_Click(object sender,EventArgs e)
{
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线程可以继续响应用户的操作。
使用独立的线程实现异步
如是你写下了下面的代码:
private void btnDownload_Click(object sender,EventArgs e)
{
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,将上面的代码改成下面这个样子:
private void btnDownload_Click(object sender,EventArgs e)
{
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 Read(byte[] buffer,int offset,int count);
它还提供了两个方法实现异步读取:
public IAsyncResult BeginRead(byte[] buffer, int offset,int count,AsyncCallback callback);
public int EndRead(IAsyncResult asyncResult);
以Begin开头的方法发起异步操作,Begin开头的方法里还会接收一个AsyncCallback类型的回调,该方法会在异步操作完成后执行。然后我们可以通过调用EndRead获得异步操作的结果。关于这种模式更详细的细节我不在这里多阐述,感兴趣的同学可以阅读《CLR via C#》26、27章,以及《.NET设计规范》里对异步模式的描述。在这里我会使用这种模式重新实现上面的代码片段:
private static readonly int BUFFER_LENGTH = 1024;
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 string DownloadString(string url);
异步版本:
public void DownloadStringAsync(string url);
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)进行搜索