《Async in C# 5.0》第五章 await到底做了什么?
有两种方式来思考C# 5.0中的async功能,特别是使用了await关键字后到底发生了什么:作为一种语言层面上的功能,你可以学学它对应的行为是什么从编译期对方法进行转换的角度看,它更像是一种语法糖(syntaticsugar)这两点就是一枚硬币的两个方面。在本章,我们会集中在第一点上来学习async。在第14章,我们会从另一个方面去进行思考,虽然这样会更复杂些,但是却会学到更多的细节,从而帮助我
我们可以通过两种方式来学习C# 5.0中的async功能,特别是使用了await关键字后到底发生了什么:
- 作为一种语言层面上的功能,你可以学学它对应的行为是什么
- 从编译器对方法进行转换的角度看,它更像是一种语法糖(syntactic sugar)
这两点就是一枚硬币的两个方面。在本章,我们会集中在第一点上来学习async。在第14章,我们会从另一个方面去进行思考,虽然这样会复杂些,但是却会学到更多的细节,从而帮助我们理清调试和性能方面的一些问题。
休眠、恢复一个方法(Hibernating and Resuming)
当程序执行到await关键字时,我们希望发生两件事:
- 当前执行代码的线程应该被释放掉,从而使你的代码变为异步的。这意味着,从一个普通的同步方法的角度来看,你的方法应该返回。
- 当你await的Task结束后,你的方法应该从上次返回的地方继续执行,就像它之前不曾返回了一样
想达到这个目的,你的方法在遇到await关键字时就必须“暂停”一下,在后续的某个时间点再“恢复”执行。我认为这个过程就像对计算机进行S4级休眠一样(参考[1])。当计算机休眠时,计算机的运行状态被存储到磁盘上,然后机器被关闭,此时你完全可以将电源从计算机上移除。回到我们说的async方法上,方法的当前状态被存储起来,然后方法会立即返回,一个awaiting方法除了会占用一点内存外,不会使用其他资源,因为执行它的线程已经被释放掉了。
![]() |
让我们再深入讨论一下刚才的例子,一个阻塞方法更像是挂起(suspend)计算机(S3级休眠)。它占用的资源更少,但是从根本上来说,它仍然在运行。 |
理想情况下,程序员不应该感知到发生了休眠这件事。尽管休眠以及恢复一个方法是非常复杂的操作,C#会确保你的代码就像什么也没有发生似的继续执行。
方法的状态
为了使你清楚当使用了await时,C#都做了哪些工作,我想有必要列出所有需要了解的关于方法状态的详细信息。
首先,方法内所有的本地变量的值都会被记住,包括
- 方法的参数
- 在方法的作用域内定义的任何变量
- 任何其它变量,比如循环中使用到的计数变量
- 如果你的方法不是static的,则还要包括this变量。只有记住了this,当方法恢复执行(resume)时,才可以使用当前类的成员变量。
上述的这些都会被存储在.NET垃圾回收堆里的一个对象中。因此,当你使用await时,.NET就会创建这样一个对象,虽然它会占用一些资源,但在大多数情况下并不会导致性能问题。
C#还要记住在方法内部await执行到了哪里——可以通过使用一个数字来表示当前方法中执行到了哪一个await关键字。
具体如何使用await表达式?这其实没有限制,例如,await可以被用做一个大表达式的一部分,一个表达式也可能包含多个await.
int myNum = await AlexsMethodAsync(await myTask, await StuffAsync());
这样就对.NET运行时提出了额外的需求——当await一个表达式时,需要记住表达式剩余部分的状态。在上面的例子中,当程序执行await StuffAsync()时,await myTask的结果就需要被记录下来。.NET IL会将这类子表达式存储在栈上,因此当使用了await关键字时就需要把这个栈存储下来。
在这之上,当程序执行到第一个await时,当前方法会返回——除非方法是async void,否则这时就会返回一个Task,因此调用者可以通过某种方式等待任务完成。C# 还必须把操作该返回Task的方法存储下来,这样当我们的方法完成后,前面返回的Task才会变为完成的状态,这样程序才会向上返回一层,回到方法的异步链中去继续执行。我们会在第14章探讨这些额外的机制。
上下文(Context)
C#在使用await时会记录各种各样的上下文,目的是当要继续执行方法时能够恢复这个上下文,这样就尽可能地将await的处理过程变得透明。
这些上下文中最重要的就是同步上下文(sychronization context),通过它的帮助可以在指定类型的线程上恢复方法的执行。这对UI程序尤为重要,因为UI程序只能在正确的线程上进行UI操作。同步上下文(sychronization context)是一个复杂的主题,第8章会包括更多相关的细节。
调用线程同样会捕获其它类型的上下文,下面会列出一些重要的context类:
ExecutionContext
这个是所有context的父类,其他的上下文都是它的一部分。.NET的特性,比如Task,会使用它来捕获和传播上下文,但是这个类自身没有自己的行为。
SecurityContext
我们可以通过这个类型找到当前线程相关的安全方面的信息。如果你的代码需要以特定的用户去运行,你也许会模拟(impersonate)这个用户,ASP.NET也可能会替你进行模拟。在这种情况下,模拟信息被存储在SecurityContext中。
CallContext
通过此类型,开发成员可以存储自定义数据,并且这些数据在逻辑线程的生命周期内都可用。尽管这在大多数场合都不是太好的做法,但这可以避免出现大量的方法参数。CallContext不支持在远程对象间传递,此时可以使用LogicalCallContext跨越AppDomains进行工作。(译者:CallConext相当于对每个逻辑线程都有一块单独的数据存储区,不同的逻辑线程间不能通过CallContext共享数据)
![]() |
值得注意的是:与CallContext的目的类似,线程的本地存储(thread local storage)在异步场景中并不起作用,因为在执行耗时操作时线程已经被释放掉了,也许线程被用作其它用途了。你的方法可能会在一个完全不同的线程上恢复执行。 |
当方法恢复执行时,C#会恢复上述类型的上下文。当然,恢复这些上下文是有代价的,因此,如果一个程序大量使用async,同时又使用了用户模拟,那么这个程序就会运行得非常慢。因此我建议:除非真的有必要这么做,否则尽量避免使用那些创建上下文的.NET功能。
什么情况下不能使用await
我们可以在标记为async的方法中的绝大多数地方使用await,但是仍然有一些地方不能使用await。下面我将解释为何在这些场景中不要使用await。
catch和finally块
在try语句块中可以尽情地使用await,但是在catch或者finally块中使用await却不是C#中有效的做法。通常在catch语句块中,异常仍然处于栈展开(stack unwinding, 参考[3])的过程中,稍后,异常可能在语句块中被重新抛出来。如果在这个时间点之前使用了await,栈信息将会发生变化,从而导致重新抛异常这件事的行为变得不确定。
上面说了不能在catch语句块中使用await,但我们完全可以在catch语句块之后使用它——通过使用return语句或者bool变量来标识致之前的操作是否抛出了异常。
举例如下,下面的写法是无效的
try
{
page = await webClient.DownloadStringTaskAsync("http://oreilly.com");
}
catch (WebException)
{
page = await DownloadStringTaskAsync("http://oreillymirror.com");
}
你应该按照下面的写法来写
bool failed = false;
try
{
page = webClient.DownloadStringTaskAsync("http://oreilly.com");
}
catch(WebException)
{
failed = true;
}
if (failed)
{
page = await webClient.DownloadStringTaskAsync("http://oreillymirror.com");
}
lock语句块
程序员使用lock来防止其它线程与当前线程在同一时刻访问同一个对象。因为异步代码通常都会释放掉启动异步代码的线程,并且当异步操作结束后(在不确定的时间之后),代码在与之前线程不同的线程上继续执行,这样来说,lock一个对象,并在await中使用这个对象,这种做法并没有意义。
在某些情况下,保护对象免受并发访问很重要,但并不要求在 await 期间禁止其他线程访问该对象。此时,您可以选择编写略显冗长的代码来显式执行双重锁定。
lock (sync)
{
// Prepare for async operation
}
int myNum = await AlexsMethodAsync();
lock (sync)
{
// Use result of async operation
}
另一个替代方案就是使用可以帮你进行并发控制的库 ——例如NAct,我们将在第10章介绍它。
如果你不得不在执行异步操作期间lock一些对象,那么只能说你不太走运了。当然了,如果你真碰到这种情况,你需要小心处理,因为通常来讲,在异步调用过程中想锁住(lock)资源,会很容易引入严重的竞争和死锁问题。此时建议你重新设计你的程序。
LINQ的查询表达式
C#中可以使用LINQ进行查询,同时支持过滤、转换、排序以及分组,这些查询语句可以在.NET集合类型上执行,也可以转化为可以在数据库或其它数据源上执行的语句。
IEnumerable<int> transformed = from x in alexsInts
where x != 9
select x + 2;
绝大多数情况下,在这样的查询表达式上使用await也不是有效的C#语句。因为这些查询语句会被编译器转化为lambda表达式,如果查询语句使用了await,那么转换后的lambda表达式就要被标记为async。问题是:目前不存在将这种隐式的lambda表达式标记上async的语法,如果真的存在,也可能会让人感到迷惑。
如果想在查询语句中使用await该怎么办呢?
你可以利用LINQ内部使用的扩展方法来达到目的,这样lambda表达式就成为显式的了,你就可以将它们标记为async,然后使用await关键字。
IEnumerable<Task<int>> tasks = alexsInts
.Where(x => x != 9)
.Select(async x => await DoSomthingAsync(x) + await DoSomthingElseAsync(x));
IEnumerable<int> transformed = await Task.WhenAll(tasks);
为了获取到await操作的结果,我使用了Task.WhenAll方法,当与一系列Task对象上工作时就需要使用这个方法。我将在第7章对此进行详细说明。
不安全代码(Unsafe Code)
被unsafe标记的代码不应该包含await。我们并不经常使用unsafe代码,unsafe代码应该是自成一体的方法,也不需要进行异步。在大多数情况下,编译器对await所做的转换会破坏unsafe代码。
异常捕获
在设计上,async方法中的异常处理行为与之在正常的同步方法中非常类似。然而,async方法所引入的额外的复杂性也意味着它与同步方法相比会有些许的不同。本章我将谈一下async是如何使异常处理变得简单的,后续我将在第9章详细讲解。
当耗时操作完成时,Task类型可以通过IsFaulted属性告诉外界它的结果是成功了还是失败了,在Task执行过程中如果发生了异常,IsFaulted属性则被设置为true。await关键字“知道”这些,所以当Task执行过程发生异常后,await会重新抛出异常。
![]() |
如果你熟悉.NET中的异常,你也许会思考这个问题:当异常被重新抛出时是否能保留正确的堆栈信息?这在过去是不可能的——一个异常只能被抛出一次。然而,在.NET 4.5中这个限制被修复了,.NET 4.5引入了一个新类型ExceptionDispatchInfo,它会与Exception协作,去捕获并重新抛出异常,并且保证重新抛出时的堆栈信息是正确的。 |
async方法本身也是知道异步操作中发生的异常的。当异步方法中发生了异常,但是却没有被捕获及处理时,这个异常就被放置到Task对象中,然后Task返回给调用者。如果调用者使用了await等待这个Task,那么异常就会在此被抛出。这样,异常就传播回了调用者,形成虚拟的堆栈——这与同步代码中的处理方式完全相同。
![]() |
之所以叫虚拟的堆栈是因为:堆栈是单个线程的概念,在标记为async的代码中,当前线程的实际堆栈也许会与异常产生的堆栈信息有很大的不同。 |
async方法是同步的,除非需要了才会变为异步
我在前面讲过,标记为async的方法除非使用await“调用”异步方法时,才会以异步的方式运行。在遇到await之前,标记为async的方法运行在调用者所在的线程上,就像普通的同步方法一样。当整个async方法链都以同步的方式运行时,了解这些就会有意义了。
请记住,标记为async的方法只有在遇到了第一个await时才会暂停,但是,在有些时候它也可以不暂停——有时“喂给”await的Task已经完成任务了,这时就不需要进行暂停了。在下列场景中,Task可以是已经完成的状态:
- Task是通过Task.FromResult方法创建完成的,我们会在第7章进行深入的讲解。
- 标记为async的方法返回的Task,但是却不会执行到await语句
- Task真的执行了异步操作,但是现在已经结束了(也许当前线程在await之前做了一些事情 促使Task完成)
- 标记为async的方法,当运行到await时返回的Task,此时的Task已经完成了
针对最后一种可能性,当你await一个已经结束的Task时会发生一些有趣的事情,整个异步方法调用链就像是以同步的方式完成的。因为在async方法的调用链中,第一个被调用的await永远都是最深的一层,只有当最深的这层方法以同步方式返回后,其它的await才会被执行到。
你也许会疑惑,如果前两种情况真的发生了,为何还要首先使用async方法?当然,如果那些方法能够确保永远都以同步的方式返回,你说的是正确的,此时使用同步代码要比使用async方法(但又不使用await)效率更高。问题是,这些方法可能只在某些情况下才会以同步方式返回。比如,方法对结果进行了缓存,此时就可以直接从缓存中把结果以同步方式返回,但是当方法需要网络请求时就需要以异步的方式运行了。
另外,为了长远考虑,当你知道这些方法在未来的某个时间点会变为异步时,你也许会让方法返回Task或者Task<T>。
<完>
参考
[2] CallContext
[3] 抛出异常与栈展开(stack unwinding)
抛出异常时,将暂停当前函数的执行,开始查找匹配的catch子句。首先检查throw本身是否在try块内部,如果是,检查与该try相关的catch子句,看是否可以处理该异常。如果不能处理,就退出当前函数,并且释放当前函数的内存并销毁局部对象,继续到上层的调用函数中查找,直到找到一个可以处理该异常的catch。这个过程称为栈展开(stack unwinding)。当处理该异常的catch结束之后,紧接着该catch之后的点继续执行。
更多推荐




所有评论(0)