5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WPFの非同期復帰の実装を覗いてみた(Async/Await/Dispatch.BeginInvoke)

Last updated at Posted at 2020-11-07

「未だにWPF使ってるのかよー」って感じですが許してください…UWPはSandbox環境の制約が面倒すぎるのです。
ここでは以下のように言葉を使います。

  • UIスレッド,メインスレッド := Mainメソッドが叩かれたスレッド
  • ワーカースレッド := メインスレッド以外のスレッド

非同期処理の要、Dispatch.BeginInvoke

Dispatch.BeginInvokeはUIスレッド以外でUIスレッドでしか呼べないメソッドを呼び出したいときに使います。

protected override void OnInitialized(EventArgs e)
{
  // メインスレッド
  new Thread(() =>
  {
     // ワーカースレッド
     for(int i = 0; i < 100000000; i++) result += result; // ハチャメチャ重い処理
     Dispatch.BeginInvoke(() => // スレッドセーフでないUI操作をキュー
     {
       // メインスレッド
       DisplayToUI(); // UIスレッドでしか呼べないメソッドを呼び出し
     });
     // ワーカースレッド
  });
}

WPFをはじめGUIプラットフォームの多くは、UIをメインスレッドからしか実行できない仕組みになっています。(スレッドセーフでない)
しかし、どうしても次の画面再描画に間に合わない(1/60秒で終わらない)ほど長い処理というものは出てきます。メインスレッドがそういった処理に占有されている間、GUIを書き直すことができないので所謂「フリーズ」を起こしてしまいます。

このような問題を回避するために、GUIプラットフォームでは重たい処理は別スレッドに回すというアプローチが見られます。そうすることでUIスレッドを遊ばせることができ、フリーズを回避できます。

もっとも単純な実装のイメージは次のようになります。

static void Main(string[] _)
{
  var uiThreadQueue = new Queue<Action>();
  // メインスレッド
  new Thread(() =>
  {
     // ワーカースレッド
     for(int i = 0; i < 100000000; i++) result += result; // ハチャメチャ重い処理
     uiThreadQueue.Enqueue(() => // スレッドセーフでないUI操作をキュー
     {
       // メインスレッド
       DisplayToUI();
     });
     // ワーカースレッド
  });
  // メインスレッド
  while(true)
  {
    while(uiThreadQueue.Count > 0) // ワーカースレッドの完了を監視
    {
      uiThreadQueue.Dequeue().Invoke();
    }
    Repaint(); // UIの更新(これが60回/秒以上実行されればフリーズはしない)
  }
}

力業のようにも見えるのですが、実際ほとんど同等の実装になっています。
ポイントは以下の点です。

  1. UIスレッドに任せたい処理のキューがある
  2. UIスレッドは初期化処理が終わったら半無限のループでキューを監視している
  3. 重い処理をワーカースレッドで実行
  4. ワーカースレッドからUIスレッドにキューを通して処理を任せる

Dispatcherクラスを見てみる

コード全体はこちら
https://github.com/dotnet/wpf/blob/master/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/Dispatcher.cs

Dispatcherクラスは先述のポイントのうち、1/2/4の役割を担っています。いろいろな例外処理が挟まっているものの、いかのメンバが機能を実装しています。

機能 実装
キュー Dispatch._queue
無限ループ Dispatch.Run さらに奥のPushFrameImplにwhileループがある
任せる Dispatch.BeginInvoke Dispatch._queueに積むだけ

ちなみにDispatch.Runについては、Application.Runから呼び出され、Application.RunMainから呼び出されます。WPFでは普通見ないのですが、Mainメソッドは当然あります。

public partial class App : System.Windows.Application
{
  public static void Main()
  {
    HogeTest.App app = new HogeTest.App();
    app.InitializeComponent();
    app.Run(); // このメソッドはアプリ終了まで抜けることはない
  }
}

これでWPFのDispatchの仕組みを完全に理解することができました。しかし、C#にはAsyncAwaitの構文がありDispatch.BeginInvokeを使うことは非常に稀であります。ということでWPFのAsyncAwaitも見ていきましょう。

AsyncAwaitの動作の仕組み

詳しくは以前に書いたこちら(async/await で書いたコードと実行されるスレッド)を見ていただきたいのですが、ざっくりと説明すると。

await HogeAsync() 描いたとしましょう。すると、その直前のSynchronizationContext(≒スレッド)を記憶しておき、HogeAsyncの中で「よびだし元のSynchronizationContextで実行してくれな処理パート」が出てきた時に記憶しておいたSynchronizationContextで処理を行うというものです。コードにするとこんなイメージ。(実際にはコンパイラの仕事が挟まれるのであくまでイメージ)

{
  ui.text = "aaaa";
  var x = await GetTextAsync();
  ui.text = text;
  await Task.Run(() => Thread.Sleep(100000));
  ui.text = "";
}
// ↓
{
  SynchronizationContext callerContext = SynchronizationContext.Current;
  ui.text = "aaaa";
  GetTextAsync(onComplete: text =>
  {
    callerContext.Post(() => // 呼び出し元のContext(≒スレッド)に戻す
    {
      ui.text = text;
      Task.Run(() =>
      {
        // Task.Runはワーカースレッドで実行するため、ここはメインスレッドではなくなっている
        Thread.Sleep(100000)
        // onComplete();
        callerContext.Post(() => // 呼び出し元のContext(≒スレッド)に戻す
        {
          ui.text = "";
        });
      });
    };
  });
}

ということで、awaitをうまく使うためには、SynchronizationContext.Current(Staticなのでプロセス内で共有の設定です)のインスタンスに最低でもSynchronizationContext.Postが実装されている必要があります。(実際にはもっといろいろ必要)
そしてPostにコールバックを渡したときにメインスレッドで実行されるようになっていればOKです。
ちなみにConsoleアプリではSynchronizationContext.Currentはデフォルトでnullであり、awaitから戻ってきてもメインスレッドに復帰しません。

WPFでは、このSynchronizationContextDispatcherSynchronizationContextにて実装されています。そして肝心のPostについては、Dispatcher.BeginInvokeが呼ばれるようになっています。

これはちょっとよくわからなかったのですが、SynchronizationContext.SetSynchronizationContextDispatcherSynchronizationContextを設定している個所が、Dispatcher.Invokeしたタイミング以外見当たらないんですよね…
つまりそれまではawaitしても帰ってくることができなくなりそうです。一回でもDispatcher.Invokeが呼ばれていればそれ以降はCurrentにContextが入り続けるので、WPFのコアが初期化している間にDispatcher.Invokeが呼ばれていい感じになるのかな…という感じです。しかし、普通に考えたら明示的に初期化するでしょって思っちゃうのですが、私が見落としているだけなんでしょうかね🤔🤔🤔

まとめ

ということで紐解いてみると結構あっけない作りだったりします。まぁ独自のGUIシステムを作ろうとでもしない限り、何も考えなくても書けますけどね。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?