深度理解c#异步编程

什么是异步?

异步(Asynchronos),正如其名称,是同步(Synchronos)的对应。很多新手往往会把异步与多线程划等号,事实上并非如此。尽管有些异步操作涉及多线程,异步和多线程并没有必然的关系,我们应当分开进行理解。
说起同步,大家一定相当了解。我们平时写的普通的控制台程序一般就是同步的。如果您是大学生,一般基础的c语言课程中写的所有程序也是同步的。同步操作往往易于理解,方便设计。而异步操作则在设计上稍微困难一些,但是用的好则能大幅度提升程序性能。
异步代码一度相当难以构建,因为他涉及各种回调地设计,可以使代码变得相当复杂。但是c#作为Task-based Asynchronous Pattern (TAP) 实现的鼻祖之一,可以利用Task快速而轻松地实现异步操作。本文将仅介绍TAP

TAP的基础使用

在TAP中,一个异步操作被抽象为Task,若它有返回值则是Task<T>,定义一个最简单的异步方法你只需要:

async Task DoAsync()
{

}

在某个方法前加上async关键字,并且让它拥有Task<T>Task类型的返回值。这样,你就成功的构造了一个异步方法。
在构造成功后,使用异步方法时,你可以:

async Task Caller()
{
    await DoAsync();
}

注意,这里使用了await关键字。这个关键字会强迫调用DoAsync函数的线程等待DoAsync方法执行完毕。await关键字只能在异步方法中使用
在MSDOCS中,TAP的教程编写者用做早饭写了个很详细的例子,若您对TAP缺少最基础的了解,请点击此处

TAP in depth

看到这里,我将假设您已经了解了TAP的基础使用。如果您没有,请返回查看上节里的教程链接。
虽然MSDOCS的教程里的例子写的非常形象,易于理解。但是这个例子有一定的误导性。看完这个文章的很多新手容易将异步方法与多线程划等号,认为所谓异步方法就是额外开一个线程执行的方法,实际上并非如此。我将举例说明。
以上一节的两个方法举例,我们来分析一下他的执行过程:

  • Caller开始执行,使用线程id为1

  • Caller调用DoAsync方法并等待,进入DoAsync方法后,查看所在线程,仍然为1

  • DoAsync执行完毕,返回Caller,查看所在线程,仍然为1

看到这个过程,可能就有人不理解了。但是我可没有乱说,不信的话我们可以加断点,查看Thread.CurrentThread.ManagedThreadId的值,我敢打赌它在Caller执行开始后就没有变过。也就是说上方的方法,完全是在一个线程执行的,和普通的方法几乎没有任何区别(唯一的区别是:多生成了两个Task,浪费更多性能)
这时候可能有人就会说异步骗人了。其实并不是异步骗人,是我们的使用方法不对。如果我们真的想要开一个新的线程,我们应该把这样:

async Task DoAsync()
{
    await Task.Yield();
}

这样的话,你会看到await Task.Yield();前后是不一样的线程。
await Task.Yield();实际上是一个语法糖,它等效于

async Task Caller()
{
    Task.Run(async ()=>await DoAsync());
}

所以,其实所有的异步方法可以分为两种:真异步和假异步,假异步和同步方法是一样的。也就是说

async Task DoAsync()
{
    Thread.Sleep(1000);
    Console.WriteLine(2);
}
async Task Caller()
{
    await DoAsync();
    Console.WriteLine(1);
    Console.ReadKey();
}

async Task DoAsync()
{
    Thread.Sleep(1000);
    Console.WriteLine(2);
}
async Task Caller()
{
    //去除await
    DoAsync();
    Console.WriteLine(1);
    Console.ReadKey();
}

效果实际是一样的,都是先输出2再输出1.
判断一个方法是否是假异步有以下的技巧:

  • 所有别人封装的异步方法一定是真异步(除非封装者完全不懂异步,这种概率很小)

  • 所有调用真异步方法并await的异步方法也是真异步方法

  • Task.Run()是个真异步方法

  • Task.Factory.StartNew()是个真异步方法

注意事项

上方,我举例时拿多线程说明了真异步和假异步的区别。但是需注意,真异步不一定是多线程的。除了使用Task.Run()构建的多线程真异步,还有一种很常见的异步,就是os操作异步。
在现代电脑上,io操作一般由os进行,而不是应用程序本身。所以io操作实际上并不占用程序本身的运算资源。因此,c#的开发者给很多基础的io操作设计了异步版本,让程序能够在os进行io操作时进行其他的活动。

async Task DoAsync()
{
    //换为由os计时的Delay
    await Task.Delay(1000);
    Console.WriteLine(2);
}
async Task Caller()
{
    //去除await
    DoAsync();
    Console.WriteLine(1);
    Console.ReadKey();
}

把我们上节举例的代码替换为上方的代码,你就会发现1比2先打印出来。因为await Task.Delay(1000);会通知os来计时,在此时我们的程序的当前线程是可以继续执行其他任务的--也就是Console.WriteLine(1);
整体过程解释如下:

  • 开始执行Caller,线程id为1.

  • 进入DoAsync方法,线程id仍然是1.

  • 线程1调用await Task.Delay(1000);,此操作交由os执行,线程1控制权被释放

  • 操作Console.WriteLine(1);发现线程1可用,使用它打印1.

  • 操作Console.ReadKey();在线程1上继续执行,开始等待用户输入

  • os执行await Task.Delay(1000);完毕,回调.net程序,通知DoAsync继续执行。

  • DoAsync请求线程池,线程池提供可用线程(不可能是线程1,线程1正在等待用户输入(Console.ReadKey();)),继续执行,打印2.

async/await的危险情况

尽管TAP强大无比,但是错误的使用它可能造成死锁。这部分我先占个坑,等待以后补充。

以上就是c#的异步操作需要注意的文章,如果对您有帮助,点一个👍吧

其他教程


本文章使用limfx的vsocde插件快速发布