理解同步上下文;Task.ConfigureAwait 的实际应用





5.00/5 (10投票s)
SynchronizationContext 类及其如何影响代码行为,以及 Task.ConfigureAwait() 的探讨
引言
同步上下文是线程运行的环境。它是一组特性,定义了线程如何响应消息。可以将其视为定义线程边界的范围。在不通过目标线程的同步上下文进行通信的情况下,任何线程都无法访问其他线程的数据,也无法将任何工作单元传递给其他线程。值得一提的是,不同的线程可能共享同一个同步上下文,并且线程拥有同步上下文是可选的。
一点历史
如果你有 Windows 开发背景,你肯定知道在 WinForms 中,例如,你不能从任何其他线程访问 UI 控件。你必须通过 `Control.BeginInvoke()` 函数,或者更精确地说,使用 `ISynchronizeInvoke` 模式,将你的任务卸载,或者换句话说,将你的代码(工作单元)委托给 UI 线程。`ISynchronizeInvoke` 允许你将一个工作单元排队到 UI 线程进行处理。如果你不遵循此模式,你的代码可能会因**跨线程访问**错误而崩溃。
然后,引入了同步上下文。实际上,对于 WinForms 而言,它内部使用了 `ISynchronizeInvoke` 模式。同步上下文的引入使得对线程环境和边界有了共同的理解。你不再需要单独考虑每个框架,你必须理解同步上下文是什么,以及如何将工作委托给其他线程进行处理。
实现
尽管同步上下文在各种 .NET 平台中的实现方式不同,但其思想是相同的,并且所有实现都派生自默认的 .NET 同步上下文类 `SynchronizationContext` (`mscorlib.dll: System`),该类引入了一个静态属性 `Current`,它返回当前线程的 `SynchronizationContext`,以及两个主要方法 `Send()` 和 `Post()`。
简而言之,`Send()` 和 `Post()` 的区别在于 `Send()` 将同步运行此工作单元。换句话说,当源线程将一些工作委托给另一个线程时,它将被阻塞,直到目标线程完成。另一方面,如果使用 `Post()`,则调用线程不会被阻塞。请参阅下图
当我们查看默认 `SynchronizationContext` 的内部实现时,我们可以看到以下内容
public virtual void Send(SendOrPostCallback d, object state) => d(state);
public virtual void Post(SendOrPostCallback d, object state) =>
ThreadPool.QueueUserWorkItem(new WaitCallback(d.Invoke), state);
`Send()` 只是执行 `delegate`,而 `Post()` 使用 `ThreadPool` 异步执行 `delegate`。
请注意,尽管 `Send()` 和 `Post()` 默认行为不同,但当它们由平台实现时可能不会。现在让我们快速了解一下各种平台如何实现同步上下文。
WinForms
在 Windows Forms 应用程序中,同步上下文通过 `WindowsFormsSynchronizationContext` (`System.Windows.Forms.dll: System.Windows.Forms`) 实现,它本身派生自默认的 `SynchronizationContext` (`mscorlib.dll: System`)。此实现分别调用 `Control.Invoke()` 和 `Control.BeginInvoke()` 来实现 `Send()` 和 `Post()`。它们确保工作单元被发送到 UI 线程。
`WindowsFormsSynchronizationContext` 何时应用?当你第一次实例化窗体时,它会被应用(即安装)。请注意,这些断言将会通过
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// SynchronizationContext is still null here
Debug.Assert(SynchronizationContext.Current == null);
var frm = new TasksForm();
// Now SynchronizationContext is installed
Debug.Assert(SynchronizationContext.Current != null &&
SynchronizationContext.Current is WindowsFormsSynchronizationContext);
Application.Run(frm);
}
WPF 和 Silverlight
在 WPF 和 Silverlight 中,同步上下文通过 `DispatcherSynchronizationContext` (`WindowsBase.dll: System.Windows.Threading`) 实现,它的作用与 WinForms 的对应项相同,它将委托的工作单元传递给 UI 线程执行,并在内部使用 `Dispatcher.Invoke()` 和 `Dispatcher.BeginInvoke()` 进行委托。
经典 ASP.NET
在经典的 ASP.NET 中,同步上下文通过 `AspNetSynchronizationContext` (`System.Web.dll: System.Web`) 实现,然而,它的实现方式与其他平台中的对应项不同。由于 ASP.NET 中没有 UI 线程的概念,并且每个请求都需要一个单独的线程进行处理,因此 `AspNetSynchronizationContext` 只是维护一个未完成操作的队列,当所有操作完成后,请求就可以标记为已完成。`Post()` 仍然将委托的工作传递给 `ThreadPool`。
`AspNetSynchronizationContext` 的唯一用途是这个吗?不是。`AspNetSynchronizationContext` 最重要的任务是确保请求线程可以访问 `HttpContext.Current` 和其他相关的身份和文化数据。你知道这一点。有时你在运行线程之前缓存 `HttpContext.Current` 的引用,这就是 `AspNetSynchronizationContext` 派上用场的时候。
ASP.NET Core
ASP.NET Core 中没有 `AspNetSynchronizationContext` 的等价物,它已被移除。为什么?它是应用于新平台的各种性能改进的一部分。它使应用程序摆脱了进入和离开同步上下文时产生的开销(我们稍后会讨论)。请在此处阅读更多关于 ASP.NET Core 的 SynchronizationContext 信息。
同步上下文的实际应用
现在,让我们看看同步上下文的实际应用。在此示例中,我们将看到如何将一个工作单元从工作线程传递到 UI 线程。启动一个新的 WinForms 项目并更新设计器代码以匹配以下内容
private void InitializeComponent()
{
this.ResultsListBox = new System.Windows.Forms.ListBox();
this.RegularThreadsButton = new System.Windows.Forms.Button();
this.UIThreadTest = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// ResultsListBox
//
this.ResultsListBox.FormattingEnabled = true;
this.ResultsListBox.Location = new System.Drawing.Point(12, 12);
this.ResultsListBox.Name = "ResultsListBox";
this.ResultsListBox.Size = new System.Drawing.Size(516, 212);
this.ResultsListBox.TabIndex = 1;
//
// RegularThreadsButton
//
this.RegularThreadsButton.Location = new System.Drawing.Point(12, 232);
this.RegularThreadsButton.Name = "RegularThreadsButton";
this.RegularThreadsButton.Size = new System.Drawing.Size(516, 23);
this.RegularThreadsButton.TabIndex = 2;
this.RegularThreadsButton.Text = "Regular Thread Test";
this.RegularThreadsButton.UseVisualStyleBackColor = true;
this.RegularThreadsButton.Click += new System.EventHandler(this.RegularThreadsButton_Click);
//
// UIThreadTest
//
this.UIThreadTest.Location = new System.Drawing.Point(12, 261);
this.UIThreadTest.Name = "UIThreadTest";
this.UIThreadTest.Size = new System.Drawing.Size(516, 23);
this.UIThreadTest.TabIndex = 3;
this.UIThreadTest.Text = "UI-Context Thread Test";
this.UIThreadTest.UseVisualStyleBackColor = true;
this.UIThreadTest.Click += new System.EventHandler(this.UIThreadTest_Click);
//
// ThreadsForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(540, 294);
this.Controls.Add(this.UIThreadTest);
this.Controls.Add(this.RegularThreadsButton);
this.Controls.Add(this.ResultsListBox);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle;
this.MaximizeBox = false;
this.Name = "ThreadsForm";
this.Text = "SynchronizationContext Sample";
this.ResumeLayout(false);
}
private System.Windows.Forms.ListBox ResultsListBox;
private System.Windows.Forms.Button RegularThreadsButton;
private System.Windows.Forms.Button UIThreadTest;
现在转到窗体代码,并添加以下内容
private void RegularThreadsButton_Click(object sender, EventArgs e)
{
RunThreads(null);
}
private void UIThreadTest_Click(object sender, EventArgs e)
{
// SynchronizationContext.Current will return
// a reference to WindowsFormsSynchronizationContext
RunThreads(SynchronizationContext.Current);
}
private void RunThreads(SynchronizationContext context)
{
this.ResultsListBox.Items.Clear();
this.ResultsListBox.Items.Add($"UI Thread {Thread.CurrentThread.ManagedThreadId}");
this.ResultsListBox.Items.Clear();
int maxThreads = 3;
for (int i = 0; i < maxThreads; i++)
{
Thread t = new Thread(UpdateListBox);
t.IsBackground = true;
t.Start(context); // passing context to thread proc
}
}
private void UpdateListBox(object state)
{
// fetching passed SynchrnozationContext
SynchronizationContext syncContext = state as SynchronizationContext;
// get thread ID
var threadId = Thread.CurrentThread.ManagedThreadId;
if (null == syncContext) // no SynchronizationContext provided
this.ResultsListBox.Items.Add($"Hello from thread {threadId},
currently executing thread is {Thread.CurrentThread.ManagedThreadId}");
else
syncContext.Send((obj) => this.ResultsListBox.Items.Add
($"Hello from thread {threadId},
currently executing thread is {Thread.CurrentThread.ManagedThreadId}"), null);
}
上述代码简单地启动了三个线程,它们只是将记录添加到列表框中,以说明当前调用和执行的线程。现在**以调试模式**运行代码,然后点击“**常规线程测试**”按钮。执行将暂停,你将收到以下异常
这里的关键是。默认情况下,我们无法从任何其他线程访问其他线程的数据(在本例中为控件)。当你这样做时,你会收到一个包装在 `InvalidOperationException` 异常中的**跨线程操作错误**。
这里有一个小提示:当你脱离调试模式工作时,WinForms 将忽略此异常。要始终启用此异常,请在 `Application.Run()` 之前将以下行添加到 `Main()` 方法中。
Control.CheckForIllegalCrossThreadCalls = true;
现在,再次运行应用程序并点击“**UI-上下文线程测试**”按钮。
由于代码通过 `SynchronizationContext.Send()` 将执行传递给主 UI 线程,我们现在在结果中可以看到调用线程是不同的,但是,工作已传递给主线程(线程 1),该线程已成功处理了代码。
任务和同步上下文
同步上下文是 `async`/`await` 模式的核心部分。当你 `await` 一个任务时,你会暂停当前 `async` 方法的执行,直到给定任务的执行完成。
让我们深入探讨上述图示的更多细节。`await` 不仅仅是等待工作线程完成!大致上,`await` 在执行异步任务之前捕获当前的同步上下文(**离开**当前同步上下文)。异步任务返回后,它会再次引用原始同步上下文(**重新进入**同步上下文),然后方法的其余部分继续执行。
让我们看看实际效果。启动一个新项目或向现有项目添加一个新窗体。转到新窗体的设计器代码并更新以匹配以下内容
private void InitializeComponent()
{
this.ResultsListBox = new System.Windows.Forms.ListBox();
this.NoContextButton = new System.Windows.Forms.Button();
this.UIContextButton = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// ResultsListBox
//
this.ResultsListBox.FormattingEnabled = true;
this.ResultsListBox.Location = new System.Drawing.Point(13, 13);
this.ResultsListBox.Name = "ResultsListBox";
this.ResultsListBox.Size = new System.Drawing.Size(429, 264);
this.ResultsListBox.TabIndex = 0;
//
// NoContextButton
//
this.NoContextButton.Location = new System.Drawing.Point(13, 284);
this.NoContextButton.Name = "NoContextButton";
this.NoContextButton.Size = new System.Drawing.Size(429, 23);
this.NoContextButton.TabIndex = 1;
this.NoContextButton.Text = "Task without Synchronization Context";
this.NoContextButton.UseVisualStyleBackColor = true;
this.NoContextButton.Click += new System.EventHandler(this.NoContextButton_Click);
//
// UIContextButton
//
this.UIContextButton.Location = new System.Drawing.Point(13, 313);
this.UIContextButton.Name = "UIContextButton";
this.UIContextButton.Size = new System.Drawing.Size(429, 23);
this.UIContextButton.TabIndex = 2;
this.UIContextButton.Text = "Task with UI Synchronization Context";
this.UIContextButton.UseVisualStyleBackColor = true;
this.UIContextButton.Click += new System.EventHandler(this.UIContextButton_Click);
//
// TasksForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(454, 345);
this.Controls.Add(this.UIContextButton);
this.Controls.Add(this.NoContextButton);
this.Controls.Add(this.ResultsListBox);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle;
this.MaximizeBox = false;
this.Name = "TasksForm";
this.Text = "TasksForm";
this.ResumeLayout(false);
}
private System.Windows.Forms.ListBox ResultsListBox;
private System.Windows.Forms.Button NoContextButton;
private System.Windows.Forms.Button UIContextButton;
现在,转到窗体代码并添加以下内容
private void NoContextButton_Click(object sender, EventArgs e)
{
RunTask(null);
}
private void UIContextButton_Click(object sender, EventArgs e)
{
RunTask(SynchronizationContext.Current);
}
private void RunTask(SynchronizationContext context)
{
this.ResultsListBox.Items.Clear();
this.ResultsListBox.Items.Add($"UI Thread {Thread.CurrentThread.ManagedThreadId}");
Task.Run(async () =>
{
if (null != context)
SynchronizationContext.SetSynchronizationContext(context);
LogMessage($"Task started");
if (null == SynchronizationContext.Current)
LogMessage($"Task synchronization context is null");
else
LogMessage($"Task synchronization context is
{SynchronizationContext.Current.GetType().Name}");
await Task.Delay(1000);
LogMessage($"Task thread is {Thread.CurrentThread.ManagedThreadId}");
LogMessage($"Control.InvokeRequired = {this.ResultsListBox.InvokeRequired}");
LogMessage($"Trying to manipulate UI...");
try
{
this.ResultsListBox.Items.Add("Successfully accessed UI directly!");
}
catch (InvalidOperationException)
{
LogMessage($"Failed!");
}
LogMessage($"Task finished");
});
}
private void LogMessage(string msg)
{
this.ResultsListBox.Invoke((Action)(() =>
{
this.ResultsListBox.Items.Add(msg);
}));
}
上述代码简单地提供了两个选项,一个不设置任务的同步上下文,使其为 `null`,另一个将其设置为 UI 线程的同步上下文。代码等待一个任务并测试当前线程的 UI 可访问性。当我们运行应用程序并点击无上下文按钮时,我们得到以下结果
`await` 之后的代码在不同的线程中运行,并且无法直接访问控件。现在,点击 UI-上下文按钮并查看结果
第二个选项只是使用 `SynchronizationContext.SetSynchronizationContext()` 调用将同步上下文设置为 UI 线程同步上下文。这影响了我们的行为,当我们调用 `await` 时,它捕获了当前的同步上下文(即 `WinFormsSynchronizationContext`),然后**离开**当前上下文到给定的任务并等待其完成。任务完成后,它再次**重新进入**当前上下文,并且你已经能够使用 UI 线程访问 UI 控件,而无需任何委托或回调。
这里有一个小提示,你可能会问自己为什么我们必须使用 `SetSynchronizationContext()`?! `await` 不是应该自动捕获同步上下文吗?是的,它是。但是由于我们正在新任务的上下文中运行(我们使用了 `Task.Run()`),它没有同步上下文。默认情况下,工作任务和线程没有同步上下文(你可以通过检查 `SynchronizationContext.Current` 来验证)。这就是为什么我们必须在调用 `Task.Run()` 之前首先引用 UI 上下文,然后我们必须使用 `SetSynchronizationContext()` 来设置它。在承诺风格的任务和 `Task.Run()` 之外,你可以使用 `ConfigureAwait()` 选项,稍后会解释。
ConfigureAwait 的实际应用
同步上下文的核心概念之一是**上下文切换**。当你 await 一个任务时,就会发生上下文切换。在等待任务之前,你会**捕获**当前上下文,**将其留给**任务上下文,然后在任务完成时**恢复**(**重新进入**)它。这个过程开销很大,在许多情况下,你不需要它!例如,如果在任务之后你不处理 UI 控件,为什么还要再次切换回原始上下文?为什么不节省时间并避免这一轮呢?
经验法则表明,如果你正在开发一个库,或者你不需要访问 UI 控件,或者你可以引用同步数据(如 `HttpContext.Current`)以备将来使用,请节省时间和精力,禁用上下文切换。
这时 `Task.ConfigureAwait()` 就派上用场了。它有一个参数 `continueOnCapturedContext`,如果设置为 `true`(如果未使用 `ConfigureAwait()` 则为默认行为),则启用上下文恢复;如果设置为 `false`,则禁用上下文恢复。
让我们看看实际效果。
WinForms 中的 ConfigureAwait
启动一个新的 WinForms 项目或向现有项目添加一个新窗体。切换到窗体设计器代码并更新以匹配以下内容
private void InitializeComponent()
{
this.ResultsListBox = new System.Windows.Forms.ListBox();
this.ConfigureTrueButton = new System.Windows.Forms.Button();
this.ConfigureFalseButton = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// ResultsListBox
//
this.ResultsListBox.FormattingEnabled = true;
this.ResultsListBox.Location = new System.Drawing.Point(12, 12);
this.ResultsListBox.Name = "ResultsListBox";
this.ResultsListBox.Size = new System.Drawing.Size(517, 342);
this.ResultsListBox.TabIndex = 0;
//
// ConfigureTrueButton
//
this.ConfigureTrueButton.Location = new System.Drawing.Point(12, 357);
this.ConfigureTrueButton.Name = "ConfigureTrueButton";
this.ConfigureTrueButton.Size = new System.Drawing.Size(516, 23);
this.ConfigureTrueButton.TabIndex = 1;
this.ConfigureTrueButton.Text = "Task.ConfigureAwait(true) Test";
this.ConfigureTrueButton.UseVisualStyleBackColor = true;
this.ConfigureTrueButton.Click +=
new System.EventHandler(this.ConfigureTrueButton_Click);
//
// ConfigureFalseButton
//
this.ConfigureFalseButton.Location = new System.Drawing.Point(12, 386);
this.ConfigureFalseButton.Name = "ConfigureFalseButton";
this.ConfigureFalseButton.Size = new System.Drawing.Size(516, 23);
this.ConfigureFalseButton.TabIndex = 2;
this.ConfigureFalseButton.Text = "Task.ConfigureAwait(false) Test";
this.ConfigureFalseButton.UseVisualStyleBackColor = true;
this.ConfigureFalseButton.Click +=
new System.EventHandler(this.ConfigureFalseButton_Click);
//
// ConfigureAwaitForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(541, 421);
this.Controls.Add(this.ConfigureFalseButton);
this.Controls.Add(this.ConfigureTrueButton);
this.Controls.Add(this.ResultsListBox);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle;
this.MaximizeBox = false;
this.Name = "ConfigureAwaitForm";
this.Text = "Task.ConfigureAwait Sample";
this.ResumeLayout(false);
}
private System.Windows.Forms.ListBox ResultsListBox;
private System.Windows.Forms.Button ConfigureTrueButton;
private System.Windows.Forms.Button ConfigureFalseButton;
现在切换到窗体代码并添加以下内容
private void ConfigureTrueButton_Click(object sender, EventArgs e)
{
AsyncTest(true);
}
private void ConfigureFalseButton_Click(object sender, EventArgs e)
{
AsyncTest(false);
}
private async void AsyncTest(bool configureAwait)
{
this.ResultsListBox.Items.Clear();
try
{
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("ar-EG");
this.ResultsListBox.Items.Add("Async test started");
this.ResultsListBox.Items.Add(string.Format("configureAwait = {0}", configureAwait));
this.ResultsListBox.Items.Add(string.Format
("Current thread ID = {0}", Thread.CurrentThread.ManagedThreadId));
this.ResultsListBox.Items.Add(string.Format
("Current culture = {0}", Thread.CurrentThread.CurrentCulture));
this.ResultsListBox.Items.Add("Awaiting a task...");
await Task.Delay(500).ConfigureAwait(configureAwait);
this.ResultsListBox.Items.Add("Task completed");
this.ResultsListBox.Items.Add(string.Format
("Current thread ID: {0}", Thread.CurrentThread.ManagedThreadId));
this.ResultsListBox.Items.Add(string.Format
("Current culture: {0}", Thread.CurrentThread.CurrentCulture));
}
catch (InvalidOperationException ex)
{
var threadId = Thread.CurrentThread.ManagedThreadId;
this.ResultsListBox.BeginInvoke((Action)(() =>
{
this.ResultsListBox.Items.Add($"{ex.GetType().Name} caught from thread {threadId}");
}));
}
}
该代码只是等待一个任务,并根据点击的按钮切换 `ConfigureAwait()`。它还在切换之前更改当前线程的文化信息。运行窗体并点击“**ConfigureAwait(true)**”按钮。
行为如预期。我们恢复了原始同步上下文,保留了线程环境数据(如文化),并且能够轻松直接访问 UI 控件。
现在点击“**ConfigureAwait(false)**”按钮并查看结果
当将 `ConfigureAwait.continueOnCapturedContext` 设置为 `false` 时,我们无法返回到原始上下文,并且由于跨线程访问而收到了 `InvalidOperationException` 错误。
ASP.NET MVC 中的 ConfigureAwait
启动一个新的 MVC 项目,并更新 *index.cshtml* 文件以匹配以下内容
@model IEnumerable<String>
@if (null != Model && Model.Any())
{
<ul>
@foreach (var val in Model)
{
<li>@val</li>
}
</ul>
}
现在,转到 `Home` 控制器并添加以下代码
private List<string> results = new List<string>();
public async Task<ActionResult> Index(bool configureAwait = false)
{
await AsyncTest(configureAwait);
return View(results);
}
private async Task AsyncTest(bool configureAwait)
{
results.Add($"Async test started, ConfigureAwait = {configureAwait}");
if (null == System.Web.HttpContext.Current)
results.Add($"HttpContext.Current is null");
else
results.Add($"HttpContext.Current is NOT null");
results.Add($"Current thread ID = {Thread.CurrentThread.ManagedThreadId}");
results.Add("Awaiting task...");
await Task.Delay(1000).ConfigureAwait(configureAwait);
results.Add("Task completed");
results.Add($"Current thread ID = {Thread.CurrentThread.ManagedThreadId}");
if (null == System.Web.HttpContext.Current)
results.Add($"HttpContext.Current is null");
else
results.Add($"HttpContext.Current is NOT null");
}
运行应用程序并检查两种情况之间的区别
你现在可以看到,当将 `ConfigureAwait.continueOnCapturedContext` 设置为 `false` 时,原始同步上下文不会恢复,并且我们失去了对 `HttpContext.Current` 的访问。另一方面,当设置为 `true` 时,我们恢复了原始同步上下文,并获得了对 `HttpContext.Current` 的访问。请注意,这里切换到原始线程并不相关,因为与桌面应用程序不同,ASP.NET 中没有 UI 线程。
最后说明
我们可以将以上所有内容总结为两点
- 为了更好的性能,当在库中或在 `await` 之后不需要访问 UI 元素时,请使用 `ConfigureAwait(false)`。
- 当需要在线程场景中重新捕获原始上下文时,请使用 `SynchronizationContext.Set()`。
最后,我希望我能够简化事情并展示同步上下文的各个方面。请随时与我分享你的想法和反馈。
代码可在 GitHub 上获取:
历史
- 2021年8月28日:初始版本