1. はじめに
ここでしっかり整理しましょう — Task と Thread は根本的に何が違うのか? なぜ C# は長年にわたりスレッドを直接操作するよりも Task を使うことを推奨しているのか? どんな状況で手動でスレッドを使い続けられるのか、またどんなときに Task スタイルで十分(かつ推奨)なのか?
「スレッド」と「タスク」が頭の中でごちゃ混ぜになって心拍が早くなってきたなら安心して。並列処理や非同期処理については、経験豊富な開発者でも混乱することがあります。
では、順序立てて説明していきます。行きましょう!
Task の登場の短い歴史
昔々(.NET 4.0 以前)、コードを並列に、または「バックグラウンド」で実行する明白な方法は新しいスレッドを作ることだけでした。例えば、new Thread(() => { ... }).Start();。スレッドはシンプルで素晴らしいですが、その分すべてが開発者の肩にかかっていました。リソース割り当て、ライフサイクル、例外処理、同期、監視、スケーリング――全部自分でやらないといけません。プログラミングではもっと怠けたいものですよね!
タスク、つまり Task(名前空間 System.Threading.Tasks.Task)が登場して状況は変わりました。タスクはスレッドではありません。もっと抽象的で柔軟な概念で、「いつか実行される仕事」を表します。場合によっては並列で実行されます。
2. Thread — 「生のスレッド」
スレッド は低レベルの実行単位で、OS のリソース(専用スタック、実行コンテキストなど)を割り当てられます。手動でスレッドを作ると、その起動、終了、ライフサイクルの管理はあなたの責任になります。
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread thread = new Thread(() => {
Console.WriteLine("スレッドからこんにちは!");
});
thread.Start();
thread.Join(); // スレッドの終了を待つ
}
}
- ここでは独自のスタック上でラムダを実行するスレッドを作成しています。
- スレッドを起動した後、Join() を呼んでその終了を待ちます。
注意点は何か?
- 各スレッドはメモリ(スタック、約 1MB)を消費します。
- .NET では手動で数千のスレッドを作るのは推奨されません — システムが苦しみます。
- Join() を忘れると、メインスレッドが子スレッドより先に終了してプログラムが途中で切れることがあります。
- スレッド内の例外は外側に出ないので、明示的にキャッチする必要があります。
- 一度起動したスレッドを「綺麗に」止める標準的な方法はありません(Stop() のようなメソッドはない)!
3. Task — 「次世代のタスク」
Task は「いつか実行される仕事」を表す、より賢い抽象化です。内部的にはタスクは通常 ThreadPool 上で実行され、余分なスレッドを無駄に増やすより効率的です。スレッドの作成は自分で行わず、プールが負荷に応じてスレッド数を調整してくれます。
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task task = Task.Run(() =>
{
Console.WriteLine("Task からこんにちは!");
});
await task; // タスクの完了を待つ
}
}
- ここではタスクが必ず別スレッドで実行されるとは限りませんが、通常はスレッドプールのスレッドで実行されます。
- タスクの完了を待つには async メソッド内で await するか、同期的に task.Wait() を使います。
4. Task と Thread の違いは?
違いを整理して、何に使うべきか、そして意外な落とし穴を挙げます。
| Thread | Task | |
|---|---|---|
| 抽象化 | OS スレッド | 仕事/タスク(スレッドを使うこともある抽象) |
| 起動 | via new Thread(...).Start() | Task.Run(...), Task.Factory.StartNew(...), async メソッド経由 |
| 直接制御 | 可能(Start、Join、優先度など) | いいえ、.NET が管理する |
| スレッドプール | いいえ、新たに作られる | はい、通常は ThreadPool を使う |
| リソース管理 | 専用スタックが割り当てられる | プールがリソースを再利用する |
| スケーラビリティ | 悪い: 1000 以上のスレッドは非効率 | 優秀: 数千のタスクは問題ない |
| 振る舞い | OS 観点で独立したスレッド | 呼び出し元のスレッドの続行か、ThreadPool 上か、状況次第 |
| 例外 | 明示的にキャッチしないと消える可能性 | 例外は Task に保存され、await や .Wait() で捕捉できる |
| キャンセル | 標準的な方法はない | はい、CancellationToken でサポート |
| 結果の取得 | Join() で待つ | await、.Wait()、.Result |
| 使用用途 | 特殊ケース — UI スレッド、長寿命スレッド | ほとんどのバックグラウンド/並列タスク |
5. いつ何を使うべきか?
いつ Thread を使う?
正直なところ、現代の .NET コードで手動でスレッドを作る必要があるケースは非常に稀です。以下のような場合に正当化されます:
- 非常に長期間動作する専用スレッドが必要なとき(例: ラジオ信号のシリアライズ、ハードウェアからのデータ処理など)で、そのスレッドが「特別」:低優先度、独自のカルチャ、独自の名前が必要なとき。
- スレッドの手動管理を要求する低レベル API と統合する場合。
- 非常に特殊なケース、例えばカスタムタスクスケジューラなど。
それ以外のほとんどの場合は Task を選ぶのがモダンで正しい選択です。
いつ Task を使う?
「バックグラウンド」や「並列」で仕事をしたいほとんどすべてのケースで:
- スレッドプール上で実行可能な任意のバックグラウンド計算(例: サーバーのリクエスト処理、ファイル解析、メール送信)。
- 非同期操作の開始(async/await) — API は Task または Task<T> を返します。
- タスクの合成、継続処理(continuations)、チェーン処理。
- キャンセル、待機、結果収集が簡単: Task は CancellationToken をサポートし、最新の API と統合しやすい。
- I/O の非同期操作: ネットワーク、ファイル、DB など。
比較
| シナリオ | Thread | Task |
|---|---|---|
| 長期間実行するスレッド(例: 独自のサービス) | はい | いいえ |
| 多数の短いタスクの実行 | いいえ | はい |
| 非同期 I/O 操作(await) | いいえ | はい |
| タスクの合成、キャンセル、チェーン | いいえ | はい |
| 優先度やカルチャの微調整 | はい(ただし稀) | いいえ、デフォルトのタスクのみ |
| 単純に CPU をコア間で分割するだけ | 時々 | はい |
6. 便利なニュアンス
Task は必ずしもスレッドではない!
最も強力なポイント: 非同期 I/O に Task を使う場合、新しいスレッドはまったく作られないことがあります! 多くの処理は IO Completion Ports やプラットフォーム固有の仕組みによって処理されます。待機中はスレッドは占有されません。
Task と非同期(I/O-bound)— await の魔法
using System;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// 非同期でサイトの内容をダウンロードする(I/O-bound)
HttpClient client = new HttpClient();
string data = await client.GetStringAsync("https://www.dotnetfoundation.org");
Console.WriteLine($"受信した文字数: {data.Length}");
}
}
- ここではタスク(Task<string>)が非同期 I/O 操作をカプセル化しています。
- スレッドはブロックされず、ダウンロード完了後にメソッドの実行が再開されます。
- このような処理に手動でスレッドを作るのは冗長で非効率です。
Task と ThreadPool
Task.Run(...) を呼んだり、非同期 API(await)を使うと、.NET は通常 ThreadPool を利用します。ThreadPool は予め作られたスレッドの集合で、待機しているスレッドがすぐに仕事を引き受けられるようにしています。負荷が少なければスレッドはアイドル、負荷が増えればスレッド数は自動的に増えますが賢く制御されます。これにより多数のタスクを扱ってもシステムへの不要な負担を避けられます。
一方で new Thread で作られたスレッドは通常「戻らない」独立した存在で、終了すると死にます。だから大量並列処理には Task の方が効率的です。
7. 典型的なミスと落とし穴
レトロに戻ってすべてスレッドで書こうとすると、素敵な冒険が待っています:メモリリーク、複雑な同期、キャンセル不能な仕事、ハングしたゾンビスレッド、エラー処理の煩雑さなど。
覚えておくべき大事なこと: 「Task」は便利で、安全でモダンです。今日の C# 開発において、手動でスレッド管理に戻る理由はほとんどありません。
GO TO FULL VERSION