# 异步编程 - 从头开始异步
作者 [Mark Sowul](https://msdn.microsoft.com/zh-cn/magazine/mt149362?author=Mark+Sowul) | 2015 年 11 月 | 获取代码: [C#](http://download.microsoft.com/download/B/A/E/BAEA7711-903C-4536-92FF-CAC9955EB848/Code_Sowul.Async.1115.zip)[VB](http://download.microsoft.com/download/B/A/E/BAEA7711-903C-4536-92FF-CAC9955EB848/VBCode_Sowul.Async.1115.zip)
借助 Microsoft .NET Framework 的最新版本,通过 async 和 await 关键字编写迅速响应的高性能应用程序不再是难事。可以毫不夸张地说,这些版本改变了 .NET 开发者编写软件的方式。曾经需要使用令人费解的嵌套式回叫网的异步代码现在可以简单编写(和理解),几乎与顺序同步代码一样。
关于如何创建和使用异步方法的资料足够多,所以我认定您已对基础知识很熟悉了。如果您不熟悉,请访问 [msdn.com/async](http://msdn.com/async) 上的 Visual Studio 文档页,这可以帮助您跟上脚步。
大多数异步文档都会提醒您注意,不能只是将异步方法插入现有代码;调用方本身需要是异步的。用 Lucian Wischik(Microsoft 语言团队的一名开发者)的话说,“异步就像是僵尸病毒”。 那么如何在不诉诸于 async void 的前提下,从一开始就在应用程序的结构中构建异步呢? 我将展示几种重构默认 UI 启动代码的方法(既适用于 Windows 窗体,也适用于 Windows Presentation Foundation (WPF)),以及如何将 UI 样本转换成面向对象的设计,并添加对 async/await 的支持。在此期间,我还会介绍何时适合或不适合使用“async void”。
在本文中,主要内容是关于 Windows 窗体;WPF 需要进行其他更改,这会造成干扰。在每一步中,我都会先讲解对 Windows 窗体应用程序的更改,然后再讨论 WPF 版本的相应差异。在本文中,我介绍了所有的基本代码更改。不过,您还可以下载随附的联机代码,查看两种环境的完整示例(和中间版本)。
## 首要步骤
Windows 窗体和 WPF 应用程序的 Visual Studio 模板本身并不真正适合在启动期间使用异步(或大致自定义启动流程)。尽管 C# 力求成为面向对象的语言(所有代码都必须在类中),但默认的启动代码促使开发者将逻辑置于静态方法和 Main 中,或置于主窗体的过度复杂的构造函数中(不适合在 MainForm 构造函数中访问数据库。这可是我的亲身经验)。 这种情况一直以来都是一个问题,但现在即使有了异步,也显然无法让应用程序进行自我异步初始化。
首先,我在 Visual Studio 中使用 Windows 窗体应用程序模板新建了一个项目。图 1 展示了 Program.cs 中的默认启动代码。
图 1:默认 Windows 窗体启动代码
~~~
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
~~~
对于 WPF,也并不那么容易。默认的 WPF 启动相当难懂,就算是要找到任何用于自定义的代码也很困难。您可以将初始化代码添加到 Application.OnStartup 中,但如何延迟显示 UI 直到您已加载完所需的数据呢? 首先,我要对 WPF 做的是,将启动流程公开为我可以编辑的代码。我将让 WPF 和 Windows Forms 处于相同的起点,然后本文中的每个步骤对二者来说就都差不多了。
在 Visual Studio 中新建 WPF 应用程序后,我使用图 2 中的代码,新建了一个名为“Program”的类。若要替换默认启动序列,请打开项目属性,然后将启动对象从“App”更改为新建的“Program”。
图 2:等效的 Windows Presentation Foundation 启动代码
~~~
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
App app = new App();
// This applies the XAML, e.g. StartupUri, Application.Resources
app.InitializeComponent();
// Shows the Window specified by StartupUri
app.Run();
}
}
~~~
如果您对图 2 中的 InitializeComponent 调用使用“转到定义”,那么当您将“App”用作启动对象时,您会看到编译器生成等效的 Main 代码(即我在此处打开“黑盒”的方法)。
## 实现面向对象的启动
首先,我会稍微地重构一下默认的启动代码,使其走上面向对象的正轨: 我会将逻辑从 Main 中移出,并移至类上。为此,我会将 Program 定义为非静态类(如我所说,默认设置无法让您走上正轨),并为其创建一个构造函数。然后,我会将设置代码移至构造函数中,并添加用于运行我的窗体的 Start 方法。
我已调用了新版 Program1,如图 3 所示。此主干展现了核心思想:若要运行程序,Main 现在创建对象并在其上调用方法,就像任何典型的面向对象的方案一样。
图 3:Program1 - 面向对象的启动的开端
~~~
[STAThread]
static void Main()
{
Program1 p = new Program1();
p.Start();
}
private readonly Form1 m_mainForm;
private Program1()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
m_mainForm = new Form1();
}
public void Start()
{
Application.Run(m_mainForm);
}
~~~
## 将应用程序从窗体中分离出来
然而,调用 Application.Run 提取窗体实例(最后在我的 Start 方法中)带来了一些问题。其中一个就是常规的体系结构问题: 我不喜欢将我的应用程序生存期绑定为显示相应的窗体。对于许多应用程序来说,这都是可以的,但还是有一些在后台运行的应用程序不应该在启动时显示任何 UI,任务栏或通知区域的图标可能除外。我见过在启动时屏幕短暂地闪烁一下,然后消失的情况。我敢肯定的是,它们的启动代码遵循类似的流程,在窗体完成加载时,它们便会尽快隐藏起来。无可否认,此时无需解决这一特定问题,但对于异步初始化来说,分离至关重要。
我会重载不提取自变量的 Run,而不使用 Application.Run(m_mainForm)。 无需将它绑定至任何特定的窗体,即可启动 UI 基础结构。这种分离意味着我必须自行显示窗体;同时也意味着,关闭窗体将不再退出应用,所以我也需要将相应的内容明确关联起来,如图 4 所示。我也会借此机会为初始化添加我的第一个挂钩。“Initialize”是一种我为窗体类创建的方法,用于保留初始化它所需的全部逻辑,如从数据库或网站检索数据。
图 4:Program2 - 消息循环和主窗体现在是分离的
~~~
private Program2()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
m_mainForm = new Form1();
m_mainForm.FormClosed += m_mainForm_FormClosed;
}
void m_mainForm_FormClosed(object sender, FormClosedEventArgs e)
{
Application.ExitThread();
}
public void Start()
{
m_mainForm.Initialize();
m_mainForm.Show();
Application.Run();
}
~~~
在 WPF 版本中,应用的 StartupUri 决定了在调用 Run 时显示的窗口;您可以在 App.xaml 标记文件中查看它的定义。不出所料,当所有 WPF 窗口均已关闭时,OnLastWindowClose 的应用程序默认 ShutdownMode 设置会关闭应用程序,这就是生存期如何绑定在一起的(请注意,这与 Windows 窗体不同。在 Windows 窗体中,如果主窗口打开了子窗口,而您恰好关闭了第一个窗口,那么应用程序会退出。在 WPF 中,除非您将两个窗口都关闭,否则应用程序不会退出)。
为了在 WPF 中实现同样的分离,我会先从 App.xaml 中删除 StartupUri。相反,我会自行创建窗口,初始化它,并在调用 App.Run 之前显示它。
~~~
public void Start()
{
MainWindow mainForm = new MainWindow();
mainForm.Initialize();
mainForm.Show();
m_app.Run();
}
~~~
在创建应用程序时,我将 app.ShutdownMode 设置为 ShutdownMode.OnExplicitShutdown,以便将应用程序生存期从窗口中分离出来:
~~~
m_app = new App();
m_app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
m_app.InitializeComponent();
~~~
为了实现显式关闭,我会为 MainWindow.Closed 附加事件处理程序。
当然,WPF 可以更好地执行分离,因此最好是初始化视图模型(而不是窗口本身): 我会创建 MainViewModel 类和我自己的 Initialize 方法。同样,应用关闭请求也应经过视图模型,所以我会向视图模型添加“CloseRequested”事件和相应的“RequestClose”方法。生成的 WPF 版本 Program2 如图 5 所列(Main 保持不变,因此我不会在此处展示它)。
图 5:Windows Presentation Foundation 版本 Program2 类
~~~
private readonly App m_app;
private Program2()
{
m_app = new App();
m_app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
m_app.InitializeComponent();
}
public void Start()
{
MainViewModel viewModel = new MainViewModel();
viewModel.CloseRequested += viewModel_CloseRequested;
viewModel.Initialize();
MainWindow mainForm = new MainWindow();
mainForm.Closed += (sender, e) =>
{
viewModel.RequestClose();
};
mainForm.DataContext = viewModel;
mainForm.Show();
m_app.Run();
}
void viewModel_CloseRequested(object sender, EventArgs e)
{
m_app.Shutdown();
}
~~~
## 提取宿主环境
现在,我已经将 Application.Run 从我的窗体中分离出来了,我想解决另一体系结构问题。现在,Application 深深嵌入到 Program 类中。就是说,我想把这个宿主环境“抽象出来”。我将从我的 Program 类中删除各种有关应用程序的 Windows 窗体方法,仅剩下与 Program 本身相关的功能,如图 6 中的 Program3 所示。最后是要在 Program 类上添加事件,以削弱关闭窗体和关闭应用程序之间的直接关系。请注意 Program3 作为类是如何与应用程序没有交互的!
图 6:Program3 - 现在可以在其他位置轻松插入
~~~
private readonly Form1 m_mainForm;
private Program3()
{
m_mainForm = new Form1();
m_mainForm.FormClosed += m_mainForm_FormClosed;
}
public void Start()
{
m_mainForm.Initialize();
m_mainForm.Show();
}
public event EventHandler<EventArgs> ExitRequested;
void m_mainForm_FormClosed(object sender, FormClosedEventArgs e)
{
OnExitRequested(EventArgs.Empty);
}
protected virtual void OnExitRequested(EventArgs e)
{
if (ExitRequested != null)
ExitRequested(this, e);
}
~~~
分离宿主环境有几大好处。第一个好处就是简化了测试(您现在可以在有限程度上测试 Program3)。第二个好处就是可以更轻松地在其他位置上重用代码,可能是嵌入更大的应用程序或“启动器”屏幕。
分离的 Main 如图 7 所示(我已将 Application 逻辑重新移入其中)。这种设计更便于集成 WPF 和 Windows 窗体,或逐步将 Windows 窗体替换为 WPF。这不在本文的介绍范围内,但您可以在随附的联机代码中找到混合应用程序示例。与前面的重构一样,这些优点并不一定至关重要: 可以说,与“手头任务”相关的是,将使异步版本更自如地运行(这一点您很快就会发现)。
图 7:Main - 现可托管任意程序
~~~
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Program3 p = new Program3();
p.ExitRequested += p_ExitRequested;
p.Start();
Application.Run();
}
static void p_ExitRequested(object sender, EventArgs e)
{
Application.ExitThread();
}
~~~
## 翘首以待的异步
现在终于有回报了。我可以让 Start 方法成为异步方法,从而使用 await 并让初始化逻辑成为异步逻辑。依照惯例,我已将 Start 重命名为 StartAsync,并将 Initialize 重命名为 InitializeAsync。同时,我还已将其返回类型更改为异步任务:
~~~
public async Task StartAsync()
{
await m_mainForm.InitializeAsync();
m_mainForm.Show();
}
~~~
为了使用它,Main 进行了以下更改:
~~~
static void Main()
{
...
p.ExitRequested += p_ExitRequested;
Task programStart = p.StartAsync();
Application.Run();
}
~~~
为了解释其工作原理,并解决十分细微但却很重要的问题,我需要深入探究 async/await 的具体情况。
await 的真正含义: 考虑一下我提出的 StartAsync 方法。应当认清(通常情况下)async 方法在遇到 await 关键字时返回。正在执行的线程继续进行,就像返回其他任何方法一样。在这种情况下,StartAsync 方法遇到“await m_mainForm.InitializeAsync”,并返回至 Main(它会继续运行),同时调用 Application.Run。这会导致违反常理的结果发生,即 Application.Run 很可能在 m_mainForm.Show 之前执行。尽管按顺序来说,它是在 m_mainForm.Show 之后发生。async 和 await 确实会简化异步编程,但这也绝非易事。
这就是 async 方法返回 Tasks 的原因;Task 完成从直觉上讲代表“返回”的 async 方法,即全部代码均已运行。至于 StartAsync,这意味着它完成了 InitializeAsync 和 m_mainForm.Show。这就是使用 async void 遇到的第一个问题: 如果没有任务对象,则 async void 方法的调用方将无法获知它何时完成执行。
如果线程已继续,且 StartAsync 已返回至其调用方,那么其余的代码如何以及何时运行呢? 这就是引入 Application.Run 的原因所在。Application.Run 是一个无限循环,负责等待执行任务(主要是处理 UI 事件)。例如,当您在窗口上移动鼠标或单击按钮时,Application.Run 消息循环会取消事件排队,并分派相应的代码作为回应,然后等待下一事件。不过,这并不严格限于 UI: 考虑在 UI 线程上运行函数的 Control.Invoke。Application.Run 也正在处理这些请求。
在这种情况下,只要 InitializeAsync 完成,StartAsync 方法的剩余部分就会发布到消息循环中。当您使用 await 时,Application.Run 会在 UI 线程上执行方法的剩余部分,就像您使用 Control.Invoke 编写回叫一样(UI 线程上是否应发生延续受控于 ConfigureAwait。有关详情,您可以访问[msdn.com/magazine/jj991977](http://msdn.com/magazine/jj991977),查看 Stephen Cleary 于 2013 年 3 月发布的文章,这是一篇关于异步编程最佳做法的文章)。
这就是将 Application.Run 从 m_mainForm 中分离出来如此重要的原因所在。Application.Run 起主导作用:它必须运行才能处理“await”之后的代码,在您真正要显示任意 UI 之前。例如,如果您尝试将 Application.Run 移出 Main 并移回 StartAsync,那么程序会立即退出: 只要执行到“await InitializeAsync”,Main 就会收回控制,然后就没有其他要运行的代码了,这就是 Main 的末尾。
这也解释了为什么必须要由下而上地开始使用 async。常见的临时反模式是调用 Task.Wait(而不是 await),因为调用方不是异步方法。但它最可能会立即死锁。问题在于 UI 线程会被 Wait 调用屏蔽,无法处理延续。没有延续,任务将无法完成,所以 Wait 调用永远不会返回任何结果(即死锁)!
Await 和 Application.Run 就是先有鸡还是先有蛋的问题: 我之前提到过存在一个十分细微的问题。我介绍过,当您调用 await 时,默认行为是继续执行 UI 线程,这正是我此时所需要的。不过,当我第一次调用 await 时,相应的基础结构并未设置,因为相应的代码尚未运行!
SynchronizationContext.Current 是此行为的关键: 调用 await 时,基础结构捕获 SynchronizationContext.Current 的值,并使用其发布延续;这就是继续执行 UI 线程的工作方式。当开始运行消息循环时,Windows 窗体或 WPF 设置同步上下文。在 StartAsync 内,此情况尚未发生: 如果您在 StartAsync 开始时检查 SynchronizationContext.Current,则会看到其为 null。如果无同步上下文,则 await 会改为将延续发布到线程池中,由于不是 UI 线程,因此无效。
WPF 版本会立即挂起,但事实证明,Windows 窗体版本会“意外”生效。默认情况下,当第一个控件创建时(即我构造 m_mainForm 时),Windows 窗体设置同步上下文(此行为受控于 WindowsFormsSynchronizationContext.AutoInstall)。由于在我创建窗体后发生了“await InitializeAsync”,因此我认为没有问题。不过,如果我将 await 调用置于 creating m_mainForm *之前*,则会遇到相同的问题。解决办法是在开始时自行设置同步上下文,如下所示:
~~~
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
SynchronizationContext.SetSynchronizationContext(
new WindowsFormsSynchronizationContext());
Program4 p = new Program4();
... as before
}
~~~
对于 WPF,等效调用为:
~~~
SynchronizationContext.SetSynchronizationContext(
new DispatcherSynchronizationContext());
~~~
## 异常处理
马上就大功告成了! 但在应用程序的根源,我还有另一个问题萦绕于心: 如果 InitializeAsync 出现异常,则程序不会进行处理。programStart 任务对象会包含异常信息,但不会采取任何措施,这样一来我的应用程序会陷入困境。如果我可以执行“await StartAsync”,则能在 Main 中捕获异常,但无法使用 await,因为 Main 不是异步的。
这说明了 async void 存在的第二个问题: 无法正确地捕获 async void 方法引发的异常,因为调用方无权访问任务对象(那么,您*应该*何时使用 async void 呢? 根据一贯的指导,async void 应主要限于事件处理程序。我之前提到的 2013 年 3 月发布的一篇文章中也介绍了此问题;我建议您阅读这篇文章,以便充分利用 async/await)。
正常情况下,TaskScheduler.UnobservedException 处理出现随后不处理的异常的任务。问题在于无法保证运行。在这种情况下,几乎可以肯定不会运行:在此类任务完成后,任务计划程序才检测未观察到的异常。只有当垃圾回收器运行时才能完成。垃圾回收器仅在它需要满足请求以获得更多内存的情况下才运行。
您可能见过这种情况发生: 异常会导致应用程序“坐视不理”,不执行任何操作,所以也就不会请求获得更多内存,进而导致垃圾回收器也不会运行。结果就是应用挂起。实际上,这就是为什么您不指定同步上下文就会导致 WPF 版本挂起的原因:WPF 窗口构造函数引发异常,因为窗口是在非 UI 线程中创建,然后异常也一直处于未处理状态。最后一项工作就是要处理 programStart 任务,并添加在出错时运行的延续。在这种情况下,如果应用程序无法自我初始化,则可以退出。
我无法在 Main 中使用 await,因为它不是异步的,但我可以新建 async 方法,目的仅在于公开(和处理)异步启动期间引发的所有异常。 它仅包括与 await 有关的 try/catch。因为此方法会处理所有异常,而不引发任何新异常,所以这又是一个 async void 行得通的少数情况:
~~~
private static async void HandleExceptions(Task task)
{
try
{
await task;
}
catch (Exception ex)
{
...log the exception, show an error to the user, etc.
Application.Exit();
}
}
~~~
Main 这样使用它:
~~~
Task programStart = p.StartAsync();
HandleExceptions(programStart);
Application.Run();
~~~
当然,像往常一样,存在一个十分细微的问题(如果 async/await 能够起到简化的作用,则您可以想象它曾经有多难)。我之前提到过,*通常情况下*,当 async 方法遇到 await 调用时,它会返回,而此方法的剩余部分会作为延续运行。不过,在某些情况下,任务可以同步完成;如果是这样的话,则代码的执行不会中断(这是一项性能优势)。然而,如果此时发生这样的情况,则意味着 HandleExceptions 方法会全部运行,然后返回,而 Application.Run 会紧随其后: 在这种情况下,如果发生异常,现在 Application.Exit 调用会*先于* Application.Run 调用发生,将不会产生任何影响。
我要做的是强制 HandleExceptions 作为延续运行: 我需要确保我在执行其他任何操作之前,“直通”Application.Run。这样一来,如果发生异常,我就知道 Application.Run 已在执行,而 Application.Exit 会正确中断它。Task.Yield 就做到了这一点: 它强制当前的异步代码路径让步于其调用方,然后作为延续恢复运行。
下面是对 HandleExceptions 的修正:
~~~
private static async void HandleExceptions(Task task)
{
try
{
// Force this to yield to the caller, so Application.Run will be executing
await Task.Yield();
await task;
}
...as before
~~~
在此示例中,当我调用“await Task.Yield”时,HandleExceptions 会返回,且 Application.Run 会执行。然后,HandleExceptions 的剩余部分会作为延续发布到当前的 SynchronizationContext 中,这意味着它会被 Application.Run 提取。
顺便说一句,对于理解 async/await 来说,我认为 Task.Yield 是一块实用的“试金石”。 如果您了解 Task.Yield 的用途,则可能会对 async/await 的工作原理有一个扎实的了解。
## 回报
现在一切就绪,是时候讲点趣味性内容了: 我将展示如何轻松地添加响应式初始屏幕,而不在单独线程中运行它。撇开趣味性不谈,如果您的应用程序无法立即“启动”,那么拥有初始屏幕就显得相当重要: 如果用户在启动您的应用程序后的好几秒内都未看到任何动态,就会形成糟糕的用户体验。
为初始屏幕启动单独的线程是低效的,也是很笨拙的做法。您必须在两个线程之间正确封送所有调用。因此,提供有关初始屏幕的进度信息是很难的,甚至是关闭它也需要调用 Invoke 或等效方法。此外,当初始屏幕最终关闭时,它通常不会正确地关注主窗体,因为如果初始屏幕和主窗体不在同一线程上,则无法设置两者之间的所有权。将其与简易的异步版本进行比较,如图 8 所示。
图 8:向 StartAsync 添加初始屏幕
~~~
public async Task StartAsync()
{
using (SplashScreen splashScreen = new SplashScreen())
{
// If user closes splash screen, quit; that would also
// be a good opportunity to set a cancellation token
splashScreen.FormClosed += m_mainForm_FormClosed;
splashScreen.Show();
m_mainForm = new Form1();
m_mainForm.FormClosed += m_mainForm_FormClosed;
await m_mainForm.InitializeAsync();
// This ensures the activation works so when the
// splash screen goes away, the main form is activated
splashScreen.Owner = m_mainForm;
m_mainForm.Show();
splashScreen.FormClosed -= m_mainForm_FormClosed;
splashScreen.Close();
}
}
~~~
## 总结
我已经展示了如何将面向对象的设计应用于您的应用程序的启动代码(无论是 Windows 窗体还是 WPF),它可以轻松地支持异步初始化。我还展示了如何解决异步启动流程中的一些十分细微的问题。您若要真正进行异步初始化,我恐怕您只能靠您自己了,但您可以在 [msdn.com/async](http://msdn.com/async) 上找到一些指南。
学会使用 async 和 await 只是个开始。现在,程序更面向对象,其他功能的实施变得更加直截了当。我可以通过对 Program 类调用相应的方法,处理命令行自变量。我可以让用户在主窗口显示前进行登录。我可以在通知区域启动应用,而不在启动时显示任何窗口。通常,面向对象的设计可便于您扩展和重用代码中的功能。
* * *
事实上,Mark Sowul*可能是在用 C# 编写软件模拟(人们如此推测)。Sowul 从一开始就是一名敬业的 .NET 开发者,通过纽约咨询公司 SolSoft Solutions 分享了他在 .NET 和 Microsoft SQL Server 方面有关体系结构和性能的丰富专业知识。通过 [mark@solsoftsolutions.com](mailto:mark@solsoftsolutions.com) 与他取得联系,访问[eepurl.com/_K7YD](http://eepurl.com/_K7YD) 订阅他的临时电子邮件,了解他对软件的独特见解。*
- 介绍
- Essential .NET - C# 异常处理
- 崛起 - 具备批判精神
- Windows 10 - 通过搜索索引器加快文件操作速度
- 最前沿的技术 - 利用用户体验驱动设计改善体系结构
- 异步编程 - 从头开始执行异步操作
- 数据点 - Aurelia 与 DocumentDB 结合: 结合之旅
- ASP.NET - 将 ASP.NET 用作高性能文件下载器
- 测试运行 - 使用 C# 执行 t-检验
- Microsoft Azure - 通过 SonarQube 和 TFS 管理技术债务
- 孜孜不倦的程序员 - 如何成为 MEAN: Express 路由
- 别让我打开话匣子 - Alan Turing 和 Ashley Madison
- 编辑寄语 - 欢迎使用 Essential .NET