揭秘 C# 中的 Async 和 Await
async在开发 .NET 项目时,你可能已经多次遇到过这两个关键字await。虽然使用它们本身就很有趣,但了解这类代码底层的工作原理也很有帮助。
在深入探讨细节之前,我们需要澄清一些关于 C# 中使用的任务和线程的常见误解。
🤔 任务 vs 线程
简而言之,任务(Task)并非线程(Thread)。如果您曾在其他语言(例如 JavaScript)中使用过Promise,那么您会很容易上手任务。任务本质上就是 Promise,它会在稍后的时间点完成,从而允许您处理异步操作返回的结果。这也意味着任务可以被触发故障,并且可以查询任务是否已完成。而线程则是操作系统级代码执行的更底层实现。
理解任务并非线程的抽象层非常重要,我们应该将任务视为对某些旨在异步执行的工作的抽象。总而言之:
- 任务不是线程
- Tasks 类似于 Promise,它提供了一个简洁的 API 来处理异步代码。
- 任务不保证并行执行
- 可以通过 Task.Run API 显式地请求任务在单独的线程上运行。
- 任务由任务调度器安排运行。
🔮 “await”关键字的魔力
正如Stephen Cleary在他精彩的博文中明确指出的那样,async 关键字仅用于启用 await。因此,异步方法会像任何其他同步方法一样运行,直到遇到 await 关键字为止。
await 关键字是关键所在。它将控制权交还给调用 await 方法的开发者,最终使得 I/O 密集型任务(例如调用 Web 服务)或 CPU 密集型任务(例如 CPU 密集型计算)能够快速响应。async 和 await 的作用仅仅是为我们提供了一些简洁的语法糖,让我们能够编写更清晰的代码。
让我们来看下面这个简单的例子:
public async Task<string> DownloadString(string url)
{
var client = new HttpClient();
var request = await client.GetAsync(url);
var download = await request.Content.ReadAsStringAsync();
return download;
}
相同的代码可能等同于其底层展开后的结果:
public Task<String> DownloadString(string url)
{
var client = new HttpClient();
var request = client.GetAsync(url);
var download = request.ContinueWith(http =>
http.Result.Content.ReadAsStringAsync());
return download.Unwrap();
}
如您所见,它实际上创建了一系列需要执行的任务,这些任务会被添加到任务调度器 (TaskScheduler) 的队列中。上面的代码勉强可以,但始终使用 C# 提供的语言结构,而不是手动完成所有操作,也是很有益处的!
执行顺序如下:
- 调用
client.GetAsync(url)会在后台通过调用底层 .NET 库来创建请求。 - 其底层代码的某些部分可能会同步运行,直到它将网络 API 的工作委托给操作系统为止。
- 此时,会创建一个 Task,并将其冒泡给异步代码的原始调用者。这仍然是一个未完成的 Task!
- 在此期间,调用者可以查询任务的状态。
- 操作系统完成网络请求后,响应会通过 I/O 完成端口返回,CPU 中断会通知 CLR 工作已完成。
- 响应将被安排由下一个可用的线程来处理,以便解包数据。
- 异步方法的其余部分将继续同步运行。
关键在于,不会有专门的线程来完成任务。这也意味着,任务的继续执行/完成并不能保证在启动它的同一个线程上运行。
☠️ 使用 async 和 await 时常见的陷阱
async当你开始使用`and`时,你可能会忍不住想把代码里的所有操作都改成“异步”的await(嗯,我就是这么做的😁)。但是,你的代码中存在一些你应该避免的陷阱。
- 使方法为 void 方法为异步 void
这是我刚开始接触 async/await 时犯的第一个错误!我有一个 void 方法,它调用了自身内部的一个 async 方法。由于使用 await 时必须将调用方法设为 async,所以我把调用方法设为了 async void!
public async void GetResult()
{
await SomeAsyncMethod();
}
这里的问题在于,GetResult 方法的调用者无法控制结果。SomeAsyncMethod()这也会导致其他副作用,例如,如果出现问题,则缺少调用堆栈进行调试;如果发生异常,应用程序会崩溃等等。
然而,有些时候这是无法避免的。如果您打开我们代码示例中的 AsyncWpfApp ,您会发现类似下面的代码:
private async void AsyncParallelBtn_Click(object sender, RoutedEventArgs e)
{
...
var output = await RunAsyncParallelDemo.Start();
...
}
如果发现类似上面的代码,这并不一定是个问题,因为在 WPF 中无法更改事件处理程序的方法签名。
不过,建议尽可能避免使用 async void。
- 忽略或忘记“等待”
很容易犯这样的错误:忘记在异步任务前使用 await,因为编译器不会报错,直接编译你的代码。在下面的例子中,我们只是简单地调用了一个异步函数,而没有await使用 await。
public void Caller()
{
Console.WriteLine("Before");
// Deliberately forgetting to await
DoSomeBackgroundWorkAsync();
Console.WriteLine("After");
}
DoSomeBackgroundWorkAsync()该方法将向调用者方法返回一个 Task(根据实现的不同,可能是 Task 或 Task),但不会实际执行它。
因此,在使用异步方法(尤其是使用第三方库时)时,请务必注意不要忘记使用 await。
- 使用 .Result 和 .Wait() 阻塞异步任务
这是开发者经常会(不知不觉地😋)陷入的另一个陷阱,即需要在同步方法中运行一些异步代码。让我们来看下面的例子:
public void DoSomeWork()
{
var result = DoAsyncWork().Result;
}
乍一看,使用这个.Result属性似乎很方便。然而,这可能会导致严重的死锁问题。为了避免这种情况,通常应该将调用方法设为异步方法。这可能需要大量工作,因为它会对你的更改产生级联效应。但这通常是更可取的做法。
MSDN 中清晰地描述了更多编写非阻塞代码的方法:
建议将调用者设为异步,这样可以避免对代码库进行大量的级联更改,但如果您正在处理遗留项目,这可能会很痛苦。
结论
希望您和我一样喜欢这篇文章。总而言之,Task 几乎总是最佳选择;它提供了更强大的 API,并且避免了浪费操作系统线程。
想了解更多内容吗?请前往我的Github仓库查看一些示例代码。
https://github.com/sahan91/c-sharp-tasks
有任何建议或发现错误?
如果您觉得我解释的内容有任何不妥之处,请在下方留言 :) 另外,如果您发现任何错误或有任何建议,请在我的 GitHub 仓库中提交 pull request。谢谢!
参考
- https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/task-parallel-library-tpl
- https://blog.stephencleary.com/2012/02/async-and-await.html
- https://markheath.net/post/async-antipatterns
- https://msdn.microsoft.com/en-us/magazine/jj991977.aspx
