async 和await 关键字在C#中被引入,使.NET平台上的异步编程更容易。这些关键字从根本上改变了大多数C#生态系统中的代码编写方式。异步编程已经成为主流,现代框架,如ASP.NET Core是完全异步的。
对C#生态系统有如此大的影响,异步编程被证明是相当有价值的。但首先是什么是异步编程?
本文将介绍异步编程,展示async 和await 关键字的用法,谈论死锁的陷阱,并在最后介绍一些使用这些关键字重构阻塞的C#代码的技巧。
让我们从术语开始。
并发 vs 并行 vs 异步
这三个术语之间有什么区别?它们都是多线程的应用,它们的定义是重叠的,而且它们经常被互换使用。这就是为什么利用多线程的实现的术语会让人困惑。
我们将通过这些术语之间的细微差别,从而为异步编程提出一个明确的定义。
让我们假设一个GUI应用程序作为例子。
同步执行:一个接一个地做事情
用户点击一个按钮,等待应用程序完成对点击事件的处理。由于一次只能发生一件事,用户界面停止响应,直到事件被完全处理。以同样的方式,当用户界面可供用户输入时,应用程序不能在后台做任何事情。
并发:在同一时间做多件事情
用户点击一个按钮,应用程序就会在后台触发一个单独的线程,以并发地执行满足用户要求的任务。负责处理UI事件的线程在启动后台线程后立即变得可用,以保持UI的响应性。
并行:同时做多份事情
用户指示应用程序处理一个文件夹中的所有文件。应用程序用处理逻辑触发了一些线程,并在这些线程中分配文件。
异步:不必等待一个任务完成后再开始另一个任务
应用程序以异步方式启动数据库查询。当查询正在进行时,它也异步地开始读取一个文件。当这两个任务都在进行时,它做了一些计算。
当所有这些任务都完成后,它使用所有这三个操作的结果来更新用户界面。
异步编程
基于上面的术语,我们可以简单地定义异步编程。
执行线程不应等待与I/O绑定或与CPU绑定的任务完成。
I/O绑定操作的例子可以是文件系统访问、数据库访问或HTTP请求。绑定CPU的操作的例子可以是调整图像的大小,转换文件,或加密/解密数据。
优点
使用异步编程有几个好处。
- 在异步活动期间,通过 "暂停 "执行并将线程释放回线程池,避免了线程池的饿死。
- 保持UI的响应速度
- 并发可能带来的性能提升
异步编程模式
.NET为执行异步操作提供了三种模式。
异步编程模式(APM):LEGACY
也被称为IAsyncResult 模式,它是通过使用两种方法来实现的:BeginOperationName 和EndOperationName 。
public class MyClass {
public IAsyncResult BeginRead(byte [] buffer, int offset, int count, AsyncCallback callback, object state) {...};
public int EndRead(IAsyncResult asyncResult);
}
来自微软的文档。
在调用
BeginOperationName,应用程序可以继续在调用线程上执行指令,而异步操作在不同的线程上进行。对于每一次对BeginOperationName的调用,应用程序还应该调用EndOperationName,以获得操作的结果。
基于事件的异步模式(EAP):LEGACY
这种模式是通过编写一个OperationNameAsync 方法和一个OperationNameCompleted 事件来实现的。
public class MyClass {
public void ReadAsync(byte [] buffer, int offset, int count) {...};
public event ReadCompletedEventHandler ReadCompleted;
}
异步操作将由async方法开始,该方法将触发Completed 事件,以便在异步操作完成后提供结果。一个使用EAP的类也可以包含一个OperationNameAsyncCancel 方法来取消一个正在进行的异步操作。
基于任务的异步模式(TAP):推荐使用
我们只有一个OperationNameAsync 方法,返回一个Task 或一个通用的Task<T> 对象。
public class MyClass {
public Task<int> ReadAsync(byte [] buffer, int offset, int count) {...};
}
Task 和 类在TAP中模拟异步操作。了解 和 类对理解TAP很重要,这对理解和使用 / 关键字很重要,所以我们来详细谈谈这两个类。Task<T> Task Task<T> async``await
任务和Task
Task 和Task<T> 类是.NET中异步编程的核心。它们促进了与它们所代表的异步操作的各种交互,例如。
- 添加延续任务
- 阻断当前线程,以等待任务的完成
- 发出取消信号(通过
CancellationTokens)
在启动一个异步操作并得到一个Task 或Task<T> 对象后,你可以继续使用当前执行线程来异步执行其他不需要任务结果的指令,或者根据需要与任务进行交互。
这里有一些使用任务的示例代码,可以直观地看到它的运行情况。
using System;
using System.Threading.Tasks;
public class Example {
public static void Main() {
Task<DataType> getDataTask = Task.Factory.StartNew(() => { return GetData(); } );
Task<ProcessedDataType> processDataTask = getDataTask.ContinueWith((data) => { return ProcessData(data);} );
Task saveDataTask = processDataTask.ContinueWith((pData) => { SaveData(pData)} );
Task<string> displayDataTask = processDataTask.ContinueWith((pData) => { return CreateDisplayString(pData); } );
Console.WriteLine(displayDataTask.Result);
saveDataTask.Wait();
}
}
让我们来浏览一下这段代码。
- 我们想获得一些数据。我们使用
Task.Factory.StartNew()来创建一个立即开始运行的任务。这个任务异步地运行GetData()方法,完成后,它将数据分配给它的.Result属性。我们把这个任务对象分配给getDataTask变量。 - 我们要处理
GetData()方法将提供的数据。调用.ContinueWith()方法,我们异步地创建另一个任务,并将其设置为getDataTask的延续。第二个任务将把第一个任务的.Result作为输入参数(data),并异步地调用ProcessData()方法。完成后,它将把处理后的数据分配给它的.Result属性。我们把这个任务分配给processDataTask这个变量。(需要注意的是,此刻我们不知道getDataTask是否已经完成,我们也不关心。我们只知道当它完成时我们想发生什么,我们为此写代码)。 - 我们想保存处理过的数据。我们用同样的方法创建第三个任务,当数据处理完成后,它将异步调用
SaveData(),并将其设置为processDataTask的延续。 - 我们还想显示处理后的数据。我们不需要在显示数据之前等待数据被保存,所以我们创建第四个任务,当数据处理完成时,它将异步地从处理过的数据中创建显示字符串,并把它也设置为
processDataTask。(现在我们有两个任务被分配为processDataTask的延续。一旦processDataTask完成,这些任务将同时启动)。 - 我们想把显示字符串打印到控制台。我们用
displayDataTask的.Result属性来调用Console.WriteLine()。.Result属性访问是一个阻塞操作;我们的执行线程将阻塞,直到displayDataTask完成。 - 我们要确保在离开
Main()方法和退出程序之前保存数据。不过在这一点上,我们并不知道saveDataTask的状态。我们调用.Wait()方法来阻塞我们的执行线程,直到saveDataTask完成。
几乎是好的
正如上面所展示的,TAP和Task/Task<T> 类在应用异步编程技术方面相当强大。但仍有改进的余地。
- 使用任务所需的模板代码是相当冗长的。
- 指派连续性并对哪个任务应该运行做出细微的决定,意味着很多细节应该由程序员来处理,这增加了复杂性并使代码容易出错。(多言多语,再加上复杂性的增加,意味着代码将难以理解,从而难以维护)。
- 尽管有这些能力,但没有办法在不阻塞执行线程的情况下等待一个任务的完成。
这些缺点会成为团队采用TAP的重大挑战。
这就是async 和await 关键字发挥作用的地方。
继续阅读《在C#中使用Async/Await的异步编程》(SitePoint)。