由非托管应用托管的托管组件中的 Await 和 SynchronizationContext [英] Await and SynchronizationContext in a managed component hosted by an unmanaged app

查看:22
本文介绍了由非托管应用托管的托管组件中的 Await 和 SynchronizationContext的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

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

我负责通过 COM 互操作将基于 .NET WinForms UserControl 的组件作为 ActiveX 公开给遗留的非托管应用.运行时要求是 .NET 4.0 + Microsoft.Bcl.Async.

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

通常,WindowsFormsSynchronizationContextApplication.Run 设置,这是托管应用程序的消息循环运行的地方.当然,对于非托管主机应用而言,情况并非如此,我对此无能为力.当然宿主app还是有自己经典的windows消息循环的,所以序列化await继续回调应该不成问题.

然而,到目前为止,我提出的解决方案都不是完美的,甚至都不能正常工作.这是一个人工示例,其中 Test 方法由宿主应用程序调用:

Task testTask;公共无效测试(){this.testTask = TestAsync();}异步任务 TestAsync(){Debug.Print("等待前的线程:{0}", Thread.CurrentThread.ManagedThreadId);var ctx1 = SynchronizationContext.Current;Debug.Print("ctx1: {0}", ctx1 != null?ctx1.GetType().Name: null);if (!(ctx1 是 WindowsFormsSynchronizationContext))SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());var ctx2 = SynchronizationContext.Current;Debug.Print("ctx2: {0}", ctx2.GetType().Name);等待 TaskEx.Delay(1000);Debug.WriteLine("等待后的线程:{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);}

调试输出:

<前>等待之前的线程:1ctx1:同步上下文ctx2:WindowsFormsSynchronizationContext等待后的线程:1ctx3:同步上下文ctx3 == ctx1:真,ctx3 == ctx2:假

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

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

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

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

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

解决方案

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

该问题是由一位好朋友 Application.DoEvents 引起的,尽管方式很新颖.Hans 有一篇很棒的帖子,说明了为什么 DoEvents 是一种邪恶.不幸的是,我无法避免在此控件中使用 DoEvents,因为遗留的非托管主机应用程序带来了同步 API 限制(在最后详细介绍).我很清楚DoEvents 的现有含义,但我相信这里有一个新含义:

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

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

这是一个重现问题的简单控制台 STA 应用程序.注意 WindowsFormsSynchronizationContext 如何在 Test 的第一遍中被(错误地)替换为 SynchronizationContext 而在第二遍中没有.强>

使用系统;使用 System.Diagnostics;使用 System.Threading;使用 System.Threading.Tasks;使用 System.Windows.Forms;命名空间控制台应用程序{课程计划{[STAThread 属性]静态无效主(字符串 [] args){Debug.Print("ApartmentState: {0}", Thread.CurrentThread.ApartmentState.ToString());Debug.Print("*** 测试 1 ***");测试();SynchronizationContext.SetSynchronizationContext(null);WindowsFormsSynchronizationContext.AutoInstall = false;Debug.Print("*** 测试 2 ***");测试();}静态无效 DumpSyncContext(字符串 id,字符串消息,对象 ctx){Debug.Print("{0}: {1} ({2})", id, ctx != null ? ctx.GetType().Name : "null", message);}静态无效测试(){Debug.Print("WindowsFormsSynchronizationContext.AutoInstall: {0}", WindowsFormsSynchronizationContext.AutoInstall);var ctx1 = SynchronizationContext.Current;DumpSyncContext("ctx1", "设置上下文之前", ctx1);if (!(ctx1 是 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: Truectx1:null(在设置上下文之前)ctx2:WindowsFormsSynchronizationContext(在 Application.DoEvents 之前)ctx3:同步上下文(在 Application.DoEvents 之后)ctx3 == ctx1:错误,ctx3 == ctx2:错误*** 测试 2 ***WindowsFormsSynchronizationContext.AutoInstall: Falsectx1: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{公共 SyncContextSetup(布尔自动安装){WindowsFormsSynchronizationContext.AutoInstall = 自动安装;SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());}}静态只读 SyncContextSetup _syncContextSetup =新的 SyncContextSetup(autoInstall: false);

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

WaitWithDoEvents 的最新版本是这里一>.[/EDITED]

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

目前正在使用现代技术重写旧版应用程序,控件也是如此.当前的实施针对的是 Windows XP 的现有客户,这些客户因我们无法控制的原因而无法升级.

最后,这是自定义等待器的实现,我在问题中提到它是缓解问题的一种选择.这是一次有趣的体验,并且有效,但不能将其视为合适的解决方案.

////<摘要>///AwaitHelpers - 自定义等待器///WithContext在await之后继续在控件的线程上///例如:await TaskEx.Delay(1000).WithContext(this)///</总结>公共静态类 AwaitHelpers{公共静态 ContextAwaiter<T>WithContext(这个Task任务,控制控制,bool alwaysAsync = false){返回新的 ContextAwaiter(任务、控制、alwaysAsync);}//ContextAwaiter公共类 ContextAwaiter<T>: INotifyCompletion{只读控制_控制;readonly TaskAwaiter;_服务员;readonly bool _alwaysAsync;public ContextAwaiter(Task task, Control control, bool alwaysAsync){_awaiter = task.GetAwaiter();_control = 控制;_alwaysAsync = alwaysAsync;}公共 ContextAwaiter<T>GetAwaiter() { 返回这个;}public bool IsCompleted { get { return !_alwaysAsync &&_awaiter.IsCompleted;} }public void OnCompleted(动作继续){如果(_alwaysAsync || _control.InvokeRequired){动作<动作>回调 = (c) =>_awaiter.OnCompleted(c);_control.BeginInvoke(回调,继续);}别的_awaiter.OnCompleted(继续);}公共 T GetResult(){返回_awaiter.GetResult();}}}

[EDITED] This appears to be a bug in the Framework's implementation of Application.DoEvents, which I've reported here. Restoring a wrong synchronization context on a UI thread may seriously affect component developers like me. The goal of the bounty is to draw more attention to this problem and to reward @MattSmith whose answer helped tracking it down.

I'm responsible for a .NET WinForms UserControl-based component exposed as ActiveX to a legacy unmanaged app, via COM interop. The runtime requirement is .NET 4.0 + Microsoft.Bcl.Async.

The component gets instantiated and used on the app's main STA UI thread. Its implementation utilizes async/await, so it expects that an instance of a serializing synchronization context has been installed on the current thread (i. e.,WindowsFormsSynchronizationContext).

Usually, WindowsFormsSynchronizationContext gets set up by Application.Run, which is where the message loop of a managed app runs. Naturally, this is not the case for the unmanaged host app, and I have no control over this. Of course, the host app still has its own classic Windows message loop, so it should not be a problem to serialize await continuation callbacks.

However, none of the solutions I've come up with so far is perfect, or even works properly. Here's an artificial example, where Test method is invoked by the host 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);
}

Debug output:

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

Although it continues on the same thread, the WindowsFormsSynchronizationContext context I'm installing on the current thread before await gets reset to the default SynchronizationContext after it, for some reason.

Why does it get reset? I've verified my component is the only .NET component being used by that app. The app itself does call CoInitialize/OleInitialize properly.

I've also tried setting up WindowsFormsSynchronizationContext in the constructor of a static singleton object, so it gets installed on the thread when my managed assembly gets loaded. That didn't help: when Test is later invoked on the same thread, the context has been already reset to the default one.

I'm considering using a custom awaiter to schedule await callbacks via control.BeginInvoke of my control, so the above would look like await TaskEx.Delay().WithContext(control). That should work for my own awaits, as long as the host app keeps pumping messages, but not for awaits inside any of the 3rd party assemblies my assembly may be referencing.

I'm still researching this. Any ideas on how to keep the correct thread affinity for await in this scenario would be appreciated.

解决方案

This is going to be a bit long. First of all, thanks Matt Smith and Hans Passant for your ideas, they have been very helpful.

The problem was caused by a good old friend, Application.DoEvents, although in a novelty way. Hans has an excellent post about why DoEvents is an evil. Unfortunately, I'm unable to avoid using DoEvents in this control, because of the synchronous API restrictions posed by the legacy unmanaged host app (more about it at the end). I'm well aware of the existing implications of DoEvents, but here I believe we have a new one:

On a thread without explicit WinForms message loop (i.e., any thread which hasn't entered Application.Run or Form.ShowDialog), calling Application.DoEvents will replace the current synchronization context with the default SynchronizationContext, provided WindowsFormsSynchronizationContext.AutoInstall is true (which is so by default).

If it is not a bug, then it's a very unpleasant undocumented behavior which may seriously affect some component developers.

Here is a simple console STA app reproducing the problem. Note how WindowsFormsSynchronizationContext gets (incorrectly) replaced with SynchronizationContext in the first pass of Test and does not in the second pass.

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);
        }
    }
}

Debug output:

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

It took some investigation of the Framework's implementation of Application.ThreadContext.RunMessageLoopInner and WindowsFormsSynchronizationContext.InstalIifNeeded/Uninstall to understand why exactly it happens. The condition is that the thread doesn't currently execute an Application message loop, as mentioned above. The relevant piece from RunMessageLoopInner:

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

Then the code inside WindowsFormsSynchronizationContext.InstallIfNeeded/Uninstall pair of methods doesn't save/restore the thread's existing synchronization context correctly. At this point, I'm not sure if it's a bug or a design feature.

The solution is to disable WindowsFormsSynchronizationContext.AutoInstall, as simple as this:

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

static readonly SyncContextSetup _syncContextSetup =
    new SyncContextSetup(autoInstall: false);

A few words about why I use Application.DoEvents in the first place here. It's a typical asynchronous-to-synchronous bridge code running on the UI thread, using a nested message loop. This is a bad practice, but the legacy host app expects all APIs to complete synchronously. The original problem is described here. At some later point, I replaced CoWaitForMultipleHandles with a combination of Application.DoEvents/MsgWaitForMultipleObjects, which now looks like this:

[EDITED] The most recent version of WaitWithDoEvents is here. [/EDITED]

The idea was to dispatch messages using .NET standard mechanism, rather than relying upon CoWaitForMultipleHandles to do so. That's when I implicitly introduced the problem with the synchronization context, due to the described behavior of DoEvents.

The legacy app is currently being rewritten using modern technologies, and so is the control. The current implementation is aimed for existing customers with Windows XP who cannot upgrade for reasons beyond our control.

Finally, here's the implementation of the custom awaiter which I mentioned in my question as an option to mitigate the problem. It was an interesting experience and it works, but it cannot be considered a proper solution.

/// <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();
        }
    }
}

这篇关于由非托管应用托管的托管组件中的 Await 和 SynchronizationContext的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆