19

[編集済み]これは、フレームワークの Application.DoEvents実装のバグのようです。ここで報告しました。UI スレッドで間違った同期コンテキストを復元すると、私のようなコンポーネント開発者に深刻な影響を与える可能性があります。報奨金の目的は、この問題により多くの注目を集め、その答えが問題の追跡に役立った @MattSmith に報酬を与えることです。

私は、COM 相互運用機能を介して、従来の管理されていないアプリUserControlにActiveX として公開される .NET WinForms ベースのコンポーネントを担当しています。ランタイム要件は、.NET 4.0 + Microsoft.Bcl.Async です。

コンポーネントはインスタンス化され、アプリのメイン STA UI スレッドで使用されます。その実装は を使用するasync/awaitため、シリアライズ同期コンテキストのインスタンスが現在のスレッド (つまりWindowsFormsSynchronizationContext) にインストールされていることが期待されます。

通常、マネージド アプリのメッセージ ループが実行WindowsFormsSynchronizationContextされる によって設定されます。Application.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: 偽

同じスレッドで続行されますが、WindowsFormsSynchronizationContext以前に現在のスレッドにインストールしているコンテキストが、何らかの理由でその後await デフォルトにリセットされます。SynchronizationContext

なぜリセットされるのですか?私のコンポーネントが、そのアプリで使用されている唯一の .NET コンポーネントであることを確認しました。アプリ自体はCoInitialize/OleInitialize正しく呼び出します。

また、静的シングルトン オブジェクトのコンストラクターで設定をWindowsFormsSynchronizationContext試みたので、マネージ アセンブリが読み込まれるとスレッドにインストールされます。それは役に立ちませんでした。Test後で同じスレッドで呼び出されたとき、コンテキストはすでにデフォルトのものにリセットされています。

カスタム awaiterを使用して、コントロールawaitを介してコールバックをスケジュールすることを検討しているcontrol.BeginInvokeため、上記は のようになりますawait TaskEx.Delay().WithContext(control)awaitsホストアプリがメッセージを送り続けている限り、それは私自身のために機能するはずですがawaits、私のアセンブリが参照している可能性のあるサードパーティのアセンブリの内部では機能しません。

私はまだこれを研究しています。このシナリオで正しいスレッド アフィニティを維持する方法についてのアイデアをawaitいただければ幸いです。

4

2 に答える 2

19

これは少し長くなります。まず、Matt SmithHans Passantのアイデアに感謝します。彼らは非常に役に立ちました。

Application.DoEventsこの問題は、斬新な方法ではありますが、古き良き友人によって引き起こされました。ハンスはなぜ悪であるかについて素晴らしい記事を書いています。DoEvents残念ながら、DoEventsこのコントロールでの使用を避けることはできません。これは、レガシ アンマネージド ホスト アプリによる同期 API の制限のためです (詳細は最後に説明します)。の既存の意味を十分に認識してDoEventsいますが、ここでは新しい意味があると思います。

明示的な WinForms メッセージ ループのないスレッド (つまり、Application.Runまたはに入っていないスレッドForm.ShowDialog) では、 を呼び出すApplication.DoEventsと、現在の同期コンテキストが既定の に置き換えSynchronizationContextられます (既定でWindowsFormsSynchronizationContext.AutoInstalltrueそうなります)。

バグでない場合は、文書化されていない非常に不快な動作であり、一部のコンポーネント開発者に深刻な影響を与える可能性があります。

問題を再現する簡単なコンソール STA アプリを次に示します。の最初のパスで(誤って) が に置き換えられ、2 番目のパスでは置き換えられないことに注意してください。WindowsFormsSynchronizationContextSynchronizationContextTest

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: 偽、ctx3 == ctx2: 偽
*** テスト 2 ***
WindowsFormsSynchronizationContext.AutoInstall: False
ctx1: null (コンテキストを設定する前)
ctx2: WindowsFormsSynchronizationContext (Application.DoEvents の前)
ctx3: WindowsFormsSynchronizationContext (Application.DoEvents の後)
ctx3 == ctx1: 偽、ctx3 == ctx2: 真

Application.ThreadContext.RunMessageLoopInnerフレームワークのand WindowsFormsSynchronizationContext.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 が同期的に完了することを想定しています。元の問題はここで説明されています。後で/CoWaitForMultipleHandlesの組み合わせに置き換えたところ、次のようになりました。Application.DoEventsMsgWaitForMultipleObjects

[編集済み]の最新バージョンWaitWithDoEventsこちらです。[/編集済み]

アイデアは、.NET 標準メカニズムに依存するのではなく、それを使用してメッセージをディスパッチするCoWaitForMultipleHandlesことでした。の動作が説明されているため、同期コンテキストの問題を暗黙のうちに導入したのはそのときですDoEvents

レガシ アプリは現在、最新のテクノロジを使用して書き直されており、コントロールも同様です。現在の実装は、Windows XP を使用している既存のお客様で、当社の管理の及ばない理由でアップグレードできない方を対象としています。

最後に、問題を軽減するためのオプションとして質問で言及したカスタム awaiter の実装を次に示します。それは興味深い経験であり、機能しますが、適切な解決策とは見なされません

/// <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();
        }
    }
}
于 2013-10-24T02:42:24.903 に答える