前言

博客园中有很多关于 .NET async/await 的介绍,但是很遗憾,很少有正确的,甚至说大多都是“从现象编原理”都不过分。

最典型的比如通过前后线程 ID 来推断其工作方式、在 async 方法中用 Thread.Sleep 来解释 Task 机制而导出多线程模型的结论、在 Task.Run 中包含 IO bound 任务来推出这是开了一个多线程在执行任务的结论等等。

看上去似乎可以解释的通,可是很遗憾,无论是从原理还是结论上看都是错误的。

要了解 .NET 中的 async/await 机制,首先需要有操作系统原理的基础,否则的话是很难理解清楚的,如果没有这些基础而试图向他人解释,大多也只是基于现象得到的错误猜想。

初看异步

说到异步大家应该都很熟悉了,2012 年 C# 5 引入了新的异步机制:Task,并且还有两个新的关键字 awaitasync,这已经不是什么新鲜事了,而且如今这个异步机制已经被各大语言借鉴,如 JavaScript、TypeScript、Rust、C++ 等等。

下面给出一个简单的对照:

语言 调度单位 关键字/方法
C# Task<>ValueTask<> asyncawait
C++ std::future<> co_await
Rust std::future::Future<> .await
JavaScript、TypeScript Promise<> asyncawait

当然,这里这并不是本文的重点,只是提一下,方便大家在有其他语言经验的情况下(如果有),可以认识到 C# 中 Taskasync/await 究竟是一个和什么可以相提并论的东西。

多线程编程

在该异步编程模型诞生之前,多线程编程模型是很多人所熟知的。一般来说,开发者会使用 Threadstd::thread 之类的东西作为线程的调度单位来进行多线程开发,每一个这样的结构表示一个对等线程,线程之间采用互斥或者信号量等方式进行同步。

多线程对于科学计算速度提升等方面效果显著,但是对于 IO 负荷的任务,例如从读取文件或者 TCP 流,大多数方案只是分配一个线程进行读取,读取过程中阻塞该线程:

void Main()
{
while (true)
{
var client = socket.Accept();
new Thread(() => ClientThread(client)).Start();
}
} void ClientThread(Socket client)
{
var buffer = new byte[1024];
while (...)
{
// read and block
client.Read(buffer, 0, 1024);
}
}

上述代码中,Main 函数在接收客户端之后即分配了一个新的用户线程用于处理该客户端,从客户端接收数据。client.Read() 执行后,该线程即被阻塞,即使阻塞期间该线程没有任何的操作,该用户线程也不会被释放,并被操作系统不断轮转调度,这显然浪费了资源。

另外,如果线程数量多起来,频繁在不同线程之间轮转切换上下文,线程的上下文也不小,会浪费掉大量的性能。

异步编程

因此我们在 Linux 上有了 epoll/io_uring 技术,在 Windows 上有了 IOCP 技术用以实现异步 IO 操作。

(这里插句题外话,吐槽一句,Linux 终于知道从 Windows 抄作业了。先前的 epoll 对比 IOCP 简直不能打,被 IOCP 全面打压,io_uring 出来了才好不容易能追上 IOCP,不过 IOCP 从 Windows Vista 时代开始每一代都有很大的优化,io_uring 能不能追得上还有待商榷)

这类 API 有一个共同的特性就是,在操作 IO 的时候,调用方控制权被让出,等待 IO 操作完成之后恢复先前的上下文,重新被调度继续运行。

所以表现就是这样的:

假设我现在需要从某设备中读取 1024 个字节长度的数据,于是我们将缓冲区的地址和内容长度等信息封装好传递给操作系统之后我们就不管了,读取什么的让操作系统去做就好了。

操作系统在内核态下利用 DMA 等方式将数据读取了 1024 个字节并写入到我们先前的 buffer 地址下,然后切换到用户态将从我们先前让出控制权的位置,对其进行调度使其继续执行。

你可以发现这么一来,在读取数据期间就没有任何的线程被阻塞,也不存在被频繁调度和切换上下文的情况,只有当 IO 操作完成之后才会被重新调度并恢复先前让出控制权时的上下文,使得后面的代码继续执行。

Task (ValueTask)

说了这么久还是没有解释 Task 到底是个什么东西,从上面的分析就可以得出,Task 其实就是一个所谓的调度单位,每个异步任务被封装为一个 Task 在 CLR 中被调度,而 Task 本身会运行在 CLR 中的预先分配好的线程池中。

总有很多人因为 Task 借助线程池执行而把 Task 归结为多线程模型,这是完全错误的。

这个时候有人跳出来了,说:你看下面这个代码

static async Task Main()
{
while (true)
{
Console.WriteLine(Environment.CurrentManagedThreadId);
await Task.Delay(1000);
}
}

输出的线程 ID 不一样欸,你骗人,这明明就是多线程!对于这种言论,我也只能说这些人从原理上理解的就是错误的。

当代码执行到 await 的时候,此时当前的控制权就已经被让出了,当前线程并没有在阻塞地等待延时结束;待 Task.Delay() 完毕后,CLR 从线程池当中挑起了一个先前分配好的已有的但是空闲的线程,将让出控制权前的上下文信息(寄存器值)恢复,使得该线程恰好可以从先前让出的位置继续执行下去。这个时候,可能挑到了先前让出前所在的那个线程,导致前后线程 ID 一致;也有可能挑到了另外一个和之前不一样的线程执行下面的代码,使得前后的线程 ID 不一致。在此过程中并没有任何的新线程被分配了出去。

但是上面和经典的多线程编程的那一套有任何的关系吗?完全没有。

至于 ValueTask 是个什么玩意,官方发现,Task 由于本身是一个 class,在运行时如果频繁反复的分配和回收会给 GC 造成不小的压力,因此出了一个 ValueTask,这个东西是 struct,分配在栈上,这样的话就不会给 GC 造成压力了,减轻了开销。不过也正因为 ValueTask 是会在栈上分配的值类型结构,因此提供的功能也不如 Task 全面。

Task.Run

由于 .NET 是允许有多个线程的,因此也提供了 Task.Run 这个方法,允许我们将 CPU bound 的任务放在上述的线程池之中的某个线程上执行,并且允许我们将该负载作为一个 Task 进行管理,仅在这一点才和多线程的采用线程池的编程比较像。

对于浏览器环境(v8),这个时候是完全没有多线程这一说的,因此你开的新的 Promise 其实是后面利用事件循环机制,将该微任务以异步的方式执行。

想一想在 JavaScript 中,Promise 是怎么用的:

let p = new Promise((resolve, reject) => {
// do something
let success = true;
let result = 123456; if (success) {
resolve(result);
}
else {
reject("failed");
}
})

然后调用:

let r = await p;
console.log(r); // 输出 123456

你只需要把这一套背后的驱动器:事件循环队列,替换成 CLR 的线程池,就差不多是 .NET 的 Task 相对 JavaScript 的 Promise 的工作方式了。

如果你把 CLR 线程池线程数量设置为 1,那就和 JavaScript 这套几乎差不多了(虽然实现上还是有差异)。

有效利用 .NET 中的异步

了解了上面的东西之后,相信对 .NET 中的异步机制应该理解得差不多了,可以看出来这一套是名副其实的 coroutine,虽然在实现上是 stackless 的。

那我们要怎么样使用 Task 来编写我们自己的异步代码呢?

事件驱动其实也可以算是一种异步模型,例如以下情景:

A 函数调用 B 函数,调用后就立马让出控制权(例如:BeginInvoke),B 函数执行完成后触发事件执行 C 函数。

private event Action CompletedEvent;

void A()
{
CompletedEvent += C;
Console.WriteLine("begin");
((Action)B).BeginInvoke();
} void B()
{
Console.WriteLine("running");
CompletedEvent?.Invoke();
} void C()
{
Console.WriteLine("end");
}

那么我们现在想要做一件事,就是把上面的事件驱动改造为利用 async/await 的异步编程模型,改造后的代码就是简单的:

async Task A()
{
Console.WriteLine("begin");
await B();
Console.WriteLine("end");
} Task B()
{
Console.WriteLine("running");
return Task.CompletedTask;
}

你可以看到,原本 C 函数的内容被放到了 A 调用 B 的下面,为什么呢?其实很简单,因为这里 await B(); 这一行以后的内容,本身就可以立即为 B 函数的回调了,只不过在内部实现上,不是直接从 B 进行调用的回调,而是 A 先让出控制权,B 执行完成后,CLR 切换上下文,将 A 调度回来继续执行剩下的代码。

如果事件相关的代码已经确定不可改动(即不能改动 B 函数),我们想将其封装为异步调用的模式,那只需要利用 TaskCompletionSource 即可:

private event Action CompletedEvent;

async Task A()
{
// 因为 TaskCompletionSource 要求必须有一个泛型参数
// 因此就随便指定了一个 bool
// 本例中其实是不需要这样的一个结果的
// 需要注意的是从 .NET 5 开始
// TaskCompletionSource 不再强制需要泛型参数
var tsc = new TaskCompletionSource<bool>();
// 随便写一个结果作为 Task 的结果
CompletedEvent += () => tsc.SetResult(false); Console.WriteLine("begin");
await tsc.Task;
Console.WriteLine("end");
} void B()
{
Console.WriteLine("running");
CompletedEvent?.Invoke();
}

顺便提一句,这个 TaskCompletionSource<T> 其实和 JavaScript 中的 Promise<T> 更像。SetResult() 方法对应 resove()SetException() 方法对应 reject()。.NET 比 JavaScript 还多了一个取消状态,因此还可以 SetCancel() 表示任务被取消了。

同步方式调用异步代码

说句真的,一般能有这个需求,都说明你的代码写的有问题,但是如果你无论如何都想以阻塞的方式去等待一个异步任务完成的话:

Task t = ...
t.GetAwaiter().GetResult();

祝你好运,这相当于,t 中的异步任务开始执行后,你将当前线程阻塞,然后等到 t 完成之后再唤醒,可以说是:毫无意义,而且很有可能因为代码编写不当而导致死锁的发生。

void async 是什么?

最后有人会问了,函数可以写 async Task Foo(),还可以写 async void Bar(),这有什么区别呢?

对于上述代码,我们一般调用的时候,分别这么写:

await Foo();
Bar();

可以发现,诶这个 Bar 函数不需要 await 诶。为什么呢?

其实这和用以下方式调用 Foo 是一样的:

_ = Foo();

换句话说就是调用后瞬间就直接抛掉不管了,不过这样你也就没法知道这个异步任务的状态和结果了。

await 必须配合 Task/ValueTask 才能用吗?

当然不是。

在 C# 中只要你的类中包含 GetAwaiter() 方法和 bool IsCompleted 属性,并且 GetAwaiter() 返回的东西包含一个 GetResult() 方法、一个 bool IsCompleted 属性和实现了 INotifyCompletion,那么这个类的对象就是可以 await 的。

public class MyTask<T>
{
public MyAwaiter<T> GetAwaiter()
{
return new MyAwaiter<T>();
}
} public class MyAwaiter<T> : INotifyCompletion
{
public bool IsCompleted { get; private set; }
public T GetResult()
{
throw new NotImplementedException();
}
public void OnCompleted(Action continuation)
{
throw new NotImplementedException();
}
} public class Program
{
static async Task Main(string[] args)
{
var obj = new MyTask<int>();
await obj;
}
}

结语

本文至此就结束了,感兴趣的小伙伴可以多多学习一下操作系统原理,对 CLR 感兴趣也可以去研究其源代码:https://github.com/dotnet/runtime

从现象猜测本质是大忌,可能解释的通但是终究只是偶然现象,而且从原理上看也是完全错误的,甚至官方的实现代码稍微变一下可能立马就无法解释的通了。

总之,通过本文希望大家能对异步和 .NET 中的异步有一个更清晰的理解。

感谢阅读。

.NET 异步详解的更多相关文章

  1. 触碰jQuery:AJAX异步详解

    触碰jQuery:AJAX异步详解 传送门:异步编程系列目录…… 示例源码:触碰jQuery:AJAX异步详解.rar AJAX 全称 Asynchronous JavaScript and XML( ...

  2. jQuery调用AJAX异步详解[转]

    AJAX 全称 Asynchronous JavaScript and XML(异步的 JavaScript 和 XML).它并非一种新的技术,而是以下几种原有技术的结合体. 1)   使用CSS和X ...

  3. 触碰jQuery:AJAX异步详解(转)

    AJAX 全称 Asynchronous JavaScript and XML(异步的 JavaScript 和 XML).它并非一种新的技术,而是以下几种原有技术的结合体. 1)   使用CSS和X ...

  4. es6常用功能与异步详解(JS高级面试题)

    callback hell方法的使用 可读性不友好 function loadImg(src,callback,fail){ var img = document.createElement('img ...

  5. Gevent中的同步与异步详解

    同步,异步概念 1.同步就是发生调用时,一定等待结果返回,整个调用才结束: 2.异步就是发生调用后,立即返回,不等待结果返回.被调用者通过状态.通知来通知调用者,或通过回调函数处理这个调用. 查询 1 ...

  6. ajax之同步异步详解

                    同步:一堆任务,按顺序去执行,只能一件件的来,如一个厨师做菜                 异步:一堆任务,可以同时进行,如多个厨师做菜                ...

  7. 【Android】Android实现Handler异步详解

    方式不止一种,这里使用的是Timer类,创建一个定时器.我们经常需要获得移动设备端口的显示屏信息,但是onCreate()方法执行的时候,OnShow()方法不一定执行了,也就是说,在执行Oncrea ...

  8. Java 多线程同步和异步详解

    java线程 同步与异步 线程池 1)多线程并发时,多个线程同时请求同一个资源,必然导致此资源的数据不安全,A线程修改了B线 程的处理的数据,而B线程又修改了A线程处理的数理.显然这是由于全局资源造成 ...

  9. python3.5+ asyncio await异步详解

    import asyncio,time from collections import defaultdict from pprint import pprint collect=defaultdic ...

随机推荐

  1. java 面向对象(三十四):泛型三 自定义泛型类、泛型接口、泛型方法

    1.举例: [Order.java] public class Order<T> { String orderName; int orderId; //类的内部结构就可以使用类的泛型 T ...

  2. 02 flask源码剖析之flask快速使用

    02 flask快速使用 目录 02 flask快速使用 1.flask与django的区别 2. 安装 3. 依赖wsgi Werkzeug 4. 快速使用flask 5. 用户登录&用户管 ...

  3. Spirngboot-自定义Starter

    一.Spring Boot Starter简介 Starter是Spring Boot中的一个非常重要的概念,Starter相当于模块,它能将模块所需的依赖整合起来并对模块内的Bean根据环境( 条件 ...

  4. 工作3年java面试题整理(自用)

    基础题目 1.Java线程的状态  一. 线程状态类型:1. 新建状态(New):新创建了一个线程对象.2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法.该 ...

  5. Ethical Hacking - GAINING ACCESS(5)

    Server Side Attack Install Metasploit cummunity/pro and active it. Create a new project for the targ ...

  6. /dev/mapper/VolGroup00-LogVol00 满了,根目录存储垃圾文件导致磁盘满了

    登录系统,df -H 发现磁盘存储快满了 解决办法 1. 使用命令查出根目录下大的垃圾文件 使用 du -sh /* | sort -nr 查看根目录下的的大文件,找的不要的 rm -rf 使用 fi ...

  7. xenomai内核解析之信号signal(一)---Linux信号机制

    版权声明:本文为本文为博主原创文章,转载请注明出处.如有错误,欢迎指正.博客地址:https://www.cnblogs.com/wsg1100/ 目录 1. Linux信号 1.1注册信号处理函数 ...

  8. CF940E Cashback 线段树优化DP

    题目描述 Since you are the best Wraith King, Nizhniy Magazin «Mir» at the centre of Vinnytsia is offerin ...

  9. Linux好学吗?怎么自学Linux?6个值得你去学习Linux的理由!

    两个多月前,我对日常工作做出了一个非常重要的决定-我决定从Windows切换到Linux,将其作为个人和办公笔记本电脑的主要系统.除了开始时一些小的驱动程序问题之外,切换非常平稳,我不会考虑返回.如果 ...

  10. StringBuilder和 String的区别?

    String在进行运算时(如赋值.拼接等)会产生一个新的实例,而 StringBuilder则不会.所以在大 量字符串拼接或频繁对某一字符串进行操作时最好使用 StringBuilder,不要使用 S ...