【问题标题】:Await and SynchronizationContext in a managed component hosted by an unmanaged app非托管应用托管的托管组件中的等待和 SynchronizationContext
【发布时间】:2013-11-01 08:14:18
【问题描述】:

[EDITED] This appears to be a bug 在框架的Application.DoEvents 实现中,我已经报告了here。在 UI 线程上恢复错误的同步上下文可能会严重影响像我这样的组件开发人员。赏金的目的是引起对这个问题的更多关注,并奖励@MattSmith,他的回答帮助追踪了它。

我负责一个基于 .NET WinForms UserControl 的组件作为 ActiveX 通过 COM 互操作向旧版非托管应用程序公开。运行时要求是 .NET 4.0 + Microsoft.Bcl.Async。

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

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

但是,到目前为止,我提出的解决方案都不是完美的,甚至都不能正常工作。这是一个人工示例,其中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:同步上下文 ctx2:WindowsFormsSynchronizationContext 等待后的线程:1 ctx3:同步上下文 ctx3 == ctx1:真,ctx3 == ctx2:假

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

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

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

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

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

【问题讨论】:

  • 很难猜到这一点。奇怪的细节是为什么 SynchronizationContext.Current 在代码开始运行时不为空。我们看不到代码是如何从非托管程序开始的。您可能正在与 Thread.ExecutionContext 作斗争,它会复制 SynchronizationContext 并恢复它。
  • @HansPassant,这种行为可以成为互操作层的一部分吗?我将尝试创建一个简单的复制案例。
  • 嗯,当然。当此代码从非托管程序的 UI 线程以外的线程激活时,不要指望这会起作用。泵送消息循环并调用 CoInitializeEx() 并指定 COINIT_APARTMENTTHREADED 的那个。
  • @HansPassant,是的,非托管应用的两个条件都已与作者进行了专门验证。
  • 不要相信他的话,验证一下。启用非托管调试并在代码上设置断点。查看调用堆栈并确认您在调用堆栈上看到 KiUserCallbackDispatch 和 InternalCallWinProc。并使用 Thread.CurrentThread.GetApartmentState() 并检查您是否获得了 STA。

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


【解决方案1】:

这会有点长。首先,感谢 Matt SmithHans Passant 的想法,他们非常有帮助。

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

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

如果它不是错误,那么它是一种非常令人不快的未记录行为,可能会严重影响某些组件开发人员。

这是一个简单的控制台 STA 应用程序,可重现该问题。 注意WindowsFormsSynchronizationContextTest 的第一遍中如何(错误地)被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:真 ctx1: null(在设置上下文之前) ctx2:WindowsFormsSynchronizationContext(在 Application.DoEvents 之前) ctx3:SynchronizationContext(在 Application.DoEvents 之后) ctx3 == ctx1:假,ctx3 == ctx2:假 *** 测试 2 *** WindowsFormsSynchronizationContext.AutoInstall:假 ctx1: null(在设置上下文之前) ctx2:WindowsFormsSynchronizationContext(在 Application.DoEvents 之前) ctx3:WindowsFormsSynchronizationContext(在 Application.DoEvents 之后) ctx3 == ctx1:假,ctx3 == ctx2:真

我们对框架对Application.ThreadContext.RunMessageLoopInnerWindowsFormsSynchronizationContext.InstalIifNeeded/Uninstall 的实现进行了一些调查,以了解为什么会发生这种情况。条件是线程当前没有执行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 这是一个典型的异步到同步桥接代码,运行在 UI 线程上,使用嵌套的消息循环。这是一种不好的做法,但旧版主机应用程序希望所有 API 同步完成。原始问题描述为here。稍后,我将CoWaitForMultipleHandles 替换为Application.DoEvents/MsgWaitForMultipleObjects 的组合,现在看起来像这样:

[已编辑] WaitWithDoEvents 的最新版本是 here[/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();
        }
    }
}

【讨论】:

  • 如果您怀疑这是一个错误,那么我认为您应该report it
  • @noseratio,出色的帖子和修复。万分感谢!你知道这个错误的状态吗? MS Connection 无法正常工作。
  • @23W 很高兴它有帮助。我相信他们出于“兼容性原因”决定将其保留原样。你可以调查他们当前的实现here
【解决方案2】:

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

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

见:

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

【讨论】:

  • 如果它适用于 CCW 控件,这看起来很有用。我不知道为什么它被否决了。
  • @Noseratio 这不是答案。这是作为答案发布的评论,正如答案本身所说的那样。它似乎也至少不适用于 OP 的情况。控件将创建自己的上下文不会改变这样一个事实,即在这种情况下,上下文不断被重置为不正确的上下文。 OP 的代码只是复制问题的一种更简单的方法。
  • @MattSmith,问题的解决方案完全相反:将WindowsFormsSynchronizationContext.AutoInstall设置为false(默认为true)并手动安装上下文(我会发布更多详细说明原因)。因此,虽然从技术上讲,您的答案不是答案,但它恰好对让我走上正确的道路非常有帮助。我希望我可以投票两次,但我不能。但是,当您的答案明天可用时,我将奖励您的答案。谢谢!
  • @Noseratio,很有趣。感谢您及时通知我。
  • @Noseratio,感谢您的赏金,很高兴它帮助您朝着正确的方向前进。
猜你喜欢
  • 1970-01-01
  • 2011-10-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-02-11
  • 2011-04-03
  • 2011-11-23
相关资源
最近更新 更多