C#的await/async异步编程陷阱2:await Task不等于异步IO

许多IO方法都有同步和异步两个版本,而同步方法可以通过Task.Run等转换成一个同样可以await的Task,那么这样做是否就等效于我们所说的异步IO了呢?

问题描述

考虑这样两个方法:

await Task.Delay(100);
Thread.Sleep(100);

这两个方法分别是异步和同步的延时100毫秒的实现。我们知道,使用Thread.Sleep会阻塞当前线程,而使用await Task.Delay则不会。但是,第二个方法可以通过Task.Run等封装成一个Task,使其也可以用于await,例如:

await Task.Run(() => { Thread.Sleep(100); });

使用这个方法同样不会阻塞线程。那么:

await Task.Delay(100);
await Task.Run(() => { Thread.Sleep(100); });

是否是等价的呢?换言之,许多IO方法都有同步和异步两个版本,而同步方法又可以通过Task.Run等转换成一个同样可以await的Task,那么——

这样做是否就等效于我们所说的异步IO了呢?

答案是NO!


设计实验

异步IO的特性通常是大吞吐量。下面我们设计一个实验,分别同时启动100个和500个上述两种的Task,然后比较它们的完成时间是否有区别。新建一个Window窗体项目,添加button1和button2两个按钮,并设置对应的click响应,如下面的代码所示:

private async void button1_Click(object sender, EventArgs e)
{
    DateTime start = DateTime.Now;

    Task[] tasks = new Task[500];
    for (int i = 0; i < tasks.Length; i++)
    {
        tasks[i] = Task.Delay(100);
    }
    await Task.WhenAll(tasks);

    Debug.WriteLine(DateTime.Now - start);
}

private async void button2_Click(object sender, EventArgs e)
{
    DateTime start = DateTime.Now;

    Task[] tasks = new Task[500];
    for (int i = 0; i < tasks.Length; i++)
    {
        tasks[i] = Task.Run(() => { Thread.Sleep(100); });
    }
    await Task.WhenAll(tasks);

    Debug.WriteLine(DateTime.Now - start);
}

先点击button1,再点击button2,在我4C8T的测试机上得到的结果分别是

00:00:00.1471703
00:00:06.0368200

两种方法相差了43倍!改变tasks的总数,可以发现Thread.Sleep花费的时间大致与[所有任务顺序执行的总时间]/[线程数]持平,而Task.Delay花费的时间和[单个任务的时间]相差不多。那么为什么会产生这样的差异呢?


原理解析

回忆《计算机组成原理》《操作系统》等课程里,我们知道从硬件层面上而言,目前的几乎所有的外部设备的IO都是异步操作,因为IO设备相对于CPU来说非常慢,工业界不会傻到让CPU一直干等着。一般来讲,CPU将数据先提供给IO设备然后去干别的事情,IO设备在处理完毕后向CPU发送中断请求,CPU再回来处理后事;这个特征在高级语言的层面上也有保留,不同语言中的个异步方法,通常都包含开始、回调这对双子星。

因此,高级语言层面上的异步IO操作正是贴合具体硬件实现的、高效率的IO,一个线程只需要处理启动回调这两条工作就可以完成一个IO操作;而同步IO操作是为了提供更简单的调用而提供的(想想,如果简单的一个Hello World也需要用异步回调那么将会非常不友好),作为代价,一个线程在启动回调之间处于等待的阻塞状态。

在这个实验中,我们用Task.Delay和Thread.Sleep分别指代了广义上的异步IO操作同步IO操作。在我4C8T的测试机上,Task.Delay小组一开始就依次启动了500个任务,在100ms后这500个任务先后结束,整个过程的启动和回调的额外开销只有47ms。而Thread.Sleep小组,每个线程在启动任务后必须等待结束才能启动下一个任务,500个任务被提交给默认线程池的8个线程中执行,因此所花费的总时间大致为[所有任务顺序执行的总时间]/[线程数]。


进阶实验

在前面的实验中,Thread.Sleep小组惨败的原因之一还包括线程池的数量就这么8个。为此,我们使用TaskCreationOptions.LongRunning选项为Thread.Sleep提供无限的线程数来再战一发。添加button3及其click处理方法如下:

private async void button3_Click(object sender, EventArgs e)
{
    DateTime start = DateTime.Now;

    Task[] tasks = new Task[500];
    for (int i = 0; i < tasks.Length; i++)
    {
        tasks[i] = Task.Factory.StartNew(() => { Thread.Sleep(100); }, TaskCreationOptions.LongRunning);
    }
    await Task.WhenAll(tasks);

    Debug.WriteLine(DateTime.Now - start);
}

点击4次button3,发现得到的结果波动非常大:

00:00:02.0453590
00:00:01.7444295
00:00:02.8185754
00:00:01.8040623

和Task.Delay小组的差距最小也有11.8倍。产生这个结果的原因在于,线程的开销是非常昂贵的,因此启动更多的线程来提高同步方法的并行度是不可取的。


总结

虽然我们可以包装同步IO方法使其同样可以通过await来达到不阻塞调用的效果,但是它和原生的异步IO方法具有本质上的区别,在于同步IO终究还是会阻塞线程池里的线程,而异步IO则不会。从提高吞吐量的角度而言,await一个包装起来的同步IO并不能享受本质的提升。

称谓(*)
邮箱
留言(*)