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

时间:2013-10-23 07:12:19

标签: c# .net asynchronous async-await com-interop

框架的This appears to be a bug实施中的

[已编辑] Application.DoEvents,我已将其报告为here。在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);
}

调试输出:

thread before await: 1
ctx1: SynchronizationContext
ctx2: WindowsFormsSynchronizationContext
thread after await: 1
ctx3: SynchronizationContext
ctx3 == ctx1: True, ctx3 == ctx2: False

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

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

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

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

我还在研究这个。 有关如何在此方案中保持await的正确线程亲和力的任何想法都将受到赞赏。

2 个答案:

答案 0 :(得分:15)

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

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

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

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

这是一个简单的控制台STA应用程序重现问题。 请注意WindowsFormsSynchronizationContextSynchronizationContext的第一次传递中Testusing 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); } } } 取代(错误)的方式,并且在第二次传递中没有。

Application.ThreadContext.RunMessageLoopInner

调试输出:

ApartmentState: STA
*** Test 1 ***
WindowsFormsSynchronizationContext.AutoInstall: True
ctx1: null (before setting up the context)
ctx2: WindowsFormsSynchronizationContext (before Application.DoEvents)
ctx3: SynchronizationContext (after Application.DoEvents)
ctx3 == ctx1: False, ctx3 == ctx2: False
*** Test 2 ***
WindowsFormsSynchronizationContext.AutoInstall: False
ctx1: null (before setting up the context)
ctx2: WindowsFormsSynchronizationContext (before Application.DoEvents)
ctx3: WindowsFormsSynchronizationContext (after Application.DoEvents)
ctx3 == ctx1: False, ctx3 == ctx2: True

对框架的WindowsFormsSynchronizationContext.InstalIifNeededUninstall / Application的实施进行了一些调查,以了解其究竟发生的原因。条件是线程当前不执行RunMessageLoopInner消息循环,如上所述。来自if (this.messageLoopCount == 1) { WindowsFormsSynchronizationContext.InstallIfNeeded(); } 的相关内容:

WindowsFormsSynchronizationContext.InstallIfNeeded

然后Uninstall / 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

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

[已编辑] CoWaitForMultipleHandles的最新版本为here。的 [/ EDITED]

我们的想法是使用.NET标准机制来发送消息,而不是依靠DoEvents来实现。那是因为我描述了/// <summary> /// AwaitHelpers - custom awaiters /// WithContext continues on the control's thread after await /// E.g.: await TaskEx.Delay(1000).WithContext(this) /// </summary> public static class AwaitHelpers { public static ContextAwaiter<T> WithContext<T>(this Task<T> task, Control control, bool alwaysAsync = false) { return new ContextAwaiter<T>(task, control, alwaysAsync); } // ContextAwaiter<T> public class ContextAwaiter<T> : INotifyCompletion { readonly Control _control; readonly TaskAwaiter<T> _awaiter; readonly bool _alwaysAsync; public ContextAwaiter(Task<T> task, Control control, bool alwaysAsync) { _awaiter = task.GetAwaiter(); _control = control; _alwaysAsync = alwaysAsync; } public ContextAwaiter<T> GetAwaiter() { return this; } public bool IsCompleted { get { return !_alwaysAsync && _awaiter.IsCompleted; } } public void OnCompleted(Action continuation) { if (_alwaysAsync || _control.InvokeRequired) { Action<Action> callback = (c) => _awaiter.OnCompleted(c); _control.BeginInvoke(callback, continuation); } else _awaiter.OnCompleted(continuation); } public T GetResult() { return _awaiter.GetResult(); } } } 的行为而隐含地引入了同步上下文的问题。

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

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

{{1}}

答案 1 :(得分:7)

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

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

请参阅:

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