原文链接:cnblogs.com/wxlevel/p/17761880.html
序言对async/await的支持已
经存在了十多年。它的涌现,改变了为 .NET 编写可伸缩代码的办法,你在不理解幕后的情形下也可以非常普遍地利用该功能。

从如下所示的同步方法开始(此方法是“同步的”,由于在全体操作完成并将掌握权返回给调用方之前,调用方将无法实行任何其他操作):
// Synchronously copy all data from source to destination.public void CopyStreamToStream(Stream source, Stream destination){ var buffer = new byte[0x1000]; int numRead; while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0) { destination.Write(buffer, 0, numRead); }}
然后,你添加几个关键字,变动几个方法名称,终极得到以下异步方法(此方法是“异步的”,由于期望的掌握权会非常快地返回给调用者,而且可能会在与全体操作干系的所有事情完成之前就返回。):
// Asynchronously copy all data from source to destination.public async Task CopyStreamToStreamAsync(Stream source, Stream destination){ var buffer = new byte[0x1000]; int numRead; while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0) { await destination.WriteAsync(buffer, 0, numRead); }}
险些在语法上相同,仍旧能够利用所有相同的掌握流布局,但现在是非壅塞的,具有显著不同的底层实行模型,并且由C#编译器和核心库在背后为你完成所有繁重的事情。
虽然在不知道底层发生了什么情形下利用类似这种async/await的支持很常见,但我坚信理解它的实际事情事理有助于更好地利用它。特殊是理解async/await所涉及的机制很必要,比如在考试测验调试涌现缺点或提高性能时特殊有帮助。因此,在本文中,我们将深入磋商await在措辞、编译器和库级别上的确切事情事理,以便你能够充分利用这些有代价的功能。
要做到这一点,我们须要回到async/await之前的时期,理解在没有它的情形下最前辈的异步代码是什么样子的。警告,那不是很都雅。
最初在最初的.NET Framework 1.0中,就有了异步编程模型模式,也称为APM模式、Begin/End模式或IAsyncResult模式。在高层次上,该模式很大略。对付一个同步操作DoStuff:
class Handler{ public int DoStuff(string arg);}
该模式将有两个相应的方法:BeginDoStuff方法和EndDoStuff方法:
class Handler{ public int DoStuff(string arg); public IAsyncResult BeginDoStuff(string arg, AsyncCallback? callback, object? state); public int EndDoStuff(IAsyncResult asyncResult);
}
BeginDoStuff方法会接管与DoStuff相同的所有参数,但还会接管一个AsyncCallback委托和一个不透明state工具,个中一个或两者都可以为null。Begin方法卖力启动异步操作,并且如果供应了回调(常日称为初始操作的“连续”),则还要卖力确保在异步操作完成时调用回调。
Begin方法还将布局一个实现IAsyncResult的类型的实例,并利用可选state来添补该IAsyncResult的AsyncState属性:
namespace System{ public interface IAsyncResult { object? AsyncState { get; } WaitHandle AsyncWaitHandle { get; } bool IsCompleted { get; } bool CompletedSynchronously { get; } } public delegate void AsyncCallback(IAsyncResult ar);}
然后,这个IAsyncResult实例将从Begin方法返回,并在终极调用时通报给AsyncCallback。当准备好利用操作结果时,调用者将该IAsyncResult实例通报给End方法,该方法卖力确保操作已完成(如果未完成,则通过壅塞同步等待它),然后返回操作的任何结果,包括传播可能发生的任何errors/exceptions。因此,无需编写如下代码来同步实行操作:
try{ int i = handler.DoStuff(arg); Use(i);}catch (Exception e){ ... // handle exceptions from DoStuff and Use}可以按以下办法利用 Begin/End 方法,异步实行相同的操作:try{ handler.BeginDoStuff(arg, iar => { try { Handler handler = (Handler)iar.AsyncState!; int i = handler.EndDoStuff(iar); Use(i); } catch (Exception e2) { ... // handle exceptions from EndDoStuff and Use } }, handler);}catch (Exception e){ ... // handle exceptions thrown from the synchronous call to BeginDoStuff}
对付利用任何措辞的基于回调的API的人来说,这该当是熟习的。
然而,事情只会因此变得更加繁芜。例如,存在“堆栈潜水”的问题。当代码重复调用导致堆栈越来越来越深,可能涌现堆栈溢出的风险。
如果操作同步完成,Begin方法可以同步调用回调函数,这意味着调用Begin的同时可能会直接调用回调函数。
而“异步”操作常日也会同步完成,这并不是由于它们担保异步完成,而是由于许可同步完成。
例如,考虑从某个网络操作(如从socket吸收)中异步读取的情形。如果每个单独的操作只须要少量的数据(例如从相应中读取一些头数据),则可以设置缓冲区,以避免大量的系统调用开销。通过将数据读入缓冲区,然后从该缓冲区中花费数据,直到该缓冲区耗尽,可以减少与socket实际交互所需的昂玉体系调用数量。这样的缓冲区可能存在于您正在利用的任何异步抽象后面,因此,你实行的第一个“异步”操作(添补缓冲区)是异步完成的,然后直到耗尽底层缓冲区之前的所有后续操作实际上都不须要实行任何I/O,而只是从缓冲区获取数据,因此所有操作都可以同步完成。当Begin方法实行个中一个这些操作并创造它同步完成时,它可以同步调用回调函数。这意味着你有一个调用Begin方法的堆栈帧,另一个堆栈帧用于Begin方法本身,以及用于回调函数的另一个堆栈帧。现在如果该回调函数再次调用Begin会发生什么?如果该操作同步完成且其回调同步调用,那么你现在又在堆栈上深入了几个帧。一贯这样重复,直到终极堆栈溢出。
这种很随意马虎重现。在.NET Core上考试测验运行这个程序:
using System.Net;using System.Net.Sockets;using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));listener.Listen();using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);client.Connect(listener.LocalEndPoint!);using Socket server = listener.Accept();_ = server.SendAsync(new byte[100_000]);var mres = new ManualResetEventSlim();byte[] buffer = new byte[1];var stream = new NetworkStream(client);void ReadAgain(){ stream.BeginRead(buffer, 0, 1, iar => { if (stream.EndRead(iar) != 0) { ReadAgain(); // uh oh! } else { mres.Set(); } }, null);};ReadAgain();mres.Wait();
这里我设置了一个大略的客户端socket和做事器socket相互连接。做事器向客户端发送100,000个字节,然后客户端连续利用BeginRead/EndRead以“异步”办法一次花费它们(这是非常低效的,只是出于传授教化目的而这样做)。
通报给BeginRead的回调通过调用EndRead完成读取,然后如果成功读取所需的字节(在这种情形下它尚未到达流的末端),则通过对ReadAgain本地函数的递归调用发出另一个BeginRead。
但是,在.NET Core中,socket操作比.NET Framework快得多,并且如果操作系统能够知足同步操作(把稳内核本身具有用于知足socket吸收操作的缓冲区),则会同步完成。因此,这个堆栈会溢出:
因此,建立了APM模型的补偿方法。有两种可能的补偿方法:
不许可同步调用AsyncCallback。如果它始终异步调用回调,纵然操作同步完成,也可以肃清堆栈溢出的风险。但是这也会降落性能,由于同步完成(或太快以至于无法不雅观察)的操作非常常见,逼迫每个操作将其回调排队会增加可丈量的开销。利用一种机制,如果操作同步完成,则许可调用者而不是回调实行后续事情。这样,你可以跳过(避免)额外的方法帧,并连续在堆栈上更深层次地进行后续事情。APM模式采取选项(2)。为此,IAsyncResult接口公开了两个干系但不同的成员:IsCompleted和CompletedSynchronously。 IsCompleted见告你操作是否已完成:你可以多次检讨它,并终极将其从false转换为true,然后保持在那里。比较之下,CompletedSynchronously从不改变(或者如果它改变了,那么它便是一个等待发生的严重bug);它用于在Begin方法的调用者和AsyncCallback之间通信,哪个卖力实行任何连续事情。如果CompletedSynchronously为false,则操作正在异步完成,并且对付操作完成后作出的任何连续事情该当留给回调来处理;毕竟,如果事情没有同步完成,则Begin的调用者无法真正处理它,由于操作尚未完成(如果调用者只是调用End,则会壅塞,直到操作完成)。然而,如果CompletedSynchronously为true,则如果回调处理连续事情,则会冒着堆栈崩溃的风险,由于它将在比它开始的地方更深的堆栈上实行该连续事情。因此,任何实现都须要关注这种堆栈崩溃的情形,须要检讨CompletedSynchronously,并且如果它为true,则须要Begin方法的调用者实行连续事情,这意味着回调不须要实行连续事情。这也是为什么CompletedSynchronously绝不能改变的缘故原由:调用者和回调须要看到相同的值,以确保无论竞态条件如何,都只实行一次连续事情。
在我们之前的DoStuff示例中,这将导致以下代码:
try{ IAsyncResult ar = handler.BeginDoStuff(arg, iar => { if (!iar.CompletedSynchronously) { try { Handler handler = (Handler)iar.AsyncState!; int i = handler.EndDoStuff(iar); Use(i); } catch (Exception e2) { ... // handle exceptions from EndDoStuff and Use } } }, handler); if (ar.CompletedSynchronously) { int i = handler.EndDoStuff(ar); Use(i); }}catch (Exception e){ ... // handle exceptions that emerge synchronously from BeginDoStuff and possibly EndDoStuff/Use}
上面是一个冗长的表述。到目前为止,我们只看了如何利用该模式...我们还没有看如何实现该模式。虽然大多数开拓职员不须要关心叶子操作(例如实现与操作系统实际交互Socket.BeginReceive/EndReceive方法),但大多数开拓职员须要关注组合这些操作(实行多个异步操作,这些操作一起形成一个更大的操作),这意味着不仅要利用其他Begin/End方法,还要自己实现它们,以便你的组合本身可以在其他地方利用。而且,你会把稳到我的DoStuff示例中没有掌握流。如果将多个操作引入个中,特殊是具有大略掌握流(例如循环)的操作,那么瞬间这就成为专家的领域,他们享受痛楚,或者是博客文章作者试图表达一个不雅观点。
因此,只是为了强调这一点,让我们实现一个完全的示例。在本文的开头,我展示了一个CopyStreamToStream方法,该方法将所有数据从一个流复制到另一个流(类似于Stream.CopyTo,但是为相识释而假设不存在它):
public void CopyStreamToStream(Stream source, Stream destination){ var buffer = new byte[0x1000]; int numRead; while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0) { destination.Write(buffer, 0, numRead); }}
直不雅观来说,我们反复从一个流中读取数据,然后将结果数据写入另一个流,再从一个流中读取并写入另一个流,依此类推,直到没有更多数据可读取。那么,我们如何利用APM模式异步实现它呢?可以像这样实现:
public IAsyncResult BeginCopyStreamToStream( Stream source, Stream destination, AsyncCallback callback, object state){ var ar = new MyAsyncResult(state); var buffer = new byte[0x1000]; Action<IAsyncResult?> readWriteLoop = null!; readWriteLoop = iar => { try { for (bool isRead = iar == null; ; isRead = !isRead) { if (isRead) { iar = source.BeginRead(buffer, 0, buffer.Length, static readResult => { if (!readResult.CompletedSynchronously) { ((Action<IAsyncResult?>)readResult.AsyncState!)(readResult); } }, readWriteLoop); if (!iar.CompletedSynchronously) { return; } } else { int numRead = source.EndRead(iar!); if (numRead == 0) { ar.Complete(null); callback?.Invoke(ar); return; } iar = destination.BeginWrite(buffer, 0, numRead, writeResult => { if (!writeResult.CompletedSynchronously) { try { destination.EndWrite(writeResult); readWriteLoop(null); } catch (Exception e2) { ar.Complete(e); callback?.Invoke(ar); } } }, null); if (!iar.CompletedSynchronously) { return; } destination.EndWrite(iar); } } } catch (Exception e) { ar.Complete(e); callback?.Invoke(ar); } }; readWriteLoop(null); return ar;}public void EndCopyStreamToStream(IAsyncResult asyncResult){ if (asyncResult is not MyAsyncResult ar) { throw new ArgumentException(null, nameof(asyncResult)); } ar.Wait();}private sealed class MyAsyncResult : IAsyncResult{ private bool _completed; private int _completedSynchronously; private ManualResetEvent? _event; private Exception? _error; public MyAsyncResult(object? state) => AsyncState = state; public object? AsyncState { get; } public void Complete(Exception? error) { lock (this) { _completed = true; _error = error; _event?.Set(); } } public void Wait() { WaitHandle? h = null; lock (this) { if (_completed) { if (_error is not null) { throw _error; } return; } h = _event ??= new ManualResetEvent(false); } h.WaitOne(); if (_error is not null) { throw _error; } } public WaitHandle AsyncWaitHandle { get { lock (this) { return _event ??= new ManualResetEvent(_completed); } } } public bool CompletedSynchronously { get { lock (this) { if (_completedSynchronously == 0) { _completedSynchronously = _completed ? 1 : -1; } return _completedSynchronously == 1; } } } public bool IsCompleted { get { lock (this) { return _completed; } } }}
哇哦,纵然有了所有那些乱七八糟的东西,它仍旧不是一个很好的实现。例如,IAsyncResult实现对每个操作进行了锁定,而不因此更加无锁的办法进行,非常是原始存储的,而不是作为ExceptionDispatchInfo存储,这将使其在传播时增强其调用堆栈,每个单独操作都须要进行大量的分配(例如,为每个BeginWrite调用分配一个委托),等等。
现在,想象一下你必须为要编写的每个方法都做所有这些事情。每次你想编写一个可重用的方法来利用另一个异步操作时,你都须要做所有这些事情。如果你想编写可重用的组合器,可以有效地操作多个离散的 IAsyncResult(类似于 Task.WhenAll),那便是另一层难度;每个操作实现和公开其自己的特定于该操作的 API 意味着没有共同措辞可以以类似的办法评论辩论它们(只管一些开拓职员编写了试图通过另一层回调来减轻包袱的库,这使得 API 可以向 Begin 方法供应适当的 AsyncCallback)。
所有这些繁芜性意味着很少有人考试测验这样做,对付那些考试测验的人来说,涌现缺点很常见。公正地说,这不是对APM模式的批评。相反,这是对基于回调的异步性的一种批评。
我们都习气了当代措辞中掌握流布局供应给我们的强大和大略性,而基于回调的方法一旦引入任何合理的繁芜性,常日会违反这种构造。其他主流措辞也没有更好的替代方案。
我们须要一种更好的办法,一种从APM模式中学习、接管其精确之处并避免其缺陷的办法。有趣的是,APM模式只是一种模式;运行时、核心库和编译器没有供应任何帮助来利用或实现该模式。
基于事宜的异步模式.NET Framework 2.0 引入了一些 API,实现了用于处理异步操作的不同模式,个中一种紧张用于在客户端运用程序的高下文中实行此操作。这种基于事宜的异步模式(EAP)也作为一对成员(至少,可能更多)涌现,这次是启动异步操作的方法和侦听其完成的事宜。因此,我们前面的 DoStuff 示例可能会公开一组成员,如下所示:
class Handler{ public int DoStuff(string arg); public void DoStuffAsync(string arg, object? userToken); public event DoStuffEventHandler? DoStuffCompleted;}public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);public class DoStuffEventArgs : AsyncCompletedEventArgs{ public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) : base(error, canceled, usertoken) => Result = result; public int Result { get; }}
你可以利用 DoStuffCompleted 事宜注册你的continuation事情,然后调用 DoStuffAsync 方法; 它会启动操作,并且在该操作完成后,将从调用方异步触发 DoStuffCompleted 事宜。然后,处理程序可以运行其continuation事情,可能验证供应的 userToken 是否与它预期的匹配,从而使多个处理程序能够同时挂接到事宜。
这种模式使一些用例变得更随意马虎一些,同时使其他用例变得更加困难(鉴于前面的APM CopyStreamToStream示例,这解释了一些事情)。它并没有被广泛地推广,它在.NET Framework的一个版本中有效地涌现又消逝,只管在其任期内添加的API,如Ping.SendAsync/Ping.PingDone:
public class Ping : Component{ public void SendAsync(string hostNameOrAddress, object? userToken); public event PingCompletedEventHandler? PingCompleted; ...}
然而,它确实增加了一个值得把稳的进步,APM模式根本没有考虑到这一点,并且这一进步一贯延续到我们本日所采取的模型中:SynchronizationContext。
SynchronizationContext也是在.NET Framework 2.0中引入的,作为一个通用调度器的抽象。特殊是,SynchronizationContext最常用的方法是Post,它将事情项排队到由该高下文表示的任何调度器。例如,SynchronizationContext的基本实现只表示ThreadPool,因此SynchronizationContext.Post的基本实现只是委托给ThreadPool.QueueUserWorkItem,该方法用于哀求ThreadPool利用个中一个线程调用供应的回调,该回调具有池的一个线程上的关联状态。然而,SynchronizationContext的核心不仅仅是支持任意调度器,而是支持按照各种运用程序模型的需求进行调度的办法。
以Windows Forms这样的UI框架。与Windows上的大多数UI框架一样,控件与特定的线程干系联,并且该线程运行一个泵,该泵运行能够与这些控件交互的事情:只有该线程该当考试测验操作这些控件,任何其他想要与控件交互的线程都该当通过发送来与UI线程的泵进行交互。Windows Forms通过Control.BeginInvoke等方法使这变得随意马虎,该方法将供应的委托和参数排队,以由与该Control干系联的任何线程运行。因此,您可以编写以下代码:
private void button1_Click(object sender, EventArgs e){ ThreadPool.QueueUserWorkItem(_ => { string message = ComputeMessage(); button1.BeginInvoke(() => { button1.Text = message; }); });}
这将把ComputeMessage()的事情卸载到一个线程池线程上(以便在处理时保持UI的相应性),然后当这项事情完成时,将一个委托排队回到与button1关联的线程以更新button1的标签。这很随意马虎。WPF也有类似的功能,只是利用了其Dispatcher类型:
private void button1_Click(object sender, RoutedEventArgs e){ ThreadPool.QueueUserWorkItem(_ => { string message = ComputeMessage(); button1.Dispatcher.InvokeAsync(() => { button1.Content = message; }); });}
.NET MAUI 也有类似的东西。但是,如果我想将此逻辑放入赞助方法中怎么办?例如
// Call ComputeMessage and then invoke the update action to update controls.internal static void ComputeMessageAndInvokeUpdate(Action<string> update) {...}
然后我可以像这样利用它:
private void button1_Click(object sender, EventArgs e){ ComputeMessageAndInvokeUpdate(message => button1.Text = message);}
但是,如何实现ComputeMessageAndInvokeUpdate,使其能够在任何这些运用程序中事情呢?它是否须要硬编码以理解每个可能的UI框架?这便是SynchronizationContext的上风所在。我们可以像这样实现该方法:
internal static void ComputeMessageAndInvokeUpdate(Action<string> update){ SynchronizationContext? sc = SynchronizationContext.Current; ThreadPool.QueueUserWorkItem(_ => { string message = ComputeMessage(); if (sc is not null) { sc.Post(_ => update(message), null); } else { update(message); } });}
它利用 SynchronizationContext 作为抽象来定位任何该当利用的“调度程序”,以返回到与 UI 交互的必要环境。然后,每个运用程序模型都确保将其发布为 SynchronizationContext.Current 一个实行“精确操作”的 SynchronizationContext 派生类型。比如, Windows Forms 中有WindowsFormsSynchronizationContext:
public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable{ public override void Post(SendOrPostCallback d, object? state) => _controlToSendTo?.BeginInvoke(d, new object?[] { state }); ...}
同理,WPF 中有DispatcherSynchronizationContext:
public sealed class DispatcherSynchronizationContext : SynchronizationContext{ public override void Post(SendOrPostCallback d, Object state) => _dispatcher.BeginInvoke(_priority, d, state); ...}
ASP.NET 曾经有一个AspNetSynchronizationContext,它实际上并不关心运行的线程是什么,而是将与给定要求关联的事情序列化,这样多个线程就不会同时访问给定的 HttpContext:
internal sealed class AspNetSynchronizationContext : AspNetSynchronizationContextBase{ public override void Post(SendOrPostCallback callback, Object state) => _state.Helper.QueueAsynchronous(() => callback(state)); ...}
这也不限于此类紧张运用程序模型。例如,xunit 是一种盛行的单元测试框架,.NET 的核心存储库利用它进行单元测试,它还采取了多个自定义 SynchronizationContext。例如,您可以许可测试并走运行,但限定许可同时运行的测试数量。这是如何启用的?通过 SynchronizationContext:
public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable{ public override void Post(SendOrPostCallback d, object? state) { var context = ExecutionContext.Capture(); workQueue.Enqueue((d, state, context)); workReady.Set(); }}
MaxConcurrencySyncContext 的“Post”方法只是将事情排队到它自己的内部事情行列步队中,然后在它自己的事情线程中处理它,个中根据所需的最大并发性掌握线程数量。你懂的。
这与基于事宜的异步模式有什么关联?
EAP和SynchronizationContext同时引入,EAP规定完成事宜该当排队到启动异步操作时SynchronizationContext为当前的任何位置。为了轻微简化它(可能不敷以担保额外的繁芜性),System.ComponentModel中还引入了一些帮助器类型,特殊是AsyncOperation和AsyncOperationManager。前者只是一个元组,包装了用户供应的状态工具和捕获的SynchronizationContext,后者只是一个大略的工厂,用于捕获和创建AsyncOperation实例。然后,EAP实现将利用它们,例如Ping.SendAsync调用AsyncOperationManager.CreateOperation来捕获SynchronizationContext,然后当操作完成时,AsyncOperation的PostOperationCompleted方法将被调用以调用存储的SynchronizationContext的Post方法。
SynchronizationContext供应了一些值得一提的小玩意儿,由于它们稍后会再次涌现。特殊地,它公开了OperationStarted和OperationCompleted方法。这些virtuals方法的基本实现为空,什么都不做,但派生的实现可能会覆盖这些方法以理解正在进行的操作。这意味着EAP实现也会在每个操作的开始和结束时调用这些OperationStarted/OperationCompleted方法,以关照任何当前的SynchronizationContext并许可它跟踪事情。这对EAP模式尤其主要,由于启动异步操作的方法都是void的无返回值:你没有任何返回值可以让你单独跟踪事情。我们会回到这个问题的。
以是,我们须要比APM模式更好的东西,随后涌现的EAP引入了一些新东西,但并没有真正办理我们面临的核心问题。我们仍旧须要更好的东西。
引入Task.NET Framework 4.0引入了System.Threading.Tasks.Task类型。从实质上讲,Task只是代表某些异步操作终极完成的数据构造(其他框架称类似类型为“promise”或“future”)。创建Task来表示某个操作,当它所代表的操作逻辑上完成时,结果将存储在该Task中。这很大略。但是,Task供应的关键功能使它比IAsyncResult更加有用,它将连续性的观点内置到自身中。这个功能意味着您可以走到任何Task并哀求在其完成时异步关照,而任务本身处理同步以确保无论任务是否已经完成,仍会调用连续性。完成,尚未完成或正在与关照要求并发完成。为什么这样具有影响力?好吧,如果您回忆一下我们对旧APM模式的谈论,那么有两个紧张问题。
您必须为每个操作实现自定义IAsyncResult实现:没有内置的IAsyncResult实现可以供任何人仅用于其需求。在调用Begin方法之前,您必须知道完成时要做什么。这使得实现组合器和其他通用程序例程以花费和组成任意异步实现成为一个重大寻衅。比较之下,利用Task,该共享表示许可您在已经启动操作之后走到异步操作并在已经启动操作之后供应连续性…您不须要将该连续性供应给启动操作的方法。每个具有异步操作的人都可以天生一个Task,每个花费异步操作的人都可以花费一个Task,并且不须要进行任何自定义操作即可将两者连接起来:Task成为促进异步操作的生产者和消费者互换的通用措辞。这已经改变了.NET的面貌。稍后再说…
现在,让我们更好地理解这实际上意味着什么。我们不会深入研究Task的繁芜代码,而是采取传授教化方法来实现一个大略版本。这不是要实现一个很好的实现,而只是足够完全的功能,以帮助理解Task的本色,末了,Task实际上只是处理折衷完成旗子暗记的数据构造。我们将从只有几个字段开始:
class MyTask{ private bool _completed; private Exception? _error; private Action<MyTask>? _continuation; private ExecutionContext? _ec; ...}
我们须要一个字段来知道任务是否已完成(completed),我们须要一个字段来存储导致任务失落败的任何缺点(error);如果我们还实现了一个通用的MyTask<TResult>,则还会有一个用于存储操作成功结果的私有TResult result字段。到目前为止,这看起来很像我们之前自定义的IAsyncResult实现(当然不是巧合)。但现在是绝妙之作,continuation字段。在这个大略的实现中,我们仅支持单个连续性,但这足以阐明目的(真正的Task采取一个工具字段,该工具字段可以是单个连续性工具或连续性工具列表)。这是将在任务完成时调用的委托。
现在,一些表面积。如上所述,Task相对付先前的模型的一个基本进步是能够在启动操作之后供应连续性事情(回调)。我们须要一个方法来许可我们这样做,因此让我们添加ContinueWith:
public void ContinueWith(Action<MyTask> action){ lock (this) { if (_completed) { ThreadPool.QueueUserWorkItem(_ => action(this)); } else if (_continuation is not null) { throw new InvalidOperationException("Unlike Task, this implementation only supports a single continuation."); } else { _continuation = action; _ec = ExecutionContext.Capture(); } }}
如果任务在调用ContinueWith时已经被标记为已完成,则ContinueWith仅排队实行委托。否则,该方法将存储委托,以便在任务完成时可以排队实行延续(它还存储了称为ExecutionContext的东西,然后在稍后调用委托时利用它,但现在不用担心这部分…我们会讲到的)。足够大略。
然后,我们须要能够标记MyTask已完成,这意味着它表示的任何异步操作已经完成。为此,我们将公开两种方法,一种用于成功标记完成(“SetResult”),一种用于利用缺点标记完成(“SetException”):
public void SetResult() => Complete(null);public void SetException(Exception error) => Complete(error);private void Complete(Exception? error){ lock (this) { if (_completed) { throw new InvalidOperationException("Already completed"); } _error = error; _completed = true; if (_continuation is not null) { ThreadPool.QueueUserWorkItem(_ => { if (_ec is not null) { ExecutionContext.Run(_ec, _ => _continuation(this), null); } else { _continuation(this); } }); } }}
我们存储任何缺点,标记任务已完成,然后如果先前已注册延续,我们将排队实行它。
末了,我们须要一种方法来传播可能在任务中发生的任何非常(如果这是一个通用的MyTask<T>,则返回它的_result);为了促进某些情形,我们还许可此方法壅塞等待任务完成,我们可以利用ContinueWith来实现(延续只是旗子暗记ManualResetEventSlim,然后调用者壅塞等待完成)。
public void Wait(){ ManualResetEventSlim? mres = null; lock (this) { if (!_completed) { mres = new ManualResetEventSlim(); ContinueWith(_ => mres.Set()); } } mres?.Wait(); if (_error is not null) { ExceptionDispatchInfo.Throw(_error); }}
基本上便是这样了。当然,真正的任务要繁芜得多,须要更高效的实现,支持任意数量的连续操作,有许多关于它该当如何行为的开关(例如,连续操作是否该当按照当前所做的办法进行排队,还是作为任务完成的一部分同步调用),可以存储多个非常而不仅仅是一个,具有分外的取消知识,有大量的帮助方法来实行常见操作(例如,Task.Run 可以创建一个代表排队在线程池上调用的委托的任务),等等。但是,这个中并没有什么神奇的东西;从实质上讲,它便是我们在这里看到的。
您可能还把稳到,我的大略 MyTask 直接在其上公开了 SetResult/SetException 方法,而 Task 没有。实际上,Task 确实有这样的方法,只不过它们是内部方法,一个名为 System.Threading.Tasks.TaskCompletionSource 的类型作为任务及其完成的单独“生产者”,这样做不是出于技能必要性,而是为了将完成方法保留在仅用于消费的地方之外。然后,您可以分发一个任务而不必担心它在您之下被完成;完成旗子暗记是创建任务的任何内容的实现细节,并且通过保留 TaskCompletionSource 可以保留自己完成它的权利。(CancellationToken 和 CancellationTokenSource 遵照类似的模式:CancellationToken 只是一个构造体包装器,用于 CancellationTokenSource,供应与消费取消旗子暗记干系的公共表面区域,但没有天生旗子暗记的能力,这是一个仅限于拥有 CancellationTokenSource 访问权限的能力。)
当然,我们可以为这个 MyTask 实现类似于 Task 供应的组合器和帮助程序。想要一个大略的 MyTask.WhenAll 吗?请看:
public static MyTask WhenAll(MyTask t1, MyTask t2){ var t = new MyTask(); int remaining = 2; Exception? e = null; Action<MyTask> continuation = completed => { e ??= completed._error; // just store a single exception for simplicity if (Interlocked.Decrement(ref remaining) == 0) { if (e is not null) t.SetException(e); else t.SetResult(); } }; t1.ContinueWith(continuation); t2.ContinueWith(continuation); return t;}
想要利用 MyTask.Run ?没问题:
public static MyTask Run(Action action){ var t = new MyTask(); ThreadPool.QueueUserWorkItem(_ => { try { action(); t.SetResult(); } catch (Exception e) { t.SetException(e); } }); return t;}
那么MyTask.Delay 呢?当然:
public static MyTask Delay(TimeSpan delay){ var t = new MyTask(); var timer = new Timer(_ => t.SetResult()); timer.Change(delay, Timeout.InfiniteTimeSpan); return t;}
你懂的。
有了Task,以前在.NET中利用的所有异步模式都成为了过去式。任何先前利用APM模式或EAP模式实现异步的地方,都会有新的返回Task的方法。
ValueTaskTask仍旧是.NET异步操作的主力,每个版本都会公开新的方法,而且在生态系统中常日会返回Task和Task<TResult>。然而,Task是一个类,这意味着创建它会产生分配。就大多数情形而言,为长期异步操作多分配一个工具是微不足道的,对付除了最看重性能的操作之外的其他操作,这不会对性能产生本色性的影响。然而,正如之前所述,同步完成异步操作是相称常见的。Stream.ReadAsync被引入以返回Task<int>,但是如果你从BufferedStream中读取,由于只须要从内存缓冲区中提取数据而不是实行系统调用和真正的I/O,有很大可能许多读取将会同步完成。须要额外分配一个工具来返回这样的数据是不幸的(请把稳,这在APM中也是如此)。对付返回非泛型Task的方法,该方法可以返回一个已完成的单例任务,实际上Task已经供应了一个这样的单例,即Task.CompletedTask。但是对付Task<TResult>,不可能为每个可能的TResult缓存一个Task。我们能做些什么来使这样的同步完成更快呢?
有可能缓存一些Task<TResult>。例如,Task<bool>非常常见,而且只有两个故意义的缓存:当结果为true时缓存一个Task<bool>,当结果为false时缓存一个Task<bool>。或者,虽然我们不肯望考试测验缓存40亿个Task<int>以适应每个可能的Int32结果,但是小的Int32值非常常见,因此我们可以为-1到8之间的几个值缓存一些。对付任意类型,缺省值是一个相称常见的值,因此我们可以为每个干系类型的Result为default(TResult)的Task<TResult>缓存一个。事实上,Task.FromResult在本日(截至最近的.NET版本)便是这样做的,利用这样一些可重复利用的Task<TResult>单例的小缓存,并在适当时返回个中之一,否则为供应的确切结果值分配一个新的Task<TResult>。还可以创建其他方案来处理其他相称常见的情形。例如,在利用Stream.ReadAsync时,常日会多次调用它来读取相同数量的字节。而且常日情形下,实现可以完备知足该计数要求。这意味着Stream.ReadAsync反复返回相同的int结果值是很常见的。为了避免在这种情形下进行多次分配,多个Stream类型(如MemoryStream)将缓存它们成功返回的末了一个Task<int>,如果下一次读取也以相同的结果成功地完成,则可以再次返回相同的Task<int>,而不是创建一个新的。但是其他情形呢?在性能开销真正主要的情形下,如何更普遍地避免同步完成的分配?
这便是ValueTask<TResult>的浸染(ValueTask<TResult>的详细解释也可用)。ValueTask<TResult>最初是一个TResult和Task<TResult>的联合体。终极,忽略所有的花哨功能,这便是它的全部(或者更确切地说,曾经是的),即立即结果或将来某个时候的结果承诺:
public readonly struct ValueTask<TResult>{ private readonly Task<TResult>? _task; private readonly TResult _result; ...}
一种方法可以返回ValueTask<TResult>而不是Task<TResult>,通过较大的返回类型和更多的间接性,避免了TResult在须要返回时已经被知道的Task<TResult>分配。
然而,在某些超级极度的高性能场景中,您希望纵然在异步完成情形下也能避免Task<TResult>的分配。例如,Socket位于网络堆栈的底部,而socket上的SendAsync和ReceiveAsync是许多做事的超级热点,同步和异步完成都非常常见(大多数发送同步完成,由于数据已经被缓冲在内核中,因此许多吸收同步完成)。如果在给定的Socket上,我们可以使这样的发送和吸收免费分配,无论操作是同步完成还是异步完成,那将是多么美好啊!
这便是System.Threading.Tasks.Sources.IValueTaskSource<TResult>进入图片的地方:
public interface IValueTaskSource<out TResult>{ ValueTaskSourceStatus GetStatus(short token); void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags); TResult GetResult(short token);}
IValueTaskSource<TResult>接口许可实现为ValueTask<TResult>供应自己的后备工具,使工具能够实现诸如GetResult之类的方法以检索操作的结果,以及OnCompleted以连接操作的延续。因此,ValueTask<TResult>对其定义进行了小型变动,其Task<TResult>? _task字段被工具? _obj字段更换:
public readonly struct ValueTask<TResult>{ private readonly object? _obj; private readonly TResult _result; ...}
而 task 字段原来是 Task<TResult> 或 null,obj 字段现在也可以是 IValueTaskSource<TResult>。一旦 Task<TResult> 被标记为已完成,它将一贯保持已完成状态,并且永久不会转换回未完成状态。比较之下,实现 IValueTaskSource<TResult> 接口的工具具有完备掌握实现的能力,并且可以自由地在完成和未完成状态之间双向转换,由于 ValueTask<TResult> 的约定是给定实例只能被利用一次,因此按布局办法来看,在底层实例被消费后不应该不雅观察到后续变动(这便是为什么存在 CA2012 等剖析规则)。这使得像 Socket 这样的类型可以池化 IValueTaskSource<TResult> 实例以用于重复调用。Socket 最多缓存两个这样的实例,一个用于读取,一个用于写入,由于 99.999% 的情形下,同时最多只有一个吸收和一个发送处于进行中。
我提到了 ValueTask<TResult>,但没有提到 ValueTask。仅在避免同步完成的情形下避免分配时,非泛型 ValueTask(表示无结果、void 操作)险些没有性能好处,由于相同的条件可以用 Task.CompletedTask 表示。但是,一旦我们关心利用可池化的底层工具来避免在异步完成情形下分配,那对付非泛型也很主要。因此,当引入 IValueTaskSource<TResult> 时,也引入了 IValueTaskSource 和 ValueTask。
因此,我们拥有 Task、Task<TResult>、ValueTask 和 ValueTask<TResult>。我们能够以各种办法与它们交互,表示任意异步操作,并连接连续项以处理这些异步操作的完成。是的,我们可以在操作完成之前或之后这样做。
但是……这些连续项仍旧是回调!
我们仍旧被迫采取连续通报样式来编码我们的异步掌握流!
!
仍旧很难搞定!
!
!
我们该如何办理这个问题?
C# 迭代器来补救实际上,办理方案的曙光涌如今 Task 之前的几年,也便是 C# 2.0 加入迭代器支持时。
“迭代器?”你问道:“你是说 IEnumerable<T> 的迭代器吗?”没错。迭代器许可您编写一个单一的方法,然后由编译器利用该方法实现 IEnumerable<T> 和/或 IEnumerator<T>。例如,如果我想创建一个可列举的序列,该序列产生斐波那契数列,我可能会编写类似于这样的代码:
public static IEnumerable<int> Fib(){ int prev = 0, next = 1; yield return prev; yield return next; while (true) { int sum = prev + next; yield return sum; prev = next; next = sum; }}
然后我可以用 foreach 列举它:
foreach (int i in Fib()){ if (i > 100) break; Console.Write($"{i} ");}
我可以通过 System.Linq.Enumerable 上的组合器将它与其他 IEnumerable<T> 组合起来:
foreach (int i in Fib().Take(12)){ Console.Write($"{i} ");}
或者我可以直接通过 IEnumerator<T> 手动列举它:
using IEnumerator<int> e = Fib().GetEnumerator();while (e.MoveNext()){ int i = e.Current; if (i > 100) break; Console.Write($"{i} ");}
以上所有结果都会产生此输出:
0 1 1 2 3 5 8 13 21 34 55 89
有趣的是,为了实现上述目的,我们须要能够多次进入和退出Fib方法。我们调用MoveNext方法,它进入方法,然后方法实行直到碰着yield return。此时,对MoveNext的调用须要返回true和返回yield值。然后我们再次调用MoveNext,我们须要能够在上一次离开的位置之后立即在Fib中规复,并利用上一次调用的所有状态。迭代器实际上是由C#措辞/编译器供应的协同程序,编译器将我的Fib迭代器扩展为完全的状态机:
public static IEnumerable<int> Fib() => new <Fib>d__0(-2);[CompilerGenerated]private sealed class <Fib>d__0 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable{ private int <>1__state; private int <>2__current; private int <>l__initialThreadId; private int <prev>5__2; private int <next>5__3; private int <sum>5__4; int IEnumerator<int>.Current => <>2__current; object IEnumerator.Current => <>2__current; public <Fib>d__0(int <>1__state) { this.<>1__state = <>1__state; <>l__initialThreadId = Environment.CurrentManagedThreadId; } private bool MoveNext() { switch (<>1__state) { default: return false; case 0: <>1__state = -1; <prev>5__2 = 0; <next>5__3 = 1; <>2__current = <prev>5__2; <>1__state = 1; return true; case 1: <>1__state = -1; <>2__current = <next>5__3; <>1__state = 2; return true; case 2: <>1__state = -1; break; case 3: <>1__state = -1; <prev>5__2 = <next>5__3; <next>5__3 = <sum>5__4; break; } <sum>5__4 = <prev>5__2 + <next>5__3; <>2__current = <sum>5__4; <>1__state = 3; return true; } IEnumerator<int> IEnumerable<int>.GetEnumerator() { if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId) { <>1__state = 0; return this; } return new <Fib>d__0(0); } IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<int>)this).GetEnumerator(); void IEnumerator.Reset() => throw new NotSupportedException(); void IDisposable.Dispose() { }}
现在,Fib的所有逻辑都在MoveNext方法中,但作为跳转表的一部分,它可以使实现分支到它上次停滞的位置,该位置在列举器类型的天生状态字段中跟踪。我编写确当地变量,例如prev、next和sum,已经被“提升”为列举器上的字段,以便它们可以在MoveNext的调用之间持久存在。
(请把稳,之前显示C#编译器如何发出实现的代码片段不会直接编译。C#编译器合成“不可言喻”的名称,这意味着它在创建类型和成员时利用一种有效的IL但无效的C#办法命名,以免冲突任何用户命名的类型和成员。我将所有名称都保留为编译器的名称,但如果您想考试测验编译它,可以将名称重命名为利用有效的C#名称。)
在我的上一个示例中,我展示了末了一种列举的形式涉及手动利用IEnumerator<T>。在这个级别上,我们手动调用MoveNext(),决定何时重新进入协程。但是...如果我可以让下一个MoveNext的调用实际上是异步操作完成时实行的续体事情的一部分呢?如果我可以yield return某些表示异步操作的内容,并且让消费代码将续体连接到该yielded工具上,然后该续体实行MoveNext呢?通过这种方法,我可以编写一个助手方法,例如:
static Task IterateAsync(IEnumerable<Task> tasks){ var tcs = new TaskCompletionSource(); IEnumerator<Task> e = tasks.GetEnumerator(); void Process() { try { if (e.MoveNext()) { e.Current.ContinueWith(t => Process()); return; } } catch (Exception e) { tcs.SetException(e); return; } tcs.SetResult(); }; Process(); return tcs.Task;}
现在这变得更有趣了。我们被授予了一组任务,我们可以遍历这些任务。每次我们将MoveNext移到下一个任务并获取一个任务时,我们就会为该任务连接一个连续操作;当该任务完成时,它将返回到实行MoveNext的相同逻辑,获取下一个任务,以此类推。这是建立在任务作为任何异步操作的单个表示的思想根本上的,因此我们吸收到的可列举工具可以是任何异步操作的序列。这样的序列可能来自于迭代器。还记得之前我们的CopyStreamToStream示例吗?那个基于APM的实现非常糟糕。比较之下,可以考虑下面的实现:
static Task CopyStreamToStreamAsync(Stream source, Stream destination){ return IterateAsync(Impl(source, destination)); static IEnumerable<Task> Impl(Stream source, Stream destination) { var buffer = new byte[0x1000]; while (true) { Task<int> read = source.ReadAsync(buffer, 0, buffer.Length); yield return read; int numRead = read.Result; if (numRead <= 0) { break; } Task write = destination.WriteAsync(buffer, 0, numRead); yield return write; write.Wait(); } }}
哇,这险些是可读的。我们正在调用IterateAsync助手,我们正在向其供应的可列举工具是由一个迭代器产生的,该迭代器处理了全体复制的掌握流。它调用Stream.ReadAsync,然后yield返回那个任务;当IterateAsync调用MoveNext并交出这个任务时,那个任务就会被挂起,当它完成时,将会调用回MoveNext,并回到yield后面的迭代器。此时,Impl逻辑会获取方法的结果,调用WriteAsync,并再次yield它天生的任务。以此类推。
这,朋友们,便是C#和.NET中异步/等待的开始。在C#编译器中,支持迭代器和异步/等待的逻辑约95%是共享的。不同的语法,涉及不同的类型,但基本上是相同的转换。看一下yield返回,你险些可以看到它们的代替物await。
事实上,在异步/等待涌现之前,一些有远见的开拓职员就已经将迭代器用于此类异步编程。类似的转换在实验性的Axum编程措辞中进行了原型设计,成为C#异步支持的关键灵感。Axum供应了一个可以放在方法上的async关键字,就像现在C#中的async一样。Task还不是普遍存在的,因此在异步方法中,Axum编译器启示式地将同步方法调用匹配到它们的APM对应方法,例如,如果它创造您调用stream.Read,则会查找并利用相应的stream.BeginRead和stream.EndRead方法,合成适当的委托通报给开始方法,同时还为正在定义的异步方法天生完全的APM实现,使其具有组合性。它乃至与SynchronizationContext集成!
虽然Axum终极被搁置了,但它作为C#中异步/等待的原型供应了一个令人敬畏和勉励的样例。
现在我们知道我们是如何到达这里的,让我们深入理解它的实际事情事理。为了参考,这里再次放出我们的同步方法示例:
public void CopyStreamToStream(Stream source, Stream destination){ var buffer = new byte[0x1000]; int numRead; while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0) { destination.Write(buffer, 0, numRead); }}
下面是利用 async/await 的相应方法的样子:
public async Task CopyStreamToStreamAsync(Stream source, Stream destination){ var buffer = new byte[0x1000]; int numRead; while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0) { await destination.WriteAsync(buffer, 0, numRead); }}
比较我们迄今为止看到的所有内容,这是一股新鲜的空气。署名从void变动为async Task,我们分别调用ReadAsync和WriteAsync而不是Read和Write,并且这两个操作都带有await前缀。便是这样。编译器和核心库接管了别的部分,从根本上改变了代码实际实行的办法。让我们深入理解一下。
编译器转换正如我们已经看到的,与迭代器一样,编译器将异步方法重写为基于状态机的方法。我们仍旧有一个与开拓职员编写的相同署名的方法(public Task CopyStreamToStreamAsync(Stream source,Stream destination)),但该方法的主体完备不同:
[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))]public Task CopyStreamToStreamAsync(Stream source, Stream destination){ <CopyStreamToStreamAsync>d__0 stateMachine = default; stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create(); stateMachine.source = source; stateMachine.destination = destination; stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task;}private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine{ public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Stream source; public Stream destination; private byte[] <buffer>5__2; private TaskAwaiter <>u__1; private TaskAwaiter<int> <>u__2; ...}
请把稳,与dev编写的唯一署名差异是短缺async关键字本身。async实际上不是方法署名的一部分;与unsafe一样,当您将其放在方法署名中时,您正在表示方法的实现细节,而不是作为条约的一部分实际公开的内容。利用async / await实现返回任务的方法是实现细节。
编译器已天生名为<CopyStreamToStreamAsync> d__0的构造体,并在堆栈年夜将该构造体的实例初始化为零。主要的是,如果异步方法同步完成,则该状态机将从未离开过堆栈。这意味着除非该方法须要异步完成(即等待某些在该点之前尚未完成的内容),否则与状态机干系的任何分配都不存在。稍后再详细先容这一点。
该构造是该方法的状态机,不仅包含开拓职员编写的所有变换逻辑,还包括用于跟踪该方法中当前位置的字段以及编译器提取出的须要在MoveNext调用之间存活的所有“本地”状态。它是我们在迭代器中看到的IEnumerable<T> / IEnumerator<T>实现的逻辑等效物。(请把稳,我展示的代码来自发行版本;在调试版本中,C#编译器实际上会将这些状态机类型天生为类,由于这样做可以在某些调试练习中有所帮助)。
在初始化状态机之后,我们看到一个调用AsyncTaskMethodBuilder.Create()的调用。虽然我们目前专注于任务,但C#措辞和编译器许可从异步方法返回任意类型(“类似任务”的类型),例如我可以编写一个public async MyTask CopyStreamToStreamAsync的方法,只要我们以适当的办法增强我们之前定义的MyTask即可。这种适当性包括声明干系的“builder”类型并通过AsyncMethodBuilder属性将其与类型干系联:
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]public class MyTask{ ...}public struct MyTaskMethodBuilder{ public static MyTaskMethodBuilder Create() { ... } public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { ... } public void SetStateMachine(IAsyncStateMachine stateMachine) { ... } public void SetResult() { ... } public void SetException(Exception exception) { ... } public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { ... } public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { ... } public MyTask Task { get { ... } }}
在这个高下文中,“builder”是指知道如何创建该类型的实例(Task属性),在适当的情形下完成它(SetResult)并返回结果或抛出非常(SetException),并处理将连续连接到尚未完成的awaited事物的钩子(AwaitOnCompleted/AwaitUnsafeOnCompleted)。在System.Threading.Tasks.Task的情形下,默认情形下与AsyncTaskMethodBuilder干系联。常日情形下,这种关联是通过运用于类型的[AsyncMethodBuilder(...)]属性供应的,但是Task被C#特殊知道,因此实际上没有利用该属性进行润色。因此,编译器已经获取了用于此异步方法的构建器,并利用Create方法布局了它的一个实例,该方法是该模式的一部分。请把稳,与状态机一样,AsyncTaskMethodBuilder也是一个构造体,因此这里也没有分配。
然后,状态机将添补此入口点方法的参数。这些参数须要在已移入MoveNext的方法体中可用,并且因此这些参数须要存储在状态机中,以便它们可以在后续调用MoveNext时被引用。状态机也被初始化为处于初始状态-1。如果调用MoveNext并且状态为-1,我们将从逻辑上开始方法的开头。
现在是最不起眼但最主要的一行:调用天生器的Start方法。这是模式的另一部分,必须在异步方法的返回位置上利用的类型上公开,用于在状态机上实行初始MoveNext。天生器的Start方法实际上只是这样的:
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{ stateMachine.MoveNext();}
这样,调用stateMachine.<>t__builder.Start(ref stateMachine)实际上只是调用stateMachine.MoveNext()。那么为什么编译器不直接发出它呢?为什么还须要Start?答案是Start有一点更多的内容,但是为了理解ExecutionContext,我们须要简要理解一下。
ExecutionContext 实行高下文我们都很熟习从方法到方法通报状态的过程。你调用一个方法,如果该方法指定了参数,你就调用带有参数的方法,以便将数据输入到被调用者中。这是显式地通报数据。但是还有其他更隐含的办法。例如,一个方法可以是无参的,但可以指示在进行方法调用之前添补某些特定的静态字段,方法将从那里提取状态。方法的署名没有指示它须要参数,由于它没有:调用者和被调用者之间存在一个隐含的协定,即调用者可能会添补某些内存位置,而被调用者可能会读取这些内存位置。如果它们是中介者,纵然调用者和被调用者可能并不知道正在发生什么,例如,方法A可能添补静态字段,然后调用B,B调用C,C调用D,终极调用E读取这些静态字段的值。这常日被称为“环境”数据:它不是通过参数通报给你的,而是只是在那里,并且如果须要,可以利用它。
我们可以更进一步,利用线程本地状态。线程本地状态在.NET中通过被标记为[ThreadStatic]的静态字段或ThreadLocal<T>类型实现,可以以相同的办法利用,但是数据仅限于当前实行线程,每个线程都可以拥有其自己隔离的字段副本。有了这个,你可以添补线程静态字段,进行方法调用,然后在方法完成时还原对线程静态字段的变动,从而实现这种隐式通报数据的完备隔离形式。
但是,异步操作如何处理?如果我们进行异步方法调用,并且该异步方法内部的逻辑想要访问该环境数据,该怎么办?如果数据存储在普通静态字段中,异步方法将能够访问它,但是你每次只能有一个这样的方法正在运行,由于多个调用者可能会在写入这些共享静态字段时覆盖彼此的状态。如果数据存储在线程静态字段中,异步方法将能够访问它,但是仅在它在调用线程上同步运行的点之前;如果它将一个continuation连接到某个它启动的操作,而该continuation终极在某个其他线程上运行,它将不再具有访问线程静态信息的能力。纵然它恰巧在同一个线程上运行,或者由于调度程序逼迫它这样做,到它运行时,数据可能已被该线程启动的某些其他操作删除和/或覆盖。对付异步操作,我们须要一种机制,该机制许可任意环境数据在这些异步点之间流动,以便在异步方法的逻辑中,无论逻辑何时何地运行,它都可以访问相同的数据。
进入ExecutionContext。ExecutionContext类型是异步操作之间环境数据流动的工具。它存在于[ThreadStatic]中,但当某些异步操作被启动时,它就会被“捕获”(一种说法是“从线程静态变量中读取副本”),存储起来,然后在异步操作的连续运行时,ExecutionContext首先被规复到在即将运行操作的线程的[ThreadStatic]中。ExecutionContext是AsyncLocal<T>实现的机制(事实上,在.NET Core中,ExecutionContext完备是关于AsyncLocal<T>的,没有其他浸染),因此,如果将值存储到AsyncLocal<T>中,然后例如将事情项排队到线程池上运行,那么该值将在运行池中的事情项内部的AsyncLocal<T>中可见:
var number = new AsyncLocal<int>();number.Value = 42;ThreadPool.QueueUserWorkItem(_ => Console.WriteLine(number.Value));number.Value = 0;Console.ReadLine();
这将每次运行时打印42。纵然我们在行列步队委托后立即将AsyncLocal<int>的值重置为0,也没有关系,由于ExecutionContext是作为QueueUserWorkItem调用的一部分而被捕获的,该捕获包括AsyncLocal<int>在那个确切时候的状态。我们可以通过实现自己的大略线程池来更详细地理解这一点:
using System.Collections.Concurrent;var number = new AsyncLocal<int>();number.Value = 42;MyThreadPool.QueueUserWorkItem(() => Console.WriteLine(number.Value));number.Value = 0;Console.ReadLine();class MyThreadPool{ private static readonly BlockingCollection<(Action, ExecutionContext?)> s_workItems = new(); public static void QueueUserWorkItem(Action workItem) { s_workItems.Add((workItem, ExecutionContext.Capture())); } static MyThreadPool() { for (int i = 0; i < Environment.ProcessorCount; i++) { new Thread(() => { while (true) { (Action action, ExecutionContext? ec) = s_workItems.Take(); if (ec is null) { action(); } else { ExecutionContext.Run(ec, s => ((Action)s!)(), action); } } }) { IsBackground = true }.UnsafeStart(); } }}
这里的MyThreadPool具有一个BlockingCollection<(Action, ExecutionContext?)>,表示它的事情项行列步队,每个事情项都是要调用的事情委托以及与该事情干系联的ExecutionContext。该池的静态布局函数会启动一堆线程,每个线程只是在一个无限循环中取出下一个事情项并运行它。如果没有为给定的委托捕获ExecutionContext,则直接调用该委托。但是,如果捕获了一个ExecutionContext,则不是直接调用该委托,而是调用ExecutionContext.Run方法,该方法将在运行委托之前将供应的ExecutionContext规复为当前高下文,然后在运行委托后重置高下文。此示例包括先前显示的AsyncLocal<int>完备相同的代码,只是这次利用的是MyThreadPool而不是ThreadPool,但每次仍将输出42,由于池正在精确地流动ExecutionContext。
顺便说一句,你会把稳到我在MyThreadPool的静态布局函数中调用了UnsafeStart。启动新线程正是该当流动ExecutionContext的异步点,事实上,Thread的Start方法利用ExecutionContext.Capture来捕获当前高下文,将其存储在线程上,然后在终极调用Thread的ThreadStart委托时利用该捕获的高下文。但是,我在这个示例中不想这样做,由于我不想让线程在静态布局函数运行时捕获任何ExecutionContext(这样做可能会使关于ExecutionContext的演示更加繁芜),因此我利用了UnsafeStart方法。以Unsafe开头的与线程干系的方法与不带Unsafe前缀的相应方法完备相同,只是它们不会捕获ExecutionContext,例如Thread.Start和Thread.UnsafeStart实行相同的事情,但是Start会捕获ExecutionContext,而UnsafeStart则不会。
回到开始当我写AsyncTaskMethodBuilder.Start的实现时,我们在谈论ExecutionContext时走了弯路。我曾经这样说过Start的实现办法是这样的:
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{ stateMachine.MoveNext();}
然后我建议简化一下。但是这种简化忽略了一个事实,即该方法实际上须要将ExecutionContext纳入考虑,并且更像这样:
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{ ExecutionContext previous = Thread.CurrentThread._executionContext; // [ThreadStatic] field try { stateMachine.MoveNext(); } finally { ExecutionContext.Restore(previous); // internal helper }}
我们不再像之前建议的那样仅仅调用stateMachine.MoveNext(),而是进行了一些操作:获取当前的ExecutionContext,然后调用MoveNext方法,在其完成后将当前高下文重置为MoveNext调用之前的高下文。
这样做的缘故原由是为了防止异步方法中的环境数据泄露到调用方。下面的示例方法解释了这一点:
async Task ElevateAsAdminAndRunAsync(){ using (WindowsIdentity identity = LoginAdmin()) { using (WindowsImpersonationContext impersonatedUser = identity.Impersonate()) { await DoSensitiveWorkAsync(); } }}
“Impersonation”是指变动有关当前用户的环境信息,使其成为其他人的信息;这使得代码可以代表其他人利用其权限和访问。在.NET中,这样的仿照流经异步操作,因此它是ExecutionContext的一部分。现在想象一下如果Start没有规复先前的高下文,并考虑以下代码:
Task t = ElevateAsAdminAndRunAsync();PrintUser();await t;
当调用Impersonate之后,此代码可能创造在ElevateAsAdminAndRunAsync内部修正的ExecutionContext在ElevateAsAdminAndRunAsync返回到其同步调用方时仍旧存在(这发生在方法等待某些尚未完成的任务的第一次await时)。假设该任务尚未完成,它将导致ElevateAsAdminAndRunAsync调用停息并返回到调用者,当前哨程上的仿照仍旧有效。这不是我们想要的。因此,Start设置了这个保护方法,确保对ExecutionContext的任何修正不会流出同步方法调用,而只会随着方法实行的任何后续事情一起流动。
MoveNext因此,调用了入口点方法,初始化状态机构造,调用了Start方法,这又调用了MoveNext方法。MoveNext是什么?它包含了开拓职员方法的所有原始逻辑,但是有很多改变。让我们先看看方法的框架。这是编译器为我们的方法发出的反编译版本,但是将天生的try块中的所有内容移除:
private void MoveNext(){ try { ... // all of the code from the CopyStreamToStreamAsync method body, but not exactly as it was written } catch (Exception exception) { <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetException(exception); return; } <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetResult();}
MoveNext方法完成了异步任务方法返回的任务(Task)时,还要处理其他事情。如果try块的主体引发未处理的非常,则任务将带有该非常被故障。如果异步方法成功到达其结束点(相称于同步方法返回),则将成功完成返回的任务。在这两种情形下,它都设置状态机的状态以指示完成。(有时我听到开拓职员理论化,认为在第一次等待之前和之后引发的非常之间存在差异……基于上述缘故原由,该当清楚这不是这种情形。任何未处理的异步方法内的非常,无论在方法的哪个位置,无论该方法是否已经被挂起,都将在上述catch块中结束,然后将被存储到从异步方法返回的任务中。)
还要把稳的是,此完成通过天生器进行,利用其SetException和SetResult方法,这些方法是编译器预期天生器模式的一部分。如果异步方法以前已经被挂起,天生器将已经必须制造一个任务作为该挂起处理的一部分(我们很快将看到如何以及在哪里处理),在这种情形下,调用SetException/SetResult将完成该任务。然而,如果异步方法以前没有挂起,则我们还没有创建任务或向调用者返回任何内容,因此天生器在如何天生任务方面具有更大的灵巧性。如果您还记得先前的入口点方法,它做的末了一件事是将任务返回给调用方,它通过返回访问天生器的Task属性(许多称为“Task”的东西,我知道)来完成。
public Task CopyStreamToStreamAsync(Stream source, Stream destination){ ... return stateMachine.<>t__builder.Task;}
这位天生器知道如果方法曾经被停息,在这种情形下它有一个已经创建的Task,并且只返回那个Task。如果方法从未停息,而且天生器还没有Task,那么它可以在这里创建一个已完成的Task。在这种情形下,如果成功完成,它可以利用Task.CompletedTask而不是分配一个新的Task,避免任何分配。对付泛型Task<TResult>,天生器可以利用Task.FromResult<TResult>(TResult result)。
天生器还可以根据其要创建的工具进行任何它认为适当的转换。例如,Task实际上有三种可能的终极状态:成功、失落败和取消。AsyncTaskMethodBuilder的SetException方法分外处理OperationCanceledException,如果供应的非常是OperationCanceledException或派生自OperationCanceledException,则将Task转换为TaskStatus.Canceled终极状态;否则,任务以TaskStatus.Faulted结束。这种差异常日在消费代码中不明显;由于非常被存储到Task中,无论它被标记为已取消或失落败,等待该Task的代码都无法不雅观察到状态之间的差异(原始非常将在任何情形下都被传播)...它只影响直接与Task交互的代码,例如通过ContinueWith,它具有使连续仅针对子集完成状态被调用的重载。
现在我们理解了生命周期方面的内容,这里是MoveNext中try块中填写的所有内容:
private void MoveNext(){ try { int num = <>1__state; TaskAwaiter<int> awaiter; if (num != 0) { if (num != 1) { <buffer>5__2 = new byte[4096]; goto IL_008b; } awaiter = <>u__2; <>u__2 = default(TaskAwaiter<int>); num = (<>1__state = -1); goto IL_00f0; } TaskAwaiter awaiter2 = <>u__1; <>u__1 = default(TaskAwaiter); num = (<>1__state = -1); IL_0084: awaiter2.GetResult(); IL_008b: awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter(); if (!awaiter.IsCompleted) { num = (<>1__state = 1); <>u__2 = awaiter; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; } IL_00f0: int result; if ((result = awaiter.GetResult()) != 0) { awaiter2 = destination.WriteAsync(<buffer>5__2, 0, result).GetAwaiter(); if (!awaiter2.IsCompleted) { num = (<>1__state = 0); <>u__1 = awaiter2; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this); return; } goto IL_0084; } } catch (Exception exception) { <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetException(exception); return; } <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetResult();}
这种繁芜性可能会让人有些熟习。还记得我们手动实现的基于APM的BeginCopyStreamToStream有多繁芜吗?这个方法并不是那么繁芜,但它更好的地方在于编译器为我们做了这些事情,将该方法重写为一种连续通报的形式,同时确保所有必要的状态都被保存以供这些连续利用。纵然如此,我们也可以仔细阅读并理解。记得状态在入口点被初始化为-1。然后我们进入MoveNext,创造此状态(现在存储在num本地变量中)既不是0也不是1,因此我们实行创建临时缓冲区的代码,然后分支到标签IL_008b,从那里调用stream.ReadAsync。请把稳,在这一点上,我们仍旧是从MoveNext同步运行的,因此也是从Start同步运行的,从入口点同步运行,这意味着开拓职员的代码调用了CopyStreamToStreamAsync,它仍旧在同步实行,尚未返回一个表示该方法终极完成的Task。这可能即将发生改变...
我们调用Stream.ReadAsync,从中得到一个Task<int>。读取可能已经同步完成,也可能异步完成,但速率非常快,已经完成,或者可能尚未完成。无论如何,我们都有了一个表示其终极完成的Task<int>,编译器天生的代码检讨这个Task<int>以确定如何连续:如果Task<int>实际上已经完成了(无论它是同步完成还是仅在我们检讨时完成),那么该方法的代码可以连续同步运行...没有必要花费不必要的开销来排队处理该方法实行的别的部分,我们反而可以连续在此处运行。但是为了处理Task<int>尚未完成的情形,编译器须要发出将连续连接到Task的代码。因此,它须要发出讯问Task“你做完了吗?”的代码。它直接与Task交谈吗?
如果在C#中您只能等待System.Threading.Tasks.Task,那将是有限定的。同样,在C#编译器必须知道每个可能被等待的类型的情形下,也会有限定。相反,C#常日在这种情形下采取API的模式。代码可以等待任何公开适当模式的内容,即“等待器”模式(就像您可以对供应适当“可列举”模式的任何内容进行foreach一样)。例如,我们可以增强我们之前编写的MyTask类型以实现等待器模式:
class MyTask{ ... public MyTaskAwaiter GetAwaiter() => new MyTaskAwaiter { _task = this }; public struct MyTaskAwaiter : ICriticalNotifyCompletion { internal MyTask _task; public bool IsCompleted => _task._completed; public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation()); public void UnsafeOnCompleted(Action continuation) => _task.ContinueWith(_ => continuation()); public void GetResult() => _task.Wait(); }}
如果一个类型公开了GetAwaiter()方法,就可以利用await等待它,而Task便是这样的类型。GetAwaiter()方法须要返回一个工具,该工具包含多个成员,个中包括一个IsCompleted属性,该属性用于在调用IsCompleted时检讨操作是否已完成。您可以在此处看到它的发生:在IL_008b处,从ReadAsync返回的Task上调用GetAwaiter()方法,然后在那个构造体awaiter实例上访问IsCompleted。如果IsCompleted返回true,则我们将跳转到IL_00f0,个中代码调用awaiter的另一个成员:GetResult()。如果操作失落败,则GetResult()卖力抛出非常以将其传播到异步方法中的await之外;否则,GetResult()卖力返回操作的结果(如果有)。在此处的ReadAsync中,如果结果为0,则我们跳出了读/写循环,进入方法的末端,调用SetResult,完成任务。
然而,回到刚才的话题,如果IsCompleted检讨返回false,那么最有趣的部分便是会发生什么。如果返回true,我们将连续处理循环,类似于在APM模式中CompletedSynchronously返回true时,调用Begin方法的调用者(而不是回调)卖力连续实行。但是,如果IsCompleted返回false,则须要停息异步方法的实行,直到await的操作完成。这意味着从MoveNext返回,并且由于这是Start的一部分,而我们仍旧在入口点方法中,因此须要将Task返回给调用方。但在所有这些之前,我们须要将一个连续项挂接到正在等待的Task上(请把稳,为了避免像APM案例中的堆栈潜入一样,如果异步操作在IsCompleted返回false之后完成,但在我们到达时尚未挂接连续项,则连续项仍旧须要从调用线程异步调用,因此它将被排队)。由于我们可以await任何东西,因此不能直接对Task实例进行操作;相反,我们须要通过一些基于模式的方法来实行此操作。
这是否意味着awaiter上存在一个方法来连接continuation?这是故意义的;毕竟,Task本身支持continuation,有ContinueWith方法等等......难道不应该是从GetAwaiter返回的TaskAwaiter公开了许可我们设置continuation的方法吗?实际上是这样的。awaiter模式哀求awaiter实现INotifyCompletion接口,个中包含一个单一方法void OnCompleted(Action continuation)。awaiter还可以选择实现ICriticalNotifyCompletion接口,它继续了INotifyCompletion并添加了一个void UnsafeOnCompleted(Action continuation)方法。根据我们之前对ExecutionContext的谈论,您可以猜到这两种方法之间的差异:两种方法都连接continuation,但是OnCompleted该当流传ExecutionContext,而UnsafeOnCompleted则不须要。这里须要两种不同方法的缘故原由,INotifyCompletion.OnCompleted和ICriticalNotifyCompletion.UnsafeOnCompleted,紧张是历史缘故原由,与代码访问安全性(Code Access Security,CAS)有关。在.NET Core中,CAS已经不存在,并且在.NET Framework中默认关闭,只有在选择遗留的部分信赖功能时才有浸染。当利用部分信赖时,CAS信息作为ExecutionContext的一部分流动,因此不流动是“不屈安”的,因此未流动ExecutionContext的方法被标记为“Unsafe”。此类方法也被标记为[SecurityCritical],部分可信代码无法调用[SecurityCritical]方法。因此,创建了两个OnCompleted的变体,编译器优先利用UnsafeOnCompleted(如果供应),但在必要时始终供应OnCompleted变体,以防awaiter须要支持部分信赖。然而,从异步方法的角度来看,构建器始终在await点之间通报ExecutionContext,因此awaiter也这样做是不必要和重复的事情。
好的,当须要停息时,awaiter暴露了一种方法来连接连续实行的方法。编译器可以直策应用它,但有一个非常关键的问题:连续实行的方法该当是什么?更主要的是,该当与什么工具干系联?请记住,状态机构造体在堆栈上,而我们目前正在运行的MoveNext调用是该实例上的方法调用。我们须要保留状态机,以便在规复时拥有所有精确的状态,这意味着状态机不能仅仅连续留在堆栈上;它须要被复制到堆上的某个地方,由于堆栈终极将用于此线程实行的其他后续、无关的事情。然后,连续实行须要在堆上的状态机副本上调用MoveNext方法。
此外,ExecutionContext在这里也是干系的。状态机须要确保在停息点捕获ExecutionContext中存储的任何环境数据,然后在规复点运用该数据,这意味着连续实行还须要合并该ExecutionContext。因此,仅创建指向状态机上MoveNext的委托是不足的。这也是不必要的开销。如果在我们停息时创建一个指向状态机上MoveNext的委托,每次这样做时,我们都将装箱状态机构造体(纵然它已经作为其他工具的一部分在堆上),并分配一个额外的委托(委托的this工具引用将指向新装箱的构造体的副本)。因此,我们须要做一个繁芜的舞蹈,确保我们只在方法第一次停息实行时将构造体从堆栈提升到堆上,但在所有其他韶光利用相同的堆工具作为MoveNext的目标,并在此过程中确保我们已经捕获了精确的高下文,并在规复时确保我们正在利用捕获的高下文来调用操作。
这比我们希望编译器发出的逻辑要多得多……相反,我们希望它封装在一个帮助程序中,缘故原由有几个。首先,这是将繁芜代码发出到每个用户的程序集中的大量繁芜代码。其次,我们希望许可在实现构建器模式的过程中定制该逻辑(我们稍后将看到为什么要这样做的示例,当我们评论辩论池化时)。第三,我们希望能够发展和改进该逻辑,并让现有的先前编译的二进制文件变得更好。这并不是一个假设性的问题;在.NET Core 2.1中,此支持的库代码已完备改写,因此操作比.NET Framework上的操作要高效得多。我们首先将磋商在.NET Framework上如何事情,然后再看看在.NET Core中发生了什么。
您可以看到由C#编译器天生的代码在停息时发生了什么:
if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false{ <>1__state = 1; <>u__2 = awaiter; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return;}
这个AwaitUnsafeOnCompleted方法的实现太繁芜了,无法在这里复制,以是我将总结一下在.NET Framework上它的浸染:
它利用ExecutionContext.Capture()来获取当前高下文。然后它分配一个MoveNextRunner工具来包装捕获的高下文以及装箱的状态机(如果这是方法第一次停息,我们还没有它,以是我们只利用null作为占位符)。然后它创建一个指向MoveNextRunner上的Run方法的Action委托;这便是它如何能够获取一个委托,在捕获的ExecutionContext高下文中调用状态机的MoveNext。如果这是方法第一次停息,我们还没有装箱的状态机,以是此时它将其装箱,通过将实例存储到一个作为IAsyncStateMachine接口类型确当地变量中,在堆上创建一个副本。然后将该箱存储到已分配的MoveNextRunner中。现在是一个有点令人费解的步骤。如果回顾一下状态机构造的定义,它包含构建器,即public AsyncTaskMethodBuilder<>t__builder;,如果回顾一下构建器的定义,则包含internal IAsyncStateMachine m_stateMachine;。构建器须要引用装箱的状态机,以便在后续停息时它可以看到已经装箱了状态机,不须要再次装箱。但是我们刚刚装箱了状态机,而该状态机包含了一个构建器,该构建器的m_stateMachine字段为空。我们须要改变该装箱状态机的构建器的m_stateMachine,使其指向其父级盒子。为了实现这一点,编译器天生的状态机构造实现了IAsyncStateMachine接口,个中包括一个void SetStateMachine(IAsyncStateMachine stateMachine)方法,该状态机构造包括该接口方法的实现:private void SetStateMachine(IAsyncStateMachine stateMachine) =><>t__builder.SetStateMachine(stateMachine);因此,构建器装箱状态机,然后将该箱通报给该箱的SetStateMachine方法,该方法调用构建器的SetStateMachine方法,将该箱存储到该字段中。末了,我们有一个表示连续的Action,并将其通报给awaiter的UnsafeOnCompleted方法。在TaskAwaiter的情形下,任务将该操作存储到任务的连续列表中,以便在任务完成时,它将调用该操作,通过MoveNextRunner.Run回调,通过ExecutionContext.Run回调,终极调用状态机的MoveNext方法以重新进入状态机并连续早年次离开的地方运行。这便是在.NET Framework上发生的情形,您可以通过分配剖析器等剖析工具来查看其结果,例如运行分配剖析器以查看每个await分配了什么。让我们看看这个屈曲的程序,我只是为了突出显示涉及的分配本钱而编写的:
using System.Threading;using System.Threading.Tasks;class Program{ static async Task Main() { var al = new AsyncLocal<int>() { Value = 42 }; for (int i = 0; i < 1000; i++) { await SomeMethodAsync(); } } static async Task SomeMethodAsync() { for (int i = 0; i < 1000; i++) { await Task.Yield(); } }}
该程序创建了一个AsyncLocal<int>工具,以流动值42通过所有后续的异步操作。然后调用SomeMethodAsync 1000次,每个方法都停息/规复1000次。在Visual Studio中,利用.NET工具分配跟踪剖析器运行此程序,得出以下结果:
这是很多分配!
让我们逐个检讨以理解它们的来源。
Action。同样,每当我们等待还没有完成的操作(这是我们的一百万个await Task.Yield()的情形),我们就会分配一个新的Action委托,以通报给该awaiter的UnsafeOnCompleted方法。MoveNextRunner。同样的问题;有一百万个这样的实例,由于在先前的步骤概述中,每当我们停息时,我们就会分配一个新的MoveNextRunner来存储Action和ExecutionContext,以便利用后者来实行前者。LogicalCallContext。另一百万个。这些是.NET Framework上AsyncLocal<T>的实现细节;AsyncLocal<T>将其数据存储到ExecutionContext的“逻辑调用高下文”中,这是一种流动ExecutionContext的一样平常状态的高等办法。因此,如果我们制作了ExecutionContext的一百万份副本,我们也会制作一百万份LogicalCallContext的副本。QueueUserWorkItemCallback。每个Task.Yield()都将一个事情项排队到线程池中,导致分配用于表示这些操作的事情项工具的一百万次分配。\6. Task<VoidResult>。有一千个,以是我们至少不在“一百万”俱乐部中。每个异步Task调用在异步完成时都须要分配一个新的Task实例来表示该调用的终极完成。<SomeMethodAsync>d__1。这是编译器天生的状态机构造的盒子。一千个方法停息,就会发生一千个盒子。QueueSegment/IThreadPoolWorkItem[]。有几千个,它们与特定的异步方法没有技能上的关联,而是与一样平常情形下将事情项排队到线程池有关。在.NET Framework中,线程池的行列步队是非循环段的链接列表。这些段不会被重复利用;对付长度为N的段,一旦N个事情项已经被排队到该段中并从该段中出队,该段就被丢弃,并留待垃圾回收。
那便是 .NET Framework。这是 .NET Core:
太俊秀了!
对付.NET Framework的这个示例,有超过500万次的分配,统共分配了约145MB的内存。然而,在.NET Core上运行相同的示例时,只有大约1000次分配,统共分配了约109KB的内存。为什么会少这么多呢?
那么其他的分配,例如Action、MoveNextRunner和<SomeMethodAsync>d__1呢?理解如何肃清剩余的分配须要深入研究在.NET Core上的事情事理。
让我们将我们的谈论倒回到我们谈论悬停韶光的时候:
if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false{ <>1__state = 1; <>u__2 = awaiter; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return;}
这里天生的代码无论是针对哪个平台,都是相同的,以是无论是.NET Framework还是.NET Core,此处的悬挂操作天生的IL代码都是相同的。然而,不同的是AwaitUnsafeOnCompleted方法的实现,在.NET Core中这个方法的实现要繁芜得多:
首先,它会调用ExecutionContext.Capture()方法来获取当前的实行高下文。然后,它就会与.NET Framework分道扬镳。在.NET Core中,builder只有一个字段:public struct AsyncTaskMethodBuilder{private Task<VoidTaskResult>? m_task;...}在捕获了ExecutionContext之后,它会检讨m_task字段是否包含AsyncStateMachineBox<TStateMachine>的实例,个中TStateMachine是编译器天生的状态机构造体的类型。这个AsyncStateMachineBox<TStateMachine>类型便是“邪术”。它的定义如下:private class AsyncStateMachineBox<TStateMachine> :Task<TResult>, IAsyncStateMachineBoxwhere TStateMachine : IAsyncStateMachine{private Action? _moveNextAction;public TStateMachine? StateMachine;public ExecutionContext? Context;...}与其拥有独立的Task不同,这是一个Task(请把稳它的基类型)。与其将状态机装箱不同,该构造体只是作为一个强类型字段存在于这个Task上。而且,与其拥有独立的MoveNextRunner来存储Action和ExecutionContext,它们只是这个类型的字段。由于这是存储在builder的m_task字段中的实例,我们可以直接访问它,而不须要在每次挂起时重新分配东西。如果ExecutionContext发生变革,我们只需将该字段覆盖为新的高下文,而不须要分配任何其他东西;我们仍旧可以通过任何Action指向精确的位置。因此,在捕获了ExecutionContext之后,如果我们已经有了这个AsyncStateMachineBox<TStateMachine>的实例,那么这不是第一次挂起该方法,我们可以将新捕获的ExecutionContext直接存储到它中。如果我们没有AsyncStateMachineBox<TStateMachine>的实例,那么我们就须要分配它:var box = new AsyncStateMachineBox<TStateMachine>();taskField = box; // important: this must be done before storing stateMachine into box.StateMachine!box.StateMachine = stateMachine;box.Context = currentContext;请把稳,源代码中的该行注释为“主要”。这取代了.NET Framework中那个繁芜的SetStateMachine操作,因此在.NET Core中根本不该用SetStateMachine。你在那里看到的taskField是对AsyncTaskMethodBuilder的m_task字段的引用。我们分配AsyncStateMachineBox<TStateMachine>,然后通过taskField将该工具存储到builder的m_task中(这是堆栈上状态机构造体中的builder),然后将堆栈上的状态机(现在已经包含了对该box的引用)复制到基于堆的AsyncStateMachineBox<TStateMachine>中,使得AsyncStateMachineBox<TStateMachine>适当且递归地引用它自身。这仍旧是一种令人费解的操作,但效率要高得多。然后,我们可以得到此实例上方法的 Action,该方法将调用其 MoveNext 方法,该方法将在调用 StateMachine 的 MoveNext 之前实行适当的 ExecutionContext 规复。并且该 Action 可以缓存到 _moveNextAction 字段中,这样任何后续利用都可以重复利用相同的 Action。然后将该 Action 通报给等待者的 UnsafeOnCompleted 以连接连续。这个阐明阐明了为什么大部分分配都已经消逝了: <SomeMethodAsync>d__1不会被装箱,而是只作为任务本身的一个字段存在,而 MoveNextRunner 不再须要,由于它只是用于存储 Action 和 ExecutionContext。但是,根据这个阐明,我们仍旧该当看到1000个 Action 分配,即每个方法调用一个,但我们没有看到。为什么呢?那些 QueueUserWorkItemCallback 工具呢……我们仍旧作为 Task.Yield() 的一部分排队,为什么它们没有涌现?
正如我所指出的,将实现细节推迟到核心库中的一个好处是,它可以随韶光演化实现方法,我们已经看到了它从 .NET Framework 演化到 .NET Core 的过程。它也进一步演化为 .NET Core 的初始重写,具有从系统的关键组件得到内部访问的附加优化。特殊是,异步根本构造知道核心类型,如 Task 和 TaskAwaiter。由于它知道它们并具有内部访问权限,以是它不必遵守公开定义的规则。C# 措辞遵照的等待程序模式哀求等待程序具有 AwaitOnCompleted 或 AwaitUnsafeOnCompleted 方法,这两种方法都将连续作为 Action,这意味着根本构造须要能够创建一个 Action 以表示连续,以便与根本构造不知道的任意等待程序一起事情。但是,如果根本构造碰着了它知道的等待程序,它不必采纳相同的代码路径。因此,对付 System.Private.CoreLib 中定义的所有核心等待程序,根本构造都有一个更大略的路径可以遵照,它根本不须要 Action。这些等待程序都知道 IAsyncStateMachineBox,能够将 box 工具本身视为续集。因此,例如,由 Task.Yield 返回的 YieldAwaitable 能够直接将 IAsyncStateMachineBox 排队到 ThreadPool 中作为事情项,而在等待任务时利用的 TaskAwaiter 能够将 IAsyncStateMachineBox 直接存储到 Task 的续集列表中。不须要 Action,不须要 QueueUserWorkItemCallback。
因此,在一种非常常见的情形下,异步方法只等待来自System.Private.CoreLib(Task、Task<TResult>、ValueTask、ValueTask<TResult>、YieldAwaitable以及这些ConfigureAwait变体)的事物时,最坏情形是全体异步方法的全体生命周期只有一个与开销干系的单个分配:如果该方法停息,则它会分配存储所有其他所需状态的单个Task派生类型,如果该方法从未停息,则不会产生其他分配。
如果须要的话,我们也可以在分摊的办法下肃清末了一个分配。正如已经展示的那样,Task有一个默认的构建器(AsyncTaskMethodBuilder),Task<TResult>也有一个默认的构建器(AsyncTaskMethodBuilder<TResult>),ValueTask和ValueTask<TResult>也有一个默认的构建器(AsyncValueTaskMethodBuilder和AsyncValueTaskMethodBuilder<TResult>)。对付ValueTask/ValueTask<TResult>,构建器本身非常大略,由于它们仅处理同步成功完成的情形,在这种情形下,异步方法在不停息的情形下完成,构建器可以返回一个ValueTask.Completed或一个包装结果值的ValueTask<TResult>。对付其他所有情形,它们只需委托给AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<TResult>,由于将返回的ValueTask/ValueTask<TResult>仅包装了一个Task,因此可以共享所有相同的逻辑。但是,.NET 6和C# 10引入了能够按方法覆盖利用的构建器的能力,并引入了一些专门为ValueTask/ValueTask<TResult>设计的构建器,这些构建器能够池化IValueTaskSource/IValueTaskSource<TResult>工具,这些工具代表终极完成,而不是利用Tasks。
我们可以在我们的示例中看到这种影响。让我们轻微地调度我们正在剖析的SomeMethodAsync,以返回ValueTask而不是Task:
static async ValueTask SomeMethodAsync(){ for (int i = 0; i < 1000; i++) { await Task.Yield(); }}
这将产生以下天生的入口点:
[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]private static ValueTask SomeMethodAsync(){ <SomeMethodAsync>d__1 stateMachine = default; stateMachine.<>t__builder = AsyncValueTaskMethodBuilder.Create(); stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task;}
现在,我们将[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]添加到SomeMethodAsync的声明中:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]static async ValueTask SomeMethodAsync(){ for (int i = 0; i < 1000; i++) { await Task.Yield(); }}
编译器输出如下:
[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))][AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]private static ValueTask SomeMethodAsync(){ <SomeMethodAsync>d__1 stateMachine = default; stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder.Create(); stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task;}
实际的C#代码天生对付全体实现,包括全体状态机(未显示)险些是相同的;唯一的差异是创建和存储的构建器类型,因此在我们以前看到构建器的引用途处利用。如果您查看PoolingAsyncValueTaskMethodBuilder的代码,您会创造它的构造险些与AsyncTaskMethodBuilder相同,包括利用一些完备相同的共享例程来实行特定的awaiter类型。关键差异是,当方法首次停息时,它不会实行new AsyncStateMachineBox<TStateMachine>(),而是实行StateMachineBox<TStateMachine>.RentFromCache(),并且在完成异步方法(SomeMethodAsync)并等待返回的ValueTask完成时,租用的框将返回到缓存中。这意味着(摊销)零分配:
缓存本身有点有趣。工具池可能是一个好主张,也可能是一个坏主张。工具的创建本钱越高,对它们进行池化的代价就越大;例如,池化非常大的数组比池化非常小的数组更有代价,由于较大的数组不仅须要更多的CPU周期和内存访问来清零,而且会对垃圾回收器产生更多的压力,导致更频繁的垃圾回收。然而,对付非常小的工具,池化它们可能是一个净负面效应。池本身仅仅是内存分配器,垃圾回收器也是内存分配器,因此,在进行池化时,您正在用一个分配器的成本来换取另一个分配器的本钱,而垃圾回收器非常善于处理大量的小型短寿命工具。如果您在工具的布局函数中进行了大量事情,则避免这些事情可以使分配器本身的成本相形见绌,从而使池化更有代价。但是,如果您在工具的布局函数中险些没有做任何事情,并且对其进行池化,则您正在打赌您的分配器(您的池)对所利用的访问模式比GC更高效,这常日是一个缺点的赌注。还存在其他本钱,有些情形下,您可能会有效地反对GC的启示式算法;例如,GC基于这样的条件进行优化,即从较高代(例如gen2)工具到较低代(例如gen0)工具的引用相对较少,但是池化工具可能会使这些条件无效。
现在,异步方法创建的工具并不是眇小的,而且它们可能涌如今超级热的路径上,因此进行池化可能是合理的。但是为了使其尽可能有代价,我们也希望尽可能避免开销。因此,池非常大略,选择使租借和归还非常快速,险些没有争用,纵然这意味着它可能会分配比更积极缓存更多的工具。对付每种状态机类型,实现会为每个线程和每个核心池化多达一个状态机盒子;这使它能够以最小的开销和最小的争用租用和归还(没有其他线程可以同时访问线程特定的缓存,而且很少有其他线程可以同时访问核心特定的缓存)。虽然这可能看起来像是一个相对较小的池,但它也非常有效地显著减少了稳态分配,由于池仅卖力存储当前未利用的工具;您可以有一百万个异步方法在任何给定时间都在运行,纵然池只能存储每个线程和每个核心一个工具,它仍旧可以避免丢失大量工具,由于它只须要存储一个工具足以将其从一个操作传输到另一个操作,而不是在该操作中利用它。
SynchronizationContext and ConfigureAwait我们之前在EAP模式的高下文中谈论了SynchronizationContext,并提到它会再次涌现。SynchronizationContext使得调用可重用的帮助程序并在任何时候和任何地方自动进行调度成为可能。因此,我们自然希望在async/await中“只要事情”,实际上它确实可以。回到之前的按钮单击处理程序:
ThreadPool.QueueUserWorkItem(_ =>{ string message = ComputeMessage(); button1.BeginInvoke(() => { button1.Text = message; });});
利用async/await,我们希望可以像以下这样编写:
button1.Text = await Task.Run(() => ComputeMessage());
ComputeMessage的调用被卸载到线程池中,方法完成后,实行会转回与按钮关联的UI线程,并在该线程上设置其Text属性。
SynchronizationContext与awaiter实现的集成(状态机天生的代码对SynchronizationContext一无所知)留给了awaiter实现自己完成,由于当被表示的异步操作完成时,awaiter卖力实际调用或排队供应的连续操作。虽然自定义的awaiter不须要尊重SynchronizationContext.Current,但Task、Task<TResult>、ValueTask和ValueTask<TResult>的awaiter都会这样做。这意味着,当您默认等待Task、Task<TResult>、ValueTask、ValueTask<TResult>乃至是Task.Yield()调用的结果时,awaiter默认会查找当前的SynchronizationContext,然后如果成功获取到非默认高下文,终极将连续操作排队到该高下文中。
如果我们查看TaskAwaiter中涉及的代码,就可以看到这一点。以下是来自Corelib的干系代码片段:
internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext){ if (continueOnCapturedContext) { SynchronizationContext? syncCtx = SynchronizationContext.Current; if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext)) { var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false); if (!AddTaskContinuation(tc, addBeforeOthers: false)) { tc.Run(this, canInlineContinuationTask: false); } return; } else { TaskScheduler? scheduler = TaskScheduler.InternalCurrent; if (scheduler != null && scheduler != TaskScheduler.Default) { var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false); if (!AddTaskContinuation(tc, addBeforeOthers: false)) { tc.Run(this, canInlineContinuationTask: false); } return; } } } ...}
这是一种确定要存储到任务作为连续的工具的方法的一部分。它被通报了stateMachineBox,正如先前所提到的,它可以直接存储到任务的连续列表中。然而,这种分外逻辑可能会包装IAsyncStateMachineBox,以便在存在调度程序的情形下也可以将其纳入个中。它会检讨当前是否有非默认的同步高下文,如果有,则创建一个SynchronizationContextAwaitTaskContinuation作为实际将被存储为连续的工具;该工具反过来又包装了原始工具和捕获的同步高下文,并知道如何在排队到后者的事情项中调用前者的MoveNext。这便是你能够在UI运用程序的某个事宜处理程序中等待并使代码在等待完成后连续在精确的线程上运行的缘故原由。这里须要把稳的下一个有趣的事情是,它不仅把稳同步高下文:如果找不到要利用的自定义同步高下文,它还会查看任务利用的TaskScheduler类型是否有须要考虑的自定义类型。与SynchronizationContext一样,如果存在非默认的类型,则利用原始框包装一个TaskSchedulerAwaitTaskContinuation作为连续工具。
但可以说,把稳到这个方法体的第一行是最有趣的:if (continueOnCapturedContext)。如果continueOnCapturedContext为true,我们才会对SynchronizationContext/TaskScheduler进行这些检讨;如果为false,则实现会像两者都是默认的一样行动并忽略它们。那么,是什么把continueOnCapturedContext设置为false呢?你可能已经猜到了:利用备受欢迎的ConfigureAwait(false)。
我在ConfigureAwait FAQ中详细谈论了ConfigureAwait,因此我建议您阅读更多信息。简而言之,ConfigureAwait(false)作为等待的一部分唯一的浸染便是将其布尔参数作为continueOnCapturedContext值通报到此函数(以及其他类似函数)中,以便跳过对SynchronizationContext/TaskScheduler的检讨,并表现得彷佛它们都不存在。对付任务来说,这许可任务在任何它认为得当的地方调用其连续,而不是被迫将它们排队以在某个特定的调度程序上实行。
我之条件到了同步高下文的另一个方面,我说我们会再次看到它:OperationStarted/OperationCompleted。现在是时候了。它们作为大家爱恨交加的特性的一部分涌现:异步void。除了ConfigureAwait之外,异步void可以说是作为异步/等待的一部分添加的最具分裂性的特性之一。它只有一个目的:事宜处理程序。在UI运用程序中,您希望能够编写如下代码:
button1.Click += async (sender, eventArgs) =>{ button1.Text = await Task.Run(() => ComputeMessage()); };
但是,如果所有的异步方法都必须像Task一样有一个返回类型,你将无法这样做。Click事宜的署名为“public event EventHandler? Click;”,个中EventHandler被定义为“public delegate void EventHandler(object? sender, EventArgs e);”,因此为了供应匹配该署名的方法,该方法须要返回void。
有许多缘故原由认为异步void方法不好,文章建议在任何可能的情形下都要避免利用它,因此剖析器已经涌现来标记利用它们的情形。个中最大的问题之一是委托推断。考虑以下程序:
using System.Diagnostics;Time(async () =>{ Console.WriteLine("Enter"); await Task.Delay(TimeSpan.FromSeconds(10)); Console.WriteLine("Exit");});static void Time(Action action){ Console.WriteLine("Timing..."); Stopwatch sw = Stopwatch.StartNew(); action(); Console.WriteLine($"...done timing: {sw.Elapsed}");}
人们很随意马虎期望这个程序的输出韶光至少为10秒,但是如果您运行它,您会创造像这样的输出:
Timing...Enter...done timing: 00:00:00.0037550
嗯?当然,基于我们在本篇文章中谈论的统统,该当理解问题出在哪里。异步lambda实际上是一个async void方法。异步方法在第一个挂出发点时返回给调用者。如果这是一个异步的Task方法,那么返回的便是Task。但是在async void的情形下,没有返回值。Time方法只知道它调用了action(),委托调用返回了;它不知道异步方法实际上仍在“运行”,并且将在往后异步完成。
这便是OperationStarted/OperationCompleted的浸染。这些异步void方法在性子上类似于前面谈论过的EAP方法:这些方法的启动是void的,因此您须要另一种机制来跟踪所有这些正在实行的操作。因此,EAP实现在操作启动时调用当前SynchronizationContext的OperationStarted,在完成时调用OperationCompleted。异步void也是如此。与异步void干系联的构建器是AsyncVoidMethodBuilder。还记得在异步方法的入口点中,编译器天生的代码如何调用构建器的静态Create方法来获取一个适当的构建器实例吗?AsyncVoidMethodBuilder利用这一点来挂钩创建并调用OperationStarted:
public static AsyncVoidMethodBuilder Create(){ SynchronizationContext? sc = SynchronizationContext.Current; sc?.OperationStarted(); return new AsyncVoidMethodBuilder() { _synchronizationContext = sc };}
同样地,当构建器通过SetResult或SetException标记为完成时,它会调用相应的OperationCompleted方法。这便是为什么像xunit这样的单元测试框架能够拥有异步void测试方法,并且仍旧可以在并发测试实行中利用最大程度的并发度的缘故原由,例如在xunit的AsyncTestSyncContext中。
有了这些知识,我们现在可以重写我们的计时示例:
using System.Diagnostics;Time(async () =>{ Console.WriteLine("Enter"); await Task.Delay(TimeSpan.FromSeconds(10)); Console.WriteLine("Exit");});static void Time(Action action){ var oldCtx = SynchronizationContext.Current; try { var newCtx = new CountdownContext(); SynchronizationContext.SetSynchronizationContext(newCtx); Console.WriteLine("Timing..."); Stopwatch sw = Stopwatch.StartNew(); action(); newCtx.SignalAndWait(); Console.WriteLine($"...done timing: {sw.Elapsed}"); } finally { SynchronizationContext.SetSynchronizationContext(oldCtx); }}sealed class CountdownContext : SynchronizationContext{ private readonly ManualResetEventSlim _mres = new ManualResetEventSlim(false); private int _remaining = 1; public override void OperationStarted() => Interlocked.Increment(ref _remaining); public override void OperationCompleted() { if (Interlocked.Decrement(ref _remaining) == 0) { _mres.Set(); } } public void SignalAndWait() { OperationCompleted(); _mres.Wait(); }}
在这里,我创建了一个同步高下文,用于跟踪挂起操作的计数,并支持阻挡等待它们全部完成。当我运行它时,我会得到类似以下的输出:
Timing...EnterExit...done timing: 00:00:10.0149074
状态机字段
到目前为止,我们已经看到了天生的入口方法以及MoveNext实现中的所有内容。我们还瞥见了状态机上定义的一些字段。让我们更仔细地看看这些。
对付之前显示的CopyStreamToStream方法:
public async Task CopyStreamToStreamAsync(Stream source, Stream destination){ var buffer = new byte[0x1000]; int numRead; while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0) { await destination.WriteAsync(buffer, 0, numRead); }}
这些是我们终极得到的字段:
private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine{ public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Stream source; public Stream destination; private byte[] <buffer>5__2; private TaskAwaiter <>u__1; private TaskAwaiter<int> <>u__2; ...}
它们分别是什么呢?
<>1__state. 这是“状态机”中的“状态”。“state”定义了状态机确当前状态,最主要的是,下一次调用MoveNext时该当实行什么。如果状态为-2,则操作已完成。如果状态为-1,则要么我们即将第一次调用MoveNext,要么MoveNext代码当前正在某个线程上运行。如果您正在调试异步方法的处理过程,并且看到状态为-1,则意味着某个线程实际上正在实行方法中包含的代码。如果状态为0或更高,则该方法已停息,并且状态的值见告您它停息在哪个await处。虽然这不是一条硬性规则(某些代码模式可能会稠浊编号),但常日分配的状态与源代码自上而下排序中await的基于0的编号相对应。因此,例如,如果异步方法的主体完备是:await A();await B();await C();await D();如果你创造状态值为2,那险些可以确定异步方法当前已经停息,正在等待从C()返回的任务完成。<>t__builder。这是状态机的天生器,例如AsyncTaskMethodBuilder用于Task,AsyncValueTaskMethodBuilder<TResult>用于ValueTask<TResult>,AsyncVoidMethodBuilder用于异步void方法,或者在异步返回类型上利用[AsyncMethodBuilder(...)]声明以供给用的任何构建器,或者通过此类属性在异步方法本身上进行覆盖。如前所述,天生器卖力异步方法的生命周期,包括创建返回任务、终极完成该任务,并作为中介器进行停息,异步方法中的代码哀求天生器停息,直到特定的awaiter完成。源/目标。这些是方法参数。你可以通过它们来判断,由于它们没有被重命名,编译器按照参数名称指定的办法对它们进行了命名。正如之条件到的那样,所有被方法体利用的参数都须要存储到状态机中,以便MoveNext方法可以访问它们。请把稳,我说的是“被利用的”。如果编译器创造一个参数在异步方法的方法体中未被利用,它可以优化掉存储该字段的须要。例如,给定以下方法:public async Task M(int someArgument){await Task.Yield();}编译器将在状态机中发出这些字段:private struct <M>d__0 : IAsyncStateMachine{public int <>1__state;public AsyncTaskMethodBuilder <>t__builder;private YieldAwaitable.YieldAwaiter <>u__1;...}请把稳,没有名为someArgument的字段。但是,如果我们变动异步方法以任何办法利用该参数:public async Task M(int someArgument){Console.WriteLine(someArgument);await Task.Yield();}它就会涌现:private struct <M>d__0 : IAsyncStateMachine{public int <>1__state;public AsyncTaskMethodBuilder <>t__builder;public int someArgument;private YieldAwaitable.YieldAwaiter <>u__1;...}<buffer>5__2;. 这是缓冲区“local”,它被提升为字段,以便可以在await点之间保持其状态。编译器会只管即便避免不必要地提升状态。须要把稳的是,源代码中还有另一个本地变量numRead,但状态机中没有相应的字段。为什么?由于这不是必要的。应当地变量被设置为ReadAsync调用的结果,然后用作WriteAsync调用的输入。在这两者之间没有await,也没有超过个中的numRead值须要被存储。就像在同步方法中JIT编译器可以选择将这样的值完备存储在寄存器中,而从未将其溢出到堆栈中一样,C#编译器可以避免将此本地变量提升为字段,由于它不须要在任何await中保留其值。常日情形下,如果C#编译器能够证明它们的值不须要在await中保留,它可以省略提升本地变量。<>u1和<>u2。异步方法中有两个await:一个是由ReadAsync返回的Task<int>,另一个是由WriteAsync返回的Task。Task.GetAwaiter()返回TaskAwaiter,Task<TResult>.GetAwaiter()返回TaskAwaiter<TResult>,两者都是不同的构造体类型。由于编译器须要在await之前获取这些等待者(IsCompleted,UnsafeOnCompleted),然后须要在await之后访问它们(GetResult),因此须要存储等待者。由于它们是不同的构造体类型,编译器须要掩护两个单独的字段来存储它们(另一种选择是将它们装箱并为等待者创建一个单独的工具字段,但这会导致额外的分配本钱)。然而,编译器会尽可能地重用字段。如果有:public async Task M(){await Task.FromResult(1);await Task.FromResult(true);await Task.FromResult(2);await Task.FromResult(false);await Task.FromResult(3);}这里有五种等待,但只涉及两种不同类型的等待者:三个是TaskAwaiter<int>,两个是TaskAwaiter<bool>。因此,在状态机上只有两个等待者字段:private struct <M>d__0 : IAsyncStateMachine{public int <>1__state;public AsyncTaskMethodBuilder <>t__builder;private TaskAwaiter<int> <>u__1;private TaskAwaiter<bool> <>u__2;...}然后,如果我将我的示例变动为:仍旧只涉及Task<int>和Task<bool>,但实际上我利用了四种不同的构造体等待类型,由于在ConfigureAwait返回的内容上调用GetAwaiter()返回的等待者类型与Task.GetAwaiter()返回的不同...这再次可以从编译器创建的等待者字段中看出:如果你想优化与异步状态机干系的小,你可以看看是否可以合并正在等待的事物的类型,从而合并这些等待者字段。private struct <M>d__0 : IAsyncStateMachine{ public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; private TaskAwaiter<int> <>u__1; private TaskAwaiter<bool> <>u__2; private ConfiguredTaskAwaitable<int>.ConfiguredTaskAwaiter <>u__3; private ConfiguredTaskAwaitable<bool>.ConfiguredTaskAwaiter <>u__4; ...}在状态机中,可能会定义其他类型的字段。特殊是,你可能会看到一些包含“wrap”一词的字段。考虑下面这个屈曲的例子:
public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;
这将产生一个具有以下字段的状态机:
private struct <M>d__0 : IAsyncStateMachine{ public int <>1__state; public AsyncTaskMethodBuilder<int> <>t__builder; private TaskAwaiter<int> <>u__1; ...}
到目前为止还没有什么特殊的。现在反转被添加表达式的顺序:
public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);
这样,你就会得到这些字段:
private struct <M>d__0 : IAsyncStateMachine{ public int <>1__state; public AsyncTaskMethodBuilder<int> <>t__builder; private int <>7__wrap1; private TaskAwaiter<int> <>u__1; ...}
现在我们有了一个额外的字段:<>7wrap1。为什么?由于我们打算了DateTime.Now.Second的值,只有在打算完之后,我们才须要等待某些东西,而第一个表达式的值须要被保留,以便将其添加到第二个表达式的结果中。因此,编译器须要确保该第一个表达式的临时结果可用于添加到await的结果中,这意味着它须要将表达式的结果溢出到一个临时变量中,它利用这个<>7wrap1字段来实现。如果你创造自己在极度优化异步方法实现以降落分配的内存量,你可以探求这样的字段,并查看是否可以通过对源代码进行小的调度来避免须要溢出,从而避免须要这样的临时变量。
总结希望这篇文章能够帮助你理解在利用async/await时发生的详细情形,但幸运的是,你常日不须要知道或关心这些。这里有很多流动的部分,它们共同创造了一种有效的办理方案,可以编写可扩展的异步代码,而不必处理回调地狱。然而,终极这些部分实际上是相对大略的:任何异步操作的通用表示,一种能够将正常掌握流重写为协程状态机实现的措辞和编译器,以及将它们全部绑在一起的模式。其他所有东西都是优化的加成。
祝你coding愉快!