由非托管应用程序托管的托管组件中的Await和SynchronizationContext
[EDITED] 这似乎是 Framework的Application.DoEvents实现中的一个错误 ,我在这里报告过 。 在UI线程上恢复错误的同步上下文可能会严重影响像我这样的组件开发人员。 赏金的目标是更多地关注这个问题并奖励@MattSmith,他的答案帮助追踪它。
我负责通过COM互操作将基于.NET WinForms UserControl
的组件作为ActiveX暴露给传统的非托管应用程序 。 运行时要求是.NET 4.0 + Microsoft.Bcl.Async。
该组件被实例化并在应用程序的主要STA UI线程上使用。 它的实现使用async/await
,因此它期望在当前线程(即WindowsFormsSynchronizationContext
)上安装序列化同步上下文的实例。
通常, WindowsFormsSynchronizationContext
由Application.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 Smith和Hans Passant的想法,他们非常乐于助人。
这个问题是由一位好老朋友Application.DoEvents
引起的,虽然是以一种新颖的方式。 汉斯有一篇很好的post,讲述为什么DoEvents
是邪恶的。 不幸的是,我无法避免在此控件中使用DoEvents
,因为旧版非托管主机应用程序提出了同步API限制(最后更多关于它)。 我很清楚DoEvents
的现有含义,但我相信我们有一个新的含义:
在没有显式WinForms消息循环的线程(即任何未输入Application.Run
或Form.ShowDialog
线程)上,调用Application.DoEvents
将使用默认的SynchronizationContext
替换当前同步上下文,前提是WindowsFormsSynchronizationContext.AutoInstall
为true
(其中是默认情况下)。
如果它不是一个bug,那么这是一个非常令人不愉快的无证行为,可能会严重影响一些组件开发人员。
这是一个简单的控制台STA应用程序重现问题。 请注意在第一次Test
, WindowsFormsSynchronizationContext
如何(错误地)替换为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.RunMessageLoopInner
和WindowsFormsSynchronizationContext.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上设置断点,以查看何时发生。
看到:
- http://msdn.microsoft.com/en-us/library/system.windows.forms.windowsformssynchronizationcontext.autoinstall.aspx
- http://msdn.microsoft.com/en-us/magazine/gg598924.aspx
(我意识到这不能回答你的问题,但不想把它放在评论中)