开发人员对Asnync 异步机制的兴趣程度很高。当然,任何新技术都必然会出现一些小问题。
我现在多次看到的一个问题是开发人员通过阻止他们的 UI 线程意外地使他们的应用程序死锁,所以我认为花一些时间来探索这种情况的常见原因以及如何避免这种困境是值得的。
就其核心而言,新的异步语言功能旨在恢复开发人员编写他们习惯编写的顺序、命令式代码的能力,但使其本质上是异步的而不是同步的。
这意味着当操作以其他方式占用当前执行线程时,它们会被卸载到其他地方,从而允许当前线程向前推进并执行其他有用的工作,同时实际上异步等待生成的操作完成。在服务器和客户端应用程序中,这对于应用程序的可伸缩性至关重要,特别是在客户端应用程序中,它对于响应能力也非常有用。
大多数 UI 框架(例如 Windows 窗体和 WPF)利用消息循环来接收和处理传入消息。这些消息包括诸如键盘上键入的键、鼠标上单击的按钮、用户界面中的控件被操纵、或需要刷新窗口区域的通知,甚至应用程序向自身发送消息的通知指定要执行的一些代码。为响应这些消息,UI 会执行一些操作,例如重绘其表面,或更改显示的文本,或向其控件之一添加项目。或运行发布到其上的代码。“消息循环”通常是代码中的循环,其中一个线程不断等待下一条消息到达、处理它、返回以获取下一条消息、处理它,等等。只要该线程能够在消息到达后立即对其进行快速处理,应用程序就会保持响应,并且应用程序的用户也会感到满意。但是,如果处理特定消息的时间过长,则运行消息循环代码的线程将无法及时获取下一条消息,响应速度也会降低。这可能表现为响应用户输入时暂停的形式,如果线程的延迟变得足够严重(例如无限延迟),应用程序就会“挂起”。
在像 Windows 窗体或 WPF 这样的框架中,当用户单击一个按钮时,通常最终会向消息循环发送一条消息,该消息将消息转换为对某种处理程序的调用,例如表示用户界面,例如:
private void button1_Click(object sender, RoutedEventArgs e)
{
string s = LoadString();
textBox1.Text = s;
}
在这里,当我单击 button1 控件时,消息将通知 WPF 调用 button1_Click 方法,该方法将依次运行方法 LoadString 以获取字符串值,并将该字符串值存储到 textBox1 控件的 Text 属性中。
只要 LoadString 执行得快,一切都好,但是 LoadString 花费的时间越长,UI 线程在 button1_Click 内部延迟的时间就越长,无法返回消息循环来拾取和处理下一条消息。
为了解决这个问题,我们可以选择异步加载字符串,这意味着我们不会阻止调用 button1_Click 的线程返回消息循环直到字符串加载完成,而是让该线程启动加载操作然后继续回到消息循环。
只有当加载操作完成时,我们才会向消息循环发送另一条消息,说“嘿,您之前启动的加载操作已完成,您可以从中断的地方继续执行。” 想象一下我们有一个方法:
public Task<string> LoadStringAsync();
此方法将非常快速地返回给它的调用者,返回一个 .NET Task<string> 对象,该对象表示异步操作的未来完成及其未来结果。
在未来某个时刻,当操作完成时,任务对象将能够分发操作的结果,在成功加载的情况下可以是字符串,在失败的情况下可以是异常。
无论哪种方式,任务对象都提供了几种机制来通知对象的持有者加载操作已完成。
一种方法是同步阻塞等待任务完成,这可以通过调用任务的 Wait 方法或访问它的 Result 来完成,这将隐式等待直到操作完成......在这两种情况下,在操作完成之前,不会完成对这些成员的调用。
另一种方法是接收异步回调,在那里您向任务注册一个委托,该委托将在任务完成时调用。
这可以使用 Task 的 ContinueWith 方法之一来完成。
使用 ContinueWith,我们现在可以重写之前的 button1_Click 方法,以便在异步等待加载操作完成时不阻塞 UI 线程:
private void button1_Click(object sender, RoutedEventArgs e)
{
Task<string> s = LoadStringAsync();
s.ContinueWith(delegate { textBox1.Text = s.Result; }); // 警告:有问题
}
这实际上是异步启动加载操作,然后在操作完成时异步运行代码以将结果存储到 UI 中。
然而,我们现在有一个新的问题。Windows 窗体、WPF 和 Silverlight 等 UI 框架都对哪些线程能够访问 UI 控件设置了限制,即只能从创建它的线程访问该控件。
然而,在这里,我们在某个任意线程上运行回调来更新 textBox1 的文本,无论 ContinueWith 的任务并行库 (TPL) 实现碰巧放置它。
为了解决这个问题,我们需要一些方法来回到 UI 线程。
不同的 UI 框架为此提供了不同的机制,但在 .NET 中它们都采用基本相同的形式,
private void button1_Click(object sender, RoutedEventArgs e)
{
Task<string> s = LoadStringAsync();
s.ContinueWith(delegate
{
Dispatcher.BeginInvoke(new Action(delegate { textBox1.Text = s.Result; }));
});
}
.NET Framework 进一步抽象了这些返回到 UI 线程的机制,通常是通过 SynchronizationContext 类将一些代码发布到特定上下文的机制。
框架可以通过 SynchronizationContext.Current 属性建立当前上下文,该属性提供表示当前环境的 SynchronizationContext 实例。
此实例的 Post 方法会将委托编组回要调用的此环境:
在 WPF 应用程序中,这意味着将您带回之前所在的调度程序或 UI 线程。
因此,我们可以将之前的代码改写如下:
private void button1_Click(object sender, RoutedEventArgs e)
{
var sc = SynchronizationContext.Current;
Task<string> s = LoadStringAsync();
s.ContinueWith(delegate
{
sc.Post(delegate { textBox1.Text = s.Result; }, null);
});
}
事实上,这种模式非常普遍,.NET 4 中的 TPL 提供了 TaskScheduler.FromCurrentSynchronizationContext() 方法,它允许您使用以下代码做同样的事情:
private void button1_Click(object sender, RoutedEventArgs e)
{
LoadStringAsync().ContinueWith(s => textBox1.Text = s.Result, TaskScheduler.FromCurrentSynchronizationContext());
}
如前所述,这是通过将委托“发布”回要执行的 UI 线程来实现的。该发布与任何其他消息一样,它需要 UI 线程遍历其消息循环、获取消息并对其进行处理(这将导致调用发布的委托)。
为了随后调用委托,线程首先需要返回消息循环,这意味着它必须离开 button1_Click 方法。
现在,上面仍然有相当多的样板代码需要编写,当您开始引入更复杂的流控制结构(如条件和循环)时,情况会变得更糟。
为了解决这个问题,新的异步语言功能允许您编写与以下相同的代码:
private void button1_Click(object sender, RoutedEventArgs e)
{
string s = await LoadStringAsync();
textBox1.Text = s;
}
出于所有意图和目的,这与之前显示的代码相同,您可以看到它更简洁……
事实上,它与我们原始同步实现所需的代码几乎相同。
但是,当然,这个是异步的:在调用 LoadStringAsync 并取回 Task<string> 对象后,该函数的其余部分将作为回调挂接,该回调将发布到当前 SynchronizationContext 以便在右侧继续执行加载完成后线程。
编译器在这里分层使用了一些非常有用的语法糖。
现在事情变得有趣了。假设 LoadStringAsync 实现如下:
static async Task<string> LoadStringAsync()
{
string firstName = await GetFirstNameAsync();
string lastName = await GetLastNameAsync();
return firstName + " " + lastName;
}
LoadStringAsync 实现为首先异步检索名字,然后异步检索姓氏,然后返回两者的串联。
注意,它使用了“await”,正如前面所指出的,它类似于前面提到的 TPL 代码,它使用延续来回发到发出 await 时当前的同步上下文。
所以,这里是关键点:为了让 LoadStringAsync 完成(即它已经加载了它的所有数据并返回了它的连接字符串,完成了它用连接结果返回的任务),
它发布到 UI 线程的委托必须已经完成. 如果 UI 线程无法回到消息循环处理消息,它将无法获取由 LoadStringAsync 完成中的异步操作导致的已发布委托,
这意味着 LoadStringAsync 的其余部分将不会运行,这意味着从 LoadStringAsync 返回的 Task<string> 将无法完成。在消息循环处理相关消息之前,它不会完成。
考虑到这一点,请考虑 button1_Click 的这个(错误的)重新实现:
private void button1_Click(object sender, RoutedEventArgs e)
{
Task<string> s = LoadStringAsync();
textBox1.Text = s.Result; // 警告:有问题
}
这段代码很有可能会挂起您的应用程序。
Task<string>.Result 属性被强类型化为一个字符串,因此它不能返回,直到它有有效的结果字符串返回;
换句话说,它阻塞直到结果可用。我们在 button1_Click 内部,然后阻止 LoadStringAsync 完成,但 LoadStringAsync 的实现取决于能够将代码异步发布回 UI 以执行,并且从 LoadStringAsync 返回的任务在它完成之前不会完成。
LoadStringAsync 正在等待 button1_Click 完成,而 button1_Click 正在等待 LoadStringAsync 完成。僵局!
这个问题可以很容易地举例说明,而无需使用任何这种复杂的机器,例如:
private void button1_Click(object sender, RoutedEventArgs e)
{
var mre = new ManualResetEvent(false);
SynchronizationContext.Current.Post(_ => mre.Set(), null);
mre.WaitOne(); // 警告:有问题
}
在这里,我们创建了一个 ManualResetEvent,一个同步原语,它允许我们同步等待(阻塞)直到设置原语。
创建之后,我们回发到UI线程设置事件,然后我们等待它被设置。
但是我们正在等待将返回消息循环以获取已发布消息以执行设置操作的线程。僵局。
这个(比预期更长的)演示想表达的是你不应该阻塞 UI 线程,不要这样做。
新的异步语言功能使异步等待您的工作完成变得容易。
所以,在你的 UI 线程上,不要这样写:
Task<string> s = LoadStringAsync();
textBox1.Text = s.Result; // BAD ON UI
你可以写成:
Task<string> s = LoadStringAsync();
textBox1.Text = await s; // GOOD ON UI
不要写成下面这样:
Task t = DoWork();
t.Wait(); // BAD ON UI
而是要写成这样:
Task t = DoWork();
await t; // GOOD ON UI
这并不是说你永远不应该阻止。相反,同步等待任务完成可能是一种非常有效的机制,并且在许多情况下比异步对应的开销更少。
在某些情况下,异步等待可能很危险。由于这些原因和其他原因,Task 和 Task<TResult> 支持这两种方法。只是意识到你在做什么和什么时候做,不要阻塞你的 UI 线程。
(最后一个注意事项:Async CTP 包括 TaskEx.ConfigureAwait 方法。您可以使用此方法来抑制封送回原始同步上下文的默认行为。
例如,这可以在 LoadStringAsync 方法中使用以防止那些等待需要返回到 UI 线程。
这不仅可以防止死锁,还可以带来更好的性能,因为我们现在不再需要强制执行回 UI 线程,而该方法实际上不需要在 UI 线程上运行。)
本文出自勇哥的网站《少有人走的路》wwww.skcircle.com,转载请注明出处!讨论可扫码加群:

