はじめに
誤りについては、優しく指摘していただけますと幸いです。
WPFのUI部品は作ったスレッドしか触れない
TextBoxやButtonはUIスレッド以外からは触れず、
触ろうとすると例外になる。
UIを複数スレッドが同時に書き換えると、
半端な状態で描画されたり、内部の整合性が壊れてしまう。
そういった事態を防ぐため、WPFの部品はUIスレッド以外から触れないようにされている。
WPFの部品はUIスレッド以外から触れない。
Dispatcherとは何か
通常、重い処理はUIスレッドから逃がして別のスレッドで行う。
しかし、重い処理の結果をTextBoxに表示したいというようなケースはある。
その場合、別のスレッドからUIスレッドに処理を戻す仕組みが必要になるが、
この仕組みがDispatcherである。
UIスレッド上ではメッセージループというループが回っている。
このループの中では、Dispatcherのキューに積まれた仕事が1つずつ処理される。
つまり、DispatcherはUIスレッド上でやってほしい処理を積んでおく場所と言える。
UIスレッドから重い処理を逃がしたい、しかし結果の表示はUI上で行いたい。
この要求に応えるために存在するのがDispatcher。
Dispatcherには処理が積まれていく。
通常の非同期処理(Task, async/await)との関係性
UIスレッドから別スレッドへと処理を逃がすのはTaskの仕事。
別スレッドからUIスレッドへと処理を戻す際の受け皿がDispatcher。
async/awaitは、その2つを繋ぎ「逃がして、戻る」を表現できるようにしたもの。
private async void Button_Click(object sender, RoutedEventArgs e)
{
ResultText.Text = "処理中...";
int result = await Task.Run(() =>
{
Thread.Sleep(5000);
return 42;
});
ResultText.Text = $"結果: {result}";
}
このようなコードの場合、await以降の処理はUIスレッドのDispatcherキューに
一旦預けられている。
預けられた処理がUIスレッドのメッセージループで取り出されたとき、
ResultTextは更新されている。
SynchronizationContext
タスクが完了するのはラムダ式を実行しているスレッドプール上のスレッド。
つまりTask.Run()で処理を逃がした先のスレッドである。
ここで何もしなければ、以降の処理はそのまま逃がしたスレッド上で実行されようとする。
しかし、以降の処理はUIスレッドで処理しなければならない。
このような場合にはDispatcherに預けなければならないわけだが、
Dispatcherには勝手に処理を拾ってくる機構はない。
そのため、誰かがどうにかして処理をDispatcherに預ける必要がある。
そのDispatcherに預けるための窓口が、SynchronizationContext である。
awaitは呼び出された時点のSynchronizationContext(≒awaitが呼ばれた側のスレッド)を自動で記録しており、タスク完了時にそこへ続きを依頼する。
これにより、開発者が何もしなくてもawait以降はUIスレッドに戻る。
Dispatcherに処理を預けるために利用する窓口がSynchronizationContext。
await以降の処理は、SynchronizationContext経由で
Dispatcherに預けられている。
public class SynchronizationContext
{
// 現在のスレッドで設定されているContext。設定がなければnull。
public static SynchronizationContext Current { get; }
// 処理を依頼する。
public virtual void Post(SendOrPostCallback d, object state);
// 処理を依頼し、終わるまで待つ。
public virtual void Send(SendOrPostCallback d, object state);
}
※「処理を依頼する」は、先ほどの話で言うところの「キューに処理を積む」と結果的には同じ。ただ正確にはキューに処理を積むのはDispatcherであってコイツではない。こいつは「積んどいて」と依頼する人。
Currentはスレッドごとに保持される。
WPFのUIスレッド以外、スレッドプールのスレッドなどでは通常null。
WPFではDispatcherSynchronizationContext型として保存されている。
class DispatcherSynchronizationContext : SynchronizationContext
{
Dispatcher _dispatcher; // UIスレッドに処理を積むDispatcher
DispatcherPriority _priority; // 既定はNormal
public override void Post(SendOrPostCallback d, object state)
=> _dispatcher.BeginInvoke(_priority, d, state);
public override void Send(SendOrPostCallback d, object state)
=> _dispatcher.Invoke(_priority, d, state);
}
関係性
関係性としてはこんな感じ(AIに作らせた)。
Task / await ← WPFを知らない共通ライブラリ側
│ 使う
▼
SynchronizationContext ← 抽象(Post / Send / Current)
△
│ 継承して実装
DispatcherSynchronizationContext ← WPF側の実装
│ 呼ぶ
▼
Dispatcher(キュー)→ UIスレッドのループが実行
UIスレッドに処理が戻るまでの流れ
UIスレッドに処理が戻るまでの流れはこうなる。
await実行時にSynchronizationContext.Currentを保持
│
▼
Task.Runの処理が終わったら
コールバックに登録した続きの処理をPostに入れて呼ぶ。
│
▼
DispatcherSynchronizationContextが保持しているDispatcherの
BeginInvoke(処理)を呼び出す。
│
▼
Dipatcherはそれを受けて処理をキューに積む。
スレッドが切り替わる様子
4つのパターンでスレッドが切り替わる(切り替わらない)様子を観測してみる。
ボタンを押すと処理が行われているスレッドのIDと、それがUIスレッドか否か、
Contextがnullか否かを表示してくれる。
WindowはUI部品で、元々Dispatcherプロパティを持っている。
<Window x:Class="DispatcherLab.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Dispatcher Lab" Height="560" Width="860">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<WrapPanel Grid.Row="0">
<Button Content="1. 基本 (await Task.Run)" Click="Exp1_Click" Margin="0,0,8,8" Padding="8,4"/>
<Button Content="2. ConfigureAwait(false)" Click="Exp2_Click" Margin="0,0,8,8" Padding="8,4"/>
<Button Content="4. Task.Run内のawait" Click="Exp4_Click" Margin="0,0,8,8" Padding="8,4"/>
<Button Content="5. Post / InvokeAsync" Click="Exp5_Click" Margin="0,0,8,8" Padding="8,4"/>
<Button Content="9. DispatcherからContext生成" Click="Exp9_Click" Margin="0,0,8,8" Padding="8,4"/>
<Button Content="ログ消去" Click="Clear_Click" Margin="0,0,8,8" Padding="8,4"/>
</WrapPanel>
<TextBox x:Name="LogBox" Grid.Row="1"
IsReadOnly="True" FontFamily="Consolas" FontSize="13"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"/>
</Grid>
</Window>
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace DispatcherLab
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
/// <summary>
/// ログ出力を行う
/// 画面への記載だけUIスレッドに依頼
/// </summary>
/// <param name="label"></param>
private void Log(string label)
{
string line =
$"{DateTime.Now:HH:mm:ss.fff} {label,-26}" +
$" Thread={Environment.CurrentManagedThreadId,2}" +
$" UIスレッド={Dispatcher.CheckAccess(),-5}" +
$" Context={SynchronizationContext.Current?.GetType().Name ?? "null"}";
Append(line);
}
private void Header(string title) => Append(Environment.NewLine + "=== " + title + " ===");
private void Append(string line)
{
Debug.WriteLine(line);
Dispatcher.InvokeAsync(() =>
{
LogBox.AppendText(line + Environment.NewLine);
LogBox.ScrollToEnd();
});
}
private void Clear_Click(object sender, RoutedEventArgs e) => LogBox.Clear();
/// <summary>
/// awaitの前後でスレッドとContextが変わる様子
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void Exp1_Click(object sender, RoutedEventArgs e)
{
Header("1. 基本 (await Task.Run)");
Log("前");
await Task.Run(() =>
{
Log("中");
Thread.Sleep(1000);
});
Log("後");
}
/// <summary>
/// UIスレッドに戻らなくなる
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void Exp2_Click(object sender, RoutedEventArgs e)
{
Header("2. ConfigureAwait(false)");
Log("前");
await Task.Run(() =>
{
Log("中");
Thread.Sleep(1000);
}).ConfigureAwait(false);
Log("後");
try
{
Title = "ここでUIを触る";
Log("Title設定 成功");
}
catch (Exception ex)
{
Log("Title設定 失敗: " + ex.GetType().Name + " / " + ex.Message);
}
}
/// <summary>
/// Currentがnullの場所でawait
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void Exp4_Click(object sender, RoutedEventArgs e)
{
Header("4. Task.Run内のawait");
Log("外 前");
await Task.Run(async () =>
{
Log("Run内 前");
await Task.Delay(500);
Log("Run内 後");
});
Log("外 後");
}
/// <summary>
/// Postを手動で書く
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Exp5_Click(object sender, RoutedEventArgs e)
{
Header("5. SynchronizationContext.Post / Dispatcher.InvokeAsync");
SynchronizationContext context = SynchronizationContext.Current;
Log("記録(UIスレッド)");
Task.Run(() =>
{
Log("Task内(裏)");
context.Post(_ => Log("Post経由"), null);
Dispatcher.InvokeAsync(() => Log("InvokeAsync経由"));
});
}
/// <summary>
/// DispatcherからContextを明示的に生成
/// ContextはDispatcherへの依頼窓口に過ぎない
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Exp9_Click(object sender, RoutedEventArgs e)
{
Header("9. new DispatcherSynchronizationContext(Dispatcher)");
Log("記録(UIスレッド)");
var ctx = new DispatcherSynchronizationContext(Dispatcher);
Task.Run(() =>
{
Log("Task内(裏)");
ctx.Post(_ => Log("手動生成Context.Post"), null);
});
}
}
}
Ex1では単にTask.Runで別のスレッドに処理が逃がされて、
その後UIスレッドに戻される様子が観測できる。
Ex2で書かれているConfigureAwait(false)は
「awaitがCurrentを記録してそこに処理を戻す動作を辞めさせる。
こうすると、await以降はタスクが完了したスレッドで続行されるため、UI部品には触れなくなる。例外が記録されいているのはそのため。
Ex4ではTask.Run内でawaitすることで更に処理が逃がされる様子が観測できる。
最終的には元のUIスレッドに戻っている様子も分かる。
Ex5はPostを手で書いたもの。
UIスレッドから逃がされた処理が、Postを経由してUIスレッドに戻っているのが分かる。
Ex9は明示的にUIスレッドに処理を戻すことを記載している。
new DispatcherSynchronizationContext(Dispatcher)でDispatcherを生成したスレッドに紐づけている。
