async/await - 你应该知道的(已更新!)






4.66/5 (40投票s)
简单了解使用 async/await 关键字时会发生什么、它们的好处以及如何避免死锁。
致谢
基于最近对本文的评论,我对它做了一些修改。感谢您的关注。
引言
有时,我们会在不了解新技术瓶颈和怪异之处的情况下使用它,这最终可能使我们陷入难以追踪的死锁等糟糕境地。基于任务的异步模式(TAP)究竟发生了什么,与 .NET 4.0 相比有哪些改进,我们如何引入死锁以及如何避免它?
考虑一下,在你的代码的某个地方,你需要做一些异步的事情,有哪些不同的方法可以做到?这里有一些模式:
- 异步编程模型(APM):这个模型也被称为
IAsyncResult
模式,其中异步操作需要Begin
和End
方法。你需要负责调用该操作的Begin
方法,并在其他地方轮询完成/取消状态,最后调用End
方法来检索该操作的实际结果。可以定义一个回调函数,当操作完成后,调用你的回调函数,或者你可以阻塞你的执行直到结果准备好。新开发不推荐使用此模式,请注意。 - 基于事件的异步模式(EAP):这种异步编程在 .NET 2.0 中引入,在新开发中不推荐使用。在此模型中,你需要事件处理程序委托类型和
EventArg
派生类型,在某些场景下,还需要一个工作线程来处理异步任务,任务完成后,向父级发出任务完成的信号。 - TAP 模型:本文将讨论它。
在上述模式中,你可能会很容易地最终创建大量消耗资源的线程,或者在最坏的情况下,你可能会在管理事件顺序和执行过程方面遇到困难。
但是,有没有一种方法可以编写同步代码,但执行起来是异步的呢?把棘手的部分留给编译器来处理?
async/await
async/await 到底做了什么?
每当你声明或看到一个函数是 async
的,这意味着这个函数是可等待的,你可以使用 await
关键字异步调用它。一旦编译器看到 await
关键字,它就会为我们完成魔术,并立即返回到调用函数,而无需等待任务完成,执行可以继续进行。
听起来不错,不是吗?
以下是说明
- 调用
doTheJob()
(这是我们的初始步骤)。 - 函数同步执行,直到在
doSomethingASync()
中遇到await
关键字,在那里返回异步任务。 - 将创建一个临时的
Task
并开始异步执行,此时安排续延以运行doTheJob()
中剩余的代码。 - 执行将在“匿名任务”函数中继续,直到到达“
return
”,然后在“doTheJob()
”中同步执行直到“return
”。 - 此函数的执行也已完成,
doTheJob()
返回的Task
已完成,任何与之关联的续延现在都可以安排执行。
这似乎有点奇怪,但实际上很容易理解。正如你可能已经注意到的,虽然你的代码看起来与同步编程非常相似,但实际上它将以 async
的方式执行。你可以简单地等待一个“耗时”的任务,让它在那里完成,并在完成后安排你的执行!
有什么好处?
- 你的同步式编码风格将被异步执行,这就是魔力(当然,在本文中,例如,在
async
/await
和Task.Run
的帮助下)。 - 你不再需要担心以前为了处理多线程异步方法而遇到的许多事件轮询/检查、线程同步和棘手的情况。
当然,你仍然需要理解异步编程以及如何同步任何共享状态,这意味着仍然存在发生竞态条件、死锁、饿死等问题的可能性,正如你在本文中所发现的。(参见“ConfigureAwait()
的作用”) - 你的代码更具可读性、可理解性,从而更易于调试。
与 .NET 4.0 中的 ContinueWith() 有何区别?
实际上,async
/await
是其后继者“ContinueWith()
”的替代品,它是在 NET 4.0 中引入的。每种方法都有其优缺点。
ContinueWith
忽略同步上下文ContinueWith
会将你推送到新线程,即使父线程已完成- 在
ContinueWith
中访问已失败Task
的.Result
会重新抛出异常 Async
/await
会尝试在可能的情况下将你保留在同一线程
所以升级到 .NET 4.5 并使用 async
和 await
…
ConfigureAwait() 的作用是什么?
它自从 .NET 4.5 引入以来,是为了解决因代码结构不佳而可能发生的死锁情况。请看下图。
发生了什么?你明白了吗?
- 当你 await 了
Fn2()
中的内容时,它实际上将f()
的执行移到了另一个具有其线程池的上下文,而Fn2()
的其余部分被包装起来,以便在f()
调用完成后执行。 - 执行控制返回到
Fn1()
并继续执行,直到遇到一个**阻塞**的等待。这就是关键。(*你实际上阻塞了“上下文 a”的执行过程,以等待另一个异步操作完成*) - 一旦“
Context b
”中的async
操作完成,它就试图访问“Context a
”来告知它:“执行已完成,让我们继续执行包装的部分”。 - 但是,“
Context a
”之前已经被阻塞了! - 死锁!
有两种方法可以解决这个问题:
- 永远不要使用任何形式的阻塞等待,而是使用
WhenAll
或When
函数来等待任务完成并继续执行。 - 允许 包装的内容 在“
Context b
”中执行。
对于第二种方法,你需要以某种方式配置 await
关键字让它知道这一点!所以你需要调用 ConfigureAwait(false)
,这样编译器就会知道包装的内容必须在 async
上下文中执行,而无需返回到调用上下文来继续包装的内容!
await doSomeJobAsync(parameters).ConfigureAwait(false);
.....
有时,强制执行在调用方上下文中继续是不必要的,例如,这是一个 I/O 操作,可以在其他上下文中的后台线程中完成,所以对于这种情况,使用 ConfigureAwait(false)
是个好主意。
如何将传统的 IAsyncResult 模式包装到 C# 5 Task 中?
你可能想知道,如果我想使用新的 async
/await
模式中不提供的 IAsyncResult
对函数怎么办?
APM 模式是通过 BeginOperation
和 EndOperation
对函数引入的。异步操作在调用 Begin
方法之前启动,结果在操作完成后可以通过调用 End
方法访问,否则你将陷入等待异步操作完成的锁定状态。
大多数 .NET API 都以新的 TAP 模式实现,你可以从 MSDN 按照此命名约定轻松识别它们。
“TAP 中的异步方法在操作名称后包含 Async 后缀;例如,GetAsync 用于 get 操作。如果你向已经包含带有 Async 后缀的方法名的类添加 TAP 方法,请使用 TaskAsync 后缀。例如,如果类已有一个 GetAsync 方法,则使用 GetTaskAsync 名称。”
所以,如果你想扩展 IAsyncResult
模式并用单个 TAP 函数调用替换它们,你可以使用 Task.Factory.FromAsync
来创建关于那些“Begin
”和“End
”方法调用的新行为。
“FromAsync 创建一个 Task,该 Task 表示一组符合异步编程模型模式的 begin 和 end 方法。”
// borrowed from MSDN:
class Program
{
static void Main(string[] args)
{
Task<string> t = GetFileStringAsync(file_path);
// Do some other work:
// ...
try
{
Console.WriteLine(t.Result.Substring(0, Math.Min(500, t.Result.Length)));
}
catch (AggregateException ae)
{
Console.WriteLine(ae.InnerException.Message);
}
Console.ReadKey();
}
const int MAX_FILE_SIZE = 14000000;
public static Task<string> GetFileStringAsync(string path)
{
FileInfo fi = new FileInfo(path);
byte[] data = null;
data = new byte[fi.Length];
FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.Read, data.Length, true);
//Task<int> returns the number of bytes read
Task<int> task = Task<int>.Factory.FromAsync(fs.BeginRead, fs.EndRead,
data, 0, data.Length, null);
// It is possible to do other work here while waiting
// for the antecedent task to complete.
// ...
// Add the continuation, which returns a Task<string>.
return task.ContinueWith((antecedent) =>
{
fs.Close();
// If we did not receive the entire file, the end of the
// data buffer will contain garbage.
if (antecedent.Result < data.Length)
Array.Resize(ref data, antecedent.Result);
// Will be returned in the Result property of the Task<string>
// at some future point after the asynchronous file I/O operation completes.
return new UTF8Encoding().GetString(data);
});
}
}
更多了解
- 不要被任何
async
函数的返回类型所迷惑。当你使用await
关键字调用它时,可以将其视为一个普通函数调用。 - 如果你想使用
await
关键字,你的函数必须用async
关键字定义。 - 如果你不在你的
async
函数中使用await
,你的async
函数实际上是同步执行的。 - 使用
Task.Delay()
而不是Thread.Sleep()
。 - 如果你正在开发一个库,或者你尝试在 ASP.NET 中使用
async
/await
,不要忘记调用ConfigureAwait(false)
,否则你肯定会引入不必要的死锁。
还有一件事,如果你需要有不同的 await
调用,并且它们之间没有依赖关系(将它们视为独立的 I/O 操作),那么同时 await
它们而不是一个接一个地 await
它们会是个好主意。
int a = await doSomethingASync(10); // Executes in 3 seconds
int b = await doSomethingASync(250); // Executes in 2 seconds
int c = await doSomethingASync(670); // Executes in 5 seconds
总执行时间将是 3+2+5 秒,即 10 秒。但这些操作之间没有依赖关系,因此下面的代码将具有更好的性能和更短的执行时间。
Task<int>[] jobs = new Task<int>[3];
jobs[0] = doSomethingASync(10); // Executes in 3 seconds
jobs[1] = doSomethingASync(250); // Executes in 2 seconds
jobs[2] = doSomethingASync(670); // Executes in 5 seconds
await Task.WhenAll(jobs);
在此模式下,总执行时间将约为 5 秒(最耗时的操作),其余部分将利用异步操作的优势。
看点
在某些情况下,完成操作所需的工作量小于异步启动操作所需的工作量。例如,从流中读取数据,其中读取操作可以通过内存中已缓冲的数据来满足。在这种情况下,操作可能同步完成,并返回一个已完成的任务。
请记住这一点。