现代应用程序广泛使用文件和网络 I/O。I/O 相关 API 传统上默认是阻塞的,导致用户体验和硬件利用率不佳,此类问题的学习和编码的难度也较大。而今基于 Task 的异步 API 和语言级异步编程模式颠覆了传统模式,使得异步编程非常简单,几乎没有新的概念需要学习。
异步代码有如下特点:
- 在等待 I/O 请求返回的过程中,通过让出线程来处理更多的服务器请求。
- 通过在等待 I/O 请求时让出线程进行 UI 交互,并将长期运行的工作过渡到其他 CPU,使用户界面的响应性更强。
- 许多较新的 .NET API 都是异步的。
- 在 .NET 中编写异步代码很容易。
使用 .NET 基于 Task 的异步模型可以直接编写 I/O 和 CPU 受限的异步代码。该模型围绕着Task
和Task<T>
类型以及 C# 的async
和await
关键字展开。本文将讲解如何使用 .NET 异步编程及一些相关基础知识。
Task 和 Task<T>
Task 是 Promise 模型的实现。简单说,它给出“承诺”:会在稍后完成工作。而 .NET 的 Task 是为了简化使用“承诺”而设计的 API。
Task 表示不返回值的操作, Task<T> 表示返回T
类型的值的操作。
重要的是要把 Task 理解为发起异步工作的抽象,而不是对线程的抽象。默认情况下,Task 在当前线程上执行,并酌情将工作委托给操作系统。可以选择通过Task.Run
API 明确要求任务在单独的线程上运行。
Task 提供了一个 API 协议,用于监视、等待和访问任务的结果值。比如,通过await
关键字等待任务执行完成,为使用 Task 提供了更高层次的抽象。
使用 await 允许你在任务运行期间执行其它有用的工作,将控制权交给其调用者,直到任务完成。你不再需要依赖回调或事件来在任务完成后继续执行后续工作。
I/O 受限异步操作
下面示例代码演示了一个典型的异步 I/O 调用操作:
public Task<string> GetHtmlAsync()
{
// 此处是同步执行
var client = new HttpClient();
return client.GetStringAsync("https://www.dotnetfoundation.org");
}
这个例子调用了一个异步方法,并返回了一个活动的 Task,它很可能还没有完成。
下面第二个代码示例增加了async
和await
关键字对任务进行操作:
public async Task<string> GetFirstCharactersCountAsync(string url, int count)
{
// 此处是同步执行
var client = new HttpClient();
// 此处 await 挂起代码的执行,把控制权交出去(线程可以去做别的事情)
var page = await client.GetStringAsync("https://www.dotnetfoundation.org");
// 任务完成后恢复了控制权,继续执行后续代码
// 此处回到了同步执行
if (count > page.Length)
{
return page;
}
else
{
return page.Substring(0, count);
}
}
使用 await 关键字告诉当前上下文赶紧生成快照并交出控制权,异步任务执行完成后会带着返回值去线程池排队等待可用线程,等到可用线程后,恢复上下文,线程继续执行后续代码。
GetStringAsync()
方法的内部通过底层 .NET 库调用资源(也许会调用其他异步方法),一直到 P/Invoke 互操作调用本地(Native)网络库。本地库随后可能会调用到一个系统 API(如 Linux 上 Socket 的write()
API)。Task 对象将通过层层传递,最终返回给初始调用者。
在整个过程中,关键的一点是,没有一个线程是专门用来处理任务的。虽然工作是在某种上下文中执行的(操作系统确实要把数据传递给设备驱动程序并中断响应),但没有线程专门用来等待请求的数据回返回。这使得系统可以处理更大的工作量,而不是干等着某个 I/O 调用完成。
虽然上面的工作看似很多,但与实际 I/O 工作所需的时间相比,简直微不足道。用一条不太精确的时间线来表示,大概是这样的:
0-1--------------------2-3
从0
到1
所花费的时间是await
交出控制权之前所花的时间。从1
到2
花费的时间是GetStringAsync
方法花费在 I/O 上的时间,没有 CPU 成本。最后,从2
到3
花费的时间是上下文重新获取控制权后继续执行的时间。
CPU 受限异步操作
CPU 受限的异步代码与 I/O 受限的异步代码有些不同。因为工作是在 CPU 上完成的,所以没有办法绕开专门的线程来进行计算。使用 async 和 await 只是为你提供了一种干净的方式来与后台线程进行交互。请注意,这并不能为共享数据提供加锁保护,如果你正在使用共享数据,仍然需要使用适当的同步策略。
下面是一个 CPU 受限的异步调用:
public async Task<int> CalculateResult(InputData data)
{
// 在线程池排队获取线程来处理任务
var expensiveResultTask = Task.Run(() => DoExpensiveCalculation(data));
// 此时此处,你可以并行地处理其它工作
var result = await expensiveResultTask;
return result;
}
CalculateResult
方法在它被调用的线程(一般可以定义为主线程)上执行。当它调用Task.Run
时,会在线程池上排队执行 CPU 受限操作 DoExpensiveCalculation
,并接收一个Task<int>
句柄。DoExpensiveCalculation
会在下一个可用的线程上并行运行,很可能是在另一个 CPU 核上。和 I/O 受限异步调用一样,一旦遇到await
,CalculateResult
的控制权就会被交给它的调用者,这样在DoExpensiveCalculation
返回结果的时候,结果就会被安排在主线程上排队运行。
对于开发者,CPU 受限和 I/O 受限的在调用方式上没什么区别。区别在于所调用资源性质的不同,不必关心底层对不同资源的调用的具体逻辑。编写代码需要考虑的是,对于 CPU 受限的异步任务,根据实际情况考虑是否需要使其和其它任务并行执行,以加快程序的整体运行时间。
异步编程模式
最后简单回顾一下 .NET 历史上提供的三种执行异步操作的模式。
基于任务的异步模式(Task-based Asynchronous Pattern,TAP),它使用单一的方法来表示异步操作的启动和完成。TAP 是在 .NET Framework 4 中引入的。它是 .NET 中异步编程的推荐方法。C# 中的 async 和 await 关键字为 TAP 添加了语言支持。
基于事件的异步模式(Event-based Asynchronous Pattern,EAP),这是基于事件的传统模式,用于提供异步行为。它需要一个具有
Async
后缀的方法和一个或多个事件。EAP 是在 .NET Framework 2.0 中引入的。它不再被推荐用于新的开发。异步编程模式(Asynchronous Programming Model,APM)模式,也称为 IAsyncResult 模式,这是使用 IAsyncResult 接口提供异步行为的传统模式。在这种模式中,需要
Begin
和End
方法同步操作(例如,BeginWrite
和EndWrite
来实现异步写操作)。这种模式也不再推荐用于新的开发。
下面简单举例对三种模式进行比较。
假设有一个 Read 方法,该方法从指定的偏移量开始将指定数量的数据读入提供的缓冲区:
public class MyClass
{
public int Read(byte [] buffer, int offset, int count);
}
若用 TAP 异步模式来改写,该方法将是简单的一个 ReadAsync 方法:
public class MyClass
{
public Task<int> ReadAsync(byte [] buffer, int offset, int count);
}
若使用 EAP 异步模式,需要额外多定义一些类型和成员:
public class MyClass
{
public void ReadAsync(byte [] buffer, int offset, int count);
public event ReadCompletedEventHandler ReadCompleted;
}
public delegate void ReadCompletedEventHandler(
object sender, ReadCompletedEventArgs e);
public class ReadCompletedEventArgs : AsyncCompletedEventArgs
{
public MyReturnType Result { get; }
}
若使用 AMP 异步模式,则需要定义两个方法,一个用于开始执行异步操作,一个用于接收异步操作结果:
public class MyClass
{
public IAsyncResult BeginRead(
byte [] buffer, int offset, int count,
AsyncCallback callback, object state);
public int EndRead(IAsyncResult asyncResult);
}
后两种异步模式已经过时不推荐使用了,这里也不再继续探讨。岁数大点的 .NET 程序员可能比较熟悉后两种异步模式,毕竟那时候没有 async/await,应该没少折腾。
参考:
https://docs.microsoft.com/en-us/dotnet/standard/async
https://docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/