C#精要 - 同步异步、多线程篇
零.为什么我会理解错
在看完clr之前,我曾对异步同步探究看了十几篇文章,但很可惜,没有完全理解,只知道了一大堆概念:IRP、异步要借用多线程…
在看完之后,我理解并甚至尝试实现一个简单的异步的时候才明白,之前无法理解是因为我不懂:
异步编程 和 异步函数 是不同的东西。也就是说,异步函数async/await 只是异步编程的一种罢了,你大可以利用ContinueWith或ThreadPool等来实现异步。
很多博客混淆了这两个概念,我不知道他们是否真正理解了,但是这会导致我这种代码先行基础后补的菜b无法理解。
所以这篇文章的理解是片面的,等有空了我再整理一下。
一.同步与异步
项目中每天都在接触,但是对这俩概念比较模糊。看《CLR via C#》刚好提到了,就去网上找到几篇好文,理解写篇自己的笔记。首先得说明的是,这一节讨论的只是概念,都是单纯的、不考虑多线程处理的同步与异步区别。
IO 概念区分
同步(Synchronous)
异步( Asynchronous)
阻塞( Blocking )
非阻塞( Nonblocking)
那首先,要弄清楚同步异步、阻塞非阻塞之间的关系。
同步异步 指的是在客户端,
同步意味着 客户端提出了一个请求以后,在回应之前只能等待。
异步意味着 客户端提出一个请求以后,还可以继续提其他请求。阻塞非阻塞 指的是服务器端,
阻塞意味着 服务器接受一个请求后,在返回结果以前不能接受其他请求。
非阻塞意味着 服务器接受一个请求后,尽管没有返回结果,还是可以继续接受其他请求。
意图
- 同步与异步意图
这个层级的还是很好理解,它们的核心是消息通信机制。
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。也就是说,代码执行会等着它,卡在那不动,直到执行结束把返回值给我才继续。
- 异步则是相反,调用在发出之后,这个调用就直接返回了,没有返回结果,我记得如果还没返回就去拿的话好像是null。也就是说,代码执行不会等它,你管你运行,返回值我不需要所以我继续往下跑,你运行完执行你自己的回调函数就行。用途有点类似于子线程。
- 阻塞与非阻塞意图
区分是调用结果返回之前,是否将调用的线程挂起,暂时不理其他请求。
不同模型的理解
直接把别人博客的图整理下拿过来了,
- 说的是单线程普通情况下,是这么执行的。按顺序一个个,等A有返回值回来了再B,B有返回值回来了再C。
- 说的是多线程同步的情况,并行运行,不展开,图简单,但实际交互非常复杂,通道、共享内存啥的。这里提这个是为了体现异步的作用。
- 说的是单线程异步执行,看得到ABC这运行,理论上是没有什么执行顺序可言的(项目经验告诉我,实际上这句话不对,但是概念上这么理解就够了,毕竟不可靠),属于是把程序执行顺序直接丢了。看上去事件花销和1的同步一样,那为什么要引入这个概念呢?看4。
- 可以看到,其实3还是比同步的情况下省了很多时间,这个waiting可能是方法里人工delay,也可能是在等待磁盘坑长的I/O操作返回结果,所以如果异步,灰色的部分就省下来了。由此也可知,异步唯一存在的阻塞情况,是无方法可执行的时候(ABC都在灰色段等着I/O给结果)。
以上用于理解概念是够了,但是实际应用会多很多(比如异步有很多方法可以实现,每种方法可控性等不同)。
搞懂最基本的同步异步之后,开始结合c#的用法,看下怎么用。
二.Async and Await
一篇不错的文章,虽然是2012年(也就是Async/Await语法糖出来的时候)的还是英文,但是把两者分析的非常透彻。刚好作为实际应用的补充。
先来介绍一下这两个C#关键字
1 | public async Task DoSomethingAsync() |
async 只有2个作用。一是是允许await这个关键字的使用,二是改变了方法结果的处理方式。async在执行开始时,是和同步运行一样的。也就是说在遇到await之前,它是同步的。
await 是异步操作的启动器。它会持续监视一个异步操作 (asynchronous operation)的执行,
如果这个异步操作已经完成了,那他就会继续跑后续代码;
如果这个异步操作未完成,就开始真正的异步:调用者会将这个async任务暂时挂起,直到await监视的异步操作处理完后,才继续执行后续代码。注意了,这个处理完后的执行,是会根据遇到await之前捕捉下来的上下文(Context) 环境来继续运行。u1s1,这听着很像unity的协程好吧。
通过上面知道了,async方法体里遇到个await、且await后面跟了个需要时间去处理的方法(上面叫异步操作,举个例子比如I/O操作吧),这个时候这个async方法会被阻塞住,但是整个线程并不会被阻塞住,而是在做其他的异步方法了,直到其他方法也卡住。
Asynchronous Operation
上面提到的异步操作。从上可知,你只需要提供异步操作就可以实现一个异步。你可以直接用微软提供支持的Task或者Task<T>
,或者将各种方法转换成一个异步操作,或者是Task.Yield会返回不是Tasks的异步操作。
关于异步操作的一个要点是:异步操作,指的不是async关键词修饰的就是异步操作,而是他是一个可以异步操作的类型。换句话说,你可以await一个类型为Task的async method,这是因为方法返回Task,而不是因为它是async的。所以你也可以await一个返回Task的非async方法:
1 | public async Task NewStuffAsync() |
Return Types
Async方法可以返回Task<T>
、Task以及void,但是在大部分情况我们都会选择前两个而不是void,因为Task<T>
、Task是可以等待的,而void不行。
那什么时候用void呢?原文是这么说的:
You have to return void when you have async event handlers.
Returning Values
这个和上面不同,这个是返回值。Task和void一样都没有返回值,但是Task<T>
有T类型的返回值。
Context
上下文。上面提到过,当await的异步操作结束后,将会根据遇到await之前捕捉下来的上下文环境继续执行代码。上下文是什么?简单来说:
- 如果你在一个UI线程上,那么就是个UI Context
- 如果你在一个ASP.NET请求上,那么就是个ASP.NET request context
- 否则,通常会是一个线程池环境(a thread pool context)。
好嘛,听君一席话。那么复杂点说呢? - 如果SynchronizationContext.Current不是null的,那么SynchronizationContext.Current就是它的上下文(UI、ASP.NET)
- 其他情况,就是当前的TaskScheduler
这两个名词暂时先不展开了。
下面看一个示例。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// WinForms 例子 (当然wpf也一样).
private async void DownloadFileButton_Click(object sender, EventArgs e)
{
// 当这个异步方法DownloadFileAsync在await的时候,UI线程并不会被阻塞。
await DownloadFileAsync(fileNameTextBox.Text);
// 直到await结束,才会在这里恢复UI Context,然后就可以连接到UI Elements了。
resultTextBox.Text = "File downloaded!";
}
// ASP.NET 例子
protected async void MyButton_Click(object sender, EventArgs e)
{
// 当我们进入await之后,ASP.NET线程并不会因此被阻塞
// 这使得这个线程仍然可以接受其他的request
await DownloadFileAsync(...);
// 直到await结束,才会在这里恢复ASP.NET Context,然后就可以连接到当前请求了
// 也许结束那一瞬间,我们在其他的线程上,但是也能拥有同样的ASP.NET Context
Response.Write("File downloaded!");
}
Avoiding Context
上下文有个大概的概念了,关键词还是await。那么,有的时候不需要去抓取整个main的上下文,比如下面的例子,一个Task中并不需要UI的上下文。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 private async Task DownloadFileAsync(string fileName)
{
// 这一个调用http api的下载任务
var fileContents = await DownloadFileContentsAsync(fileName).ConfigureAwait(false);
// 因为上面设置了ConfigureAwait(false),我们不在原来的上下文中
// 那我们在什么上下文中?在线程池
// 将文件数据写入磁盘
await WriteToDiskAsync(fileName,fileContents).ConfigureAwait(false);
}
// WinForms、Wpf例
private async void DownloadFileButton_Click(object sender, EventArgs e)
{
// 当我们进入await后,UI线程并没有被这个下载任务所阻塞
await DownloadFileAsync(fileNameTextBox.Text);
// 直到await结束,才会在这里恢复UI Context,然后就可以连接到UI Elements了。
resultTextBox.Text = "File downloaded!";
}
上述例子还需要注意的是,每个层级的async方法都有自己的上下文。DownloadFileButton_Click
方法中是由UI上下文启动的,随后进入DownloadFileAsync
也是由UI上下文启动的,但是随着ConfigureAwait(false)
的设置,又会跳出UI上下文,转到线程池上下文中继续运行。最后,当DownloadFileAsync
方法执行结束回到DownloadFileButton_Click
方法后,又会回到UI上下文继续。
所以有一个优化方法就是,设置不需要UI上下文的异步方法ConfigureAwait(false)
。
Async Composition
1 | public async Task DoOperationsConcurrentlyAsync() |
三.非常重要,暂停一下
从上可知,异步和同步大概是什么了。根据上两篇去理解,异步就是为了去解决I/O阻塞画面线程问题而推出的技术,而在C#里就是async await Task
这三个关键词组合去实现的。执行起来是你做A遇到了子任务B,结果子任务B里有I/O卡壳了,你就立刻回头去做A剩下的直到B结束了会做B这样,看上去就是主线程自己的事。那么实际上呢?直接实践一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32 class Program
{
static void Main(string[] args)
{
Console.OutputEncoding = Encoding.GetEncoding(936);
Console.WriteLine($"头部已执行,当前主线程Id为:{Thread.CurrentThread.ManagedThreadId}");
CallerWithAsync("Jack");
Console.WriteLine($"尾部已执行,当前主线程Id为:{Thread.CurrentThread.ManagedThreadId}");
Console.ReadKey();
Console.Read();
}
async static void CallerWithAsync(string name)
{
Console.WriteLine($"异步调用头部执行,当前线程Id为:{Thread.CurrentThread.ManagedThreadId}");
string result = await SayHiAsync(name);
Console.WriteLine(result);
}
static async Task<string> SayHiAsync(string name)
{
Console.WriteLine($"测试断点1,此刻线程为: {Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(5000);
Console.WriteLine($"测试断点2,此刻线程为: {Thread.CurrentThread.ManagedThreadId}");
return $"Hello,{name}";
}
}
运行结果如下:
可以看到“测试断点2,此刻线程为: 4”,并不是主线程1,所以说C#的异步实践并不是和第一篇里说的一样,并不是在一个线程上解决的!!!当然还有很多其他方法,这个就不拓展了,只讨论最常用最简洁的实践方案。
原因是什么呢?最直接地说,是因为第一篇的文章是基于单线程的最简单情况去理解同步与异步的,而C#实现不是,毕竟考虑到性能后的实现是很复杂的。而且,你要把一个任务挂起等执行完毕,那总得有线程去处理它对吧。
为了明白这是什么意思,需要理解一下多线程与异步之间的关系。
异步同步,其实和多线程还是单线程并不是一个维度的概念。我的理解是异步是一种程序运行的优化机制、是运行过程最终目的,而多线程还是单线程是一种可供你选择的条件,你可以单线程异步也可以多线程异步。
传统异步(第一篇里诉说的):遇到await时,将目前线程挂起,去做其他的事,不停调度。
实际上基于多线程的异步编程(C#异步实现方法之一):遇到Task.Run()
的多开线程指令或者真正需要异步挂起某任务时,会从线程池取一个新线程(如果不够用就开新的),然后把这个线程拿来处理挂起的任务,主线程则是返回到方法体外去执行剩下的代码。注意了,新开线程(也就是真正开始异步)的情况,并不是遇到await那一刻,就像图中的log那样,直到断点1都还是同步的,但是Task.Delay就会开始真正的异步。
总结:C#中的异步可以简单的用async 和 await 配合来实现,使用异步的函数,在没有调用await前,还是按顺序单线程执行的,当运行到await的时候,系统才会异步调用其他的方法来运行,如果没有await, 函数就是同步按顺序的运行。所以,await才是异步中的关键部分,在await 范围内的代码,是多线程方式运行的(当然没有Task就不会取线程),可以将需要异步处理的代码放在await中运行,或者简单的用一个Task.Delay来延时,以达到异步切换代码运行的效果。await 后面接的是一个Task, 每一个Task在运行时,由系统的Task池来分配,以实现异步的功能。
这里再来说说用aysnc和直接用thread的区别,其实简单来讲,就是效率的问题,async用的线程池,在await中运行的代码是由线程池分配的线程,根据系统的任务,自动分配和释放,而用 new thread的方法,通常是需要手动控制的。很显然,在处理一些短时间,且对运行的时间性和稳定性不是特别严格的问题时,用async会很有优势,但是对于一些在后台需要长时间稳定运行的程序,用thread会更好,可以保证它在运行的过程中,不过有别的代码来插队。
四.I/O操作时,没有线程在执行
There is no thread 和 微软Docs:异步编程 ,两篇文章大意是,当遇到await进行I/O操作时,因为现在的磁盘很牛逼,支持Direct Memory Access (DMA)操作,拥有DMA功能的硬件在和内存进行数据交换的时候可以不消耗CPU资源。所以程序遇到I/O异步时,只是从线程池里拿了个线程,进行一次CPU操作命令之后这个线程就没事了,他不负责执行I/O、也不实时监视I/O的运行情况、更不会被阻塞。它理论来说直接送回线程池了,然后可以去处理其他操作,此时硬件自己和内存交换数据。I/O完成之后,硬件会触发一个中断来通知操作完成。
以下来自微软官方文档:
调用系统 API 后,请求位于内核空间,一路来到操作系统的网络子系统(例如 Linux 内核中的
/net
)。 此处操作系统将对网络请求进行异步 处理。 所用操作系统不同,细节可能有所不同(可能会将设备驱动程序调用安排为发送回运行时的信号,或者会执行设备驱动程序调用然后 有一个信号发送回来),但最终都会通知运行时网络请求正在进行中。 此时,设备驱动程序工作处于已计划、正在进行或是已完成(请求已“通过网络”发出),但由于这些均为异步进行,设备驱动程序可立即着手处理其他事项!例如,在 Windows 中操作系统线程调用网络设备驱动程序并要求它通过表示操作的中断请求数据包 (IRP) 执行网络操作。 设备驱动程序接收 IRP,调用网络,将 IRP 标记为“待定”,并返回到操作系统。 由于现在操作系统线程了解到 IRP 为“待定”,因此无需再为此作业进行进一步操作,将其“返回”,这样它就可用于完成其他工作。
请求完成且数据通过设备驱动程序返回后,会经由中断通知 CPU 新接收到的数据。 处理中断的方式因操作系统不同而有所不同,但最终都会通过操作系统将数据传递到系统互操作调用(例如,Linux 中的中断处理程序将安排 IRQ 的下半部分通过操作系统异步向上传递数据)。 这也是异步发生的! 在下一个可用线程能执行异步方法且“解包”已完成任务的结果前,结果会排入队列。
五.思考
看了十几篇文章之后,对异步稍微是理解进了一小步。在此留下我的最简短理解:
异步,就是为了让单个线程不会因为某个长时间I/O操作而卡死自己,从而达到压榨线程剩余价值的目的。(特别是UI主线程对于客户端来说的剩余价值特别大)
关于应用上,整理了一下工作会用到的:
适用范围
当需要执行I/O操作时,使用异步操作比使用线程+同步 I/O操作更合适。I/O操作不仅包括了直接的文件、网络的读写,还包括数据库操作、Web Service、HttpRequest以及.net Remoting等跨进程的调用。
而线程的适用范围则是那种需要长时间CPU运算的场合,例如耗时较长的图形处理和算法执行。工作中用到过的:
大数据量Collection.AsParallel.ForEach(Task.Run(你的任务))
对照表
使用以下方式… | 而不是… | 若要执行此操作… |
---|---|---|
await | Task.Wait 或 Task.Result | 检索后台任务的结果 |
await Task.WhenAny | Task.WaitAny | 等待任何任务完成 |
await Task.WhenAll | Task.WaitAll | 等待所有任务完成 |
await Task.Delay | Thread.Sleep | 等待一段时间 |
补充:
再深入的话,可能要自己尝试实现一下线程池+异步I/O。以后再补吧。
ref:
https://blog.csdn.net/qq_36936155/article/details/78991050
https://blog.stephencleary.com/2012/02/async-and-await.html
https://blog.stephencleary.com/2013/11/there-is-no-thread.html
https://docs.microsoft.com/zh-cn/dotnet/csharp/async
https://docs.microsoft.com/zh-cn/dotnet/standard/async-in-depth