タスク並列ライブラリ (TPL) は Windows Forms のような GUI アプリでは使い難い点があるということを今更ながらですが学びましたので、備忘録として書いておきます。
最近の PC の CPU はマルチコアが当たり前になっているようですが、PC のマルチコアを有効に使うプログラミングをサポートするため、.NET Framework 4.0 で導入されたタスク並列ライブラリ (TPL・・・Parallel.For とか Parallel.Invoke とか) を利用するという話を巷でよく耳にします。
マルチスレッドの基本的な話ですが、@IT の記事「第1回 マルチスレッドはこんなときに使う (1/2)」の「マルチスレッドの動作原理」セクションを見てください。リンク切れになると困るので画像だけ借用して以下に表示しておきます。
② のようになると OS がスレッドを切り替えて処理を行うのでそのオーバーヘッドの分逆に遅くなるということになります。なので、マルチスレッドアプリで処置時間の短縮を図るなら ③ のようにマルチコアを利用できる環境が必要で、さらにマルチコアを有効に利用するプログラミングを行うという話になると思います。
その際に自分がよく聞くのがタスク並列ライブラリ (TPL) や Parallel LINQ (PLINQ) を使うという話です。Microsoft のドキュメント「.NET での並列プログラミング」や、ググるとヒットする記事例えば @IT の記事「ループをParallelクラスで並列処理にするには?」を読むと TPL, PLINQ は並列処理には万能のような気がしてました。
Microsoft のドキュメント「タスク並列ライブラリ (TPL)」には、
"TPL は、使用可能なすべてのプロセッサを最も効率的に使用するように、コンカレンシーの程度を動的に拡大します。The TPL scales the degree of concurrency dynamically to most efficiently use all the processors that are available."
・・・と言う記述がありますし、PC のマルチコアを有効に使うという局面に限れば最強のように思えます。
しかし、Windows Forms のような GUI アプリではそうでもなさそうな感じです。自分が気が付いた限りですが、以下の 2 点が問題だと思いました。(自分が回避策を知らないだけという可能性は否定できませんが
)
-
並列に実行される複数のメソッドがすべて完了するまで UI がブロックされる。 ⇒ PLINQ 以外は async / await を使って回避できる方法がありました。下の【2021/6/13 追記】を見てください
-
並列実行するメソッドには非同期版は使えない。
以下のサンプルコードの ParallelInvoke_Click メソッドは Parallel.Invoke を使って 5 つの同期版メソッド Work を並列に実行するものですが、5 つのメソッドがすべて完了するまで UI がブロックされるので、アプリはフリーズしたようになります。その他の TPL, PLINQ を使った xxxxx_Click メソッドも同様で、完了するまで UI がブロックされます。それを回避する手段は、自分が探した限りですが、なさそうです。
さらに、非同期版メソッド WorkAsync は使えません。詳しくはサンプルコードのコメントに書きましたので見てください。なので、ライブラリなどで非同期版メソッドしか提供されてない場合は何ともならないと思われます。
というわけで、下のサンプルコードの WhenAll1_Click, WhenAll2_Click メソッドのように、Parallel.Invoke など使わないで、await Task.WhenAll(...) で待機するようにし、並列化については OS に任せるのが良さそうと思いました。(実際にサンプルを動かしてみると並列化してくれているような感じはしました。② のようになっている可能性は否定しきれませんが)
非同期版メソッド WorkAsync も使えます。下のサンプルコードの WhenAll2_Click メソッドを見てください。上の画像がその実行結果です。(ThreadID が 1 で UI スレッドと同じなのは、WorkAsync メソッドの中で ManagedThreadId を取得するのが UI スレッドだからです)
using System;
using System.Windows.Forms;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
namespace WinFormsApp1
{
public partial class TaskParallelLibrary : Form
{
public TaskParallelLibrary()
{
InitializeComponent();
}
// 同期版メソッド
private string Work(int n)
{
int id = Thread.CurrentThread.ManagedThreadId;
string retunVlaue =
$"n = {n}, ThreadID = {id}, start:{DateTime.Now:ss.fff}, ";
Thread.Sleep(3000);
retunVlaue += $"end:{DateTime.Now:ss.fff}";
return retunVlaue;
}
// 非同期版メソッド
private async Task<string> WorkAsync(int n)
{
int id = Thread.CurrentThread.ManagedThreadId;
string retunVlaue =
$"n = {n}, ThreadID = {id}, start: {DateTime.Now:ss.fff}, ";
await Task.Delay(3000);
retunVlaue += $"end: {DateTime.Now:ss.fff}";
return retunVlaue;
}
// 非同期版メソッド 5 つを for ループで逐次実行
private async void InOrder_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
for (int n = 0; n < 5; n++)
{
this.label1.Text += await WorkAsync(n) + "\r\n";
}
this.label1.Text += "完了";
}
// 同期版メソッド 5 つを Parallel.Invoke で実行
// Executes each of the provided actions, possibly in parallel.
// 終了まで UI はブロックされる
private void ParallelInvoke_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
string[] results = new string[5];
Parallel.Invoke(
() => results[0] = Work(0),
() => results[1] = Work(1),
() => results[2] = Work(2),
() => results[3] = Work(3),
() => results[4] = Work(4));
foreach (string result in results)
{
this.label1.Text += result + "\r\n";
}
this.label1.Text += "完了";
// 非同期版メソッド WorkAsync は使えない
// Task.Result でデッドロックになる
//Task<string> result1 = null, result2 = null;
//Parallel.Invoke(
// () => result1 = WorkAsync(0),
// () => result2 = WorkAsync(1));
//this.label1.Text += result1.Result + "\r\n";
//this.label1.Text += result2.Result + "\r\n";
// await で待つことなく終わってしまう
//string result1 = "", result2 = "";
//Parallel.Invoke(
// async () => result1 = await WorkAsync(0),
// async () => result2 = await WorkAsync(1));
//this.label1.Text += result1 + "\r\n";
//this.label1.Text += result2 + "\r\n";
}
// 同期版メソッド 5 つを Parallel.For で実行
// Executes a for loop in which iterations may run in parallel.
// 終了まで UI はブロックされる
private void ParallelFor_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
string[] results = new string[5];
Parallel.For(0, 5, n => results[n] = Work(n));
foreach (string result in results)
{
this.label1.Text += result + "\r\n";
}
this.label1.Text += "完了";
}
// 同期版メソッド 5 つを Parallel.ForEach で実行
// Executes a foreach operation in which iterations may run in parallel.
// 終了まで UI はブロックされる
private void ParallelForEach_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
string[] results = new string[5];
Parallel.ForEach(Enumerable.Range(0, 5),
n => results[n] = Work(n));
foreach (string result in results)
{
this.label1.Text += result + "\r\n";
}
this.label1.Text += "完了";
}
// 同期版メソッド 5 つを PLINQ で実行
// PLINQ は、システムのすべてのプロセッサを十分に活用しようとする。
// そのために、データ ソースをセグメントにパーティション分割し、
// 複数のプロセッサで個々のワーカー スレッドの各セグメントに対して
// クエリを並行実行します。
// 終了まで UI はブロックされる
private void PLINQ_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
var results = Enumerable.Range(0, 5).AsParallel().
Select(n => Work(n));
foreach (string result in results)
{
this.label1.Text += result + "\r\n";
}
this.label1.Text += "完了";
}
// ***** 以下 TPL, PLINQ に代えて Task.WhenAll を使用 *****
// 同期版メソッド 5 つを Task.Run で実行、
// await Task.WhenAll で待機
private async void WhenAll1_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
string[] results = new string[5];
var taskList = new List<Task>();
for (int n = 0; n < 5; n++)
{
int i = n;
taskList.Add(Task.Run(() => results[i] = Work(i)));
}
// WaitAll ではブロックされてしまう。
// WhenAll でないと await で待機できないので注意
await Task.WhenAll(taskList.ToArray());
foreach (string result in results)
{
this.label1.Text += result + "\r\n";
}
this.label1.Text += "完了";
}
// 非同期版メソッド 5 つを実行、
// await Task.WhenAll で待機
private async void WhenAll2_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
Task<string>[] results = new Task<string>[5];
for (int n = 0; n < 5; n++)
{
int i = n;
results[i] = WorkAsync(i);
}
// WaitAll ではデッドロックになる
await Task.WhenAll(results);
foreach (Task<string> result in results)
{
this.label1.Text += result.Result + "\r\n";
}
this.label1.Text += "完了";
}
}
}
-----【2021/6/13 追記】-----
UI スレッドがブロックされるのを回避する方法ですが、Parallel LINQ (PLINQ) 以外は、.NET Framework 4.5 から導入された async / await を使って TPL の部分を await Task.Run で動かすと UI スレッドはブロックされず、メッセージループでマウスのクリックやキーボードのストロークなどのユーザーイベントが処理されることが分かりました。
具体的には、Parallel.For を使った場合を例に取ると、以下のようにします。
private async void ParallelFor_Click(object sender, EventArgs e)
{
int id = Thread.CurrentThread.ManagedThreadId;
this.label1.Text = $"UI Thread ID = {id}\r\n";
string[] results = new string[5];
await Task.Run(() => Parallel.For(0, 5, n => results[n] = Work(n)));
foreach (string result in results)
{
this.label1.Text += result + "\r\n";
}
this.label1.Text += "完了";
}
ただ、async / await が使えるなら、上のコード例の await Task.WhenAll で待機することもできますので、TPL は使わなくても良いのではないかという気はします。上に書いた "TPL は、使用可能なすべてのプロセッサを最も効率的に使用するように、コンカレンシーの程度を動的に拡大します" という点に意味があるのかもしれませんが。
なお、Parallel LINQ (PLINQ) の方は以下のようにしてみたのですが UI スレッドがブロックされるのは回避できないようで、マウスのクリックなどのユーザーイベントには無反応(フリーズ状態)のままになります。
var results = await Task.Run(() => Enumerable.Range(0, 5)
.AsParallel()
.Select(n => Work(n)));
PLINQ では回避できない理由や対応策は分かりません。今後の検討課題ということで。