C#中异步的用法、原则和基本原理

文章目录

工作三年有余,由于工作中需要使用大量C#,所以也看了很多相关的书籍和资料。由于C#当年就是模仿Java推出的,语法看起来也像是类C语言,因此学习成本不是很高。但随着语言的发展,它和Java相比还是有了很大区别,比如dynamic变量、async异步方法等都是很独特的用法。因此想写一些文章来介绍一些有趣的C#原理,这篇文章先从C#的async方法开始

Async

说实话,async是我用下来感觉最独特又最常用的C#语言特性。我记得在上学时候背的Java八股文------什么时候应该用线程池?我印象里有一个答案就是进行较长时间操作,比如从DB里读数据时,最好使用线程池。而在C#的世界里,我们的服务端代码通常用异步方法来实现费时的IO操作。

如果希望深入理解async-await的原理,有一篇来自于.Net开发者的blog讲得非常透彻,其中介绍了C#中异步编程的发展过程、await状态机大概长什么样、只有Task可以await吗这样的问题

新手常写出的异步代码

如果进行过JavaScript开发,则可以很好地理解异步代码,因为JS通常是单线程模型编程,因此使用了丰富的async-await代码。在刚接触异步代码时,看着别人写的async方法,经常不知道怎么集成到自己的代码中。比方说一不小心调用了一个async方法,编译器会让你在调用者的签名上也加上async,一旦加上async,可能又会让你把返回值改为Task<T>的形式......这样一串改下去,人都要崩溃了,最后查了半天API,会做出一个新手常见的错误,不await了,直接读Task.Result来拿结果!这样就不用一串改签名了。下面展示一个坏的(相当于把异步方法当同步方法来用)和一个好的例子:

csharp 复制代码
/// method written by others
Task DoSomethingAsync();
Task<int> DoSomethingWithReturnValueAsync();

int BadExampleDoSomethingAsync()
{
    DoSomethingAsync.Wait();
    return DoSomethingWithReturnValueAsync().Result;
}

async Task<int> GoodExapmleDoSomethingAsync()
{
    await DoSomethingAsync();
    return await DoSomethingWithReturnValueAsync();
}

我们从这两个例子出发,介绍async方法的一些语法特性:

  1. async方法中,至少要有一个await语句。否则编译器会强烈建议(可抑制报警)你把签名里的async去掉
  2. async方法通常返回TaskTask<T>ValueTask<T>。它们仨的主要区别在于:Task一般是异步void方法的返回值;Task<T>一般是异步且有返回值的方法的返回值;ValueTask<T>是值类型(而前两种都是引用类型),或者说是struct类型,具有栈上分配的特性,在高性能场景中使用可以减少堆内存的分配与回收------此外还有一些坑(比如只能await一次),因此使用前建议仔细查看文档。通常情况下我们只用关注前两种Task就好。

那回到刚刚的问题,作为一个C#小萌新,怎么和已有的async方法集成?

  1. 如果你的方法无需考虑异步执行,比方你写的方法的调用方不是异步函数,那就用Task.Result或者Task.Wait()这两个方法将异步调用转化为同步调用
  2. 如果你的方法需要异步执行------通常情况是你要写一段业务逻辑塞到已有的异步方法中,并在业务逻辑里执行一些异步方法。那么在所有调用async方法的地方加上await -> 为你的方法签名加上async -> 为你的方法的调用方加上await

异步的一些常见用法、原则和原理

async和await关键字的关系:async是实现细节,Task是桥梁

async签名有两个作用:

  1. 把返回值包装在Task内,
  2. 让await关键字在当前方法中生效

所以async方法必须返回Task类型,但是理论上可以没有await语句(需要手动关闭编译器报警)。如果从async方法调用者的角度看,其实它并不知道调用的是async方法,它只知道返回值是Task。因此await是对Task进行的操作,而不是async方法。比方说下面的代码是完全可以正确运行的,说明await等待的不是async方法。它们之间可以说通过Task进行了完美的解耦。

csharp 复制代码
/// method written by others
Task DoSomethingAsync();

async Task<int> MyDoSomethingAsync()
{
    // We are not awaiting for an async method, we are awaiting for a task!
    Task task = DoSomethingAsync();
    await task;
    return 0;
}

我们再看一个例子,说明async关键字实际上是一种"实现细节",而不是"契约"。尝试创建一个接口,带有async方法。我们知道C#中接口是不能有方法体的,因此会收到一个报错:async只能用于修饰方法体。同样的,也不能将其用在abstract方法上。对于抽象方法、接口方法来说,重要的是定义"契约",而契约只关心调用方法的入参、返回值,不关心内部到底是异步实现还是同步实现。希望这个例子可以更好地帮助大家理解async关键字,从而更好地调用async方法。

csharp 复制代码
interface TestInterface
{
    // CompilerError: The 'async' modifier can only be used in methods that have a body.
    async Task Foo();

    // This line is correct
    Task Bar();
}

将同步方法变为异步方法

有时候实现的接口非得返回Task,但是方法的逻辑很简单,不需要任何异步调用,比如说返回一个固定值。有了上面的铺垫,我们知道只要让方法返回Task类即可,而不用真的用拐弯抹角的方式实现带有async签名的方法。可以用Task.CompletedTaskTask.FromResult()来让同步方法返回TaskTask<T>

尽量不要让async方法返回void,或者说千万不要!

除了eventHandler这种特殊例子之外,千万不要让async方法返回void------虽然编译器默认不报错,而且通常情况下能正常工作。我们可以把Task看成是异步方法执行的一个"句柄",了解句柄的同学都知道,句柄丢了,就丧失了对句柄指向资源的控制能力。因此void异步方法就像断了线的风筝,一旦触发,就只能知道它会执行,除非共享变量或者通信,否则不知道是否结束、是否抛出异常,也不可停止。此外void异步方法也不可await,因为上文说了await是对Task而不是async方法生效。

另外在VisualStudio中编写单元测试的时候,如果UT莫名其妙不运行,请检查是否错误使用了async void方法。曾经我百思不得其解,最终在编译的Warning中找到了答案。

因此最好不要使用async void!

异步方法的异常处理

如果执行期间抛出异常,那只有await Task时才抛异常。因此下面的try-catch语句不会正常工作------这甚至是我实际解决过的一个bug。其原因就是当await Task时才会抛出异常。为什么会如此设计呢?因为从语义上来说,调用一个async方法,就是让这个方法与当前方法分开运行、互不影响,只不过async方法会返回一个Task句柄,让调用者可以知道异步方法的执行情况。那么当且仅当调用者希望知道异步方法执行状态的时候,才能让调用者抛出异常。

所以这个bug的正确处理方式是把try-catch语句包裹await Task.WhenAll(tasks);

csharp 复制代码
async Task DoManyThingsAsync()
{
    try
    {
        int[] inputParams = {1,2,3};
        List<Task> tasks = new List<Task>();
        foreach (int param in inputParams) 
        {
            tasks.Add(DoSomethingAsync(param))
        }
    }
    catch 
    {
        // we cannot catch any exceptions here
    }

    // If one of tasks throws exception, the whole method failed.
    await Task.WhenAll(tasks);
}

异步方法的调度和如何增加异步任务并行度

我相信接触过异步方法一段时间后,就会好奇.Net会如何调度异步方法,是启动一个线程来执行异步方法,还是把方法丢到线程池里,还是顺序执行?简而言之,异步方法会首先尝试在当前线程、当前调用栈上继续执行,当碰到第一个真正需要异步等待的语句时(比方说发生了异步IO调用,或者Task.Delay()),才会返回到上层继续执行。

因此,不是所有的异步方法都会异步执行,大多数情况下异步方法都会在当前调用栈上继续执行。为什么呢?因为大多数情况下层层嵌套的异步方法都可以顺序执行,只有很小的一部分是真需要异步等待。而这些需要异步等待的方法,可能也可以同步返回------比方说异步读取socket缓冲区,而缓冲区里早已有了足够的数据。尝试将异步方法同步执行可以最大程度减少上下文切换的成本,提高程序执行的性能。JavaScript执行引擎也有类似的异步调度行为。

说完了原理,我们来讲讲这一调度行为带来的坑。我在实际工作中遇到过一个例子:在并行化某段代码时,使用了Task.WhenAll(),执行时间并没有缩短。其中DoSomethingAsync方法是一个计算密集型逻辑。

csharp 复制代码
// It's a computing-bound method
async Task<int> DoSomethingAsync(int val)

async Task ParallelRunTasksFailedAsync() 
{
    int[] paramArr = { 1,2,3 };

    /*
        Old code: use a for loop to process each param
    */
    var tasks1 = paramArr.Select(p => DoSomethingAsync(p)).ToList();
    await Task.WhenAll(tasks1);
    var result1 = tasks1.Select(t => t.Result).ToList();
}

这段代码看起来非常合理,但是如果不了解await的调度方式,是无法提高性能的。由于这段代码是计算密集型代码,因此在执行过程中不会出现异步等待IO的情况,所以当一个task在执行时,别的task也分不到资源运行,因此整体的执行流程就像是顺序执行一样。如果这段代码是IO密集型代码,那还是有很客观的性能提升的,因为当一个task在等待IO时,可以将CPU资源让出,执行另一个task的逻辑,这样整体的执行时间约等于执行时间最长的Task的时间。

阐述了问题,接下来说怎么解决:可以使用PLINQ或者Task.Run()来并发执行计算密集型操作,它们都会将任务放到多线程或者背景线程中执行,从而增加任务执行的线程数。比如:

csharp 复制代码
async Task ParallelRunTasksFailedAsync() 
{
    int[] paramArr = { 1,2,3 };

    // use PLINQ
    var tasks1 = paramArr.AsParallel().Select(async p => await DoSomethingAsync(p)).ToList();
    await Task.WhenAll(tasks1);
    result1 = tasks1.Select(t => t.Result).ToList();

    // use Task.Run
    tasks1 = paramArr.Select(p => Task.Run(() => DoSomethingAsync(p))).ToList();
    await Task.WhenAll(tasks1);
    result1 = tasks1.Select(t => t.Result).ToList();
}

实现自定义的async方法:TaskCompletionSource的用法

通常情况下我们的async方法都是通过await async方法产生,比如调用Task.Run()或其它自定义的async方法。那么就有一个先有鸡还是先有蛋的问题------世界上第一个async方法是如何产生的?换句话说,我们该如何让一个不调用async方法的方法返回Task,且具有异步行为(不是简单地将同步方法用Task.FromResult()包装一下)呢?举个例子,我有一个耗时很长的计算方法,因此我希望它是异步的。这个方法被调用时,会向线程池提交一个任务,并返回给调用者一个Task,Task是未Complete的状态。当计算成功后,这个方法将Task置为Completed。

我们先思考一个有趣的问题:Task是如何产生的?当创建async方法时,编译器会自动帮我们包裹一个Task返回给上层,但我们对它没有任何控制行为,甚至都不知道它如何创建出来;如果要创建Task,我们也很少new一个Task出来,一般是通过Task的静态工厂(如Task.FromResult())方法------但也只能创建一个已经完成的Task。那么问题就变成了,在C#中,我们如何创建一个Task,并且控制它从创建到结束的状态变化呢?

C#提供了TaskCompletionSource<T>,它包含一个Task实例,和一系列操纵Task实例的接口。比方说它可以设置Task为Complete或者Cancel或者有Exception。简单的使用方法如下,调用者可以直接await返回的Task:

csharp 复制代码
Task<int> HeavyComputation()
{
    var taskCompletionSource = new TaskCompletionSource<int>();
    ThreadPool.QueueUserWorkItem(async (state) =>
    {
        // use delay to represent a long-running task
        await Task.Delay(1000);
        taskCompletionSource.SetResult(2);
    });

    return taskCompletionSource.Task;
}

从这个例子可以更加清楚地看出,一个方法是否是异步执行的,与它内部是否进行了await也没有关系。所以从调用者角度出发,我们可以认为返回Task类型的就是异步方法,在异步方法返回的Task上执行await就可以方便地进行异步编程。而异步方法本身如何实现异步,比如是由async关键字实现,还是由TaskCompletionSource实现,调用者都不用担心。

相关推荐
basketball6162 分钟前
Python torchvision.transforms 下常用图像处理方法
开发语言·图像处理·python
ABAP 成9 分钟前
.NET Framework 4.0可用EXCEL导入至DataTable
c#
宁酱醇10 分钟前
各种各样的bug合集
开发语言·笔记·python·gitlab·bug
啊吧怪不啊吧17 分钟前
Linux常见指令介绍下(入门级)
linux·开发语言·centos
谷晓光18 分钟前
Python 中 `r` 前缀:字符串处理的“防转义利器”
开发语言·python
Tiger Z24 分钟前
R 语言科研绘图第 41 期 --- 桑基图-基础
开发语言·r语言·贴图
chuxinweihui37 分钟前
数据结构——二叉树,堆
c语言·开发语言·数据结构·学习·算法·链表
陈大大陈1 小时前
基于 C++ 的用户认证系统开发:从注册登录到Redis 缓存优化
java·linux·开发语言·数据结构·c++·算法·缓存
看到我,请让我去学习1 小时前
C语言基础(day0424)
c语言·开发语言·数据结构
studyer_domi1 小时前
Matlab 复合模糊PID
开发语言·matlab