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

nos*_*tio 17 .net c# asynchronous com-interop async-await

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

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

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

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

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

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);
}
Run Code Online (Sandbox Code Playgroud)

调试输出:

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稍后在同一个线程上调用时,上下文已经重置为默认值.

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

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

nos*_*tio 16

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

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

上没有明确的WinForms消息循环的线程(即,还没有进入任何线程Application.RunForm.ShowDialog),调用Application.DoEvents将替换用默认的当前同步上下文SynchronizationContext提供,WindowsFormsSynchronizationContext.AutoInstalltrue(这是因此,缺省).

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

这是一个简单的控制台STA应用程序重现问题.注意如何在第一次传递中WindowsFormsSynchronizationContext(不正确地)替换SynchronizationContext,Test而不是在第二次传递中.

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);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

调试输出:

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

对框架的实现进行了一些调查,Application.ThreadContext.RunMessageLoopInner并且WindowsFormsSynchronizationContext.InstalIifNeeded/或者Uninstall理解为什么会发生这种情况.条件是线程当前不执行Application消息循环,如上所述.相关文章来自RunMessageLoopInner:

if (this.messageLoopCount == 1)
{
    WindowsFormsSynchronizationContext.InstallIfNeeded();
}
Run Code Online (Sandbox Code Playgroud)

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

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

struct SyncContextSetup
{
    public SyncContextSetup(bool autoInstall)
    {
        WindowsFormsSynchronizationContext.AutoInstall = autoInstall;
        SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());
    }
}

static readonly SyncContextSetup _syncContextSetup =
    new SyncContextSetup(autoInstall: false);
Run Code Online (Sandbox Code Playgroud)

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

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

我们的想法是使用.NET标准机制来发送消息,而不是依赖于CoWaitForMultipleHandles这样做.那时我隐含地引入了同步上下文的问题,由于描述的行为DoEvents.

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

最后,这是我在问题中提到的自定义等待器的实现,作为缓解问题的选项.这是一个有趣的经历,它的工作原理,但它不能被认为是一个合适的解决方案.

/// <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();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)


Mat*_*ith 7

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

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

看到:

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

  • @Noseratio,有趣。感谢您让我随时了解情况。 (2认同)
  • @Noseratio,感谢您的赏金,很高兴它有助于引导您朝着正确的方向前进。 (2认同)