C#笔记——异步编程
本文最后更新于:2024年7月19日 下午
异步编程
最重要的话说在前头
之前的内容写得太烂了所以我决定重新整理这一部分,还是先不要读这篇文章了。
本篇文章主要介绍:基于任务的异步模式(TAP)
你也许会觉得异步等于多线程,毕竟它们表面上看上去都是一样的,那就是使某些操作不阻塞主线程。但是异步不等于多线程,多线程只是异步的一种实现方法!多线程也不等于异步!分清异步和多线程谢谢!
线程和进程
首先来了解一下线程和进程的概念与区别。
启动程序时,系统会在内存中创建一个新的进程。进程是构成运行程序的资源的集合。这些资源包括虚地址空间、文件句柄和程序运行所需的其他许多东西。
在进程内部,系统创建了一个称为线程的内核(kernel)对象,它代表了真正执行的程序。一旦进程建立,系统会在Main
方法的第一行语句处开始线程的执行。
以下是关于线程的一些知识点:
- 默认情况下,一个进程只包含一个线程,从程序的开始一直执行到结束。
- 线程可以派生其他线程,因此在任意时刻,一个进程都可能包含不同状态的多个线程,它们执行程序的不同部分。
- 如果一个进程拥有多个线程,它们将共享进程的资源。
- 系统为处理器执行所调度的单元是线程,不是进程。
什么事异步
以我的话来说,就是“改变代码执行的顺序”。比如我们现在要煎蛋、煎培根,然后倒一杯果汁,如果是同步执行,那就是“先煎蛋,煎完蛋后,然后才开始煎培根,,煎完培根后,最后才开始倒果汁”;
如果改为异步执行,那么就会“在煎蛋的时候,我们把锅丢在那不管先,然后跑去拿另外一个锅煎起了培根,但是我们仍然把锅丢在那里先不管,开始直接跑去倒果汁”,这样事情的完成顺序就可能变成“倒果汁、煎蛋/培根、煎培根/蛋”了。
当然还分为单纯的异步操作和异步多线程操作(通常都是后者居多)。后面会提到。
异步方法中的代码并不会自动在新线程中执行。
这里再借用一个杨中科老师的《ASP.NET Core技术内幕与项目实战》书中的例子(非原文内容,精简过):
餐馆点餐时,服务员固定在一桌客人旁边等待顾客点餐,点完后才继续接待下一桌客人,如果客人太多就会出现服务员忙不过来的情况,这就是“同步点餐”。
如果是服务员把菜单和点菜单给客人留下后,就去招待别的客人,待这桌客人自己写完点菜单后,服务员再过来收点菜单,这种模式下,服务员可以同时服务更多客人,这就是“异步点餐”。
应用场景
一个例子是,有时候我们的程序会向其他服务器发起请求获取数据,或是从数据库获取数据。但是一般情况下这里都会耗费大量的时间来等待其他服务器的响应,而我们不希望这种情况发生,我们更希望在等待的同时可以继续执行后面的代码,并在其他服务器回复后再返回。
另一个例子是,我们在设计交互式GUI程序时,有一些操作可能需要耗费大量时间,比如按下一个按钮后计算机执行大量计算,此时如果不使用异步操作的话,将冻结我们的GUI界面,使得用户在计算机执行完这个任务前无法继续与GUI界面进行交互。
这里也正好涉及到两个概念:I/O 绑定和 CPU 绑定。在这里,前者是 I/O 绑定,后者是 CPU 绑定。
那么在这里我自己理解一下,I/O绑定不需要开多线程,CPU绑定则需要使用异步多线程
但是有一个疑问,如果是单线程的话,异步的意义在哪呢?我自己觉得是因为异步实际上是通过状态机实现的,所以即使是单线程也能够产生“程序在同时执行多个任务”的效果。
如何区别 CPU 绑定与 I/O绑定?
以下是编写代码前应考虑的两个问题:
- 你的代码是否会“等待”某些内容,例如数据库中的数据? 如果答案为“是”,则你的工作是 I/O 绑定。
- 你的代码是否要执行开销巨大的计算? 如果答案为“是”,则你的工作是 CPU 绑定。
如果你的工作为 I/O 绑定,请使用async
和await
(而不使用Task.Run
)。不应使用任务并行库。(单纯异步await
,即单线程异步)
如果你的工作属于 CPU 绑定,并且你重视响应能力,请使用async
和await
,但在另一个线程上使用Task.Run
生成工作。如果该工作同时适用于并发和并行,还应考虑使用任务并行库。(异步多线程,用Task.Run
就会开启一个后台线程)
简单说说 async/await
await
关键字的意思是:调用异步方法,等异步方法执行结束后再继续向下执行
async/await 特性的结构
该特性由三个部分组成:
- 调用方法(calling method):该方法调用异步方法,然后在异步方法执行其任务的时候继续执行(可能在相同的线程上,也可能在不同的线程上)。
- 异步方法(async method):该方法异步执行其工作,然后立即返回到调用方法。
- await 表达式:用于异步方法内部,指明需要异步执行的任务。一个异步方法可以包含任意多个
await
表达式,不过如果一个都不包含的话编译器会发出警告。
async/await 的一些知识
- 对于调用者来说,被调用方法是否修饰为
async
没有区别,修饰为async
只是为了在方法内使用await
关键字。 - 只要方法的返回值是
Task
类型,我们就可以用await
关键字对其进行调用,而不用管被调用的方法是否用async
修饰。 - 关于“线程切换”:异步调用前的线程在异步等待期间会被放回线程池,等异步等待结束之后,一个新的空闲线程会从线程池中被获取,异步等待调用后续的代码会运行在这个新的空闲线程中。
async/await 背后的原理
编译器会把async
方法编译成一个类,并根据await
调用把方法切分为多个状态,对async
方法的调用就会被拆分为若干次对MoveNext
方法的调用(状态机)。
什么事异步方法
异步方法就是:在完成其工作之前即返回到调用方法,然后在调用方法继续执行的时候完成其工作。(应该说,异步方法可以实现在完成其工作之前立即返回到调用方法并继续执行下面的代码,而不是直接用“就是”二字划等号)
在语法上,异步方法具有如下特点:
- 方法头中包含
async
方法修饰符。 - 包含一个或多个
await
表达式,表示可以异步完成的任务。 - 必须具备以下几种返回类型之一:
- void
- Task
- Task
- ValueTask
- 任何具有公开可访问的
GetAwaiter
方法的类型。
- 异步方法的形参可以为任意类型、任意数量,但不能为
out
或ref
参数。 - 按照约定,异步方法的名称应该以
Async
为后缀 - 除了方法以外,
lambda
表达式和匿名方法也可以作为异步对象。
以下是一个异步方法的例子:
1 |
|
异步方法是如何提升性能的呢?
以下为《ASP.NET Core技术内幕与项目实战》中的内容:
当需要等待一个异步操作的时候,这个线程就会被放回线程池;当异步调用执行结束后,程序再从线程池取出一个线程来执行后续代码,因此服务器中的每个线程都不会空等某个操作,服务器处理并发请求的能力也就提升了。
异步方法的返回类型
上面提到异步方法的返回类型必须是:void
、Task
、Task<T>
、ValueTask<T>
其中之一,下面介绍一下这几种返回类型。
Task
如果调用方法不需要从异步方法中返回某个值,但需要检查异步方法的状态,那么异步方法可以返回一个Task
类型的对象。
在这种情况下,如果异步方法中包含任何return
语句,则它们不能返回任何东西。
Task
如果调用方法要从调用中获取一个T
类型的值,异步方法的返回类型就必须是Task<T>
。
调用方法将通过读取Task
的Result
属性来获取这个T
类型的值。
ValueTask
这是一个值类型对象,它与Task<T>
类似,但用于任务结果可能已经可用的情况。
因为它是一个值对象,所以它可以放在栈上,而无须像Task<T>
对象那样在堆上分配空间。
void
如果调用方法仅仅想执行异步方法,而不需要与它做任何进一步的交互时,异步方法可以返回void
类型。
任何返回Task<T>
类型的异步方法,其返回值必须为T
类型或可以隐式转换为T
的类型。
await
表达式
await
表达式制定了一个异步执行的任务。其语法为:await task
,由await
关键字和一个空闲对象(称为任务)组成。默认情况下,这个任务在当前线程上异步运行。
一个空闲对象即是一个awaitable
类型的实例。awaitable
类型是指包含GetAwaiter
方法的类型,该方法没有参数,返回一个awaitable
类型的对象。这也是为什么上文提到的异步方法返回的类型需要是那几种之一。
创建自己的Task
当然我们也可以自行编写方法,作为await
表达式的任务。最简单的方式是在你的方法中使用Task.Run
方法来创建一个Task
。
关于Task.Run
,有一点很重要,即它在不同的线程上运行你的方法。
下面以Task.Run
的其中一种签名Task Run(Func<TReturn> func)
作为例子,以三种不同的方式来编写任务:
1 |
|
可以通过Thread.CurrentThread.ManagedThreadId
来获取当前线程的线程ID,这样就知道方法是否运行在不同的线程上了。
上面代码的运行结果为随机输出三个不大于10的非负整型数,例如:1 4 2
。
下面来解释一下上面的三种Task.Run
写法:
- 第一个实例(变量
a
)使用GetNum
创建名为getNum
的Func<int>
委托。然后在下一行将该委托用于Task.Run
方法。 - 第二个实例(变量
b
)在Task.Run
方法的参数列表中创建Func<int>
委托。 - 第三个实例(变量
c
)直接使用与Func<int>
委托兼容的lambda
表达式,该lambda
表达式将隐式转换为该委托。
其实Task.Run
有八种重载,下表列出了这八种重载:
返回类型 | 签名 |
---|---|
Task |
Run(Action action) |
Task |
Run(Action action, CancellationToken token) |
Task<TResult> |
Run(Func<TResult> function) |
Task<TResult> |
Run(Func<TResult> function, CancellationToken token) |
Task |
Run(Func<Task> function) |
Task |
Run(Func<Task> function, CancellationToken token) |
Task<TResult> |
Run(Func<Task<TResult>> function) |
Task<TResult> |
Run(Func<Task<TResult>> function, CancellationToken token) |
下表是可作为Task.Run
方法第一个参数的委托类型:
委托类型 | 签名 | 含义 |
---|---|---|
Action |
void Action() |
不需要参数且无返回值的方法 |
Func<TResult> |
TResult Func() |
不需要参数,但返回TResult 类型对象的方法 |
Func<Task> |
Task Func() |
不需要参数,但返回简单Task 对象的方法 |
Func<Task<TResult>> |
Task<TResult> Func() |
不需要参数,但返回Task<T> 类型对象的方法 |
取消一个异步操作
一些 .NET 异步方法允许我们请求终止执行。这里涉及到两个不同的类:CancellationToken
和CancellationTokenSource
。
下面是一些需要注意的点:
CancellationToken
对象包含一个任务是否应被取消的信息- 拥有
CancellationToken
对象的任务需要定期检查其令牌(token
)状态。如果CancellationToken
对象的IsCancellationRequested
属性为true
,任务需停止其操作并返回。 CancellationToken
是不可逆的,并且只能使用一次。一旦IsCancellationRequested
属性被设置为true
,就不能再更改了。CancellationTokenSource
对象创建可分配给不同任务的CancellationToken
对象。任何持有CancellationTokenSource
的对象都可以调用其Cancel
方法,这会将CancellationToken
的IsCancellationRequested
属性设置为true
。
下面是一个例子:
1 |
|
上面代码的输出结果如下:
1 |
|
需要注意的是,该过程是协同的,即调用CancellationTokenSource
的Cancel
时,它本身并不会执行取消操作,而是会将CancellationToken
的IsCancellationRequested
属性设置为true
。包含CancellationToken
的代码负责检查该属性,并判断是否需要停止执行并返回。
异常处理和await
表达式
to do…
再调用方法中同步地等待任务
我们在等待单个Task
对象完成其任务时,可以使用Wait()
方法。但是如果我们需要等待一组Task
对象的话呢?Task
类提供了两个静态方法:
WaitAll
:等待所有任务结束WaitAny
:等待某一个任务结束
下面是一个例子:
1 |
|
如果既不调用WaitAll()
也不调用WaitAny()
的话,输出如下:
1 |
|
如果调用WaitAll()
方法,输出如下:
1 |
|
如果调用WaitAny()
方法,输出如下:
1 |
|
WaitAll
和WaitAny
分别还包含4个重载,除了完成任务之外,还允许以不同的方式继续执行,如设置超时时间或使用CancellationToken
来强制执行处理的后续部分。
在异步方法中异步地等待任务
下面是一个例子:
1 |
|
使用Task.Delay
方法的输出如下:
1 |
|
使用Thread.Sleep
方法的输出如下:
1 |
|
可以看出,Task.Delay
不会阻塞线程,线程可以继续处理其他工作。
异步方法的应用
await
/async
模式
MainWindow.xaml
中的代码如下:
1 |
|
MainWindow.xaml.cs
中的代码如下:
1 |
|
BackgroundWorker
模式(选读)
MainWindow.xaml
中的代码如下:
1 |
|
MainWindow.xaml.cs
中的代码如下:
1 |
|
并行循环
.NET 还提供了一个任务并行库Task Parallel Library
,用于并行编程,这里只简单介绍下其中的并行循环,两者都位于System.Threading.Tasks
命名空间中。
Parallel.For
Parallel.For
方法有 12 个重载,其中一个是:public static ParallelLoopResult.For(int fromInclusive, int toExclusive, Action body)
。
fromInclusive
参数是迭代系列的第一个整数。toExclusive
参数是比迭代系列的最后一个索引号大 1 的整数。body
是接受单个输入参数的委托。
下面是一个例子:它的一个输出如下(因为是并行处理所以每次输出都可能不一样):1
Parallel.For(0, 15, i => { Console.WriteLine($"The square of {i} is {i * i}"); });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15The square of 0 is 0
The square of 10 is 100
The square of 1 is 1
The square of 2 is 4
The square of 3 is 9
The square of 4 is 16
The square of 5 is 25
The square of 6 is 36
The square of 8 is 64
The square of 9 is 81
The square of 7 is 49
The square of 13 is 169
The square of 12 is 144
The square of 11 is 121
The square of 14 is 196
Parallel.ForEach
该方法也有很多重载,这里简单地以:static ParallelLoopResult ForEach<TSource>( IEnumerable<TSource> source, Action<TSource> body)
举例说明:
TSource
是集合中对象的类型。source
是TSource
对象的集合。body
是要应用到集合中每一个元素上的Lambda
表达式。
下面是一个例子:它的一个输出如下(因为是并行处理所以每次输出都可能不一样):1
2string[] squares = new string[] { "We", "hold", "these", "truths", "to", "be", "self-evident", "that", "all", "men", "are", "created", "equal" };
Parallel.ForEach(squares, i => Console.WriteLine($"\"{i}\" has {i.Length} letters"));```1
2
3
4
5
6
7
8
9
10
11
12
13"created" has 7 letters
"be" has 2 letters
"these" has 5 letters
"hold" has 4 letters
"We" has 2 letters
"truths" has 6 letters
"men" has 3 letters
"equal" has 5 letters
"are" has 3 letters
"all" has 3 letters
"that" has 4 letters
"to" has 2 letters
"self-evident" has 12 letters
定时器
简单介绍一下System.Threading
命名空间中的Timer
类,也就是定时器类,它提供了一种定期重复运行异步方法的方式。
在 .NET BCL 中还存在着好几个Timer
类,比如System.Windows.Forms.Timer
和System.Timers.Timer
,可以了解下。
Timer
类的其中一个常用构造函数是:Timer(TimerCallback callback, object state, uint dueTime, uint period)
。
其中第一个参数是回调的名字,第二个参数是传给回调的对象(可以通过这个对象来传递信息),第三个参数是等待多少毫秒后第一次调用,最后一个参数是每隔多少毫秒进行一次调用。
如果想要改变已创建的Timer
类对象的dueTime
和period
,可以使用Change
方法。
下面是一个演示:
1 |
|
下面是输出结果
1 |
|
异步编程模式(APM)(不建议看)
异步调用同步方法
使用IAsyncResult
设计模式的异步操作是通过名为BeginOperationName
和EndOperationName
的两个方法来实现的,这两个方法分别开始和结束异步操作 OperationName。IAsyncResult
对象存储有关异步操作的信息,其中几个重要成员有:
AsyncState
: 一个特定于应用程序的可选对象,其中包含有关异步操作的信息AsyncWaitHandle
: 一个WaitHandle
,可用来在异步操作完成之前阻止应用程序(或者说调用方)继续往下执行CompletedSynchronously
一个值,指示异步操作是否是在用于调用BeginOperationName
的线程上完成,而不是在单独的ThreadPool
线程上完成IsCompleted
: 一个值,指示异步操作是否已完成
阻止应用继续执行
在应用等待异步操作完成期间,可以通过下面两种方法来阻止应用的主线程继续往下执行:
- 使用
AsyncWaitHandle
:result.AsyncWaitHandle.WaitOne()
- 使用
EndOperationName
:Dns.EndGetHostEntry(result)
轮询异步操作的状态
使用IsCompleted
属性来确定操作是否已完成:
1 |
|
使用 AsyncCallback 委托结束异步操作
此方法可以在单独的线程中处理异步操作结果。
1 |
|
其中,第二个参数是异步操作结束后会调用的委托(一系列方法);第三个参数则是一个用户定义对象(Object
),其中包含操作的相关信息,当操作完成时,此对象会被传递给requestCallback
委托。
希望本文章能够帮到您~