「未だに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回/秒以上実行されればフリーズはしない)
}
}
力業のようにも見えるのですが、実際ほとんど同等の実装になっています。
ポイントは以下の点です。
- UIスレッドに任せたい処理のキューがある
- UIスレッドは初期化処理が終わったら半無限のループでキューを監視している
- 重い処理をワーカースレッドで実行
- ワーカースレッドからUIスレッドにキューを通して処理を任せる
Dispatcher
クラスを見てみる
Dispatcher
クラスは先述のポイントのうち、1/2/4の役割を担っています。いろいろな例外処理が挟まっているものの、いかのメンバが機能を実装しています。
機能 | 実装 | |
---|---|---|
キュー | Dispatch._queue |
|
無限ループ | Dispatch.Run |
さらに奥のPushFrameImpl にwhileループがある |
任せる | Dispatch.BeginInvoke |
Dispatch._queue に積むだけ |
ちなみにDispatch.Run
については、Application.Run
から呼び出され、Application.Run
はMain
から呼び出されます。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では、このSynchronizationContext
がDispatcherSynchronizationContext
にて実装されています。そして肝心のPost
については、Dispatcher.BeginInvoke
が呼ばれるようになっています。
これはちょっとよくわからなかったのですが、SynchronizationContext.SetSynchronizationContext
でDispatcherSynchronizationContext
を設定している個所が、Dispatcher.Invoke
したタイミング以外見当たらないんですよね…
つまりそれまではawaitしても帰ってくることができなくなりそうです。一回でもDispatcher.Invoke
が呼ばれていればそれ以降はCurrentにContextが入り続けるので、WPFのコアが初期化している間にDispatcher.Invoke
が呼ばれていい感じになるのかな…という感じです。しかし、普通に考えたら明示的に初期化するでしょって思っちゃうのですが、私が見落としているだけなんでしょうかね🤔🤔🤔
まとめ
ということで紐解いてみると結構あっけない作りだったりします。まぁ独自のGUIシステムを作ろうとでもしない限り、何も考えなくても書けますけどね。