C#的await/async异步编程陷阱1:async方法并不一定会异步执行

基于的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,当前线程阻塞。

——实际执行一下,就会发现FakeDelayAsyncDelay的表现是一样的,两者都是同步执行。实际上,在IDE中打出这个方法时,就会有警告:

说的正是这个意思。

场景二

考虑下面的FakeDelayAsync2FakeDelayAsync3方法

async Task FakeDelayAsync2()
{
    Delay();
}

async void FakeDelayAsync3()
{
    await FakeDelayAsync2();
}

FakeDelayAsync3方法中,IDE没有任何警告,那么在调用这个方法时,先验来说,会有两种可能:

×系统将FakeDelayAsync2方法插入线程池执行,当前线程不会被阻塞,因为这个方法是async Task

√系统直接调用Delay,当前线程阻塞。

——实际执行一下,就会发现FakeDelayAsync3Delay的表现还是一样的。即便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方法——依然是同步执行的!

总结

总结以上四个场景得到的结果,实际上,我们可以得知:

  1. 首先,不论是async void还是async Task,不论IDE有没有绿波浪警告,都有可能以同步的方法执行,从而阻塞调用线程。
  2. 一个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等转换成一个同样可以awaitTask,那么——

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

答案是NO!

设计实验

异步IO的特性通常是大吞吐量。下面我们设计一个实验,分别同时启动100个和500个上述两种的Task,然后比较它们的完成时间是否有区别。新建一个Window窗体项目,添加button1button2两个按钮,并设置对应的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.DelayThread.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并不能享受本质的提升。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值