异步编程
异步编程是一种重要的并发编程模型,它对并发任务的控制比多线程模型灵活得多,而且上手更简单,相比困难的多线程并发编程,异步编程模型下新手也很容易写出健壮性、性能都不错的代码,当然异步模型在底层可能是操作系统直接支持,也可能是轮询和线程池模拟的,但这并不重要,我们通常不必关心其底层实现。
不过这里首先要澄清一个常见误区:异步不等于多线程。异步的核心目标是在等待耗时操作(如网络请求、文件读写)期间不阻塞当前线程,让线程可以去处理其它任务。对于IO密集型操作,异步模型能够显著提升吞吐量;而CPU密集型任务应当交给线程池去并行计算。如果熟悉JavaScript语言,就会明白在需要进行异步网络请求的GUI程序中,如果直接使用多线程模型来实现是多么麻烦,而异步编程配合事件循环则非常自然。C#语言引入了Task对象来实现异步编程,并在C# 5.0引入了async/await语法,使得我们能够用接近同步代码的写法来编写异步逻辑。
另外一点值得注意的是,异步编程如果只使用回调方式,很容易陷入回调地狱(callback hell),async/await正是解决这个问题的利器。
Task对象
C#中的Task对象封装了异步调用,我们可以使用Task对象配合Lambda表达式创建一个异步任务,并发创建异步任务,任务就会并发执行,使用非常简单。这里我们简单介绍Task对象的使用,详细内容可以参考并发编程相关章节。
创建异步任务
下面例子中,我们创建了1个异步任务,并调用执行。
// 打印主线程ID
Console.WriteLine("Main thread name: {0}", Thread.CurrentThread.ManagedThreadId);
// 创建异步任务
Task task = new Task(() =>
{
// 打印异步任务线程ID
Console.WriteLine("Task thread name: {0}", Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("任务执行完毕");
});
// 启动异步任务
task.Start();
// 主线程等待异步任务执行完成
task.Wait();
代码中,我们使用new关键字创建了一个Task对象,其构造函数接收一个委托类型参数,我们这里直接传入一个Lambda表达式,其中包含我们异步任务的内容。创建完成后,我们调用task.Start()方法启动异步任务。最终,我们使用task.Wait()方法,在主线程中阻塞等待该任务执行完成。
此外,创建和执行task对象,其实可以合并为一步Task.Run()方法,它接收一个委托参数,能够直接创建并启动任务,实际开发中这种写法更加常用。
Task task = Task.Run(() =>
{
Console.WriteLine("任务执行完毕");
});
带返回值的任务
如果异步任务需要返回结果,可以使用泛型的Task<T>,其中T是返回值的类型。任务的返回值通过Result属性获取,但要注意访问Result会阻塞当前线程直到任务完成,在异步代码中应优先使用await(见后文)。
Task<int> task = Task.Run(() =>
{
// 模拟计算
return 1 + 1;
});
// 阻塞等待任务完成并获取结果
int result = task.Result; // 2
并发执行与等待
前面例子中我们使用Task.Wait()或Task.WaitAll()来等待任务完成,但它们是阻塞式的,会占用当前线程。在异步方法中,更推荐使用await Task.WhenAll()和await Task.WhenAny(),它们是非阻塞的。
Task.WhenAll(tasks):等待所有任务完成。Task.WhenAny(tasks):等待任意一个任务完成,返回先完成的任务。
下面例子我们并发调用了3个任务。
namespace Gacfox.Demo.DemoNetCore;
class Program
{
static async Task Main()
{
// 三个任务被创建后立即开始执行
Task t1 = DownloadAsync("ftp://aaa.com");
Task t2 = DownloadAsync("ftp://bbb.com");
Task t3 = DownloadAsync("ftp://ccc.com");
// 非阻塞地等待三个任务全部完成
await Task.WhenAll(t1, t2, t3);
Console.WriteLine("全部下载完成");
}
static async Task DownloadAsync(string url)
{
// Task.Delay用于模拟异步耗时操作,它不会阻塞线程
await Task.Delay(3000);
Console.WriteLine("{0} 数据下载完成", url);
}
}
注意这里用Task.Delay()而不是Thread.Sleep()来模拟耗时操作:Thread.Sleep()会阻塞当前线程,Task.Delay()则是真正的异步等待,等待过程中线程会被释放,这也是异步编程的价值所在。
任务中的异常处理
如果异步任务内部抛出异常,异常会被封装进Task对象中:
- 对任务
await时,异常会原样抛出,可以直接用try...catch捕获。 - 对任务使用
Wait()或访问Result时,抛出的是一个AggregateException,内部包含实际异常,需要通过其InnerException取出。 - 如果任务异常一直没有被观察(既没
await也没Wait),可能被静默丢弃,导致问题难以排查。
因此在异步方法中,推荐始终使用await来执行任务,这样异常处理方式和同步代码保持一致。
try
{
await DownloadAsync("ftp://aaa.com");
}
catch (Exception ex)
{
Console.WriteLine("下载失败:{0}", ex.Message);
}
async/await
async和await是C# 5.0引入的一对关键字,它们必须结合Task使用,是编写异步代码的标准方式。
async关键字修饰的方法被称为异步方法,它通常返回Task或Task<T>(后者用于带返回值的异步方法),表示方法将以任务形式在未来继续执行。await关键字用于等待一个异步任务完成,等待期间会释放当前线程,任务完成后继续执行后续代码。
下面是一个最简单的异步方法。
public async Task DownloadDataAsync(string url)
{
await Task.Delay(3000);
Console.WriteLine("{0} 数据下载完成", url);
}
串行和并发
多个await按顺序书写时,实际表达的是任务的串行执行关系:上一个任务完成后才继续执行下一个。
考虑这样一种情况,我们有3个异步任务,C任务需要依赖B任务的返回值,B任务又需要依赖A任务的返回值,那么最终的调用顺序应该是串行A->B->C。
public async Task DownloadAllData()
{
await DownloadDataAsync("ftp://aaa.com");
await DownloadDataAsync("ftp://bbb.com");
await DownloadDataAsync("ftp://ccc.com");
}
但如果这几个任务之间没有依赖关系,串行执行会让总耗时累加,此时应当改用Task.WhenAll()让它们并发执行。
public async Task DownloadAllData()
{
Task t1 = DownloadDataAsync("ftp://aaa.com");
Task t2 = DownloadDataAsync("ftp://bbb.com");
Task t3 = DownloadDataAsync("ftp://ccc.com");
await Task.WhenAll(t1, t2, t3);
}
显而易见,async/await能够使用类似同步代码的方式编写异步代码,既能使用异步编程模型,又能保持同步代码模式的简洁性,这样代码可读性比起嵌套任务就大大提高了。
异步方法的返回类型
异步方法的返回类型主要有以下几种:
| 返回类型 | 说明 |
|---|---|
Task |
无返回值的异步方法 |
Task<T> |
有返回值T的异步方法 |
void |
仅用于事件处理器,其它场景应避免使用 |
ValueTask / ValueTask<T> |
轻量级任务,用于可能同步完成的高频调用场景,避免Task的分配开销 |
此外,异步方法一般以Async作为方法名后缀(如DownloadDataAsync),这是.NET的命名约定,便于调用者一眼看出该方法是异步的。
async void的危害
async void方法无法被调用方await,因此其内部抛出的异常不会被调用方捕获,而是直接抛到同步上下文,这可能导致程序崩溃且难以排查。因此async void只应用于事件处理器(因为事件处理器签名固定为void),其它场景一律应使用async Task。
不要阻塞异步代码
一个非常常见的错误是在异步代码中使用.Wait()或.Result来同步等待,例如下面这个例子。
// 错误写法:可能造成死锁
service.DownloadAllData().Wait();
在带有同步上下文的环境(如WinForms、WPF等GUI程序)中,await默认会尝试切回原线程继续执行,而.Wait()又恰好阻塞了原线程,二者互相等待就会造成死锁。正确的做法是“async all the way”,即一路使用await,包括程序入口。
static async Task Main()
{
await service.DownloadAllData();
}
ConfigureAwait
如前所述,await默认会捕获当前的同步上下文,并在等待完成后切回该上下文继续执行。对于类库代码,如果并不依赖特定线程上下文,可以在await后加上ConfigureAwait(false),告诉运行时无需切回原上下文,从而减少线程切换开销并避免潜在死锁。
public async Task DownloadDataAsync(string url)
{
await Task.Delay(3000).ConfigureAwait(false);
// ...
}
而在编写应用程序(尤其是GUI程序)时,通常需要保持默认行为,以便await之后仍能安全地操作UI控件。
取消异步任务
耗时操作往往需要支持取消,C#通过CancellationToken(取消令牌)来实现。支持取消的方法一般会接收一个CancellationToken参数,并在内部将其传递给各个可取消的操作。
public async Task DownloadDataAsync(string url, CancellationToken token)
{
// Task.Delay支持传入取消令牌
await Task.Delay(3000, token);
Console.WriteLine("{0} 数据下载完成", url);
}
调用方通过CancellationTokenSource创建并控制令牌。
var cts = new CancellationTokenSource();
// 1秒后自动取消
cts.CancelAfter(1000);
try
{
await DownloadDataAsync("ftp://aaa.com", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("下载已取消");
}
任务被取消时,通常会抛出OperationCanceledException,我们可以在调用处捕获并做相应处理。