前言
博客园(cnblogs.com)关于.NETasync/await的介绍有很多,但遗憾的是,正确的却寥寥无几,说大多数是“从现象中构造原理”也不过分。
最典型的例子包括通过前后线程ID推断其工作模式,使用Thread.Sleep讲解异步模式下的Task机制并介绍多线程模型的推断,以及在Task.Run中包含IO绑定任务推断这是一个多线程在执行任务等方面的推论。
虽然看起来可以解释,但遗憾的是,无论是原理上还是推论上都是错误的。
要理解.NET中的async/await机制,首先需要有操作系统原理的基础。 否则很难理解清楚。 如果没有这个基础就试图向别人解释,大部分只是基于现象的错误猜测。
乍一看是异步的
说起异步,大家应该都很熟悉了。 2012年,C#5引入了新的异步机制:Task,并且新增了两个关键字await和async。 这并不是什么新鲜事,现在这种异步机制早已从各大语言中借鉴而来,比如 JavaScript、TypeScript、Rust、C++ 等。
下面简单对比一下:
语言调度单元关键词/方法
C#
任务、价值任务
异步,等待
C++
标准::未来
共同等待
锈
std::未来::未来
。等待
JavaScript、打字稿
承诺
异步,等待
当然,这不是本文的重点。 只是提一下,如果您有其他语言的经验(如果有的话),您可以意识到 C# 中的 Task 和 async/await 是相同且具有可比性的。
多线程编程
在异步编程模型诞生之前,多线程编程模型已经为很多人所熟悉。 一般来说,开发者会使用Thread、std::thread等作为多线程开发的线程调度单元。 每个这样的结构代表一个对等线程,并且线程使用互斥或信号量。 以及其他同步方法。
多线程对于提高科学估算率有明显的效果,但是对于IO负载任务,例如读取文件或TCP流,大多数解决方案只是分配一个线程进行读取,并在读取过程中阻塞该线程:
无效主()
而(真)
var 客户端 = 套接字。 接受();
新线程(() => ClientThread(客户端)).Start();
void ClientThread(套接字客户端)
var buffer = 新字节[1024];
尽管 (...)
// 读取并阻塞
客户。 读取(缓冲区,0,1024);
上面的代码中,Main函数接收到客户端后,分配一个新的用户线程来处理客户端,接收客户端的数据。 client.Read()执行后,线程被阻塞。 即使线程在阻塞期间不执行任何操作,用户线程也不会被释放,会被操作系统不断调度,这可能会造成资源的浪费。
另外,如果线程数量增多,不同线程之间频繁切换上下文,那么线程的上下文就会很大,会浪费大量的性能。
异步编程
所以对于这个工作(IO),我们在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字节的数据并写入到我们之前的缓冲区地址中。 然后它切换到用户模式并从我们最初放弃控制的位置开始调度。 使其继续下去。
可以发现,这样在数据读取的过程中,没有任何线程被阻塞,也没有频繁的调度和上下文切换。 只有当IO操作完成后,才能重新调度,恢复原来的控制权。 上下文,允许其旁边的代码继续执行。
当然,我们这里谈论的是操作系统的异步IO实现方法,以便读者能够理解异步行为本身。 它和.NET中的异步还是有区别的,Task本身和操作系统无关。
任务(值任务)
说了这么久,还是没有解释什么是Task。 从前面的分析可以得出,虽然Task是所谓的调度单元,但每个异步任务都被封装为一个Task并在CLR中进行调度,而Task本身会运行在预先分配的线程池中CLR。
总有很多人将Task归结为多线程模型,因为Task使用了线程池来执行。 这是完全错误的。
这时候有人跳出来说:看下面的代码
静态异步任务 Main()
而(真)
Console.WriteLine(Environment.CurrentManagedThreadId);
等待任务。 延迟(1000);
输出线程ID不一样,你骗钱,这明显是多线程啊! 对于这些言论,我只能说,那些人的原则理解是错误的。
当代码执行到await时,当前的控制权已经放弃,当前线程并没有阻塞等待延迟结束; Task.Delay()完成后,CLR从线程池中发起线程池。 最初分配的现有且空闲的线程将恢复放弃控制之前的上下文信息,以便该线程可以从原始位置继续执行。 此时可能会选择之前转移的线程,导致前后线程ID一致; 也可能是选择了另一个与之前不同的线程来执行下面的代码,使得线程ID前后不一致。 在此过程中,不会分配新的线程。
由于.NET中采用的是stackless的方式typescript 多线程,所以这里需要进行CPS转换,大概是这个过程:
使用系统;
使用 System.Threading.Tasks;
公共C类
公共异步任务 M()
var a = 1;
等待任务.延迟(1000);
Console.WriteLine(a);
编译后:
公共C类
[StructLayout(LayoutKind.Auto)]
[编译器生成]
私有结构 d__0 :IAsyncStateMachine
公共 int 1__state;
公共 AsyncTaskMethodBuilder t__builder;
私有 int 5__2;
私有 TaskAwaiter u__1;
私有无效 MoveNext()
int num = 1__state;
尝试
TaskAwaiter等待者;
如果(数字!= 0)
5__2 = 1;
等待者 = Task.Delay(1000).GetAwaiter();
if (!awaiter.IsCompleted)
num = (1__state = 0);
u__1 = 等待者;
t__builder.AwaitUnsafeOnCompleted(ref waiter, ref this);
返回;
别的
等待者 = u__1;
u__1 = 默认(TaskAwaiter);
num = (1__state = -1);
等待者.GetResult();
Console.WriteLine(5__2);
catch(异常异常)
1__状态=-2;
t__builder.SetException(异常);
返回;
1__状态=-2;
t__builder.SetResult();
无效 IAsyncStateMachine.MoveNext()
//ILSpy 从 MoveNext 中的 .override 指令生成此显式接口实现
this.MoveNext();
[调试器隐藏]
私有无效 SetStateMachine(IAsyncStateMachine stateMachine)
t__builder.SetStateMachine(stateMachine);
无效 IAsyncStateMachine。 SetStateMachine(IAsyncStateMachine 状态机)
//ILSpy 从 SetStateMachine 中的 .override 指令生成此显式接口实现
this.SetStateMachine(stateMachine);
[AsyncStateMachine(typeof(d__0))]
公共任务 M()
d__0 状态机 = 默认(d__0);
stateMachine.t__builder = AsyncTaskMethodBuilder.Create();
stateMachine.1__state = -1;
stateMachine.t__builder.Start(ref stateMachine);
返回stateMachine.t__builder.Task;
可以看到,原来的变量a被塞进了5__2(相当于备份上下文)。 Task状态转换后,调用MoveNext(相当于状态转换后重新调度)继续驱动程序代码执行。 里面的num表示当前状态。 如果num为0,则说明该Task完成,那么接下来执行下面的代码Console.WriteLine(5__2);。
当然,在WPF等地方,因为使用SynchronizationContext来控制调度行为,所以从上面可以得到不同的推论。 与此相关的是.ConfigureAwait()的用法,但这不是本文的重点,所以我不会这样做。 扩张。
但这和经典的多线程编程一样吗? 不一样。
至于ValueTask是什么,官方发现由于Task本身是一个类,运行时频繁重复的分配和回收会给GC带来很大的压力,所以创建了ValueTask。 这个东西是一个struct,分配在栈上。 这样既不会给GC带来压力,又可以减少开支。 但由于ValueTask是一个值类型结构,会在栈上分配,所以提供的功能不如Task那么全面。
任务.运行
由于.NET允许多线程,所以它还提供了Task.Run方法,它允许我们将CPU密集型任务放在上述线程池中的某个线程上执行,并允许我们将负载作为Task进行管理,只有这一点与使用线程池的多线程编程类似。
对于浏览器环境(v8)来说,此时还没有多线程这样的东西,所以你新打开的Promise似乎是利用了storm Loop机制来异步执行微任务。
思考一下 JavaScript 中如何使用 Promise:
10
11
12
让 p = new Promise((解决, 拒绝) => {
// 做一点事
让成功= true;
让结果= 123456;
如果(成功){
解决(结果);
别的 {
拒绝(“失败”);
})
然后调用:
让 r = 等待 p;
控制台.log(r); //输出123456
您只需要用 CLR 的线程池替换这组背后的驱动程序:事件循环队列,这几乎就是 .NET 的 Task 相对于 JavaScript 的 Promise 的工作方式。
如果将CLR线程池线程数设置为1,则与JavaScript几乎相同(尽管实现上仍存在差异)。
这时候就会有人问:“我在Task.Run中有好几层Task.Run,但是为什么层数深了之后上面的层都不执行呢?” 这是因为里面提到的线程池已经耗尽了。 ,以下任务仍在排队等待调度。
自己封装异步逻辑
了解了前面的事情之后,相信你对于.NET中的异步机制应该已经有了一个近乎完整的了解了。 可见这套是名副其实的协程,而且在实现上是无栈的。 至于有些人谈论哪些状态机,它们只是实现过程中使用的手段,并不是重要的东西。
那么我们如何使用Task来编译我们自己的异步代码呢?
虽然事件驱动也可以看成是异步模型,例如下面的情况:
函数A调用函数B,发起调用后直接返回(BeginInvoke)。 函数B执行后,触发风暴执行函数C。
私有事件 Action CompletedEvent;
无效A()
已完成事件 += C;
Console.WriteLine("开始");
((Action)B).BeginInvoke();
无效 B()
Console.WriteLine("运行");
CompletedEvent?.Invoke();
无效 C()
Console.WriteLine("结束");
所以我们现在要做的一件事就是将里面的 Fengbo 驱动改造成使用 async/await 的异步编程模型。 转换后的代码很简单:
异步任务A()
Console.WriteLine("开始");
等待 B();
Console.WriteLine("结束");
任务B()
Console.WriteLine("运行");
返回任务.CompletedTask;
可以看到原来C函数的内容放在了A对B的调用下面,为什么呢? 其实很简单,因为await B();后面的内容这里的线可以理解为B功能的反弹。 但在内部实现上,并不是B直接反弹,而是A先放弃控制权。 B 执行完毕后,CLR 切换上下文并调度 A 回去继续执行剩余的代码。
如果与storm相关的代码已经确定不能更改(即B函数不能更改),而我们想将其封装成异步调用方式,则只需要使用TaskCompletionSource即可:
私有事件 Action CompletedEvent;
异步任务A()
// 因为TaskCompletionSource需要一个子类参数
// 所以我随机指定了一个bool
// 看来这个例子不需要这样的结果
// 需要注意的是,从.NET 5开始
// TaskCompletionSource 不再需要子类参数
var tsc = new TaskCompletionSource();
//将任意结果写入Task的结果
CompletedEvent += () => tsc.SetResult(false);
Console.WriteLine("开始");
((Action)B).BeginInvoke();
等待 tsc.Task;
Console.WriteLine("结束");
无效 B()
Console.WriteLine("运行");
CompletedEvent?.Invoke();
顺便说一下,这个TaskCompletionSource看起来更像JavaScript中的Promise。 SetResult()方法对应于resolve(),SetException()方法对应于reject()。 .NET 比 JavaScript 多了一种取消状态,因此也可以使用 SetCancelled() 来指示任务已被取消。
同步方法调用异步代码
说实话,一般如果你有这个需求,就说明你的代码有问题。 但是,如果您无论如何都想以阻塞方法等待异步任务完成:
任务 t = ...
t.GetAwaiter().GetResult();
祝你好运。 这相当于t中的异步任务开始执行后,阻塞当前线程,然后等待t执行完成后再将其唤醒。 可以说是没有意义的typescript 多线程,很可能是代码编译不当导致的。 发生死锁。
什么是无效异步?
最后,有人可能会问,函数可以写成 async Task Foo() 或 async void Bar() 吗? 有什么区别?
对于上面的代码,我们平时调用的时候是这样写的:
等待 Foo();
酒吧();
可以发现这个Bar函数不需要await。 为什么?
事实上,这与调用 Foo 是一样的:
_ = Foo();
换句话说,你调用它后立即忽略它,但随后你将不知道这个异步任务的状态和结果。
wait 必须与 Task/ValueTask 一起使用吗?
当然不是。
在C#中,只要你的类包含GetAwaiter()方法和bool IsCompleted属性,并且GetAwaiter()返回的东西包含GetResult()方法、bool IsCompleted属性并实现INotifyCompletion,那么该类的对象就可以await 。
公共类我的任务
公共 MyAwaiter GetAwaiter()
返回新的 MyAwaiter();
公共类 MyAwaiter :INotifyCompletion
公共 bool IsCompleted { 得到; 私人套装; }
公共 T GetResult()
抛出新的NotImplementedException();
public void OnCompleted(操作继续)
抛出新的NotImplementedException();
公开课节目
静态异步任务 Main(string[] args)
var obj = new MyTask();
等待对象;
结语
本文到此结束。 有兴趣的朋友可以进一步了解操作系统原理。 如果你对CLR感兴趣,还可以研究一下它的源码:。
.NET的异步和线程密不可分,但它们与多线程的编程方法和思想有本质的不同。 我希望您不要混淆异步和多线程,它们既相关又不同。
从现象推断本质,是三忌。 或许可以解释,但这只是巧合现象,从原理上看是完全错误的。 即使官方的实现代码稍有改动,也可能不会立即得到解释。
总之,通过这篇文章,希望大家能够对.NET中的异步和异步有更清晰的认识。
谢谢阅读。