WPFでワーカーを使う理由
C# のワーカーとは、メインスレッドとは別のスレッドを起動し、そこで処理を行うための仕組みです。
この説明だけを見ると、async / await を用いた非同期処理と同じもののように感じられるかもしれません。しかし、ワーカーは単なる非同期実行とは異なる設計を取ることができます。
たとえば、Channel を使ったキューを通じてワーカーへ命令を渡し、ワーカー側ではループ処理によってキューを待ち受け、順番に処理を実行するという構造を構築できます。
これは GUI が持つメッセージループとよく似た構造です。
このようにすることで、
-
処理は別スレッドで実行される
-
実行順序はキューによって保証される
という仕組みを同時に実現できます。
非同期処理だけでは難しい問題
近年の GUI アプリケーションでは、UI を固まらせないために非同期処理を行うことが UX の基本となりました。
しかし、単純に非同期処理を導入しただけでは、次のような問題が発生します。
-
処理中でもボタンが押せてしまう
-
同じ処理が二重に実行される
-
実行中・待機中・キャンセル中などの状態管理が複雑になる
結果として、
「あらゆる操作パターンを想定した制御コード」
を書かなければならなくなります。
ワーカーを使うことで得られるもの
ワーカーを導入し、処理要求をキュー経由で渡す設計にすると、
-
実行順序はキューが管理する
-
同時実行は原理的に発生しない
-
二重実行を意識した UI 制御が不要になる
という状態を作ることができます。
UI 側はただ
だけでよく、
実行中かどうか、順番待ちが何件あるかといった管理はワーカー側に集約できます。
高速化のためではなく、設計を単純にするための仕組み
ワーカーはマルチスレッド処理ではありますが、処理の高速化を目的とした仕組みではありません。
むしろ目的は、
-
処理の実行順序を明確にする
-
状態管理を一箇所に集約する
-
GUI 側のロジックを単純化する
ことにあります。
GUI アプリケーションはもともとイベントドリブンで構築されていますが、
ワーカーもまた「キューによるイベントドリブン処理」として設計できます。
そのため、
GUI × ワーカー
イベントドリブン × イベントドリブン
という構造になり、両者の相性は非常に良いと考えられます。
ワーカーのデモプログラム
ソースコード
ファイル名:WpfChannelWorker.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>
ファイル名:CounterWorker.cs
using System.Threading.Channels;
namespace WpfChannelWorker;
// ==========================================
// カウントアップ専用ワーカー
// ==========================================
public sealed class CounterWorker
{
// -------- メッセージ --------
private record Request(TaskCompletionSource<int> Completion);
// -------- リクエストキュー --------
private readonly Channel<Request> _channel =
Channel.CreateUnbounded<Request>();
// -------- ワーカー内部状態 --------
private int _counter = 0;
public CounterWorker()
{
// 常駐ワーカー起動
_ = Task.Run(WorkerLoop);
}
// ==========================================
// UI から呼ぶ唯一の API
// ==========================================
public async Task<int> CountUpAsync()
{
var tcs = new TaskCompletionSource<int>();
await _channel.Writer.WriteAsync(
new Request(tcs));
return await tcs.Task;
}
// ==========================================
// ワーカー本体(単一スレッド Actor)
// ==========================================
private async Task WorkerLoop()
{
await foreach (var req in _channel.Reader.ReadAllAsync())
{
// 疑似的な重い処理
await Task.Delay(300);
_counter++;
req.Completion.SetResult(_counter);
}
}
}
ファイル名:MainWindow.xaml.cs
using System.Windows;
namespace WpfChannelWorker;
public partial class MainWindow : Window
{
private readonly CounterWorker _worker = new();
public MainWindow()
{
InitializeComponent();
Btn.Click += Btn_Click;
}
private async void Btn_Click(object sender, RoutedEventArgs e)
{
int value = await _worker.CountUpAsync();
Btn.Content = value.ToString();
}
}
ファイル名:MainWindow.xaml
<Window x:Class="WpfChannelWorker.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfChannelWorker"
mc:Ignorable="d"
Title="Channel Worker"
FontSize="20"
Height="200" Width="300">
<Grid>
<Button x:Name="Btn"
Content="0"
Width="140"
Height="40"/>
</Grid>
</Window>
実行例
ボタンを押すと表示されている数値がカウントアップされます。


今回のサンプルプログラムにおけるワーカーの処理内容は、単純なカウントアップのみで、処理自体に特別な意味はありません。
また、一般的なサンプルにならい、重たい処理を模擬する目的で次のコードを入れています。
await Task.Delay(300);
ただし、このワーカーは「重たい処理専用」の仕組みではありません。
本来の目的は、UI スレッドから切り離すことが出来るアプリケーション処理全般をワーカーに任せることです。


コメント