C#のThreadとは?使い方・非同期処理との違い・安全な停止方法を初心者向けに解説

はじめに

C#で処理が重くなったとき、「別の処理として動かしたい」「画面を止めずにバックグラウンドで実行したい」と考える場面があります。そのときに登場する代表的な仕組みがThreadです。

ただし、現代のC#ではThreadを直接使うより、Taskasync/awaitを使う場面のほうが多くなっています。とはいえ、c# threadを理解しておくと、非同期処理、並列処理、UIフリーズ、競合、デッドロック、キャンセル処理などの理解が一気に深まります。

この記事では、C#のThreadとは何か、基本的な使い方、Taskasync/awaitとの違い、安全な停止方法、実践的なサンプルコードまで初心者向けに解説します。

1. C#のThreadとは?まず押さえるべき基本

1-1. Threadは別の実行経路で処理を動かすための仕組み

Threadとは、プログラム内で処理を実行するための「実行経路」です。C#ではSystem.Threading.Threadクラスを使うことで、新しいスレッドを作成し、メインの処理とは別にコードを実行できます。Microsoftの公式ドキュメントでも、Threadクラスはスレッドの作成・制御、優先度の設定、状態の取得を行うクラスとして説明されています。

通常、C#のコンソールアプリやWindowsアプリは、起動時にまず1つのメインスレッドで動きます。そのメインスレッドだけで重い計算や長時間処理を実行すると、他の処理が待たされてしまいます。そこでThreadを使うと、別のスレッドに処理を任せることができます。

たとえば、次のような処理を別スレッドで動かせます。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread = new Thread(Work);
thread.Start();

Console.WriteLine("メインスレッドの処理");
}

static void Work()
{
Console.WriteLine("別スレッドの処理");
}
}

このコードでは、Mainメソッドを実行しているメインスレッドとは別に、Workメソッドを実行するスレッドを作成しています。

1-2. メインスレッドとワーカースレッドの違い

メインスレッドとは、アプリケーションが最初に実行する中心的なスレッドです。コンソールアプリならMainメソッド、Windows FormsやWPFならUIを操作するスレッドがメインスレッドにあたります。

一方、ワーカースレッドとは、メインスレッドとは別に作成され、裏側で処理を実行するスレッドです。重い計算、ファイル処理、ログ出力、監視処理などを任せることがあります。

ただし、UIアプリでは注意が必要です。Windows FormsやWPFのUI部品は、基本的にUIスレッドから操作する必要があります。別スレッドから直接ラベルやボタンを更新すると例外や不安定な動作の原因になります。

悪い例は次のようなコードです。

C#
// UIアプリでは危険な例
new Thread(() =>
{
label1.Text = "更新しました"; // 別スレッドからUIを直接操作している
}).Start();

UIを更新したい場合は、Windows FormsならInvoke、WPFならDispatcher.Invokeなどを使って、UIスレッドに処理を戻す必要があります。

1-3. C#でThreadを使うと何ができるのか

C#でThreadを使うと、複数の処理を同時進行のように動かせます。正確には、CPUのコア数やOSのスケジューリングによって実行タイミングは制御されますが、プログラム上は複数の処理を並行して進められます。

たとえば、次のようなことができます。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread1 = new Thread(() =>
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"スレッド1: {i}");
Thread.Sleep(500);
}
});

Thread thread2 = new Thread(() =>
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"スレッド2: {i}");
Thread.Sleep(500);
}
});

thread1.Start();
thread2.Start();
}
}

このコードでは、2つのスレッドがそれぞれループ処理を実行します。出力順は毎回同じとは限りません。スレッドの実行順はOSが管理するため、開発者が完全に制御できるものではありません。

1-4. Threadが必要になる代表的な場面

Threadが必要になる代表的な場面は、長時間実行される処理をメイン処理から切り離したいときです。

たとえば、次のような場面があります。

  • 重い計算処理を画面操作と分離したい

  • 常駐する監視処理を動かしたい

  • 特定の処理専用の実行スレッドを持ちたい

  • スレッド名や優先度などを細かく制御したい

  • COMやSTAなど、特定のスレッド状態が必要な処理を扱いたい

ただし、単に非同期で処理を実行したいだけなら、現在のC#ではTaskasync/awaitを使うほうが一般的です。Threadは低レベルな制御ができる一方で、停止、例外、同期、リソース管理を自分で慎重に扱う必要があります。

2. C#でThreadを使う基本構文とサンプルコード

2-1. System.Threading名前空間を使う準備

C#でThreadを使うには、基本的にSystem.Threading名前空間を使用します。

C#
using System.Threading;

ThreadThreadPoolMonitorMutexSemaphoreSlimCancellationTokenInterlockedなど、スレッド関連の多くのクラスはこの名前空間に含まれています。

2-2. Threadクラスで別スレッドを作成する方法

Threadクラスで別スレッドを作る基本形は次のとおりです。

C#
Thread thread = new Thread(実行したいメソッド);

具体例は次のとおりです。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread = new Thread(PrintMessage);
thread.Start();

Console.WriteLine("Mainメソッド側の処理です");
}

static void PrintMessage()
{
Console.WriteLine("別スレッドで実行されています");
}
}

new Thread(PrintMessage)の時点では、まだ処理は開始されません。実際にスレッドを開始するにはStartメソッドを呼び出します。

2-3. Startメソッドでスレッドを開始する

Startメソッドは、作成したスレッドを実行可能状態にします。公式ドキュメントでも、Startはスレッドの状態をRunningに変更するメソッドとして説明されています。

C#
Thread thread = new Thread(Work);
thread.Start();

注意点として、同じThreadインスタンスに対してStartを2回呼び出すことはできません。

C#
Thread thread = new Thread(Work);
thread.Start();

// これは例外の原因になる
// thread.Start();

同じ処理をもう一度実行したい場合は、新しいThreadインスタンスを作成します。

C#
Thread thread1 = new Thread(Work);
thread1.Start();

Thread thread2 = new Thread(Work);
thread2.Start();

2-4. ラムダ式を使ってThreadを簡潔に書く方法

短い処理であれば、メソッドを別に定義せず、ラムダ式で書くこともできます。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread = new Thread(() =>
{
Console.WriteLine("ラムダ式で別スレッドを実行");
});

thread.Start();
}
}

ループ処理も次のように書けます。

C#
Thread thread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"処理中: {i}");
Thread.Sleep(1000);
}
});

thread.Start();

ラムダ式を使うとコードは短くなりますが、処理が長くなる場合はメソッドに分けたほうが読みやすくなります。

2-5. 引数を渡してThreadを実行する方法

Threadに引数を渡す方法はいくつかあります。初心者におすすめなのはラムダ式を使う方法です。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
string message = "こんにちは";

Thread thread = new Thread(() =>
{
PrintMessage(message);
});

thread.Start();
}

static void PrintMessage(string message)
{
Console.WriteLine(message);
}
}

ParameterizedThreadStartを使う方法もあります。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread = new Thread(PrintMessage);
thread.Start("C# Thread");
}

static void PrintMessage(object? value)
{
string? message = value as string;
Console.WriteLine(message);
}
}

ただし、ParameterizedThreadStartでは引数がobject型になるため、型変換が必要です。型安全に書きたい場合はラムダ式のほうが扱いやすいです。

3. Threadの主要メソッドとプロパティ

3-1. Start:スレッドを開始する

Startはスレッドを開始するメソッドです。

C#
Thread thread = new Thread(() =>
{
Console.WriteLine("処理開始");
});

thread.Start();

Startを呼び出すと、指定したメソッドやラムダ式が別スレッドで実行されます。ただし、開始タイミングや実行順はOSのスケジューラに依存します。

3-2. Sleep:一定時間スレッドを停止する

Thread.Sleepは、現在実行中のスレッドを指定時間だけ停止します。公式ドキュメントでも、Sleepは現在のスレッドを指定したミリ秒または時間だけ中断するメソッドとして説明されています。

C#
Console.WriteLine("開始");
Thread.Sleep(1000);
Console.WriteLine("1秒後に表示");

注意点は、Thread.Sleepは「現在のスレッドをブロックする」ことです。UIスレッドでThread.Sleepを呼ぶと、その間画面が固まります。

C#
// UIスレッドで実行すると画面が止まる原因になる
Thread.Sleep(5000);

非同期処理で待機したい場合は、通常Task.Delayを使います。

C#
await Task.Delay(5000);

3-3. Join:スレッドの終了を待機する

Joinは、指定したスレッドが終了するまで現在のスレッドを待機させるメソッドです。公式ドキュメントでは、Joinは対象スレッドが終了するまで呼び出し元スレッドをブロックすると説明されています。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread = new Thread(() =>
{
Thread.Sleep(2000);
Console.WriteLine("別スレッド終了");
});

thread.Start();
thread.Join();

Console.WriteLine("すべての処理が終了しました");
}
}

Joinを使うと、別スレッドの完了を待ってから後続処理を実行できます。ただし、UIスレッドで長時間Joinすると画面がフリーズするため注意が必要です。

3-4. IsAlive:スレッドが実行中か確認する

IsAliveは、スレッドがまだ実行中かどうかを確認するプロパティです。公式ドキュメントでは、現在のスレッドの実行状態を示す値を取得するプロパティとして説明されています。

C#
Thread thread = new Thread(() =>
{
Thread.Sleep(3000);
});

thread.Start();

Console.WriteLine(thread.IsAlive); // Trueの可能性が高い

thread.Join();

Console.WriteLine(thread.IsAlive); // False

IsAliveは状態確認には便利ですが、「実行中なら安全に止める」といった制御をこれだけで行うのは危険です。停止には後述するフラグやCancellationTokenを使います。

3-5. Name・Priority・IsBackgroundの使い方

Nameはスレッド名、Priorityは優先度、IsBackgroundはバックグラウンドスレッドかどうかを設定するプロパティです。

C#
Thread thread = new Thread(() =>
{
Console.WriteLine($"Thread Name: {Thread.CurrentThread.Name}");
});

thread.Name = "WorkerThread";
thread.Priority = ThreadPriority.Normal;
thread.IsBackground = true;

thread.Start();

IsBackgroundtrueにすると、そのスレッドはバックグラウンドスレッドになります。フォアグラウンドスレッドがすべて終了すると、バックグラウンドスレッドが実行中でもプロセスは終了します。

C#
Thread thread = new Thread(() =>
{
while (true)
{
Console.WriteLine("バックグラウンド処理中");
Thread.Sleep(1000);
}
});

thread.IsBackground = true;
thread.Start();

Console.WriteLine("Main終了");

バックグラウンドスレッドは便利ですが、処理途中でアプリが終了する可能性があります。ファイル書き込みやデータ保存など、途中終了すると困る処理には注意が必要です。

4. C#のThreadと非同期処理の違い

4-1. ThreadとTaskの違い

Threadは実際のスレッドを直接作成・制御する低レベルな仕組みです。一方、Taskは「実行される作業」を表す高レベルな抽象化です。Taskは結果、例外、キャンセル、継続処理を扱いやすく設計されています。

Threadの例です。

C#
Thread thread = new Thread(() =>
{
Console.WriteLine("Threadで実行");
});

thread.Start();
thread.Join();

Taskの例です。

C#
Task task = Task.Run(() =>
{
Console.WriteLine("Taskで実行");
});

await task;

Task.Runは指定された処理をThreadPool上で実行し、処理を表すTaskを返します。

初心者が実務で書くコードでは、ThreadよりTaskのほうが扱いやすいことが多いです。

4-2. Threadとasync/awaitの違い

async/awaitは、非同期処理を読みやすく書くための構文です。重要なのは、async/awaitを使ったからといって必ず新しいスレッドが作られるわけではないという点です。Microsoftのドキュメントでも、asyncawaitは追加スレッドの作成を引き起こさず、非同期メソッドはそれ自体のスレッドで実行されるわけではないと説明されています。

たとえば、ファイル読み込みやHTTP通信のようなI/O待ちでは、スレッドを占有せずに待機できることが大きなメリットです。

C#
async Task<string> DownloadAsync(HttpClient client, string url)
{
string result = await client.GetStringAsync(url);
return result;
}

このコードは「別スレッドでずっと待っている」のではなく、待機中は呼び出し元へ制御を返し、完了後に続きから処理を再開します。

4-3. Thread.SleepとTask.Delayの違い

Thread.Sleepは現在のスレッドを止めます。

C#
Thread.Sleep(1000);

一方、Task.Delayは非同期的な待機を表します。

C#
await Task.Delay(1000);

違いを簡単にまとめると、Thread.Sleepはスレッドを占有したまま待つ方法、Task.Delayはスレッドを占有しにくい非同期の待機方法です。UIアプリやWebアプリで待機処理を書く場合、基本的にはTask.Delayを使うほうが適しています。

悪い例です。

C#
async Task BadAsync()
{
Thread.Sleep(3000); // asyncメソッド内でもスレッドをブロックする
}

良い例です。

C#
async Task GoodAsync()
{
await Task.Delay(3000); // 非同期的に待機する
}

4-4. CPUバウンド処理とI/Oバウンド処理で使い分ける

処理は大きく「CPUバウンド」と「I/Oバウンド」に分けられます。

CPUバウンドとは、計算処理のようにCPUを多く使う処理です。画像加工、大量データの集計、暗号化、数値計算などが該当します。

I/Oバウンドとは、外部との入出力待ちが中心の処理です。HTTP通信、データベースアクセス、ファイル読み書きなどが該当します。

MicrosoftのC#非同期プログラミングのガイドでも、I/OバウンドかCPUバウンドかを識別することが重要で、CPUバウンドの高コストな計算ではTask.Runで別スレッドに処理を移す選択肢が示されています。

使い分けの目安は次のとおりです。

C#
// CPUバウンド: Task.Runで別スレッドへ
int result = await Task.Run(() => HeavyCalculation());

// I/Oバウンド: 非同期APIをそのままawait
string html = await httpClient.GetStringAsync(url);

4-5. 現代のC#ではThreadよりTaskが推奨される理由

現代のC#では、Threadを直接使うよりもTaskasync/awaitを使うほうが推奨される場面が多いです。

主な理由は次のとおりです。

  • 戻り値を扱いやすい

  • 例外をawaitで受け取りやすい

  • キャンセル処理を統合しやすい

  • Task.WhenAllTask.WhenAnyで複数処理を扱いやすい

  • ThreadPoolを利用でき、スレッド作成コストを抑えやすい

  • コードが読みやすくなりやすい

Threadは「スレッドそのもの」を扱いますが、Taskは「実行される処理」を扱います。業務アプリでは、スレッドそのものを細かく制御したいケースより、処理の完了、結果、例外、キャンセルを扱いたいケースのほうが多いため、Taskのほうが適しています。

5. Threadを使うときに起きやすい問題

5-1. UIがフリーズする原因

UIがフリーズする主な原因は、UIスレッドで重い処理やブロッキング処理を実行してしまうことです。

たとえば、ボタンクリック時に次のようなコードを書くと、5秒間画面が操作できなくなります。

C#
private void button1_Click(object sender, EventArgs e)
{
Thread.Sleep(5000);
label1.Text = "完了";
}

改善するには、重い処理を別スレッドやTask.Runに逃がし、UI更新はUIスレッドで行います。

C#
private async void button1_Click(object sender, EventArgs e)
{
await Task.Run(() =>
{
Thread.Sleep(5000);
});

label1.Text = "完了";
}

CPUを使う重い処理ならTask.Run、通信やファイルI/Oなら非同期APIをawaitするのが基本です。

5-2. 共有データの競合と race condition

複数のスレッドが同じ変数を同時に読み書きすると、予期しない結果になることがあります。これを競合状態、またはrace conditionと呼びます。

次のコードは、期待どおりに200000にならない可能性があります。

C#
using System;
using System.Threading;

class Program
{
static int counter = 0;

static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Console.WriteLine(counter);
}

static void Increment()
{
for (int i = 0; i < 100000; i++)
{
counter++;
}
}
}

counter++は一見1つの操作に見えますが、実際には読み取り、加算、書き込みの複数ステップです。複数スレッドが同時に操作すると、更新が失われることがあります。

5-3. デッドロックが発生する仕組み

デッドロックとは、複数のスレッドが互いに相手の解放待ちになり、処理が永久に進まなくなる状態です。

典型例は、ロックを取得する順番がスレッドごとに異なるケースです。

C#
object lockA = new object();
object lockB = new object();

Thread t1 = new Thread(() =>
{
lock (lockA)
{
Thread.Sleep(100);
lock (lockB)
{
Console.WriteLine("t1");
}
}
});

Thread t2 = new Thread(() =>
{
lock (lockB)
{
Thread.Sleep(100);
lock (lockA)
{
Console.WriteLine("t2");
}
}
});

t1.Start();
t2.Start();

t1lockAを持ったままlockBを待ち、t2lockBを持ったままlockAを待つ可能性があります。これがデッドロックです。

対策は、ロックを取得する順番を統一すること、ロック範囲を短くすること、ロック中に外部処理や長時間処理を呼ばないことです。

5-4. 例外がメインスレッドに伝わらない問題

Thread内で発生した例外は、呼び出し元のメインスレッドで普通にtry-catchしても捕まえられません。

C#
try
{
Thread thread = new Thread(() =>
{
throw new InvalidOperationException("エラー発生");
});

thread.Start();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message); // ここでは捕まえられない
}

Thread内の例外は、そのスレッド内で処理する必要があります。

C#
Thread thread = new Thread(() =>
{
try
{
throw new InvalidOperationException("エラー発生");
}
catch (Exception ex)
{
Console.WriteLine($"別スレッドで例外処理: {ex.Message}");
}
});

thread.Start();

一方、Taskではawait時に例外を受け取りやすくなります。

C#
try
{
await Task.Run(() =>
{
throw new InvalidOperationException("エラー発生");
});
}
catch (Exception ex)
{
Console.WriteLine($"Taskの例外: {ex.Message}");
}

この点も、実務でTaskが好まれる理由の1つです。

5-5. スレッドの作りすぎによるパフォーマンス低下

Threadを大量に作成すると、メモリ消費やコンテキストスイッチのコストが増え、逆にパフォーマンスが低下します。

悪い例です。

C#
for (int i = 0; i < 1000; i++)
{
new Thread(() =>
{
Thread.Sleep(1000);
}).Start();
}

大量の短い処理を並行実行したい場合は、通常Threadを直接作るのではなく、ThreadPoolTaskを使います。

C#
for (int i = 0; i < 1000; i++)
{
Task.Run(() =>
{
Thread.Sleep(1000);
});
}

ただし、Task.Runなら無制限に投げてよいという意味ではありません。大量の処理を投げる場合は、同時実行数の制御も重要です。

6. Threadを安全に扱うための同期処理

6-1. lockを使って共有データを保護する

lockは、複数スレッドから共有データを同時に操作しないようにするための基本的な構文です。MicrosoftのC#リファレンスでも、lock文は共有リソースへの排他的アクセスを確保し、同時に1つのスレッドだけが本文を実行するようにすると説明されています。

先ほどのcounter++の例をlockで修正すると、次のようになります。

C#
using System;
using System.Threading;

class Program
{
static int counter = 0;
static readonly object lockObj = new object();

static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Console.WriteLine(counter);
}

static void Increment()
{
for (int i = 0; i < 100000; i++)
{
lock (lockObj)
{
counter++;
}
}
}
}

lockに使うオブジェクトは、専用のprivate readonlyフィールドにするのが基本です。

C#
private readonly object _lock = new object();

thistypeof(...)、文字列リテラルをロック対象にするのは避けます。外部コードとロック対象が共有され、予期しないデッドロックを起こす可能性があるためです。

6-2. Monitor・Mutex・SemaphoreSlimの違い

C#にはlock以外にも同期用の仕組みがあります。

Monitorは、lockの内部で使われる基本的な同期機構です。lock文は概念的にはMonitor.EnterMonitor.Exitを安全に呼ぶ構文と考えられます。

C#
object lockObj = new object();
bool lockTaken = false;

try
{
Monitor.Enter(lockObj, ref lockTaken);
// 共有データを操作
}
finally
{
if (lockTaken)
{
Monitor.Exit(lockObj);
}
}

Mutexは、プロセスをまたいだ排他制御にも使える同期機構です。複数アプリケーション間で同じリソースを守りたい場合に使われることがあります。

SemaphoreSlimは、同時に入れるスレッド数を制限するための仕組みです。たとえば「同時に3つまで処理を許可する」といった制御ができます。

C#
SemaphoreSlim semaphore = new SemaphoreSlim(3);

await semaphore.WaitAsync();
try
{
// 同時に最大3つまで実行したい処理
}
finally
{
semaphore.Release();
}

簡単にまとめると、同一プロセス内の単純な排他にはlock、細かく制御したい場合はMonitor、プロセス間の排他にはMutex、同時実行数制限にはSemaphoreSlimがよく使われます。

6-3. Interlockedで数値操作を安全に行う

単純な数値の加算や減算であれば、lockよりInterlockedが便利です。

C#
using System;
using System.Threading;

class Program
{
static int counter = 0;

static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Console.WriteLine(counter);
}

static void Increment()
{
for (int i = 0; i < 100000; i++)
{
Interlocked.Increment(ref counter);
}
}
}

Interlocked.Incrementは、複数スレッドから同時に呼ばれても安全に加算できます。カウンター、採番、簡単なフラグ更新などに向いています。

6-4. volatileを使う場面と注意点

volatileは、複数スレッドで共有するフィールドについて、値の読み書きが最適化によって意図せず古い値のまま扱われることを防ぎたい場合に使います。

C#
class Worker
{
private volatile bool _stopRequested = false;

public void Stop()
{
_stopRequested = true;
}

public void Run()
{
while (!_stopRequested)
{
// 処理
}
}
}

ただし、volatileは万能ではありません。counter++のような複合操作を安全にするものではありません。

C#
private volatile int counter;

// これはvolatileでも安全とは限らない
counter++;

複合操作にはlockInterlockedを使う必要があります。

6-5. スレッドセーフなコードを書く基本ルール

スレッドセーフなコードを書くには、次の考え方が重要です。

まず、共有データをできるだけ減らします。複数スレッドで同じ変数を触らなければ、競合は起きにくくなります。

次に、共有データを操作する場合は、lockInterlocked、スレッドセーフなコレクションなどを使います。

また、ロックの範囲はできるだけ短くします。ロック中に時間のかかる処理、外部API呼び出し、ファイルI/O、UI操作などを行うと、パフォーマンス低下やデッドロックの原因になります。

さらに、ロックを複数使う場合は、取得順序を統一します。

C#
// 常に lockA → lockB の順番にする
lock (lockA)
{
lock (lockB)
{
// 処理
}
}

スレッドセーフな設計では、「どのデータが共有されるのか」「誰がいつ書き換えるのか」「同時実行されたときに壊れないか」を常に意識する必要があります。

7. Threadを安全に停止する方法

7-1. Thread.Abortを使ってはいけない理由

昔のC#ではThread.Abortを使ってスレッドを強制終了するコードが見られました。しかし、現在は使うべきではありません。Microsoftのドキュメントでは、Thread.Abortは obsolete とされており、.NET 5以降および.NET Coreでは実行時にPlatformNotSupportedExceptionをスローすると説明されています。

強制終了が危険な理由は、スレッドがどの処理の途中で止まるかわからないからです。

たとえば、次のような問題が起きる可能性があります。

  • ファイル書き込みの途中で止まる

  • ロックを持ったまま止まる

  • データが中途半端な状態で残る

  • finallyや後始末処理が意図どおり動かない

  • アプリ全体が不安定になる

そのため、スレッドを止めたい場合は「外側から強制的に殺す」のではなく、「スレッド自身に終了してもらう」設計にします。

7-2. フラグを使ってスレッドに終了を通知する

基本的な停止方法は、終了要求を表すフラグを用意し、スレッド側で定期的に確認する方法です。

C#
using System;
using System.Threading;

class Program
{
static volatile bool stopRequested = false;

static void Main()
{
Thread thread = new Thread(Work);
thread.Start();

Thread.Sleep(3000);

stopRequested = true;
thread.Join();

Console.WriteLine("終了しました");
}

static void Work()
{
while (!stopRequested)
{
Console.WriteLine("処理中...");
Thread.Sleep(500);
}

Console.WriteLine("終了要求を受け取りました");
}
}

この方法では、メインスレッドがstopRequested = trueにし、ワーカースレッドがそれを見て自分でループを抜けます。

ポイントは、スレッドの外から無理やり止めるのではなく、スレッド側が安全なタイミングで終了することです。

7-3. CancellationTokenを使った安全なキャンセル方法

より現代的で推奨される方法は、CancellationTokenを使うことです。CancellationTokenは、スレッド、スレッドプールの作業項目、Taskなどの間で協調的なキャンセルを可能にする仕組みです。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
using CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Thread thread = new Thread(() => Work(token));
thread.Start();

Thread.Sleep(3000);

cts.Cancel();
thread.Join();

Console.WriteLine("終了しました");
}

static void Work(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("処理中...");
Thread.Sleep(500);
}

Console.WriteLine("キャンセル要求を受け取りました");
}
}

CancellationTokenのキャンセルは「協調的キャンセル」です。つまり、キャンセル要求を出しただけで処理が自動的に止まるわけではありません。処理側がIsCancellationRequestedを確認して終了する必要があります。Microsoftのドキュメントでも、キャンセル要求は必要なクリーンアップ後に操作をできるだけ早く停止すべきことを意味すると説明されています。

7-4. Joinで終了を待ってリソースを解放する

キャンセル要求を出した後は、Joinでスレッドの終了を待つと安全です。

C#
cts.Cancel();
thread.Join();

Cancelは「終了してください」と通知するだけです。実際に終了したことを確認するにはJoinが必要です。

特に次のような後処理がある場合は、スレッド終了を待つことが重要です。

  • ファイルを閉じる

  • DB接続を解放する

  • ログを書き終える

  • 一時データを削除する

  • 共有状態を整える

終了要求、ループ脱出、後処理、Joinの順番を意識すると、安全なスレッド停止を実装しやすくなります。

7-5. 無限ループ処理を安全に止める実装例

常駐処理や監視処理では、while (true)のような無限ループを書くことがあります。ただし、本当に抜け道のない無限ループにしてしまうと、安全に停止できません。

悪い例です。

C#
while (true)
{
Console.WriteLine("監視中...");
Thread.Sleep(1000);
}

改善例です。

C#
using System;
using System.Threading;

class MonitorWorker
{
private readonly CancellationToken _token;

public MonitorWorker(CancellationToken token)
{
_token = token;
}

public void Run()
{
try
{
while (!_token.IsCancellationRequested)
{
Console.WriteLine("監視中...");
Thread.Sleep(1000);
}
}
finally
{
Console.WriteLine("後処理を実行します");
}
}
}

class Program
{
static void Main()
{
using CancellationTokenSource cts = new CancellationTokenSource();

MonitorWorker worker = new MonitorWorker(cts.Token);
Thread thread = new Thread(worker.Run);

thread.Start();

Thread.Sleep(5000);

cts.Cancel();
thread.Join();

Console.WriteLine("監視処理を停止しました");
}
}

このように、ループ条件にキャンセル確認を入れ、finallyで後処理を書くと、安全に終了しやすくなります。

8. ThreadとThreadPoolの違い

8-1. ThreadPoolとは何か

ThreadPoolは、あらかじめ管理されたスレッドの集まりです。毎回新しいThreadを作成するのではなく、共用のスレッドを使って処理を実行します。

Microsoftのドキュメントでは、.NETのスレッドプールはスループットを最適化するためにワーカースレッドを作成・破棄すると説明されています。

ThreadPoolを直接使う例は次のとおりです。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine("ThreadPoolで実行");
});

Console.ReadLine();
}
}

ただし、現在はThreadPool.QueueUserWorkItemを直接使うより、Task.Runを使うことが多いです。

8-2. ThreadPoolを使うメリット

ThreadPoolを使うメリットは、スレッド作成コストを抑えられることです。

Threadを直接作る場合、作成や破棄にコストがかかります。短い処理を大量に実行する場合、毎回Threadを作成すると効率が悪くなります。

一方、ThreadPoolでは既存のスレッドを再利用できるため、短時間の処理を多数実行するのに向いています。

C#
for (int i = 0; i < 10; i++)
{
int number = i;

ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine($"処理: {number}");
});
}

8-3. ThreadとThreadPoolの使い分け

ThreadThreadPoolの使い分けは、処理の性質で考えます。

Threadが向いているのは、長時間動作する専用スレッドが必要な場合です。たとえば、常駐監視、専用のメッセージループ、特定のスレッド設定が必要な処理などです。

ThreadPoolが向いているのは、短時間の作業を多数実行したい場合です。たとえば、小さな計算、短いバックグラウンド処理、並行実行したい作業などです。

ただし、実務ではThreadPoolを直接使うより、Task.RunやTPLを通して使うことが多いです。

8-4. Task.RunがThreadPoolを利用する仕組み

Task.Runは、指定された処理をThreadPoolにキューイングし、その処理を表すTaskを返します。

C#
Task task = Task.Run(() =>
{
Console.WriteLine("ThreadPool上で実行される");
});

await task;

Task.Runを使うと、スレッドの作成や再利用を自分で管理しなくて済みます。また、awaitで完了待ちができ、例外も扱いやすくなります。

C#
try
{
await Task.Run(() =>
{
throw new Exception("エラー");
});
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

このように、Task.RunThreadPoolの便利なラッパーとして使えます。

8-5. 長時間処理でThreadPoolを使うときの注意点

ThreadPoolは便利ですが、長時間ブロックする処理を大量に入れると、スレッドプールのスレッドが埋まり、他の処理が遅くなることがあります。

悪い例です。

C#
for (int i = 0; i < 100; i++)
{
Task.Run(() =>
{
Thread.Sleep(60000); // 長時間ブロック
});
}

ThreadPoolのスレッドを長時間占有すると、他のTask.Runや内部処理にも影響する場合があります。

長時間ずっと動く専用処理であれば、明示的なThreadを検討することがあります。ただし、多くの場合は、非同期API、キュー、ChannelBackgroundServiceなど、用途に合った設計を選ぶほうが安全です。

9. 実践例で学ぶC# Threadの使い方

9-1. 重い計算処理を別スレッドで実行する例

次の例では、重い計算処理を別スレッドで実行します。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread = new Thread(() =>
{
long result = HeavyCalculation();
Console.WriteLine($"計算結果: {result}");
});

thread.Start();

Console.WriteLine("メインスレッドは別の処理を続行できます");

thread.Join();
Console.WriteLine("終了");
}

static long HeavyCalculation()
{
long sum = 0;

for (int i = 0; i < 100000000; i++)
{
sum += i;
}

return sum;
}
}

このコードでは、重い計算を別スレッドに任せています。ただし、戻り値をメインスレッドで扱いたい場合は、共有変数や同期が必要になります。その点ではTask<long>のほうが自然です。

C#
long result = await Task.Run(() => HeavyCalculation());
Console.WriteLine(result);

9-2. 複数の処理を並行して実行する例

複数のThreadを作成すると、複数の処理を並行して動かせます。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread t1 = new Thread(() => DoWork("A"));
Thread t2 = new Thread(() => DoWork("B"));
Thread t3 = new Thread(() => DoWork("C"));

t1.Start();
t2.Start();
t3.Start();

t1.Join();
t2.Join();
t3.Join();

Console.WriteLine("すべて完了");
}

static void DoWork(string name)
{
for (int i = 1; i <= 3; i++)
{
Console.WriteLine($"{name}: {i}");
Thread.Sleep(1000);
}
}
}

ただし、単純な並行処理であれば、Task.WhenAllのほうが簡潔です。

C#
Task t1 = Task.Run(() => DoWork("A"));
Task t2 = Task.Run(() => DoWork("B"));
Task t3 = Task.Run(() => DoWork("C"));

await Task.WhenAll(t1, t2, t3);

9-3. 処理の進捗を表示する例

コンソールアプリで進捗を表示する例です。

C#
using System;
using System.Threading;

class Program
{
static int progress = 0;
static readonly object lockObj = new object();

static void Main()
{
Thread worker = new Thread(Work);
worker.Start();

while (worker.IsAlive)
{
lock (lockObj)
{
Console.WriteLine($"進捗: {progress}%");
}

Thread.Sleep(500);
}

Console.WriteLine("完了");
}

static void Work()
{
for (int i = 1; i <= 100; i++)
{
lock (lockObj)
{
progress = i;
}

Thread.Sleep(50);
}
}
}

複数スレッドでprogressを読み書きするため、lockで保護しています。単純な整数の更新であればInterlocked.Exchangeを使う方法もあります。

C#
Interlocked.Exchange(ref progress, i);

9-4. キャンセル可能なバックグラウンド処理の例

次の例では、CancellationTokenを使ってバックグラウンド処理をキャンセルできるようにしています。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
using CancellationTokenSource cts = new CancellationTokenSource();

Thread thread = new Thread(() =>
{
BackgroundWork(cts.Token);
});

thread.Start();

Console.WriteLine("Enterキーで停止します");
Console.ReadLine();

cts.Cancel();
thread.Join();

Console.WriteLine("停止しました");
}

static void BackgroundWork(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("バックグラウンド処理中...");
Thread.Sleep(1000);
}

Console.WriteLine("キャンセルを検知しました");
}
}

このパターンは、監視処理、定期実行処理、バックグラウンド処理の基本形として理解しやすいです。

9-5. ThreadからTaskへ書き換える例

Threadで書いたコードをTaskへ書き換えると、より現代的な形になります。

Thread版です。

C#
Thread thread = new Thread(() =>
{
Console.WriteLine("処理中");
Thread.Sleep(1000);
});

thread.Start();
thread.Join();

Console.WriteLine("完了");

Task版です。

C#
await Task.Run(() =>
{
Console.WriteLine("処理中");
Thread.Sleep(1000);
});

Console.WriteLine("完了");

キャンセル可能にする場合は次のように書けます。

C#
using CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task task = Task.Run(() =>
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("処理中");
Thread.Sleep(500);
}
}, token);

cts.Cancel();

await task;

ただし、このコードではThread.Sleep中はキャンセルにすぐ反応できません。非同期処理ならTask.DelayCancellationTokenを渡すほうが自然です。

C#
using CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task task = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("処理中");
await Task.Delay(500, token);
}
}, token);

cts.Cancel();

try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました");
}

10. C#初心者がThreadを学ぶときのよくある疑問

10-1. Threadは今でも使うべきか

Threadは今でも使える仕組みですが、日常的な非同期処理や並行処理では、まずTaskasync/awaitを検討するのがおすすめです。

Threadを使うべき場面は、スレッドそのものを細かく制御したい場合です。たとえば、長時間動く専用スレッドが必要な場合、スレッド名や優先度を設定したい場合、特定のアパートメント状態が必要な場合などです。

一方、単に「重い処理を裏で動かしたい」「複数処理を同時に実行したい」「非同期で待ちたい」という目的であれば、Taskのほうが扱いやすいです。

10-2. ThreadとTaskはどちらを先に学ぶべきか

初心者には、まずTaskasync/awaitを学ぶことをおすすめします。実務で使う頻度が高く、戻り値、例外、キャンセル、複数処理の待機を扱いやすいからです。

ただし、Threadの基礎も理解しておくべきです。Task.Runが内部でThreadPoolを使うこと、async/awaitが必ずしも新しいスレッドを作るわけではないこと、UIフリーズがなぜ起こるのかを理解するには、スレッドの知識が必要です。

学ぶ順番としては、次の流れがわかりやすいです。

  1. 同期処理とブロッキングを理解する

  2. Threadの基本を理解する

  3. Taskを理解する

  4. async/awaitを理解する

  5. lockCancellationTokenを理解する

10-3. async/awaitを使えばThreadは不要なのか

async/awaitを使えばThreadが完全に不要になるわけではありません。

async/awaitは非同期処理を扱いやすくする構文であり、スレッドを直接制御するための仕組みではありません。Microsoftのドキュメントでも、asyncawaitは追加スレッドを作成しないと説明されています。

I/O待ちにはasync/awaitが非常に有効です。一方、CPUを使う重い処理をUIスレッドから逃がしたい場合は、Task.Runでバックグラウンドスレッドに移すことがあります。

C#
// I/Oバウンド
string result = await httpClient.GetStringAsync(url);

// CPUバウンド
int value = await Task.Run(() => HeavyCalculation());

つまり、async/awaitTaskThreadはそれぞれ役割が異なります。

10-4. マルチスレッドと非同期処理は同じ意味なのか

マルチスレッドと非同期処理は同じ意味ではありません。

マルチスレッドは、複数のスレッドを使って処理することです。ThreadThreadPoolTask.RunによるCPU処理などが関係します。

非同期処理は、ある処理の完了を待っている間に、呼び出し元をブロックしないようにする考え方です。HTTP通信やファイルI/Oなどでは、必ずしも専用スレッドを占有する必要はありません。

たとえば、await httpClient.GetStringAsync(url)は非同期処理ですが、「新しいThreadを作ってそこで待つ」という意味ではありません。

この違いを理解すると、Thread.SleepTask.Delayの違い、ThreadTaskの違い、UIフリーズの原因がわかりやすくなります。

10-5. Threadでエラーが出たときの確認ポイント

Threadでエラーが出た場合は、まず次の点を確認します。

スレッド内で発生した例外を、そのスレッド内でtry-catchしているか確認します。メインスレッド側のtry-catchでは捕まえられない例外があります。

共有データを複数スレッドから同時に操作していないか確認します。List<T>Dictionary<TKey, TValue>などを複数スレッドから同時に更新すると、例外やデータ破損の原因になります。

UI部品を別スレッドから直接操作していないか確認します。Windows FormsやWPFでは、UI更新はUIスレッドに戻して行います。

Thread.Abortを使っていないか確認します。現在の.NETではThread.Abortは使うべきではなく、CancellationTokenや終了フラグで安全に止めます。

Joinで待機している場所がUIスレッドではないか確認します。UIスレッドで長時間Joinすると画面が固まります。

スレッドを大量に作成していないか確認します。大量の短時間処理には、ThreadではなくTaskThreadPoolを検討します。

まとめ

C#のThreadは、メイン処理とは別の実行経路で処理を動かすための基本的な仕組みです。Threadを使うと、重い処理を別スレッドで実行したり、複数の処理を並行して動かしたりできます。

一方で、Threadを直接扱う場合は、競合、デッドロック、例外処理、停止処理、UIフリーズ、スレッド作成コストなどに注意が必要です。特に、共有データを扱う場合はlockInterlockedを使い、停止にはThread.Abortではなく、フラグやCancellationTokenを使うことが重要です。

現代のC#では、通常の非同期処理にはTaskasync/awaitを使うのが基本です。Task.RunThreadPoolを利用して処理を実行し、awaitによって完了待ちや例外処理を自然に書けます。

初心者はまず、Threadでスレッドの基本を理解し、そのうえでTaskasync/awaitCancellationTokenlockを学ぶと、C#の非同期処理とマルチスレッド処理を体系的に理解できます。