今回から数回に分けて、C# のマルチスレッド、並列処理、非同期処理を整理します。
まずこの記事では、低レベルな Thread クラスを中心に、マルチスレッド処理の基本を見ていきます。
現在の .NET では、直接 Thread を作るよりも Task や async/await を使う場面が多いです。
それでも、Thread の概念を理解しておくと、Task、ThreadPool、Parallel、async/await の動きも見通しやすくなります。
プロセス、AppDomain、Load Context、スレッドの関係
.NET アプリケーションの実行構造を、大きい順に見ると次のようになります。
- プロセス: OS が管理する実行中プログラムの単位
- AppDomain: CLR が管理する論理的な実行領域
- AssemblyLoadContext: アセンブリの読み込みと分離を扱う文脈
- スレッド: CPU が実際に命令を実行する流れ
プロセスは少なくとも 1 つのスレッドを持ちます。
アプリケーション起動時に実行される Main メソッドも、どこかのスレッド上で動いています。
複数のスレッドを使うと、重い処理を別の実行経路に逃がしたり、複数の処理を同時進行させたりできます。
一方で、同じデータを複数スレッドから同時に触ると、順序やタイミングによって結果が壊れることがあります。
並行処理の問題
マルチスレッド処理で最初に注意するべきなのは、「同時に動くこと」そのものではなく、「同じ状態を共有すること」です。
次のコードは、複数スレッドから同じ counter を増やす例です。
using System;
using System.Threading;
int counter = 0;
void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(counter);
期待値は 200000 です。
しかし実際には、それより小さい値になる可能性があります。
counter++ は一見 1 つの処理に見えますが、内部的には「読む」「増やす」「書く」という複数の操作です。
2 つのスレッドが同時にこの処理へ入ると、片方の更新が失われることがあります。
このような問題を防ぐために、スレッド同期が必要になります。
スレッド同期の役割
スレッド同期とは、複数のスレッドが同じリソースへアクセスするとき、実行順序を制御する仕組みです。
代表的な目的は次の通りです。
- 共有データの破損を防ぐ
- 同時に実行してはいけない処理を 1 つずつ実行する
- ある処理が終わるまで別の処理を待たせる
- スレッド間で合図を送る
同期を使うと安全性は上がりますが、使いすぎると並列実行のメリットが減ります。
また、ロックの取り方を間違えるとデッドロックが発生します。
マルチスレッドでは、「どこを共有しているのか」「どこを同時実行してよいのか」を意識することが大切です。
System.Threading 名前空間
System.Threading 名前空間には、スレッド処理に関する基本型が含まれています。
代表的な型は次の通りです。
Thread
OS スレッドを表すクラスです。手動でスレッドを作成・開始できます。
ThreadStart
引数なしのスレッド開始メソッドを表すデリゲートです。
ParameterizedThreadStart
object? 型の引数を 1 つ受け取るスレッド開始メソッドを表すデリゲートです。
AutoResetEvent
スレッド間で「合図」を送るための同期プリミティブです。1 つの待機スレッドを起こすと、自動的に非シグナル状態へ戻ります。
Monitor
lock 文の内部で使われる同期機構です。明示的に Enter / Exit を呼び出すこともできます。
Interlocked
数値の加算や交換などをアトミックに行うための型です。
Thread クラスで現在のスレッド情報を取得する
Thread.CurrentThread を使うと、現在実行中のスレッドを取得できます。
using System;
using System.Threading;
Thread current = Thread.CurrentThread;
current.Name = "Main Thread";
Console.WriteLine($"Name: {current.Name}");
Console.WriteLine($"ManagedThreadId: {current.ManagedThreadId}");
Console.WriteLine($"IsAlive: {current.IsAlive}");
Console.WriteLine($"IsBackground: {current.IsBackground}");
Console.WriteLine($"Priority: {current.Priority}");
Console.WriteLine($"ThreadState: {current.ThreadState}");
ManagedThreadId は、.NET が管理するスレッド ID です。
OS のネイティブスレッド ID と同じとは限りません。
Thread の Name プロパティ
Name は、スレッドを人間が識別しやすくするための名前です。
using System;
using System.Threading;
Thread worker = new Thread(() =>
{
Console.WriteLine($"Worker name: {Thread.CurrentThread.Name}");
});
worker.Name = "File Import Worker";
worker.Start();
worker.Join();
注意点として、Name は一度設定すると、通常は再設定できません。
デバッグやログ出力で役立つため、長く動くスレッドには名前を付けておくと追跡しやすくなります。
Thread の Priority プロパティ
Priority は、スレッドの実行優先度を示します。
using System;
using System.Threading;
Thread worker = new Thread(() =>
{
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"Working... {i}");
Thread.Sleep(500);
}
});
worker.Priority = ThreadPriority.AboveNormal;
worker.Start();
worker.Join();
優先度を上げても、必ず速くなるわけではありません。
CPU スケジューリングのヒントになるだけで、OS 全体の負荷や他プロセスの状態にも左右されます。
通常のアプリケーションでは、優先度を頻繁に変更するより、Task や ThreadPool に任せる方が扱いやすいです。
ThreadStart デリゲートでスレッドを作成する
引数なしのメソッドを別スレッドで実行したい場合は、ThreadStart を使えます。
using System;
using System.Threading;
static void PrintNumbers()
{
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"Worker: {i}");
Thread.Sleep(300);
}
}
ThreadStart start = new ThreadStart(PrintNumbers);
Thread worker = new Thread(start);
worker.Start();
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"Main: {i}");
Thread.Sleep(300);
}
worker.Join();
Start() はスレッドを開始します。
Join() は、そのスレッドが終了するまで現在のスレッドを待機させます。
ParameterizedThreadStart デリゲートで値を渡す
スレッドへ値を渡したい場合は、ParameterizedThreadStart を使えます。
using System;
using System.Threading;
static void PrintMessage(object? state)
{
string message = state as string ?? "(no message)";
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"{message}: {i}");
Thread.Sleep(300);
}
}
Thread worker = new Thread(new ParameterizedThreadStart(PrintMessage));
worker.Start("Hello from worker");
worker.Join();
引数は object? なので、型安全ではありません。
現在の C# では、ラムダ式でローカル変数をキャプチャする方が書きやすい場面も多いです。
using System;
using System.Threading;
string message = "Hello with lambda";
Thread worker = new Thread(() =>
{
Console.WriteLine(message);
});
worker.Start();
worker.Join();
AutoResetEvent でスレッド間に合図を送る
AutoResetEvent は、あるスレッドが別のスレッドへ「準備できた」「処理してよい」と合図するために使えます。
using System;
using System.Threading;
AutoResetEvent signal = new AutoResetEvent(false);
Thread worker = new Thread(() =>
{
Console.WriteLine("Worker: 合図を待っています。");
signal.WaitOne();
Console.WriteLine("Worker: 合図を受け取りました。");
});
worker.Start();
Thread.Sleep(1000);
Console.WriteLine("Main: 合図を送ります。");
signal.Set();
worker.Join();
signal.Dispose();
WaitOne() は合図が来るまで待機します。
Set() は待機中のスレッドを 1 つ起こします。
AutoResetEvent は 1 つのスレッドを起こすと、自動的に非シグナル状態へ戻ります。
フォアグラウンドスレッドとバックグラウンドスレッド
Thread には、フォアグラウンドスレッドとバックグラウンドスレッドがあります。
- フォアグラウンドスレッド: 終了するまでプロセスを生かし続ける
- バックグラウンドスレッド: フォアグラウンドスレッドがすべて終わると、途中でも終了される
using System;
using System.Threading;
Thread backgroundWorker = new Thread(() =>
{
while (true)
{
Console.WriteLine("Background worker is running...");
Thread.Sleep(500);
}
});
backgroundWorker.IsBackground = true;
backgroundWorker.Start();
Console.WriteLine("Main thread is ending.");
このコードでは、メインスレッドが終了すると、バックグラウンドスレッドもプロセス終了に巻き込まれます。
ログ出力や監視のような補助処理ではバックグラウンドスレッドが使われることがあります。
ただし、重要な後始末をバックグラウンドスレッドだけに任せると、処理が途中で止まる可能性があります。
まとめ
Thread クラスは、C# でスレッドを直接扱うための基本 API です。
現在のアプリケーションでは Task、Parallel、async/await を使う場面が多いものの、スレッドの基本を理解しておくと、それらの高レベル API が何を隠してくれているのかが見えやすくなります。
次回は、共有データを守るための lock、Monitor、Interlocked、そして ThreadPool について見ていきます。