此前,.NET blog发表了一篇文章什么是.NET,为什么你应该选择它?。它给出了.NET的高层次概述,总结了各个组件和设计决策,并承诺对所涉及的领域发表更深入的文章。这篇文章是这一系列文章的第一篇,我们将深入探讨C#和.NET中async/await
的历史、背后的设计决策和实现细节。
对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
来说,当你想更深入理解时,了解其机制特别有帮助,比如,当你试图调试Bug或提高性能时。在这篇文章中,我们将深入探讨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
委托和一个不透明的状态object
,这二者都可能为null
。Begin方法负责启动异步操作,如果提供了回调(通常被称为初始操作的 "continuation"),它还负责确保异步操作完成后回调被调用。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方法,该方法负责确保操作完成(如果没有完成,则同步等待操作完成,并阻塞),然后返回操作的任何结果,包括传播可能发生的任何错误/异常。因此,与其写像下面这样的代码来同步执行操作:
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模式选择了后者。为此,IAsyncResult
接口暴露了两个相关但不同的成员:IsCompleted
和CompletedSynchronously
。IsCompleted
告诉你操作是否已经完成:你可以多次检查它,它最终会从false
变为true
,然后停在那里。相比之下,CompletedSynchronously
则不会发生修改(或者说,如果它修改了,那就是一个隐藏的烦人Bug);它是用于确定,Begin方法的调用者和AsyncCallback
中的哪一个会负责执行任何continuation。如果CompletedSynchronously
是false
,那么操作就是异步完成的,任何响应操作完成的continuation都应该留给回调;毕竟,如果工作没有同步完成,Begin的调用者不能真的去处理它,因为操作还没有完成(如果调用者只是调用End,它将阻塞直到操作完成)。然而,如果CompletedSynchronously
为true
,那么,如果回调来处理continuation,那么就有可能出现栈深潜,因为它将在栈上执行比一开始更深的continuation。因此,任何与此类栈深潜相关的实现都需要检查CompletedSynchronously
,如果它是true
,那就让Begin方法的调用者执行continuation,这意味着回调则不需要执行continuation。这也是为什么CompletedSynchronously
必须永不发生修改:调用者和回调需要看到相同的值,以确保无论竞争条件如何,continuation都只执行一次。
在我们之前的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
调用分配一个委托),等等。现在,想象一下,你必须为你想实现的每个方法都做一遍这些事。每次你想实现一个可重用的方法来使用另一个异步操作时,你都需要做这些工作。如果你想编写可重用的组合子,可以有效地操作多个离散的IAsyncResults
(想想Task.WhenAll
),则更为复杂;每一个实现并公开其特定于该操作的API的操作都意味着没有类似的通用语言来讨论它们(尽管一些开发人员编写了库试图减轻一些负担,通常是通过另一层回调,使API能够为Begin方法提供适当的AsyncCallback
)。
这些复杂的情况意味着很少有人尝试过,而对于那些尝试过的人来说,Bug则层出不穷。有一说一,这并不是对APM模式的批评。相反,这是对常见的基于回调的异步的批判。我们都已经习惯了现代语言中的控制流结构为我们提供的强大和简洁,一旦引入即使合理的复杂度,基于回调的方法都通常会与这些结构发生冲突。其他主流语言也没有比这更好的选择。
我们需要一种更好的方法,一种从APM模式中学习到的方法,然后取其精华,去其糟粕。值得注意的是,APM模式仅仅是一种模式;运行时、核心库和编译器并没有为使用或实现该模式提供任何帮助。
基于事件的异步模式
.NET Framework 2.0引入了一些API,这些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
是否与预期的相匹配。
这种模式使一些用例变得简单了一点,但也使某些用例变得更加复杂(考虑前面的APMCopyStreamToStream
示例,它能说明一些问题)。它没有得到广泛推广,而且它在.NET Framework的某个版本里昙花一现,尽管留下了在其存续期间添加的API,如Ping.SendAsync/Ping.PingCompleted
:
public class Ping : Component
{
public void SendAsync(string hostNameOrAddress, object? userToken);
public event PingCompletedEventHandler? PingCompleted;
...
}
然而,它确实迈出了一大步,APM模式根本没有考虑到这一点,而且它也一直continuation到我们今天所采用的模型中: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
这样的方法简化了这个过程,该方法将提供的委托和参数排队,由与该控件相关的任何线程来运行。因此,你可以写这样的代码:
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有这个:
public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable
{
public override void Post(SendOrPostCallback d, object? state) =>
_controlToSendTo?.BeginInvoke(d, new object?[] { state });
...
}
public sealed class DispatcherSynchronizationContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, Object state) =>
_dispatcher.BeginInvoke(_priority, d, state);
...
}
ASP.NET曾经有一个,但实际上它并不关心工作在什么线程上运行,而是关心与某个请求相关的工作被串行化,这样多个线程就不会同时访问同一个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
方法。这些虚方法的基类实现是空的,什么都不做,但派生实现可以重写这些方法以了解执行中的操作。这意味着EAP的实现也会在每个操作的开始和结束时调用OperationStarted/OperationCompleted
,以便通知任何存在的SynchronizationContext
,并允许它跟踪工作。这与EAP模式息息相关,因为启动异步操作的方法是void
的:你无法得到任何允许你单独跟踪工作的返回值。我们会回到这个问题上。
所以,我们需要比APM模式更好的东西,随后的EAP引入了一些新的东西,但并没有真正解决我们的核心问题。我们还需要更好的。
走入Task
.NET Framework 4.0引入了System.Threading.Tasks.Task
类型。从本质上讲,Task
只是一个数据结构,它表示某些异步操作的最终完成(其他框架将类似类型称为“promise”或“future”)。创建一个Task
来表示某个操作,然后当它逻辑上表示的操作完成时,结果将保存到该Task
中。很简单。但Task
提供的一个关键特性使它比IAsyncResult
更有用,那就是它在自身里建立了continuation的概念。这一特性意味着你可以走到任何Task
前,要求在其完成时异步收到通知,由任务本身去处理同步,以确保continuation被调用,无论任务是否已经完成、尚未完成,还是正在与通知请求同时完成。为什么会有这样的影响?嗯,如果你还记得我们对旧APM模式的讨论,有两个主要问题。
任务本身负责处理同步,以确保无论任务是否已经完成、尚未完成或正在与通知请求同时完成,都能调用continuation。为什么这么强大?OK,如果你还记得我们对旧的APM模式的讨论,它有两个主要问题。
- 你必须为每个操作实现一个自定义的
IAsyncResult
:没有任何内置的IAsyncResult
实现,任何人都可以根据自己的需要使用。 - 在Begin方法被调用之前,你必须知道它完成后你想做什么。这使得实现组合子和其他用于消费和组合任意异步实现的通用例程非常困难。
相比之下,有了Task
,这一共享的结构可以让你在启动操作之后走近异步操作,或是提供一个continuation......你不需要向启动操作的方法提供这个continuation。每个有异步操作的人都可以产生一个Task
,每个消费异步操作的人都可以消费一个Task
,不需要任何额外工作来连接这两者:Task
成为使异步操作的生产者和消费者之间可以交流的语言。而这也改变了.NET的面貌。稍后会讨论更多...
现在,让我们更好地理解它的实际意义。与其深入研究Task
的复杂代码,我们不如来一些教学性质的内容,只实现一个简单的版本。这并非是一个很棒的实现,只是在功能上足够完整,以帮助理解什么是Task
,说到底,它实际上只是一个数据结构,用于协调完成信号的设置和接收。我们将从几个字段开始:
class MyTask
{
private bool _completed;
private Exception? _error;
private Action<MyTask>? _continuation;
private ExecutionContext? _ec;
...
}
我们需要一个字段来知道任务是否已经完成(_completed
),并且我们需要一个字段来保存任何导致任务失败的错误(_error
);如果我们还实现了一个泛型的MyTask<TResult>
,那么还会有一个private TResult _result
字段用于保存操作的成功结果。到目前为止,这看起来很像我们之前的自定义IAsyncResult
实现(当然不是巧合)。但现在是最重要的部分,_continuation
字段。在这个简单的实现中,我们只支持一个continuation,但这已经足够说明问题了(真正的Task
采用了一个object
字段,可以是一个单独的continuation对象或一个List<>
的continuation对象)。这是一个委托,当任务完成时将被调用。
现在,来看一些表层的东西。如前文所述,与以前的模式相比,Task
的一个基本进步是能够在操作开始后提供continuation(回调)。我们需要一个方法来让我们做到这一点,所以让我们添加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);
}
});
}
}
}
我们保存任何错误,然后标记任务已经完成,如果先前已经注册了一个continuation,我们就排队等待调用。
最后,我们需要一种方法来传播任务中可能发生的任何异常(如果这是一个泛型的MyTask<T>
,则返回其_result
);为了方便某些情况,我们还允许这个方法阻塞等待任务完成,我们可以用ContinueWith
来实现(continuation就是发出一个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
要复杂得多,有更高效的实现,还支持任意数量的continuation,以及许多关于它应该如何表现的设置(例如,是应该像这里所做的那样排队,还是应该作为任务完成的一部分被同步调用),还能够保存多个异常而非只有一个,还能知道是否取消任务,以及大量的辅助方法来进行常见操作(例如,Task.Run
可以创建一个Task
来代表一个在线程池中排队被调用的委托),等等。但这些都没有什么神奇之处;其核心就是我们在这里看到的东西。
你可能还注意到,这个简单的MyTask
直接有public的SetResult/SetException
方法,而Task
没有。实际上,Task
确实有这样的方法,只不过它们是internal
的,有一个System.Threading.Tasks.TaskCompletionSource
类充当任务及其完成的独立的“生产者”;这样做并非出于技术上的必要性,而是为了让完成方法远离只用于消费的部分。然后,你就可以分发一个任务,而不必担心它会在你控制之外完成;完成信号是创建任务的一个实现细节,并且还通过保存TaskCompletionSource
来保留完成这个任务的权利。(CancellationToken
和CancellationTokenSource
遵循类似的模式:CancellationToken
只是CancellationTokensSource
的包装,只提供与消费取消信号相关的接口,但不能产生取消信号,产生取消信号的能力仅限于能够访问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
的方法。
还有ValueTask
时至今日,Task
仍然是.NET中异步的主力军,每个版本都有新的方法暴露出来,整个生态系统都在例行地返回Task
和Task<TResult>
。然而,Task
是一个类,这意味着创建一个任务需要进行对象的分配。在大多数情况下,对于一个长期存在的异步操作来说,一个额外的分配是微不足道的,除了对性能敏感的操作外,不会对性能产生显著影响。然而,正如之前所指出的,异步操作的同步完成是相当普遍的。Stream.ReadAsync
的引入是为了返回一个Task<int>
,但是如果你从一个BufferedStream
中读取数据,那么你的很多读取都很有可能是同步完成的,因为你只需要从一个内存缓冲区中提取数据,而不是执行系统调用和实际的I/O。不可避免地分配一个额外的对象来返回这些数据是很尴尬的(注意这也是APM的情况)。对于非泛型的返回Task
的方法,该方法可以直接返回一个已经完成的单例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。或者对于任意类型,default
是一个相当常见的值,所以我们可以缓存一个Task<TResult>
,其中Result
是每个相关类型的default(TResult)
。事实上,如今的Task.FromResult
也是这样做的(从最近的.NET版本开始),使用一个小型的可重用的Task<TResult>
单例缓存,并在适当的时候返回其中的一个,或者为提供的结果值分配一个新的Task<TResult>
。还可以有其他方案来处理其他合理的常见情况。例如,当使用Stream.ReadAsync
时,在同一个流上多次调用它是很常见的,允许读取的count
都是一样的。而实现能够完全满足该count
要求的情况也很常见。这意味着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
来挂接操作的continuation。就这样,ValueTask<TResult>
对其定义做了一个小小的改变,其Task<TResult>? _task
字段被一个object? _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>
。我们能够以各种方式与它们进行交互,来表示任意的异步操作,并挂上continuation来处理这些异步操作的完成。是的,我们可以在操作完成之前或之后这样做。
但是......这些continuation仍然是回调!我们仍然被迫进入一个continuation!
我们仍然被迫采用continuation传递的方式来编写我们的异步控制流!!!
这仍然是非常难搞的!!!!
我们怎样才能解决这个问题????
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
,而对Current
的后续访问需要返回产生的值。然后我们再次调用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
的下一次调用实际成为异步操作完成时执行的continuation的一部分呢?如果我可以返回一个代表异步操作的东西,并让消费代码将一个continuation挂到那个返回的对象上,然后该continuation去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
得到一个Task
,我们就会给这个Task
挂上一个continuation;当这个Task
完成后,它就会回过头来,直接调用同一个逻辑,MoveNext
,得到下一个Task
,以此类推。这是建立在Task
可以代表任意异步操作的想法之上的,所以我们输入的枚举可以是一个任何异步操作的序列。这样的序列从何而来?当然是来自迭代器。还记得我们之前的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 return
该Task
;在调用MoveNext
之后,生成的任务将被移交给IterateAsync
,IterateAsync
将给该Task
挂上一个continuation,当它完成后将会调用MoveNext
,最后回到这个迭代器中。在这一点上,Impl
逻辑得到方法的结果,调用WriteAsync
,并再次产生它的Task
。以此类推。
我的老伙计,这就是C#和.NET中async/await
的开始。在C#编译器中,支持迭代器和async/await
的逻辑大约有95%是共享的。语法不同,涉及的类型也不同,但从根本上说是相同的转换。仔细观察yield return
,你几乎可以看到await
代替了它们。
事实上,在async/await
出现之前,一些有进取心的开发者就以这种方式将迭代器用于异步编程。在实验性的Axum编程语言中也有类似的变换,其成为了C#异步支持的重要灵感来源。Axum提供了一个可以放在方法上的async
关键字,就像现在C#中的async
一样。那时候Task
还不是很普遍,所以在异步方法中,Axum编译器启发式地将同步方法调用与APM对应的方法相匹配,例如,如果它看到你调用stream.Read
,它会找到并利用相应的stream.BeginRead
和stream.EndRead
方法,合成适当的委托来传递给Begin方法,同时还为定义的异步方法生成完整的APM实现,这样它就是可组合的。它甚至还集成了SynchronizationContext
! 虽然Axum最终被束之高阁,但它是最终成为C#中async/await
的一个炫酷且具有推动作用的原型。
async/await
的背后
现在我们知道了我们是如何一步步来到这里的,现在让我们深入了解它的工作原理。作为参考,这里又是我们的同步方法的例子:
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;
...
}
请注意,与开发者所写的唯一不同的签名是缺少async
关键字本身。async
实际上不是方法签名的一部分;与unsafe
一样,当你把它放在方法签名中时,表示的是方法的实现细节,而不是作为实际暴露出的约定的一部分。使用async/await
来实现一个返回任务的方法,就是一个实现细节。
编译器已经生成了一个名为<CopyStreamToStreamAsync>d__0
的struct,并在栈上零初始化了该struct的一个实例。重要的是,如果异步方法同步完成,这个状态机就不会离开栈。这意味着没有与状态机相关的分配,除非该方法需要异步完成,这意味着它在等待一些在那个时间点还没有完成的东西。稍后会有更多关于这方面的内容。
这个struct是该方法的状态机,不仅包含了开发者所写的所有转移逻辑,还包含了用于跟踪该方法当前位置的字段,以及编译器从该方法中提取的需要在MoveNext
调用之间存活的所有"局部"状态。它在逻辑上等同于我们在迭代器中看到的IEnumerable<T>/IEnumerator<T>
实现。(请注意,我所展示的代码来自于Release版本;在调试版本中,C#编译器实际上会将这些状态机类型生成为类,因为这样做有助于某些调试工作)。
在初始化状态机之后,我们可以看到对AsyncTaskMethodBuilder.Create()
的调用。虽然我们目前关注的是Task
,但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
),并处理挂到尚未完成的等待中的事物的continuation(AwaitOnCompleted/AwaitUnsafeOnCompleted
)。System.Threading.Tasks.Task
默认与AsyncTaskMethodBuilder
相关。通常情况下,这种关联是通过应用于该类型的[AsyncMethodBuilder(...)]特性来提供的,但Task
是C#预知的,所以实际上并没有该特性的装饰。因此,编译器已经找到了用于这个async
方法的builder,并且正在使用模式中的Create
方法构造它的实例。注意,和状态机一样,AsyncTaskMethodBuilder
也是一个struct,所以这里也没有分配。
然后,往状态机里填入这个入口点方法的参数。这些参数需要被移入MoveNext
的方法主体所使用,因此这些参数需要被保存在状态机中,以便它们可以被后续调用MoveNext
的代码所引用。该状态机也被初始化为-1状态。如果MoveNext
被调用且状态为-1,那么我们最终会在该方法的逻辑上的起始处开始执行。
现在是最不起眼但最有意义的一行:对builder的Start
方法的调用。这是模式的另一部分,必须在async
方法的返回位置使用的类型上暴露出来,它被用来在状态机上执行初始的MoveNext
。builder的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
,D
最终调用E
读取这些静态数据的值。这通常被称为"环境"数据:它不是通过参数传递给你的,而只是放在那里,如果需要的话就可以使用。
更进一步,我们可以使用线程本地的状态。线程本地状态,在.NET中是通过带有[ThreadStatic]
特性的静态字段或通过ThreadLocal<T>
类型实现的,二者用法相同,但数据只限于当前执行的线程,每个线程都能拥有这些字段的独立副本。有了它,你可以填入线程静态数据,进行方法调用,然后在方法完成后将变化恢复回去,使这种隐式传递的数据有一个完全隔离的形式。
但是,异步的情况下呢?如果我们进行异步方法的调用,并且异步方法内部的逻辑想要访问环境数据,它会怎么做?如果数据存储在一般静态变量中,异步方法将能够访问它,但你一次只能有一个这样的方法在运行,因为当多个调用者写入这些共享静态字段时,最终会覆盖对方的状态。如果数据被存储在线程静态变量中,异步方法将能够访问它,但只到它停止在调用线程上同步运行为止;如果它把一个continuation连接到它发起的某些操作上,并且该continuation最终运行在其他线程上,它将不再有机会访问线程静态信息。即使它碰巧运行在同一个线程上,无论是偶然还是因为调度器强制它运行,当它运行时,数据很可能已经被删除和/或被该线程发起的其他操作所覆盖。对于异步来说,我们需要的是一种机制,允许任意的环境数据在这些异步点上流转,这样在整个异步方法的逻辑中,无论何时何地,该逻辑都可能运行,还可以访问相同的数据。
进入ExecutionContext
。ExecutionContext
类是环境数据从异步操作流向异步操作的载体。它具有[ThreadStatic]
特性,但是当一些异步操作被启动时,它会被"捕获"(一种花哨的说法是"从该线程静态中读取副本")、保存,然后当该异步操作的continuation执行时,ExecutionContext
首先被恢复到即将运行该操作的线程的[ThreadStatic]
里。ExecutionContext
是用于实现AsyncLocal<T>
的机制(事实上,在.NET Core中,ExecutionContext
是完全与AsyncLocal<T>
相关的,仅此而已),这样,如果你将一个值存储到AsyncLocal<T>
中,然后,例如排队在ThreadPool
上运行一个工作项,该值将在工作项内部的AsyncLocal<T>
中可见:
var number = new AsyncLocal<int>();
number.Value = 42;
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine(number.Value));
number.Value = 0;
Console.ReadLine();
以上代码将在每次运行时都打印42。在我们为委托排队后的那一刻,我们将AsyncLocal的值重置为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
上,然后在最终调用Thread
的ThreadStart
委托时使用捕获的上下文。但我不想在这个例子中这样做,因为我不想让Threads
在静态构造函数运行时捕获碰巧存在的任何ExecutionContext
(这样做会使关于ExecutionContext
的演示更加复杂),所以我用UnsafeStart
方法代替。以Unsafe开头的线程相关方法与缺少Unsafe前缀的相应方法的行为完全相同,只是它们不捕获ExecutionContext
,例如,Thread.Start
和Thread.UnsafeStart
的工作相同,但Start
捕获ExecutionContext
,UnsafeStart
则不捕获。
回到开始
当我在写AsyncTaskMethodBuilder.Start
的实现时,我们额外讨论了ExecutionContext
,我认为会很有效:
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();
}
}
}
"冒充"是指改变当前用户的环境信息,使之成为其他人的信息;这让代码代表其他人行事,使用他们的权限和访问能力。在.NET中,这种冒充行为会在异步操作中流转,这意味着它是ExecutionContext
的一部分。现在想象一下,如果Start
没有恢复之前的上下文,考虑一下这段代码:
Task t = ElevateAsAdminAndRunAsync();
PrintUser();
await t;
从这段代码可以发现,在ElevateAdminAndRunAsync
返回到其同步调用者后,ElevateAdminAndRunAsync
内部修改的ExecutionContext
仍然存在(这发生在该方法第一次await
时)。这是因为在调用Impersonate
后,我们调用DoSensitiveWorkAsync
并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
执行的是什么工作,它都需要在所有的工作完成后,完成async Task
方法返回的Task
。如果try
块抛出了一个未处理的异常,那么该任务就会因该异常而出现故障。而如果异步方法成功走到最后(相当于同步方法的返回),它将成功完成返回的任务。在这两种情况下,它都会设置状态机的状态以表示完成。(我有时会听到开发者主张,当涉及到异常时,在第一个await
之前和之后抛出的异常是有区别的......基于上述情况,应该很清楚并非如此。任何在async
方法中未被处理的异常,不管它在方法中的什么位置,也不管方法是否已经让出,最终都会出现在上述的catch
块中,然后将捕获的异常保存到从async
方法返回的Task
中)
还要注意的是,这个过程是通过builder进行的,使用它的SetException
和SetResult
方法,这是编译器所期望的builder模式的一部分。如果异步方法之前已经挂起了,那么builder将不得不创建一个Task
作为挂起处理的一部分(我们很快就会看到如何以及在哪里),在这种情况下,调用SetException/SetResult
将完成该Task
。然而,如果异步方法之前没有挂起,那么我们还没有创建Task
,也没有返回任何东西给调用者,所以builder在如何产生该任务方面更加灵活。如果你还记得之前在入口方法中,它所做的最后一件事是将Task
返回给调用者,它是通过返回builder的Task
属性来实现的:
public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
...
return stateMachine.<>t__builder.Task;
}
builder知道该方法是否已挂起,在这种情况下,它有一个已经创建的Task
,并返回该Task
。如果该方法从未挂起,并且builder还没有Task
,那么它可以在这里创建一个已完成的Task
。在这种情况下,如果成功完成,它可以只使用Task.CompletedTask
,而不用分配新的Task
,从而避免分配。在泛型Task<TResult>
的情况下,builder只能使用Task.FromResult<TResult>(TResult-result)
。
builder也可以进行它认为适合它所创建的对象种类的翻译。例如,任务实际上有三种可能的最终状态:成功、失败和被取消。AsyncTaskMethodBuilder
的SetException
方法对OperationCanceledException
进行了特殊处理,如果提供的异常是OperationCanceledException
或派生自OperationCanceledException
,则将任务转换为TaskStatus.Canceled
的最终状态;否则,任务以TaskStatus.Faulted
结束。这样的区别在消费者代码中往往并不明显;因为无论异常被标记为Canceled
还是Faulted
,都会被保存到Task
中,await
该Task
的代码将无法观察到状态之间的区别(无论哪种情况,原始的异常都会被传播)......它只影响到与任务直接交互的代码,例如通过ContinueWith
,它的重载可以只在完成状态时调用continuation。
现在我们了解了生命周期方面的内容,下面是在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
是多么复杂吗?虽然这个没有那么复杂,但也好在编译器为我们做了许多工作,以一种continuation传递的形式重写了这个方法,同时确保所有必要的状态在这些continuation中被保存下来。即便如此,我们还是可以继续看下去。请记住,在入口处状态被初始化为-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
挂上一个continuation。因此,它需要生成代码,询问任务"你完成了吗?",那么它是否直接与任务对话来提出这个问题呢?
如果在C#中唯一可以await
的是System.Threading.Tasks.Task
,无异于戴上镣铐。同样,如果C#编译器必须知道可以await
的所有可能的类型,那将是另一个牢笼。相反,C#在这种情况下会做它通常会做的事情:它使用了一种API模式。代码可以await
任何暴露了适当的模式的东西,即“awaiter”模式(就像你可以foreach
任何提供适当“可枚举”模式的东西一样)。例如,我们可以增强我们之前编写的MyTask
类型,以实现awaiter模式:
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
就是这样。这个方法需要返回一些东西,而这些东西又暴露了几个成员,包括一个IsCompleted
属性,它是用来检查操作是否已经完成的。你可以看到这样的情况:在IL_008b
,调用了从ReadAsync
返回的Task
的GetAwaiter
,然后访问那个awiter实例的IsCompleted
。如果IsCompleted
返回true
,那么我们最终会落到IL_00f0
,在那里代码会调用awiter的另一个成员:GetResult()
。如果操作失败了,GetResult()
则会抛出一个异常,以便将其从异步方法的await
中传播出去;否则,GetResult()
返回操作的结果,如果有结果的话。在ReadAsync
的例子中,如果结果是0,那么我们就跳出读/写循环,到方法的结尾调用SetResult
,然后我们就完成了。
不过,退一步说,有趣的是,如果IsCompleted
返回false
,会发生什么。如果它返回true
,我们就继续处理这个循环,类似于APM模式中CompletedSynchronously
返回true
,Begin方法的调用者而不是回调负责继续执行。但是如果IsCompleted
返回false
,我们需要挂起异步方法的执行,直到await
的操作完成。这意味着从MoveNext
中返回,由于这是Start
的一部分,我们仍然在入口方法中,这意味着将任务返回给调用者。但在这一切发生之前,我们需要给被await
的任务挂上一个continuation(注意,为了避免APM案例中的栈深潜,如果异步操作在IsCompleted
返回false
后完成,但在我们挂上continuation之前,continuation仍然需要从调用线程中被异步调用,因此它将被排队)。由于我们可以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.UnsafeOnComplete
,这在很大程度上是历史遗留问题,与代码访问安全(Code Access Security,简称CAS)有关。CAS不再存在于.NET Core中,并且在.NET Framework中默认关闭,只有当你选择使用legacy的部分信任功能时才有效。当使用部分信任时,CAS信息作为ExecutionContext
的一部分流转,因此不流转是“Unsafe”的,这就是为什么不流转ExecutionContext
的方法前缀为“Unsafe”。这样的方法也带有[SecurityCreditic]
特性,并且部分受信任的代码不能调用[SecurityCcritical]
方法。因此,创建了OnCompleted
的两个变体,编译器更喜欢使用UnsafeOnCompleted
(如果提供的话),但OnCompleted
变体总是单独提供,以防awaiter需要支持部分信任。然而,从异步方法的角度来看,builder总是在等待点之间流转ExecutionContext
,因此,同样这样做的awaiter就是不必要且重复的工作了。
OK,所以awaiter确实暴露了一个方法来连接continuation。编译器可以直接使用它,除了一个非常关键的问题:continuation到底应该是什么?更重要的是,它应该与什么对象相关联?请记住,状态机结构在栈上,而我们当前运行的MoveNext
调用是对该实例的方法调用。我们需要保留状态机,这样在恢复时我们才会有正确的状态,这意味着状态机不能只停留在栈上;它需要被复制到堆上的某个地方,因为栈最后将被用于该线程执行的其他后续不相关的工作。然后,continuation需要调用堆上状态机副本上的MoveNext
方法。
此外,还需要考虑ExecutionContext
。状态机需要确保存储在ExecutionContext
中的任何环境数据在挂起时被捕获,然后在恢复时被应用,这意味着continuation也需要纳入该ExecutionContext
。所以,仅仅创建一个指向状态机上MoveNext
的委托是不够的。这也是不可取的开销。如果我们在挂起时创建一个指向状态机上MoveNext
的委托,那么每次这样做我们都要对状态机结构进行装箱(即使它已经作为其他对象的一部分在堆上)并分配一个额外的委托(该委托的这个对象引用将指向该结构的一个新装箱副本)。因此,我们需要做一个复杂的操作,即确保我们只在方法第一次暂停执行时将该结构从堆栈中提升到堆中,而在其他时候都使用相同的堆对象作为MoveNext
的目标,并在此过程中确保我们捕获了正确的上下文,并在恢复时确保我们使用捕获的上下文来调用该操作。
这比我们希望编译器生成的逻辑要多得多...我们反而希望它被封装在一个Helper中,有诸多原因。首先,要向每个用户的程序集中加入大量复杂的代码。第二,我们希望在实现builder模式的过程中允许对该逻辑进行自定义(我们将在后面讨论池化的时候看到一个例子)。第三,我们希望能够发展和改进这种逻辑,让现有的先前编译好的二进制文件变得更好。这不只是一个假设;在.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;
}
我们将状态id存储到state
字段中,该状态id指示方法恢复时应该跳转到的位置。然后,我们将awaiter本身持久化到一个字段中,以便在恢复后使用它来调用GetResult
。然后,在MoveNext
返回之前,我们要做的最后一件事是调用<>t_builder.AwaitUnsafeOnCompleted(ref awaiter,ref this)
,要求builder为该状态机连接一个continuation到awaiter。(注意,它调用builder的AwaitUnsafeOnCompleted
而不是builder的AwaitOnCompleted
,因为awaiter实现ICriticalNotifyCompletion
;状态机去处理流转的ExecutionContext
,所以我们不需要awaiter也这样做……如前所述,这样做只是多此一举。)
AwaitUnsafeOnCompleted
方法的实现过于复杂,无法都贴在这里,所以我总结一下它在.NET Framework上所做的工作:
- 使用
ExecutionContext.Capture()
来抓取当前上下文。 - 然后,它分配一个
MoveNextRunner
对象,以包裹捕获的上下文以及装箱的状态机(如果这是该方法第一次挂起,我们还没有这个对象,所以我们用null作为占位符)。 - 然后,它为
MoveNextRunner
上的Run
方法创建了一个Action
委托;这就是它如何获得委托的过程,该委托将在捕获的ExecutionContext
的上下文中调用状态机的MoveNext
。 - 如果这是该方法第一次挂起,我们还没有装箱的状态机,所以此时它将其装箱,通过将实例保存到一个类型为
IAsyncStateMachine
接口的本地变量里,以在堆上创建一个副本。然后,这个装箱的实例被保存到被分配的MoveNextRunner
中。 - 接下来是一个有些令人费解的步骤。如果回头看一下状态机结构的定义,它有一个builder,
public AsyncTaskMethodBuilder <>t__builder;
,然后看一下builder的定义,它有一个internal IAsyncStateMachine m_stateMachine;
。builder需要引用已装箱的状态机,这样在随后的暂停中,它可以看到它已经对状态机进行了装箱,不需要再进行装箱。但我们刚刚对状态机进行了装箱,而该状态机包含一个builder,其m_stateMachine
字段为空。我们需要修改那个被装箱的状态机的builder的m_stateMachine
,使之指向它的父箱。为了实现这一点,编译器生成的状态机结构所实现的IAsyncStateMachine
接口包括一个void SetStateMachine(IAsyncStateMachine stateMachine);
方法,而该状态机结构包括该接口方法的实现:
所以builder将状态机装箱,然后将该装箱传给装箱的private void SetStateMachine(IAsyncStateMachine stateMachine) => <>t__builder.SetStateMachine(stateMachine);
SetStateMachine
方法,后者调用builder的SetStateMachine
方法,将该装箱存储到该字段。哇哦。 - 最后,我们有一个代表continuation的
Action
,它被传递给awiter的UnsafeOnCompleted
方法。在TaskAwaiter
的情况下,任务将把该Action
存储到任务的continuation列表中,这样当任务完成时,它将调用该Action
,再通过MoveNextRunner.Run
回调,然后通过ExecutionContext.Run
回调,最后调用状态机的MoveNext
方法,重新进入状态机并从它离开的地方继续运行。
这就是.NET Framework上的实现,您可以在profiler中观察到这一结果,例如通过运行分配profiler来查看每个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对象分配profiler执行此操作,它会产生以下结果:
出现了...大量的分配! 让我们看看它们是怎么回事:
ExecutionContext
。它被分配了超过一百万个。为什么?因为在.NET Framework中,ExecutionContext
是一个可变的数据结构。由于我们希望流转的数据是在一个异步操作被发出(fork)时出现的,而且我们不希望它在发出后观测到变化,所以我们需要复制ExecutionContext
。每一个发出操作都需要这样的拷贝,所以如果有1000个对SomeMethodAsync
的调用,其中每个都要暂停/恢复1000次,我们就有一百万个ExecutionContext
实例。非常吓人。Action
。类似地,每次我们await
尚未完成的操作时(比如这一百万个await Task.Yield()
),我们都会分配一个新的Action
委托,以将其传递给该awaiter的UnsafeOnCompleted
方法。MoveNextRunner
。同样的情况,有一百万个,因为在前面的介绍中,每次我们暂停时,我们都会分配一个新的MoveNextRunner
来存储Action
和ExecutionContext
,以便使用后者执行前者。LogicalCallContext
。又是一百万个。这些是.NET Framework上AsyncLocal<T>
的一个实现细节;AsyncLocal<T>
将其数据存储到ExecutionContext
的“逻辑调用上下文”中,这是对与ExecutionContext
一起流转的一般状态的一种优雅的表示。因此,如果我们创建了一百万个ExecutionContext
的副本,我们也会创建一百万个LogicalCallContext
的副本。QueueUserWorkItemCallback
。每个Task.Yield()
都会向线程池排队一个工作项,导致用于表示这一百万个操作的工作项对象的一百万次分配。Task<VoidResult>
。这类任务有1000个,所幸我们已经脱离了"百万"俱乐部。每个异步完成的await Task
调用都需要分配一个新的Task
实例来表示该调用的最终完成。<SomeMethodAsync>d__1
。这是编译器生成的装箱的状态机结构。1000个方法挂起,1000个箱子。QueueSegment/IThreadPoolWorkItem[]
。这些东西有几千个,从技术上讲,它们与具体的异步方法无关,而是与被排入线程池的工作有关,一般来说。在.NET Framework中,线程池的队列是一个无环段的链表。这些段不会被重复使用;对于一个长度为N的段,一旦有N个工作项目被排入该段并从该段排出,该段就会被丢弃,等待GC回收。
以上是.NET Framework上的情况。而这是 .NET Core 上的情况:
好多了! 在.NET Framework上的这个例子,有超过500万次分配,总共分配了~145MB的内存。而在.NET Core上的同一个例子,只有~1000次分配,总共只有~109KB。为什么少了这么多?
ExecutionContext
。在.NET Core中,ExecutionContext
现在是不可变的。这样做的坏处是,每次对上下文的改变,例如向AsyncLocal<T>
设置一个值,都需要分配一个新的ExecutionContext
。然而,好处是,流转上下文比改变上下文更常见,而且由于ExecutionContext
现在是不可变的,我们不再需要为流转上下文而拷贝一份。"捕获"上下文实际上只是从一个字段中读取它,而不是读取它再拷贝一个。因此,流转不仅比改变更常见,而且也更廉价。LogicalCallContext
。这东西在.NET Core中已经不存在了。在.NET Core中,ExecutionContext
只保存AsyncLocal<T>
。其他在ExecutionContext
中有自己特殊位置的东西都是以AsyncLocal<T>
为模型的。例如,在.NET Framework中,冒充将作为SecurityContext
的一部分流转,而SecurityContext
是ExecutionContext
的一部分;在.NET Core中,冒充通过AsyncLocal<SafeAccessTokenHandle>
流转,它使用valueChangedHandler
来对当前线程进行修改。QueueSegment/IThreadPoolWorkItem[]
。在.NET Core中,ThreadPool
的全局队列现在被实现为ConcurrentQueue<T>
,而ConcurrentQueue<T>
被重写为一个大小不固定的环形段的链表。一旦一个段的大小足够大,因为稳态出队能够跟上稳态入队的速度,所以该段永远不会被填满,就不需要再分配额外的段,一个足够大的段就这样一直使用。
那么其余的分配,如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>, IAsyncStateMachineBox where TStateMachine : IAsyncStateMachine { private Action? _moveNextAction; public TStateMachine? StateMachine; public ExecutionContext? Context; ... }
与其说包含一个单独的
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;
注意带有"important"注释的那一行。它取代了.NET Framework中复杂的
SetStateMachine
,因此SetStateMachine
实际上在.NET Core中根本没用上。你看到的taskField
是一个指向AsyncTaskMethodBuilder
的m_task
字段的引用。我们分配AsyncStateMachineBox<TStateMachine>
,然后通过taskField
将该对象保存到builder的m_task
中(这就是栈上的状态机结构中的builder)、 然后将这个栈上的状态机(现在已经包含了对装箱的引用)复制到堆上的AsyncStateMachineBox<TStateMachine>
中,这样AsyncStateMachineBox<TStateMachine>
最终就可以合理地、递归地引用自己。这仍然令人费解,但却是一种更有效的费解。 -
然后,我们可以获得该实例上的方法的
Action
,该方法将调用其MoveNext
方法,在调用到StateMachine
的MoveNext
之前做适当的ExecutionContext
恢复。这个Action
可以被缓存到_moveNextAction
字段中,这样任何后续使用都可以重复使用同一个Action
。然后,该Action
被传递给awiter的UnsafeOnCompleted
,以挂接continuation。
这就解释了为什么大部分其余的分配都不见了: <SomeMethodAsync>d__1
没有被装箱,而只是作为任务本身的一个字段存在,MoveNextRunner
也不再需要了,因为它的存在只是为了存储Action
和ExecutionContext
。但是,根据这个解释,我们还是应该看到1000个Action
的分配,每个方法调用对应一个,但是并没有。为什么?那些QueueUserWorkItemCallback
对象呢......还是要作为Task.Yield()
的一部分进行排队,为什么那些没有显示出来?
就像我指出的,将实现细节放到核心库中的一个好处是,它可以随着时间的推移改进实现,我们已经看到它是如何从.NET Framework发展到.NET Core的。从最初为.NET Core重写开始,它也有了进一步的发展,有了额外的优化,这得益于对系统中关键组件的internal
访问。特别是,异步基础设施知道核心类型,如Task
和TaskAwaiter
。因为它知道这些类型并有internal
访问权,所以它不必按公开定义的规则行事。C#所遵循的awaiter模式要求awaiter有一个AwaitOnCompleted
或AwaitUnsafeOnCompleted
方法,这两个方法都将continuation作为一个Action
,这意味着基础设施需要能够创建一个Action
来表示continuation,以便与基础设施不知道的任意awaiter友好相处。但是如果基础设施遇到了它所知道的awaiter,它就没有必要使用同样的代码路径。对于System.Private.CoreLib
中定义的所有核心awaiter,基础设施可以走一个更精简的路径,这个路径根本不需要Action
。这些awaiter都知道IAsyncStateMachineBoxes
,并且能够将装箱对象本身作为continuation。因此,例如,由Task.Yield
返回的YieldAwaitable
能够将IAsyncStateMachineBox
本身作为一个工作项直接排入ThreadPool
,而在await
一个Task
时使用的TaskAwaiter
能够将IAsyncStateMachineBox
本身直接存入任务的continuation列表。不需要Action
,也不需要QueueUserWorkItemCallback
。
因此,在非常常见的情况下,一个异步方法只等待来自System.Private.CoreLib
的东西(Task
、Task<TResult>
、ValueTask
、ValueTask<TResult>
、YieldAwaitable
以及它们的ConfigureAwait
变体)。最坏的情况是只有一次与异步方法的整个生命周期相关的分配开销:如果该方法曾经挂起过,它会分配那个单一Task派生类,它存储了所有需要的状态;如果该方法从未挂起过,就不会产生额外的分配。
如果需要的话,我们也可以免去这一次分配,至少是以一种摊销的方式。就像展示的那样,有一个与Task
相关的默认builder(AsyncTaskMethodBuilder
),同样有一个与Task<TResult>
相关的默认builder(AsyncTaskMethodBuilder<TResult>
)以及与ValueTask
和ValueTask<TResult>
相关的默认builder(分别是AsyncValueTaskMethodBuilder
和AsyncValueTaskMethodBuilder<TResult>
)。对于ValueTask/ValueTask<TResult>
,builder其实是相当简单的,因为它们本身只处理同步和成功完成的情况,在这种情况下,异步方法完成时不需要暂停,builder可以只返回一个ValueTask.Completed
或者一个包含结果值的ValueTask<TResult>
。对于其他的情况,他们只需委托给AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<TResult>
,因为将要返回的ValueTask/ValueTask<TResult>
只是包装了一个Task
,它可以共享所有相同的逻辑。但是在.NET 6和C# 10中,一个方法拥有了可以重写逐方法使用的builder的能力,并且为ValueTask/ValueTask<TResult>
引入了几个专门的builder,能够池化代表最终完成的IValueTaskSource/IValueTaskSource<TResult>
对象而不是使用Task
。
我们可以在我们的例子中看到它的影响。让我们稍微调整一下我们正在分析的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#代码,包括整个状态机(未展现),几乎是相同的;唯一的区别是创建和保存的builder的类型,因此在我们之前看到的对builder的引用的地方都使用了这个类型。如果你看一下PoolingAsyncValueTaskMethodBuilder
的代码,你会发现它的结构几乎与AsyncTaskMethodBuilder
相同,包括使用一些完全相同的共享例程来做一些事情,比如对已知awaiter类型进行特殊处理。关键的区别在于,当方法第一次挂起时,它不是new AsyncStateMachineBox<TStateMachine>()
,而是StateMachineBox<TStateMachine>.RentFromCache()
,在异步方法(SomeMethodAsync
)完成和返回的ValueTask
的await完成后,借来的箱子被归还到缓存中。这意味着(摊销的)零分配:
这个缓存本身就很有意思。对象池是把双刃剑。一个对象的创建成本越高,对其进行池化就越有价值;因此,例如,对非常大的数组进行池化比对非常小的数组进行池化更有价值,因为更大的数组不仅需要更多的CPU周期和内存访问来清零,它们还给垃圾收集器带来更大的压力,使其更频繁地收集垃圾。不过,对于非常小的对象,池化可能是负收益的。池子只是内存分配器,GC也是如此,所以当你池化的时候,你在用一个分配器的相关成本换取另一个分配器的相关成本,而GC在处理大量微小、短命的对象时非常有效。如果你在一个对象的构造函数中做了大量的工作,避免这些工作会使分配器本身的成本相形见绌,从而使池化变得有价值。但是,如果你在一个对象的构造函数中几乎没有做任何工作,但你却把它放入池中,你在赌你的分配器(你的池子)对于所采用的访问模式比GC更有效,而这往往赢不了。还有其他的成本,在某些情况下,你最终会有效地对抗GC的启发式方法;例如,GC的优化是基于这样的前提:从高代(如gen2)对象到低代(如gen0)对象的引用是相对罕见的,但是池化对象会使这些前提失效。
现在,由异步方法创建的对象并不小巧,而且它们可能在超热的路径上,所以池化可能是合理的。但为了让它尽可能有价值,我们也想尽可能避免开销。因此,这个池子非常简单,选择让租借和返回的速度非常快,几乎没有竞争,即使这意味着它最终可能会比,更积极地缓存更多的资源,分配得更多。对于每一种状态机类型,实现上至多每个线程和每个核心都有一个装箱的状态机;这使得它能够以最小的开销和最小的竞争来租借和归还(其他线程不可以同时访问线程专用缓存,也很少有其他线程同时访问核心专用缓存)。虽然这看起来是一个相对较小的池子,但它在显著减少稳态分配方面也相当有效,因为池子只负责存储当前不使用的对象;你可以在任何时刻都有一百万个异步方法都在运行,即使池子在每个线程和每个核心只能存储一个对象,它仍然可以避免丢弃大量对象,因为它只需要存储一个对象足够久,就可以把它从一个操作转移到另一个操作,而非在该操作使用它时。
SynchronizationContext
与ConfigureAwait
我们之前在EAP模式的部分中讨论过SynchronizationContext
,并提到它将再次出现。SynchronizationContext
使得我们可以写出可重用的helper,并在调用环境认为合适的任何时间和地点自动进行调度。因此,人们很自然地期望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负责实际调用或排队continuation。虽然自定义的awaiter不需要考虑SynchronizationContext.Current
,但Task
、Task<TResult>
、ValueTask
和ValueTask<TResult>
的awaiter都是如此。这意味着,默认情况下,当你await
一个Task
、Task<TResult>
、ValueTask
、ValueTask<TResult>
,甚至Task.Yield()
调用的结果时,awaiter默认会查找当前的SynchronizationContext
,然后如果它成功得到一个非默认的,将最终把continuation排队到该上下文。
如果我们查看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;
}
}
}
...
}
这是一个方法的一部分,它决定将什么对象作为continuation保存到Task
中。它被传递给stateMachineBox
,正如前面所提到的,stateMachineBox
可以直接保存到Task
的continuation列表中。然而,这个特殊的逻辑可能会将IAsyncStateMachineBox
包起来,如果有一个调度器的话,也可以将其加入。它检查当前是否有一个非默认的SynchronizationContext
,如果有,它就创建一个SynchronizationContextAwaitTaskContinuation
作为实际的对象,这个对象将被保存为continuation;这个对象反过来包装原始的和捕获的SynchronizationContext
,并知道,如何在排队到后者的工作里,调用前者的MoveNext
。这就是为什么你能够将await作为UI应用程序中一些事件处理程序的一部分,并让await完成后的代码在正确的线程上继续。接下来要注意的是,它不仅仅关注SynchronizationContext
:如果它找不到要使用的自定义SynchronizationContext
,它还要看Task
使用的TaskScheduler
类型是否有需要考虑的自定义类型。与SynchronizationContext
一样,如果有一个非默认的TaskScheduler
,它就会被包裹在一个TaskSchedulerAwaitTaskContinuation
的原始箱中,作为continuation对象使用。
但可以说,这里最值得注意的是方法体的第一行:if(continueOnCapturedContext)
。只有当continueOnCapturedContext
为true
时,我们才会对SynchronizationContext/TaskScheduler
进行这些检查;如果是false
,实现就会像默认的那样,忽略它们。请问,是什么将continueOnCapturedContext
设置为false
呢?你可能已经猜到了:使用流行的ConfigureAwait(false)
。
我在ConfigureAwait FAQ中详细讨论了ConfigureAwait
,所以我建议你看一看以获取更多信息。我只想说,ConfigureAwait(false)
作为await
的一部分所做的唯一事情是将其参数Boolean
作为continueOnCapturedContext
的值送入该函数(以及其他类似的函数),以便跳过对SynchronizationContext/TaskScheduler
的检查,并表现得好像它们都不存在。在Task
的情况下,这允许Task
在它认为合适的地方调用它的continuation,而不是被迫排队到某些特定的调度器上执行。
我之前提到了SynchronizationContext
的另一个方面,我说过我们会再次看到它:OperationStarted/OperationCompleted
。现在是时候了。它们作为每个人都乐于厌恶的功能的一部分出现了:async void
。除了ConfigureAwait
之外,async void
可以说是作为async/await
的一部分而增加的最有争议的功能之一。它的加入只有一个原因:事件处理程序。在一个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
。
async 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
嗯?当然,基于我们在这篇文章中讨论的一切,应该知道问题出在哪里。async lambda
实际上是一个async void
方法。异步方法在遇到第一个暂停点的时候会返回给调用者。如果这是一个async Tssk
方法,那么这个Task
就会被返回。但如果是一个async void
方法,就不会有任何返回值。Time
方法只知道它调用了action()
,并且委托调用返回了;它不知道这个异步方法实际上还在"运行",并将在之后异步完成。
这就是OperationStarted/OperationCompleted
的来历。这种async void
方法在性质上与前面讨论的EAP方法类似:这种方法的启动是void
,因此你需要一些其他的机制,以便能够跟踪执行中的所有此类操作。因此,EAP实现在操作启动时调用当前SynchronizationContext
的OperationStarted
,在操作完成时调用OperationCompleted
,而async void
也是如此。与async void
相关的builder是AsyncVoidMethodBuilder
。还记得在一个异步方法的入口处,编译器生成的代码是如何调用builder的静态Create
方法来获得一个合适的builder实例的吗?AsyncVoidMethodBuilder
利用了这一点,以便钩住其创建并调用OperationStarted
:
public static AsyncVoidMethodBuilder Create()
{
SynchronizationContext? sc = SynchronizationContext.Current;
sc?.OperationStarted();
return new AsyncVoidMethodBuilder() { _synchronizationContext = sc };
}
同样地,当builder通过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();
}
}
在这里,我创建了一个SynchronizationContext
,它跟踪了一个挂起操作的数量,并支持阻塞等待它们全部完成。运行它,得到了这样的输出:
Timing...
Enter
Exit
...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
。这是"状态机"中的"状态"。它定义了状态机当前所处的状态,以及,最重要的是,在下一次调用MoveNext
时应该做什么。如果状态为-2,说明操作已经完成。如果状态为-1,要么我们即将第一次调用MoveNext
,要么MoveNext
代码目前正在某个线程上运行。如果你在调试一个异步方法的处理过程中,看到状态为-1,这意味着在某个地方有某个线程正在实际执行该方法中包含的代码。如果状态为0或以上,则该方法被挂起,而状态的值告诉你它是在哪个await
中暂停的。虽然这不是一个硬性规定(某些代码模式可能会混淆编号),但一般来说,分配的状态对应于源代码中从上到下排序的await的编号(从0开始)。因此,举例来说,如果一个异步方法的主体是:await A(); await B(); await C(); await D();
而你发现状态值是2,这几乎可以确定异步方法目前已经挂起,等待从C()返回的任务完成。
-
<>t__builder
。这是状态机的builder,例如:AsyncTaskMethodBuilder
用于Task
,AsyncValueTaskMethodBuilder<TResult>
用于ValueTask<TResult>
,AsyncVoidMethodBuilder
用于async void
方法,或者任何通过[AsyncMethodBuilder(...)]
特性声明的builder,用于异步返回类型或是带有这个特性的异步方法。如前所述,builder负责异步方法的生命周期,包括创建返回任务,最终完成该任务,并作为挂起的中介,异步方法中的代码会要求builder挂起,直到特定的awaiter完成。 -
source/destination
。这些是方法参数。你可以轻松分辨出来,因为它们没有改名;编译器并没有改变它们的名字。如前所述,所有被方法体使用的参数都需要被存储到状态机中,以便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的字段。但是,如果我们改变async方法,以任何方式实际使用该参数:
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
。这是被提升为字段的缓冲区“局部变量”,以便它可以在await点之间存活。编译器尽可能地保持状态不被不必要地提升。注意,源代码中的另一个局部变量,numRead
,在状态机中没有对应的字段。为什么?因为它不是必要的。该局部变量被设置为ReadAsync
调用的结果,然后用作WriteAsync
调用的输入。在这两者之间没有await,因此不需要存储numRead
的值。就像在同步方法中,JIT编译器可以选择将这样的值完全存储在寄存器中,并且从不实际将其放到栈上一样,C#编译器可以避免将此局部变量提升为字段,因为它不需要在任何await之间保留其值。一般来说,如果C#编译器可以证明它们的值不需要在await之间保留,则可以省略提升本地变量。 -
<>u__1
和<>u__2
。异步方法中有两个await:一个是ReadAsync
返回的Task<int>
,另一个是WriteAsync
返回的Task
。Task.GetAwaiter()
返回TaskAwaiter
,Task<TResult>.GetAwaiter()
返回TaskAwaiter<TResult>
,它们都是不同的struct。由于编译器需要在await之前获取这些awaiter(IsCompleted
,UnsafeOnCompleted
),然后在await之后访问它们(GetResult
),因此需要存储这些awaiter。而且由于它们是不同的struct,编译器需要维护两个单独的字段来实现(另一种选择是对它们进行装箱,然后使用一个单独的对象字段来存储awaiter,但这将导致额外的分配成本)。编译器会尽可能地重用字段。如果我有:public async Task M() { await Task.FromResult(1); await Task.FromResult(true); await Task.FromResult(2); await Task.FromResult(false); await Task.FromResult(3); }
这里有5个await,但只涉及两种不同类型的awaiter:三个是
TaskAwaiter<int>
,两个是TaskAwaiter<bool>
。因此,状态机上只有两个awaiter字段:private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; private TaskAwaiter<int> <>u__1; private TaskAwaiter<bool> <>u__2; ... }
如果我改成这样:
public async Task M() { await Task.FromResult(1); await Task.FromResult(true); await Task.FromResult(2).ConfigureAwait(false); await Task.FromResult(false).ConfigureAwait(false); await Task.FromResult(3); }
仍然只涉及
Task<int>
和Task<bool>
,但实际上我使用了四种不同的struct awaiter,因为从ConfigureAwait
返回东西上调用GetAwaiter()
返回的awaiter与Task.GetAwaiter()
返回的awaiter并不相同……这也可以从编译器创建的awaiter字段中看出: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; ... }
如果你想自己优化与异步状态机相关的大小,你可以看看是否可以合并正在等待的东西的类型,从而合并这些awaiter字段。
状态机上还可能有其他类型的字段。值得注意的是,你可能会看到一些包含"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;
...
}
现在我们多了一个:<>7__wrap1
。为什么?因为我们计算了DateTime.Now.Second
的值,而且只有在计算它之后,我们才需要等待一些东西,而第一个表达式的值需要保留,以便将其加到第二个表达式的结果上。因此,编译器需要确保该第一个表达式的临时结果可用于加到await的结果上,这意味着它需要将该表达式的结果放到一个临时变量中,它使用<>7__wrap1
字段来实现。如果你发现自己正在大力优化异步方法的实现以降低分配的内存量,你可以找找这些字段,看看源代码中的微调是否可以避免保存为临时变量。
总结
希望这篇文章能帮助你了解使用async/await
时底层发生了什么,但幸运的是,你通常不需要知道或关心。这里有很多可移动的部分,它们都是为了创建一个高效的解决方案,用于编写可扩展的异步代码,而无需处理回调。但到头来,这些部分实际上并不困难:任意异步操作的通用表示,一种语言和编译器,能够将正常的控制流重写为协程的状态机实现,以及将它们绑定在一起的模式。其他都是优化的奖赏。
祝你编码愉快!