表題の「非同期プログラミング」と言うと高度な話のように思えるかもしれませんが、実は大したことは書いてなくて、ある機会にいろいろ調べたことを備忘録的に書いただけです。
上の画像はそのいろいろ調べたことの一つで、Windows Forms アプリで非同期にメソッドを実行して ManagedThreadId プロパティの値がどうなるかを調べた結果です。説明は下の (2) に書きました。
(1) コンソールアプリの ManagedThreadId
以下のコードを実行すると結果はどうなるでしょう? ManagedThreadId は 1 ⇒ 3 ⇒ 3 とか、時々、1 ⇒ 3 ⇒ 4 と言うようにすべて違うスレッドになります。
static async Task Main(string[] args)
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
await Task.Run(() =>
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
});
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
}
ちなみに、上の Task.Run メソッドは何かと言うと "スレッドプール上で実行する指定された作業をキューに配置し、その作業を表す Task オブジェクトを戻します。" というものだそうです。要するに、上のコード例で言うと、Run メソッドの引数にある Console.WriteLine をメインスレッドとは別のスレッドで実行するものです。
何故 1 ⇒ 3 ⇒ 4 と言うようにすべて違うスレッドになるかというと、3 つの Console.WriteLine を実行するスレッドは、OS がその時の状況に応じてスレッドプールから良しなに選んでくるからということらしいです。
Microsoft のドキュメント「非同期プログラミングのベストプラクティス」の「すべて非同期にする」のセクションに以下のように書いてあります。
"コンソールアプリケーションでは、一度に 1 つのチャンクに制限する SynchronizationContext ではなく、スレッドプールを備えた SynchronizationContext を使用するため、await が完了するとき、スレッド プールのスレッドで async メソッドの残り処理のスケジュールが設定されます。"
"They have a thread pool SynchronizationContext instead of a one-chunk-at-a-time SynchronizationContext, so when the await completes, it schedules the remainder of the async method on a thread pool thread."
(注: 上の「一度に 1 つのチャンクに制限する SynchronizationContext」というのは GUI アプリと ASP.NET アプリに使われるものだそうです)
それが実行結果が 1 ⇒ 3 ⇒ 4 と言うようにすべて違うスレッドになることがあるという理由のようです。
(2) Windows Forms アプリの ManagedThreadId
この記事の一番上の画像が Windows Forms アプリで非同期にメソッドを実行して ManagedThreadId の値がどうなるか調べた結果です。
コンソールアプリとの一番大きな違いは。メインスレッド(UI スレッド)の ManagedThreadId の値は同じになる(上の画像の例では 1 で固定)、即ち同じスレッドが使われ続けるということです。
Microsoft のドキュメント「非同期プログラミングのベストプラクティス」の「すべて非同期にする」のセクションに以下のように書いてあり、これがメインスレッドには同じスレッドが使われ続ける理由のようです。
"既定では、未完了の Task を待機するときは、現在のコンテキストがキャプチャされ、Task が完了するときのメソッドの再開に使用されます。このコンテキストは現在の SynchronizationContext で、Null の場合は現在の TaskScheduler になります。GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext があります。await が完了するときは、キャプチャしたコンテキスト内で async メソッドの残りを実行しようとします。"
"By default, when an incomplete Task is awaited, the current context is captured and used to resume the method when the Task completes. This context is the current SynchronizationContext unless it’s null, in which case it’s the current TaskScheduler. GUI and ASP.NET applications have a SynchronizationContext that permits only one chunk of code to run at a time. When the await completes, it attempts to execute the remainder of the async method within the captured context."
メッセージループでマウスのクリックやキーボードのストロークなどのユーザーイベントを処理して UI に反映する必要があることも、Windows アプリの UI スレッドは常に同じ&固定になる理由なのかもしれません(想像です)。
メッセージループに関しては、@IT の記事「第3回 Windowsアプリケーションの正体 (3/4)」や Microsoft のドキュメント「Application.Run メソッド」が自分的には参考になりました。
上の画像の Windows Forms アプリのコードを以下に書いておきます。
using System;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;
// メインスレッドの ManagedThreadId は最後まで同じ ID が保たれる
namespace WinFormsApp1
{
public partial class Form7 : Form
{
public Form7()
{
InitializeComponent();
textBox1.Text = "呼び出されたメソッド内の";
}
private async void button2_Click(object sender, EventArgs e)
{
label1.Text =
$"メイン ID: {Thread.CurrentThread.ManagedThreadId} / ";
label2.Text = "";
// await キーワードは TimeCosumingMethod の中で別スレッド
// で実行される部分が終了するのを待つという意味。
// その間メッセージループは処理されるのでフリーズはしない
label2.Text = await TimeCosumingMethod(textBox1.Text);
label1.Text += Thread.CurrentThread.ManagedThreadId;
}
private void button1_Click(object sender, EventArgs e)
{
label3.Text = "[処理中にクリック]ボタンがクリックされた " +
Thread.CurrentThread.ManagedThreadId;
}
private async Task<string> TimeCosumingMethod(string s)
{
if (string.IsNullOrEmpty(s))
{
throw new ArgumentException("引数が無い");
}
s += $" ID: {Thread.CurrentThread.ManagedThreadId} / ";
s += await MyMethod();
s += Thread.CurrentThread.ManagedThreadId;
return s;
}
private async Task<string> MyMethod()
{
string s1 = $"MyMethod {Thread.CurrentThread.ManagedThreadId} (IN), ";
string s2 = await MyMethod2();
s1 += $"{Thread.CurrentThread.ManagedThreadId} (OUT) / " + s2;
return s1;
}
private async Task<string> MyMethod2()
{
string s = $"MyMethod2 {Thread.CurrentThread.ManagedThreadId} (IN), ";
// Task.Delay(3000) で 3000 ms 後に完了するタスクを作成。
// そのタスクは別スレッドで実行される。await があるので
// タスクの完了を待つ
await Task.Delay(3000);
s += $"{Thread.CurrentThread.ManagedThreadId} (OUT) / ";
return s;
}
}
}
上記のコードでは UI スレッドと別のスレッドで実行されるのは MyMethod2 メソッドの中の Task.Delay(3000)
だけになるようです。上の画像で ManagedThreadId がすべて 1 になっているのがそれを裏付けていると思います。
ちなみに、TimeCosumingMethod メソッドの中で s += await MyMethod();
を s += await Task.Run(() => MyMethod());
に変更すると、MyMethod メソッドは UI スレッドとは別のスレッドで実行されます。結果は以下の画像のとおりで、赤枠の部分が上のケースとは異なってきます。
以上、コンソールアプリと Windows Forms アプリの ManagedThreadId の話でした。デッドロックの話など続きがありますが、記事が長くなりすぎるので別の記事として書きます。