由非托管应用程序托管的托管组件中的Await和SynchronizationContext

[EDITED] 这似乎是 Framework的Application.DoEvents实现中的一个错误 ,我在这里报告过 。 在UI线程上恢复错误的同步上下文可能会严重影响像我这样的组件开发人员。 赏金的目标是更多地关注这个问题并奖励@MattSmith,他的答案帮助追踪它。

我负责通过COM互操作将基于.NET WinForms UserControl的组件作为ActiveX暴露给传统的非托管应用程序 。 运行时要求是.NET 4.0 + Microsoft.Bcl.Async。

该组件被实例化并在应用程序的主要STA UI线程上使用。 它的实现使用async/await ,因此它期望在当前线程(即WindowsFormsSynchronizationContext )上安装序列化同步上下文的实例。

通常, WindowsFormsSynchronizationContextApplication.Run设置,这是托管应用程序的消息循环运行的地方。 当然, 这不是非托管主机应用程序的情况 ,我无法控制它。 当然,主机应用程序仍然有自己的经典Windows消息循环,因此序列化await延续回调应该不是问题。

然而,到目前为止我提出的解决方案都不是完美的,甚至都不能正常工作。 这是一个人为的例子,主机app调用Test方法:

 Task testTask; public void Test() { this.testTask = TestAsync(); } async Task TestAsync() { Debug.Print("thread before await: {0}", Thread.CurrentThread.ManagedThreadId); var ctx1 = SynchronizationContext.Current; Debug.Print("ctx1: {0}", ctx1 != null? ctx1.GetType().Name: null); if (!(ctx1 is WindowsFormsSynchronizationContext)) SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); var ctx2 = SynchronizationContext.Current; Debug.Print("ctx2: {0}", ctx2.GetType().Name); await TaskEx.Delay(1000); Debug.WriteLine("thread after await: {0}", Thread.CurrentThread.ManagedThreadId); var ctx3 = SynchronizationContext.Current; Debug.Print("ctx3: {0}", ctx3 != null? ctx3.GetType().Name: null); Debug.Print("ctx3 == ctx1: {0}, ctx3 == ctx2: {1}", ctx3 == ctx1, ctx3 == ctx2); } 

调试输出:

等待之前的线程:1
 ctx1:SynchronizationContext
 ctx2:WindowsFormsSynchronizationContext
等待后的线程:1
 ctx3:SynchronizationContext
 ctx3 == ctx1:是的,ctx3 == ctx2:错误

虽然它继续在同一个线程上,但由于某种原因,我在当前线程上安装的WindowsFormsSynchronizationContext上下文在await 被重置为默认的SynchronizationContext

为什么会重置? 我已经validation我的组件是该应用程序使用的唯一.NET组件。 应用程序本身确实正确调用了CoInitialize/OleInitialize

我还尝试在静态单例对象的构造函数中设置WindowsFormsSynchronizationContext ,因此当我的托管程序集加载时,它会安装在线程上。 这没有用:当稍后在同一个线程上调用Test时,上下文已经重置为默认值。

我正在考虑使用自定义awaiter通过我的控件的control.BeginInvoke安排await回调,所以上面看起来像await TaskEx.Delay().WithContext(control) 。 这应该适用于我自己的awaits ,只要主机应用程序不断提取消息,但不是awaits我的程序集可能引用的任何第三方程序集内部awaits

我还在研究这个。 关于如何在这种情况下保持正确的线程亲和力await任何想法将不胜感激。

这将有点长。 首先,感谢Matt SmithHans Passant的想法,他们非常乐于助人。

这个问题是由一位好老朋友Application.DoEvents引起的,虽然是以一种新颖的方式。 汉斯有一篇很好的post,讲述为什么DoEvents是邪恶的。 不幸的是,我无法避免在此控件中使用DoEvents ,因为旧版非托管主机应用程序提出了同步API限制(最后更多关于它)。 我很清楚DoEvents的现有含义,但我相信我们有一个新的含义:

在没有显式WinForms消息循环的线程(即任何未输入Application.RunForm.ShowDialog线程)上,调用Application.DoEvents将使用默认的SynchronizationContext替换当前同步上下文,前提是WindowsFormsSynchronizationContext.AutoInstalltrue (其中是默认情况下)。

如果它不是一个bug,那么这是一个非常令人不愉快的无证行为,可能会严重影响一些组件开发人员。

这是一个简单的控制台STA应用程序重现问题。 请注意在第一次TestWindowsFormsSynchronizationContext如何(错误地)替换为SynchronizationContext ,而不是在第二次传递中。

 using System; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; namespace ConsoleApplication { class Program { [STAThreadAttribute] static void Main(string[] args) { Debug.Print("ApartmentState: {0}", Thread.CurrentThread.ApartmentState.ToString()); Debug.Print("*** Test 1 ***"); Test(); SynchronizationContext.SetSynchronizationContext(null); WindowsFormsSynchronizationContext.AutoInstall = false; Debug.Print("*** Test 2 ***"); Test(); } static void DumpSyncContext(string id, string message, object ctx) { Debug.Print("{0}: {1} ({2})", id, ctx != null ? ctx.GetType().Name : "null", message); } static void Test() { Debug.Print("WindowsFormsSynchronizationContext.AutoInstall: {0}", WindowsFormsSynchronizationContext.AutoInstall); var ctx1 = SynchronizationContext.Current; DumpSyncContext("ctx1", "before setting up the context", ctx1); if (!(ctx1 is WindowsFormsSynchronizationContext)) SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); var ctx2 = SynchronizationContext.Current; DumpSyncContext("ctx2", "before Application.DoEvents", ctx2); Application.DoEvents(); var ctx3 = SynchronizationContext.Current; DumpSyncContext("ctx3", "after Application.DoEvents", ctx3); Debug.Print("ctx3 == ctx1: {0}, ctx3 == ctx2: {1}", ctx3 == ctx1, ctx3 == ctx2); } } } 

调试输出:

公寓州:STA
 ***测试1 ***
 WindowsFormsSynchronizationContext.AutoInstall:True
 ctx1:null(在设置上下文之前)
 ctx2:WindowsFormsSynchronizationContext(在Application.DoEvents之前)
 ctx3:SynchronizationContext(在Application.DoEvents之后)
 ctx3 == ctx1:False,ctx3 == ctx2:False
 ***测试2 ***
 WindowsFormsSynchronizationContext.AutoInstall:False
 ctx1:null(在设置上下文之前)
 ctx2:WindowsFormsSynchronizationContext(在Application.DoEvents之前)
 ctx3:WindowsFormsSynchronizationContext(在Application.DoEvents之后)
 ctx3 == ctx1:False,ctx3 == ctx2:True

对Framework的Application.ThreadContext.RunMessageLoopInnerWindowsFormsSynchronizationContext.InstalIifNeeded / Uninstall的实现进行了一些调查,以了解为什么会发生这种情况。 条件是线程当前不执行Application消息循环,如上所述。 RunMessageLoopInner的相关部分:

 if (this.messageLoopCount == 1) { WindowsFormsSynchronizationContext.InstallIfNeeded(); } 

然后, WindowsFormsSynchronizationContext.InstallIfNeeded / Uninstall对方法的代码不能正确保存/恢复线程的现有同步上下文 。 此时,我不确定这是一个错误还是一个设计function。

解决方案是禁用WindowsFormsSynchronizationContext.AutoInstall ,就像这样简单:

 struct SyncContextSetup { public SyncContextSetup(bool autoInstall) { WindowsFormsSynchronizationContext.AutoInstall = autoInstall; SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); } } static readonly SyncContextSetup _syncContextSetup = new SyncContextSetup(autoInstall: false); 

关于为什么我在这里首先使用Application.DoEvents几句话。 它是使用嵌套消息循环在UI线程上运行的典型异步到同步桥代码。 这是一种不好的做法,但遗留主机应用程序希望所有API同步完成。 这里描述了原始问题。 稍后,我用Application.DoEvents / MsgWaitForMultipleObjects的组合替换了CoWaitForMultipleHandles ,现在看起来像这样:

[已编辑] WaitWithDoEvents最新版本在这里 。 [/ EDITED]

我们的想法是使用.NET标准机制来发送消息,而不是依靠CoWaitForMultipleHandles来实现。 那是因为DoEvents的描述行为,我隐含地引入了同步上下文的问题。

传统应用程序目前正在使用现代技术进行重写,控件也是如此。 目前的实施针对Windows XP的现有客户,他们因我们无法控制的原因无法升级。

最后,这里是我在问题中提到的自定义awaiter的实现,作为缓解问题的选项。 这是一次有趣的体验并且有效,但它不能被认为是一个合适的解决方案

 ///  /// AwaitHelpers - custom awaiters /// WithContext continues on the control's thread after await /// Eg: await TaskEx.Delay(1000).WithContext(this) ///  public static class AwaitHelpers { public static ContextAwaiter WithContext(this Task task, Control control, bool alwaysAsync = false) { return new ContextAwaiter(task, control, alwaysAsync); } // ContextAwaiter public class ContextAwaiter : INotifyCompletion { readonly Control _control; readonly TaskAwaiter _awaiter; readonly bool _alwaysAsync; public ContextAwaiter(Task task, Control control, bool alwaysAsync) { _awaiter = task.GetAwaiter(); _control = control; _alwaysAsync = alwaysAsync; } public ContextAwaiter GetAwaiter() { return this; } public bool IsCompleted { get { return !_alwaysAsync && _awaiter.IsCompleted; } } public void OnCompleted(Action continuation) { if (_alwaysAsync || _control.InvokeRequired) { Action callback = (c) => _awaiter.OnCompleted(c); _control.BeginInvoke(callback, continuation); } else _awaiter.OnCompleted(continuation); } public T GetResult() { return _awaiter.GetResult(); } } } 

创建控件时会自动安装WindowsFormsSynchronizationContext,除非您已将其关闭。 因此,除非有人在创建控件后踩踏它,否则没有什么特别的事情要做。

您可以在WindowsFormsSynchronizationContext.InstalIifNeeded上设置断点,以查看何时发生。

看到:

(我意识到这不能回答你的问题,但不想把它放在评论中)