本文颇为古老,但并未失去其相关性。 当涉及到异步/等待时,通常会出现一个链接。 我找不到俄语翻译,所以我决定帮助一个不流利的人。
长期以来,异步编程一直是最有经验的开发者的王国,他们渴望受虐狂-那些有足够的空闲时间,爱好和心理能力来考虑非线性执行流中的回调的人。 随着Microsoft .NET Framework 4.5的问世,C#和Visual Basic给我们带来了异步,因此,凡人现在可以几乎像同步方法一样容易地编写异步方法。 不再需要回调。 不再需要从一个同步上下文到另一个同步上下文的显式封送处理代码。 无需担心执行结果或异常如何移动。 无需为了使开发异步代码方便而使编程语言的手段变形的技巧。 简而言之,不再有麻烦和头痛。
当然,尽管现在开始编写异步方法很容易(请参见《 MSDN杂志》 [2011年10月]中的Eric Lippert和Mads Torgersen的文章),但需要正确理解才能做到。内幕发生了什么。 每当语言或库提高开发人员可以使用的抽象级别时,不可避免地会伴随着降低生产率的隐性成本。 在许多情况下,这些成本可以忽略不计,因此大多数程序员可以在大多数情况下将其忽略。 但是,高级开发人员应充分了解存在哪些成本,以便采取必要的措施并解决可能出现的问题(如果它们表现出来)。 在C#和Visual Basic中使用异步编程工具时,这是必需的。
在本文中,我将描述异步方法的输入和输出,解释如何实现异步方法,并讨论一些较小的成本。 请注意,这不是建议以微优化和性能的名义将可读代码扭曲为难以维护的内容。 这只是有助于诊断您可能遇到的问题的知识,以及克服这些问题的一系列工具。 此外,本文基于.NET Framework 4.5版预览,并且最终发行版中的特定实现细节可能会更改。
获得舒适的思维模式
数十年来,程序员一直在使用高级编程语言C#,Visual Basic,F#和C ++开发高效的应用程序。 这种经验使程序员可以评估各种操作的成本并获得有关最佳开发技术的知识。 例如,在大多数情况下,调用同步方法是相对经济的,特别是如果编译器可以将调用方法的内容直接嵌入到调用点中时。 因此,开发人员习惯于将代码分成小的,易于维护的方法,而不必担心增加调用次数带来的负面影响。 这些程序员的思维模型旨在处理方法调用。
随着异步方法的出现,需要一种新的思维模型。 C#和Visual Basic及其编译器能够创建一种幻象,即异步方法可以用作其同步副本,尽管内部的一切都是完全错误的。 编译器为程序员生成了大量代码,这与开发人员在需要手工完成时编写的用于支持异步的标准模板非常相似。 此外,由编译器生成的代码包含对.NET Framework库函数的调用,从而进一步减少了程序员需要做的工作量。 为了拥有正确的思维模型并使用它做出明智的决策,重要的是要了解编译器为您生成的内容。
更多方法,更少调用
使用同步代码时,运行内容为空的方法几乎毫无用处。 对于异步方法,情况并非如此。 考虑一下这种异步方法,它由一条指令组成(由于缺少await语句,该指令将被同步执行):
public static async Task SimpleBodyAsync() { Console.WriteLine("Hello, Async World!"); }
中间语言反编译器(IL)将在编译后显示此函数的真实内容,并输出类似于图1的内容。什么是将简单的单行代码转换为两种方法,其中一种属于状态机的辅助类。 第一个是存根方法,其签名与程序员编写的签名相似(此方法具有相同的名称,相同的作用域,采用相同的参数并返回相同的类型),但不包含程序员编写的代码。 它仅包含用于初始设置的标准样板。 初始设置代码初始化表示异步方法所需的状态机,并使用对MoveNext实用程序方法的调用来启动它。 状态机的对象类型包含一个具有异步方法执行状态的变量,允许您在异步等待点之间切换时保存它。 它还包含由程序员编写的代码,对其进行了修改以确保将执行结果和异常传输到返回的Task对象; 保持方法中的当前位置,以便可以在恢复后从该位置继续执行操作,等等。
图1异步方法模板
[DebuggerStepThrough] public static Task SimpleBodyAsync() { <SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0(); d__.<>t__builder = AsyncTaskMethodBuilder.Create(); d__.MoveNext(); return d__.<>t__builder.Task; } [CompilerGenerated] [StructLayout(LayoutKind.Sequential)] private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine { private int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Action <>t__MoveNextDelegate; public void MoveNext() { try { if (this.<>1__state == -1) return; Console.WriteLine("Hello, Async World!"); } catch (Exception e) { this.<>1__state = -1; this.<>t__builder.SetException(e); return; } this.<>1__state = -1; this.<>t__builder.SetResult(); } ... }
当您想知道对异步方法的调用要花多少钱时,请记住这种模式。 需要使用MoveNext方法中的try / catch块来防止编译器可能会尝试通过JIT嵌入此方法,因此至少我们得到了调用该方法的开销,而当使用同步方法时,此调用很可能不会(提供)简约内容)。 我们将收到一些对Framework过程的调用(例如SetResult)。 以及状态机对象字段中的几个写操作。 当然,我们需要将所有这些成本与Console.WriteLine的成本进行比较,后者可能会占优势(它们包括锁定,I / O等的成本)。请注意环境为您做出的优化。 例如,状态机的对象被实现为结构(struct)。 仅当该方法需要暂停执行,等待操作完成时,才会将该结构放入托管堆中,而在这种简单方法中永远不会发生这种情况。 因此,这种异步方法的模式将不需要从堆分配内存。 编译器和运行时将尝试最小化内存分配操作的数量。
什么时候不使用异步
.NET Framework尝试使用各种优化方法为异步方法生成有效的实现。 尽管如此,开发人员根据他们的经验经常会使用他们的优化方法,这在尝试使用通用方法时对于编译器和运行时的自动化可能是冒险且不切实际的。 如果您没有忘记这一点,那么在许多特定情况下,拒绝使用异步方法是有益的,特别是,这适用于可以用于更精细设置的库中的方法。 通常,当可以肯定地知道该方法可以同步执行时,就会发生这种情况,因为它所依赖的数据已经准备就绪。
创建异步方法时,.NET Framework开发人员花费大量时间来优化内存管理操作的数量。 这是必需的,因为内存管理在异步基础结构的性能上会产生最大的成本。 为对象分配内存的操作通常相对便宜。 为对象分配内存类似于在超市中向购物车中填充产品-将它们放入购物车中时,您不会花任何钱。 当您在结帐时付款,拿出钱包并支付可观的钱时,就会发生支出。 而且,如果内存分配很容易,则后续的垃圾回收会严重影响应用程序性能。 当您开始垃圾回收时,将对当前位于内存中但没有链接的对象进行扫描和标记。 放置的对象越多,标记它们所花费的时间就越长。 另外,放置的大型对象的数量越多,需要进行垃圾回收的频率就越高。 使用内存的这一方面对系统具有全局影响:异步方法产生的垃圾越多,即使微型测试未证明会产生大量成本,应用程序运行的速度也会越慢。
对于暂停执行(等待尚未就绪的数据)的异步方法,环境必须创建Task类型的对象,该对象将从方法中返回,因为该对象用作对调用的唯一引用。 但是,通常可以进行异步方法调用而无需暂停。 然后,运行时可以从缓存中返回先前完成的Task对象,该对象可以一次又一次地使用,而无需创建新的Task对象。 是的,仅在某些条件下才允许这样做,例如,当异步方法返回非通用(非通用)对象Task,Task或通过引用类型TResult指定通用Task时,从该方法返回null。 尽管这些条件的列表随着时间的推移而不断扩大,但是如果您知道该操作是如何实现的,那就更好了。
考虑将此类型的实现作为MemoryStream。 MemoryStream继承自Stream,并重新定义了.NET 4.5中实现的新方法:ReadAsync,WriteAsync和FlushAsync,以便提供特定于内存的代码优化。 由于读取操作是从位于内存中的缓冲区执行的,也就是说,它实际上是内存区域的副本,因此,如果以同步模式执行ReadAsync,则将获得最佳性能。 异步方法中的此实现可能如下所示:
public override async Task<int> ReadAsync(byte [] buffer, int offset, int count, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return this.Read(buffer, offset, count); }
很简单。 并且由于Read是一个同步调用,并且该方法没有await语句来控制期望,因此对该ReadAsync的所有调用实际上都将同步执行。 现在让我们看一下使用线程的标准情况,例如复制操作:
byte [] buffer = new byte[0x1000]; int numRead; while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) { await source.WriteAsync(buffer, 0, numRead); }
请注意,在给定的ReadAsync示例中,始终使用相同的缓冲区长度参数调用源流,这意味着很有可能还会重复返回值(读取的字节数)。 除非在极少数情况下,否则ReadAsync的实现不太可能使用缓存的Task对象作为返回值,但是您可以做到这一点。
考虑该方法的另一个实现选项,如图2所示。利用该方法的标准脚本中其固有方面的优点,我们可以通过排除内存分配操作来优化实现,这在运行时不太可能实现。 如果读取了相同数量的字节,我们可以通过返回在上一个ReadAsync调用中使用的同一Task对象来完全消除内存丢失。 对于这样的低级操作(可能非常快并且将被重复调用),此优化将产生重大影响,尤其是在垃圾回收的数量方面。
图2优化任务创建
private Task<int> m_lastTask; public override Task<int> ReadAsync(byte [] buffer, int offset, int count, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { var tcs = new TaskCompletionSource<int>(); tcs.SetCanceled(); return tcs.Task; } try { int numRead = this.Read(buffer, offset, count); return m_lastTask != null && numRead == m_lastTask.Result ? m_lastTask : (m_lastTask = Task.FromResult(numRead)); } catch(Exception e) { var tcs = new TaskCompletionSource<int>(); tcs.SetException(e); return tcs.Task; } }
如果需要缓存,可以使用通过消除不必要的Task对象创建的类似优化方法。 考虑一种旨在检索网页内容并将其缓存以供将来参考的方法。 作为异步方法,可以这样编写(使用.NET 4.5的新System.Net.Http.dll库):
private static ConcurrentDictionary<string,string> s_urlToContents; public static async Task<string> GetContentsAsync(string url) { string contents; if (!s_urlToContents.TryGetValue(url, out contents)) { var response = await new HttpClient().GetAsync(url); contents = response.EnsureSuccessStatusCode().Content.ReadAsString(); s_urlToContents.TryAdd(url, contents); } return contents; }
这是一个前额实现。 对于在缓存中找不到数据的GetContentsAsync调用,与通过网络接收数据的成本相比,可以忽略创建新Task对象的开销。 但是,在从缓存中获取数据的情况下,如果仅包装并提供可用的本地数据,则这些成本将变得非常大。
为了消除这些成本(如果需要获得高性能),可以重写该方法,如图3所示。现在,我们有两种方法:公共委托的同步公共方法和异步私有方法。 Dictionary集合现在缓存创建的Task对象,而不是它们的内容,因此可以通过简单地访问该集合以返回现有Task对象的方式,来进行以后尝试检索以前成功获取的页面内容的尝试。 在内部,您可以利用Task对象的ContinueWith方法,如果页面加载成功,我们可以将执行的对象保存在集合中。 当然,此代码更加复杂,并且需要像往常一样在优化性能时进行大量开发和支持:您不希望花时间编写代码,直到性能测试表明这些复杂性导致其改进为止,这是令人印象深刻的。 哪些改进实际上将取决于应用方法。 您可以采用一个测试套件,该套件可以模拟常见的用例并评估结果,以确定该游戏是否值得一试。
图3手动缓存任务
private static ConcurrentDictionary<string,Task<string>> s_urlToContents; public static Task<string> GetContentsAsync(string url) { Task<string> contents; if (!s_urlToContents.TryGetValue(url, out contents)) { contents = GetContentsInternalAsync(url); contents.ContinueWith(delegate { s_urlToContents.TryAdd(url, contents); }, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuatOptions.ExecuteSynchronously, TaskScheduler.Default); } return contents; } private static async Task<string> GetContentsInternalAsync(string url) { var response = await new HttpClient().GetAsync(url); return response.EnsureSuccessStatusCode().Content.ReadAsString(); }
与Task对象关联的另一种优化方法是确定是否从异步方法中完全返回这样的对象。 C#和Visual Basic都支持返回空值(void)的异步方法,并且它们根本不创建Task对象。 库中的异步方法应始终返回Task和Task,因为在设计库时,您无法知道将不会使用它们等待完成。 但是,在开发应用程序时,返回void的方法可以找到自己的位置。 存在此类方法的主要原因是提供现有的事件驱动环境,例如ASP.NET和Windows Presentation Foundation(WPF)。 使用异步和等待,这些方法使实现按钮处理程序,页面加载事件等变得容易。 如果打算使用带void的异步方法,请谨慎处理异常:来自该异常的异常将在调用该方法时处于活动状态的任何SynchronizationContext中弹出。
不要忘记上下文
.NET Framework中有许多不同的上下文:LogicalCallContext,SynchronizationContext,HostExecutionContext,SecurityContext,ExecutionContext和其他上下文(它们的数量巨大,可能表明该框架的创建者出于经济上的动机创建了新的上下文,但我确定不是这样)。 其中一些上下文不仅在功能方面而且在性能方面都严重影响异步方法。
SynchronizationContext SynchronizationContext在异步方法中扮演重要角色。 “同步上下文”仅是一种抽象,以确保对特定库或环境的详细信息的委托调用进行编组。 例如,WPF具有一个DispatcherSynchronizationContext,用于表示Dispatcher的用户界面(UI)流:向此同步上下文发送委托会使该委托排队,以由Dispatcher在其流中执行。 ASP.NET提供一个AspNetSynchronizationContext,用于确保处理ASP.NET请求中涉及的异步操作被确保顺序执行,并绑定到正确的HttpContext状态。 好吧等等 通常,.NET Framework中大约有10个SynchronizationContext专业化,有些是开放的,有些是内部的。
当等待.NET Framework可以实现此功能的Task或其他类型的对象时,等待它们的对象(例如TaskAwaiter)将在等待(await)开始时捕获当前的SynchronizationContext。 等待完成后,如果捕获了SynchronizationContext,则将异步方法的继续发送到此同步上下文。 因此,编写从UI流中调用的异步方法的程序员无需手动编组对UI流的调用即可更新UI控件:框架自动执行此编组。
不幸的是,这种封送处理是有代价的。 对于使用await来实现其控制流的应用程序开发人员,自动封送处理是正确的解决方案。 图书馆通常会有一个完全不同的故事。 对于应用程序开发人员来说,这种封送处理主要是代码控制执行代码的上下文所必需的,例如,访问UI控件或访问与所需ASP.NET请求相对应的HttpContext。 但是,通常不需要库来满足这种要求。 结果,自动封送处理通常带来完全不必要的额外费用。 让我们再来看一下将数据从一个流复制到另一个流的代码:
byte [] buffer = new byte[0x1000]; int numRead; while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) { await source.WriteAsync(buffer, 0, numRead); }
如果从UI流中调用此副本,则每个读取和写入操作都将强制执行返回到UI流。 对于源和异步读取和写入的流(即,大多数实现)中的兆字节数据,这意味着从后台流到UI流的切换次数约为500。 要处理“任务”和“任务”类型中的此行为,将创建ConfigureAwait方法。 此方法接受用于控制封送处理的布尔类型的continueOnCapturedContext参数。 如果为true(默认值),则await自动将控制权返回给捕获的SynchronizationContext。 如果使用false,则同步上下文将被忽略,环境将继续在被中断的线程中执行异步操作。 实现此逻辑将提供线程之间的复制代码的更有效版本:
byte [] buffer = new byte[0x1000]; int numRead; while((numRead = await source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) { await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false); }
对于库开发人员而言,这种加速本身足以使人们始终考虑使用ConfigureAwait,但极少数情况除外,在这种情况下,库对运行时有足够的了解,并且它将需要通过访问正确的上下文来执行该方法。
除了性能,还有另一个原因是在开发库时需要使用ConfigureAwait。 想象一下,使用WPF中的UI流调用使用不带ConfigureAwait的代码版本实现的CopyStreamToStreamAsync方法,例如:
private void button1_Click(object sender, EventArgs args) { Stream src = …, dst = …; Task t = CopyStreamToStreamAsync(src, dst); t.Wait(); // deadlock! }
在这种情况下,程序员必须将button1_Click作为异步方法编写,在这种异步方法中,期望await操作符执行Task,而不能使用此对象的同步Wait方法。 在许多其他情况下,都需要使用Wait方法,但是在UI流中使用它来等待几乎总是一个错误,如下所示。 在Task完成之前,Wait方法不会返回。 对于CopyStreamToStreamAsync,其异步流将尝试通过将数据发送到捕获的SynchronizationContext来返回执行,并且直到此类传输完成(因为它们是继续其操作所必需的),才能完成。 但是,这些分派又无法执行,因为必须处理它们的UI线程被Wait调用阻塞了。 这是导致死锁的周期性依赖关系。 如果使用ConfigureAwait(false)实现CopyStreamToStreamAsync,则将没有依赖关系和阻塞。
ExecutionContext ExecutionContext是.NET Framework的重要组成部分,但大多数程序员仍然幸福地没有意识到它的存在。 ExecutionContext – , SecurityContext LogicalCallContext, , . , ThreadPool.QueueUserWorkItem, Task.Run, Delegate.BeginInvoke, Stream.BeginRead, WebClient.DownloadStringAsync Framework, ExecutionContext ExecutionContext.Run ( ). , , ThreadPool.QueueUserWorkItem, Windows (identity), WaitCallback. , Task.Run LogicalCallContext, LogicalCallContext Action. ExecutionContext .
Framework , ExecutionContext, , . Windows LogicalCallContext . (WindowsIdentity.Impersonate CallContext.LogicalSetData) .
. C# Visual Basic , . await. , , - . C# Visual Basic («») , await (boxed) , .
. , . , , , .
C# Visual Basic , . ,
public static async Task FooAsync() { var dto = DateTimeOffset.Now; var dt = dto.DateTime; await Task.Yield(); Console.WriteLine(dt); }
dto await, . , , - dto:
Figure 4
[StructLayout(LayoutKind.Sequential), CompilerGenerated] private struct <FooAsync>d__0 : <>t__IStateMachine { private int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Action <>t__MoveNextDelegate; public DateTimeOffset <dto>5__1; public DateTime <dt>5__2; private object <>t__stack; private object <>t__awaiter; public void MoveNext(); [DebuggerHidden] public void <>t__SetMoveNextDelegate(Action param0); }
, . , , , , . , :
public static async Task FooAsync() { var dt = DateTimeOffset.Now.DateTime; await Task.Yield(); Console.WriteLine(dt); }
, .NET (GC) , , , : 0, , , (.NET GC 0, 1 2). , GC . , , , , , , . 0, , , . , , , .
( , ). JIT , , , , . , , . , , , , . , , . , C# Visual Basic , , .
C# Visual Basic , awaits: . await , Task , , . , , :
public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { return Sum(await a, await b, await c); } private static int Sum(int a, int b, int c) { return a + b + c; }
C# “await b” Sum. await, Sum, - async , «» await. , await . , , CLR, , , . , <>t__stack. , , Tuple<int, int> <>__stack. , , , . , SumAsync :
public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { int ra = await a; int rb = await b; int rc = await c; return Sum(ra, rb, rc); }
, ra, rb rc, . , : . , , , . , , , , .
, , . Sum , await , . , await , . await , Task.WhenAll:
public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { int [] results = await Task.WhenAll(a, b, c); return Sum(results[0], results[1], results[2]); }
Task.WhenAll Task<TResult[]>, , , , . . , WhenAll, Task Task. , , , , , WhenAll , . WhenAll, , , params, . , , . Figure 5
Figure 5
public static Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { return (a.Status == TaskStatus.RanToCompletion && b.Status == TaskStatus.RanToCompletion && c.Status == TaskStatus.RanToCompletion) ? Task.FromResult(Sum(a.Result, b.Result, c.Result)) : SumAsyncInternal(a, b, c); } private static async Task<int> SumAsyncInternal(Task<int> a, Task<int> b, Task<int> c) { await Task.WhenAll((Task)a, b, c).ConfigureAwait(false); return Sum(a.Result, b.Result, c.Result); }
, . , . , . , , : , , / , . .NET Framework , . , .NET Framework, . , , Framework, , , .