基于的await/async
异步编程模式在C# 5.0和.NET 4.5中引入,也被称为“基于任务的异步编程模型 (TAP) ”。它有效地避免了异步任务回调嵌套的地狱,而且非常易于使用,但是对它深度理解却比学会使用它困难得多。
await/async
的异步方法通常会被安插到线程池中运行,也可以设置为启动新的线程执行;总之,它一般不会阻塞当前调用的线程,例如:
async void DelayAsync()
{
await Task.Delay(100);
}
void Delay()
{
Thread.Sleep(100);
}
我们把Delay
看作某个耗时的方法,而DelayAsync
视作它的异步版本,那么,我在一个窗体程序中分别调用这两个方法:
private void button1_Click(object sender, EventArgs e)
{
for (int i = 0; i < 50; i++)
{
DelayAsync();
}
}
private void button2_Click(object sender, EventArgs e)
{
for (int i = 0; i < 50; i++)
{
Delay();
}
}
就会发现,点击button2
时UI线程阻塞了5秒钟,而点击button1
时则完全没有异样。那么——
async
方法一定就会异步执行而不会阻塞线程吗?
答案是NO!
为了解释原因,下面我们精心构造一些场景来看这个问题。
场景一
考虑下面的FakeDelayAsync
方法
async void FakeDelayAsync()
{
Delay();
}
FakeDelayAsync
方法被标记上了async
关键字,但内部实现却是一个同步的Delay
方法。调用这个方法时,先验来说,会有两种可能:
×系统将Delay
方法插入线程池执行,当前线程不会被阻塞,因为这个方法是async
方法。
√系统直接调用Delay
,当前线程阻塞。
——实际执行一下,就会发现FakeDelayAsync
和Delay
的表现是一样的,两者都是同步执行。实际上,在IDE中打出这个方法时,就会有警告:
说的正是这个意思。
场景二
考虑下面的FakeDelayAsync2
和FakeDelayAsync3
方法
async Task FakeDelayAsync2()
{
Delay();
}
async void FakeDelayAsync3()
{
await FakeDelayAsync2();
}
在FakeDelayAsync3
方法中,IDE没有任何警告,那么在调用这个方法时,先验来说,会有两种可能:
×系统将FakeDelayAsync2
方法插入线程池执行,当前线程不会被阻塞,因为这个方法是async Task
。
√系统直接调用Delay
,当前线程阻塞。
——实际执行一下,就会发现FakeDelayAsync3
和Delay
的表现还是一样的。即便IDE在FakeDelayAsync3
中没有给出任何的警告,但它调用FakeDelayAsync2
方法是完完全全地同步执行的!
场景三
你也许会认为,FakeDelayAsync3
中虽然没有警告,但FakeDelayAsync2
中也有啊!那么,考虑下面的情形:
async Task HalfFakeDelayAsync(bool isAsync)
{
if (isAsync)
{
Delay();
}
else
{
await Task.Delay(100);
}
}
async void FakeDelayAsync4()
{
await HalfFakeDelayAsync(false);
}
构建一个确实存在await
关键字的HalfFakeDelayAsync
方法,再在FakeDelayAsync4
中调用它。
那么,调用FakeDelayAsync4
方法时,即便IDE全程没有给出任何的警告,它依然是同步执行。
场景四
你也许会认为,上面的场景都是别有用心构造出来的,或是因为“错误的”编程而导致async
方法被同步执行的,那么,我们可以考察下面这个完全使用.NET Framework所提供async
方法的例子:
async void FakeDelayAsync5()
{
SemaphoreSlim semaphore = new SemaphoreSlim(int.MaxValue);
for (int i = 0; i < 10000000; i++)
{
await semaphore.WaitAsync();
}
}
这个FakeDelayAsync5
方法——依然是同步执行的!
总结
总结以上四个场景得到的结果,实际上,我们可以得知:
- 首先,不论是
async void
还是async Task
,不论IDE有没有绿波浪警告,都有可能以同步的方法执行,从而阻塞调用线程。 - 一个
async void
或者async Task
究竟是否会以异步方式执行,取决于其本身内部逻辑是否会释放当前线程。
因此,被标记为async
的方法,仅仅是异步的一个必要不充分条件。
C#的await/async
异步编程陷阱2: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并不能享受本质的提升。