C#のスレッド入門:Threadの使い方からTask・asyncとの違いまでわかりやすく解説

はじめに

C#で時間のかかる処理を実行すると、アプリケーションが固まったように見えることがあります。たとえば、ファイルの変換や大量データの計算をメインスレッド上で実行すると、その処理が終わるまで画面操作や別の処理を進められません。

このような場面で役立つのが、処理を並行して動かす「スレッド」です。C#では、System.Threading.Threadを使ってスレッドを直接作成できます。

ただし、現在のC#では、すべての並行処理にThreadを使うわけではありません。一般的なバックグラウンド処理にはTask、通信やファイルアクセスなどの待ち時間が発生する処理にはasyncawaitを使うのが基本です。

この記事では、C#のスレッドの仕組みからThreadの基本的な使い方、排他制御、ThreadPoolTaskasyncawaitとの違いまで、サンプルコードを交えて解説します。

1. C#のスレッドとは?まず押さえる基本

1-1. スレッドとは何か:処理を並行して動かす仕組み

スレッドとは、プログラム内で処理を実行する流れのことです。

通常、C#のプログラムを起動すると、最初にメインスレッドが作られます。メインスレッドだけを使う場合、基本的には処理が上から順番に実行されます。

Console.WriteLine("処理1");Console.WriteLine("処理2");Console.WriteLine("処理3");

一方、複数のスレッドを使うと、異なる処理を並行して進められます。

メインスレッド:画面操作を受け付ける別スレッド  :大量データを計算する

ただし、「並行」と「同時」は必ずしも同じではありません。

CPUコアが1つの場合、OSが複数のスレッドを短い間隔で切り替えることで、同時に動いているように見せます。CPUコアが複数ある場合は、複数のスレッドが実際に同時実行されることもあります。

1-2. プロセスとスレッドの違い

プロセスは、実行中のアプリケーションを管理する単位です。スレッドは、そのプロセス内で処理を実行する単位です。

1つのプロセスには、1つ以上のスレッドが存在します。

プロセス├── メインスレッド├── ワーカースレッド1└── ワーカースレッド2

プロセス同士は、通常、それぞれ独立したメモリ空間を持ちます。そのため、異なるプロセス間でデータを共有するには、プロセス間通信などの仕組みが必要です。

一方、同じプロセス内のスレッドは、フィールドやオブジェクトなどのメモリを共有できます。データを共有しやすい反面、複数のスレッドが同時に同じデータを書き換えると、競合状態が発生する可能性があります。

1-3. C#でスレッドを使う代表的な場面

C#でスレッドが関係する代表的な場面には、次のようなものがあります。

  • 大量の計算をバックグラウンドで実行する

  • 複数の処理を並行して進める

  • UIアプリケーションの操作不能を防ぐ

  • 常駐監視処理を実行する

  • 専用スレッドが必要な外部ライブラリを利用する

  • スレッドの優先度やバックグラウンド属性を細かく制御する

ただし、単に「画面を固めずに処理したい」「非同期でAPIを呼び出したい」という目的であれば、ThreadよりもTaskasyncawaitが適している場合がほとんどです。

1-4. スレッドを使うメリット・デメリット

スレッドを使う主なメリットは、処理を並行化できることです。メインスレッドとは別に重い処理を実行すれば、UIの応答性を保ちやすくなります。CPUコアを有効活用できる処理では、全体の実行時間を短縮できる可能性もあります。

一方、スレッドには次のようなデメリットがあります。

  • スレッドの作成や切り替えにコストがかかる

  • 実行順序が一定ではない

  • 共有データの競合が起こる

  • デッドロックが発生する可能性がある

  • 例外処理やキャンセル処理が複雑になる

  • スレッド数を増やしすぎると、かえって遅くなる

そのため、C#のスレッド処理では、必要以上にThreadを直接作らないことが重要です。

2. C#でThreadを使う基本手順

2-1. System.Threading.Threadとは

Threadは、C#でOSスレッドを直接作成・制御するためのクラスです。名前空間はSystem.Threadingです。

using System.Threading;

Threadを使うと、次のような操作ができます。

  • 新しいスレッドを作成する

  • スレッドを開始する

  • スレッドの終了を待つ

  • スレッド名を設定する

  • バックグラウンドスレッドにする

  • 優先度を設定する

  • 現在実行中のスレッドを取得する

Threadオブジェクトを作成しただけでは、処理は開始されません。Startメソッドを呼び出すことで、新しいスレッド上で処理が実行されます。

2-2. Threadを作成してStartで実行する基本コード

次の例では、Workerメソッドを別スレッドで実行しています。

using System;using System.Threading;

class Program{static void Main(){Thread thread = new Thread(Worker);

    thread.Start();Console.WriteLine("メインスレッドの処理");}static void Worker(){Console.WriteLine("別スレッドの処理");}

}

new Thread(Worker)で、スレッド上で実行するメソッドを指定します。続いてthread.Start()を呼び出すと、新しいスレッドが開始されます。

メインスレッドと新しく作成したスレッドは並行して動くため、出力順序は毎回同じとは限りません。

2-3. ラムダ式を使ってスレッドを起動する方法

実行する処理が短い場合は、ラムダ式を使うと簡潔に記述できます。

using System;using System.Threading;

class Program{static void Main(){Thread thread = new Thread(() =>{Console.WriteLine("ラムダ式で実行した処理");});

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

}

1回しか使わない短い処理であれば、別メソッドを定義する必要がありません。

ただし、処理が長くなる場合は、可読性やテストのしやすさを考えて、専用メソッドに分けたほうがよいでしょう。

2-4. スレッドに引数を渡す方法

ラムダ式で変数をキャプチャすると、型を保ったままスレッドに値を渡せます。

using System;using System.Threading;

class Program{static void Main(){string message = "別スレッドに渡す文字列";int count = 3;

    Thread thread = new Thread(() =>{for (int i = 0; i < count; i++){Console.WriteLine(message);}});thread.Start();thread.Join();}

}

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

using System;using System.Threading;

class Program{static void Main(){Thread thread = new Thread(ShowMessage);thread.Start("こんにちは");thread.Join();}

static void ShowMessage(object? value){if (value is string message){Console.WriteLine(message);}}

}

ただし、ParameterizedThreadStartで渡される値はobject型です。キャストや型チェックが必要になるため、通常はラムダ式を使う方法のほうが型安全です。

2-5. Joinでスレッドの終了を待つ方法

Joinメソッドは、対象のスレッドが終了するまで、現在のスレッドを待機させます。

using System;using System.Threading;

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

    thread.Start();Console.WriteLine("終了を待ちます");thread.Join();Console.WriteLine("すべての処理が完了しました");}

}

この例では、メインスレッドがthread.Join()で待機するため、最後のメッセージは別スレッドの終了後に表示されます。

待機時間を指定することもできます。

bool completed = thread.Join(3000);

if (!completed){Console.WriteLine("3秒以内に処理が終了しませんでした");}

戻り値がtrueならスレッドは時間内に終了し、falseならタイムアウトしています。

2-6. Sleepで処理を一時停止する方法

Thread.Sleepを使うと、現在のスレッドを指定時間だけ停止できます。

Console.WriteLine("開始");

Thread.Sleep(1000);

Console.WriteLine("1秒後");

引数の単位はミリ秒です。TimeSpanも指定できます。

Thread.Sleep(TimeSpan.FromSeconds(1));

Thread.Sleepは、そのスレッドを完全に待機させる同期的な処理です。UIスレッドで実行すると画面が操作不能になり、スレッドプール上で多用すると利用可能なスレッドを占有します。

非同期処理の待機では、通常はThread.SleepではなくTask.Delayを使います。

await Task.Delay(1000);

Task.Delayは待機中にスレッドを占有しないため、I/O待ちや一定時間後の再実行に向いています。

3. Threadのよく使うプロパティとメソッド

3-1. Thread.CurrentThreadで現在のスレッドを取得する

Thread.CurrentThreadを使うと、現在処理を実行しているスレッドを取得できます。

using System;using System.Threading;

class Program{static void Main(){Console.WriteLine($"メインスレッドID: {Thread.CurrentThread.ManagedThreadId}");

    Thread thread = new Thread(() =>{Console.WriteLine($"別スレッドID: {Thread.CurrentThread.ManagedThreadId}");});thread.Start();thread.Join();}

}

ManagedThreadIdは、マネージドスレッドを識別するためのIDです。ログに出力すると、どのスレッドで処理されたかを確認しやすくなります。

ただし、スレッドIDを業務上の識別子として保存したり、特定の値になることを前提にしたりするべきではありません。

3-2. Nameでスレッド名を設定する

Nameプロパティを使うと、スレッドに名前を付けられます。

using System;using System.Threading;

class Program{static void Main(){Thread thread = new Thread(() =>{Console.WriteLine($"スレッド名: {Thread.CurrentThread.Name}");});

    thread.Name = "DataProcessingThread";thread.Start();thread.Join();}

}

スレッド名は、デバッグやログ解析に役立ちます。

一般的には、同じスレッドのNameを何度も変更するのではなく、スレッド作成時に分かりやすい名前を設定します。

3-3. IsAliveでスレッドの実行状態を確認する

IsAliveプロパティは、スレッドが開始済みで、まだ終了していないかどうかを示します。

using System;using System.Threading;

class Program{static void Main(){Thread thread = new Thread(() =>{Thread.Sleep(1000);});

    Console.WriteLine(thread.IsAlive); // Falsethread.Start();Console.WriteLine(thread.IsAlive); // 通常はTruethread.Join();Console.WriteLine(thread.IsAlive); // False}

}

ただし、IsAliveを確認した直後にスレッドの状態が変わる可能性があります。そのため、IsAliveの結果だけを頼りに厳密な同期制御を行うのは避けましょう。

終了を確実に待つ場合はJoinを使用します。

3-4. Priorityでスレッドの優先度を設定する

Priorityプロパティを使うと、スレッドの優先度を指定できます。

Thread thread = new Thread(() =>{Console.WriteLine("処理を実行");});

thread.Priority = ThreadPriority.AboveNormal;thread.Start();thread.Join();

指定できる主な値は次のとおりです。

  • Lowest

  • BelowNormal

  • Normal

  • AboveNormal

  • Highest

優先度は、OSのスレッドスケジューラーに対する目安です。Highestを指定したからといって、必ず最初に実行されたり、処理時間が短くなったりするわけではありません。

不適切に高い優先度を設定すると、ほかの処理が実行されにくくなる可能性があります。特別な理由がなければ、既定値のNormalを使用するのが安全です。

3-5. BackgroundスレッドとForegroundスレッドの違い

C#のスレッドには、フォアグラウンドスレッドとバックグラウンドスレッドがあります。

通常、Threadで作成したスレッドはフォアグラウンドスレッドです。フォアグラウンドスレッドが1つでも動いている間、プロセスは終了しません。

バックグラウンドスレッドにするには、IsBackgroundプロパティをtrueにします。

Thread thread = new Thread(() =>{while (true){Console.WriteLine("監視中");Thread.Sleep(1000);}});

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

バックグラウンドスレッドだけが残った場合、プロセスは終了できます。その際、バックグラウンドスレッドの処理は途中で終了する可能性があります。

したがって、ファイル保存やデータベース更新など、確実な完了が必要な処理をバックグラウンドスレッド任せにするのは危険です。

4. C#のスレッド処理で注意すべき問題

4-1. 共有変数への同時アクセスで起こる競合状態

複数のスレッドが同じ変数を同時に更新すると、期待した結果にならないことがあります。この問題を競合状態、またはレースコンディションと呼びます。

using System;using System.Threading;

class Program{private static int _counter;

static void Main(){Thread thread1 = new Thread(Increment);Thread thread2 = new Thread(Increment);thread1.Start();thread2.Start();thread1.Join();thread2.Join();Console.WriteLine(_counter);}static void Increment(){for (int i = 0; i < 100_000; i++){_counter++;}}

}

理論上は200000になるはずですが、実際にはそれより小さな値になることがあります。

_counter++は、内部的には単純な1操作ではありません。おおよそ次の処理に分かれます。

  1. 現在の値を読み取る

  2. 値に1を加える

  3. 新しい値を書き込む

複数のスレッドが同じ値を読み取って更新すると、一方の更新結果が失われます。

4-2. lockを使って排他制御する方法

lockを使うと、同じロックオブジェクトに対して、同時に1つのスレッドだけが処理を実行できるようになります。

using System;using System.Threading;

class Program{private static int _counter;private static readonly object LockObject = new();

static void Main(){Thread thread1 = new Thread(Increment);Thread thread2 = new Thread(Increment);thread1.Start();thread2.Start();thread1.Join();thread2.Join();Console.WriteLine(_counter);}static void Increment(){for (int i = 0; i < 100_000; i++){lock (LockObject){_counter++;}}}

}

lock内の処理を実行しているスレッドがある間、ほかのスレッドはロックが解放されるまで待機します。

ロック用のオブジェクトには、外部からアクセスできない専用オブジェクトを使うのが基本です。

private readonly object _syncRoot = new();

this、文字列、公開オブジェクトなどをロック対象にすると、意図しない場所とロックが競合する可能性があります。

単純な数値の加算や減算であれば、Interlockedを使う方法もあります。

Interlocked.Increment(ref _counter);

短い単純操作では、lockよりInterlockedが適している場合があります。

4-3. Monitor・Mutex・SemaphoreSlimの使い分け

C#には、lock以外にも複数の同期方法があります。

Monitorは、lockの基礎となる仕組みです。EnterExitを明示的に呼び出せます。

bool lockTaken = false;

try{Monitor.Enter(LockObject, ref lockTaken);

// 排他的に実行する処理

}finally{if (lockTaken){Monitor.Exit(LockObject);}}

通常の排他制御では、記述が簡潔で安全なlockを優先します。タイムアウトやWaitPulseなど、より細かい制御が必要な場合にMonitorを検討します。

Mutexは、プロセスをまたいだ排他制御が可能です。

using Mutex mutex = new(false, "MyApplicationMutex");

mutex.WaitOne();

try{// 同時に1つだけ実行する処理}finally{mutex.ReleaseMutex();}

同じPC上でアプリケーションの二重起動を防止する用途などに使えます。ただし、OSの同期オブジェクトを利用するため、同一プロセス内だけで使うlockより負荷が高くなります。

SemaphoreSlimは、同時実行できる処理数を制限するために使います。

using System;using System.Threading;using System.Threading.Tasks;

class Program{private static readonly SemaphoreSlim Semaphore = new(2);

static async Task Main(){Task[] tasks ={RunAsync(1),RunAsync(2),RunAsync(3),RunAsync(4)};await Task.WhenAll(tasks);}static async Task RunAsync(int id){await Semaphore.WaitAsync();try{Console.WriteLine($"{id}: 開始");await Task.Delay(1000);Console.WriteLine($"{id}: 終了");}finally{Semaphore.Release();}}

}

この例では、同時に実行される処理を2つまでに制限しています。

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

  • 1プロセス内の単純な排他制御:lock

  • lockより細かい同期制御:Monitor

  • プロセスをまたぐ排他制御:Mutex

  • 同時実行数の制限:SemaphoreSlim

4-4. デッドロックが起こる原因と回避方法

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

private static readonly object LockA = new();private static readonly object LockB = new();

static void Method1(){lock (LockA){Thread.Sleep(100);

    lock (LockB){Console.WriteLine("Method1");}}

}

static void Method2(){lock (LockB){Thread.Sleep(100);

    lock (LockA){Console.WriteLine("Method2");}}

}

Method1LockAを取得してからLockBを待ちます。Method2LockBを取得してからLockAを待ちます。

それぞれが最初のロックを保持した状態になると、互いに次のロックを取得できません。

デッドロックを避ける主な方法は次のとおりです。

  • 複数のロックは必ず同じ順序で取得する

  • ロック中に時間のかかる処理をしない

  • ロック中に外部コードを呼び出さない

  • ロックの入れ子を減らす

  • 必要に応じてタイムアウト付きのロックを使う

  • 同期的な待機と非同期処理を不用意に混在させない

複数のロックが必要な設計では、取得順序をプロジェクト内で統一しておくことが重要です。

4-5. スレッドセーフなコードを書くポイント

スレッドセーフとは、複数のスレッドから同時に利用しても、データの破損や不整合が起こらない性質です。

スレッドセーフなコードを書くには、次の点を意識します。

  • 変更可能な共有データを減らす

  • 共有データへのアクセスを同期する

  • 不変オブジェクトを活用する

  • ConcurrentDictionaryなどの並行コレクションを使う

  • 単純な更新にはInterlockedを使う

  • ロックの責任範囲を明確にする

  • メソッド内のローカル変数を優先する

  • 読み取り専用データを活用する

たとえば、通常のDictionaryを複数スレッドから同時更新するのではなく、ConcurrentDictionaryを検討します。

using System.Collections.Concurrent;

ConcurrentDictionary<string, int> scores = new();

scores.AddOrUpdate("Alice",1,(_, currentValue) => currentValue + 1);

ただし、スレッドセーフなコレクションを使っても、複数操作をまとめた一連の処理まで自動的に安全になるとは限りません。処理全体で整合性が必要な場合は、別途同期方法を検討します。

5. Threadを使った実践サンプル

5-1. 重い処理を別スレッドで実行するサンプル

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

using System;using System.Threading;

class Program{static void Main(){long result = 0;

    Thread worker = new Thread(() =&gt;{result = Calculate();});worker.Start();Console.WriteLine("メインスレッドでは別の処理を実行できます");worker.Join();Console.WriteLine($"計算結果: {result}");}static long Calculate(){long total = 0;for (int i = 0; i &lt; 10_000_000; i++){total += i;}return total;}

}

resultはメインスレッドとワーカースレッドで共有されています。ただし、メインスレッドはJoinでワーカースレッドの終了を待ってから値を読み取っているため、この例では計算完了後の結果を取得できます。

実務では、結果や例外を扱いやすいTask<long>を使う方法が一般的です。

5-2. 複数スレッドを同時に起動するサンプル

複数のThreadを作成し、それぞれ異なる処理を実行できます。

using System;using System.Threading;

class Program{static void Main(){Thread[] threads = new Thread[3];

    for (int i = 0; i &lt; threads.Length; i++){int threadNumber = i + 1;threads<span data-placeholder-token="true" class="text-token-text-primary cursor-text rounded-sm" style="background-color: color-mix(in srgb, var(--theme-user-selection-bg, var(--selection)) 30%, transparent); padding-top: 4px; padding-bottom: 4px;">[i]</span> = new Thread(() =&gt;{for (int j = 1; j &lt;= 3; j++){Console.WriteLine($"スレッド{threadNumber}: 処理{j}");Thread.Sleep(300);}});threads<span data-placeholder-token="true" class="text-token-text-primary cursor-text rounded-sm" style="background-color: color-mix(in srgb, var(--theme-user-selection-bg, var(--selection)) 30%, transparent); padding-top: 4px; padding-bottom: 4px;">[i]</span>.Start();}foreach (Thread thread in threads){thread.Join();}Console.WriteLine("すべてのスレッドが終了しました");}

}

ループ内でthreadNumberというローカル変数を作ることで、各スレッドに個別の番号を渡しています。

複数のスレッドは並行して実行されるため、コンソールへの出力順序は一定ではありません。

5-3. 共有カウンターをlockで安全に更新するサンプル

using System;using System.Threading;

class Program{private static readonly object SyncRoot = new();private static int _counter;

static void Main(){Thread[] threads = new Thread<span data-placeholder-token="true" class="text-token-text-primary cursor-text rounded-sm" style="background-color: color-mix(in srgb, var(--theme-user-selection-bg, var(--selection)) 30%, transparent); padding-top: 4px; padding-bottom: 4px;">[4]</span>;for (int i = 0; i &lt; threads.Length; i++){threads<span data-placeholder-token="true" class="text-token-text-primary cursor-text rounded-sm" style="background-color: color-mix(in srgb, var(--theme-user-selection-bg, var(--selection)) 30%, transparent); padding-top: 4px; padding-bottom: 4px;">[i]</span> = new Thread(IncrementCounter);threads<span data-placeholder-token="true" class="text-token-text-primary cursor-text rounded-sm" style="background-color: color-mix(in srgb, var(--theme-user-selection-bg, var(--selection)) 30%, transparent); padding-top: 4px; padding-bottom: 4px;">[i]</span>.Start();}foreach (Thread thread in threads){thread.Join();}Console.WriteLine($"カウンター: {_counter}");}static void IncrementCounter(){for (int i = 0; i &lt; 10_000; i++){lock (SyncRoot){_counter++;}}}

}

4つのスレッドがそれぞれ1万回更新するため、最終結果は40000になります。

この処理はInterlocked.Incrementでも実装できます。

static void IncrementCounter(){for (int i = 0; i < 10_000; i++){Interlocked.Increment(ref _counter);}}

単一の整数を増減するだけなら、こちらのほうが簡潔です。

5-4. Backgroundスレッドを使うサンプル

using System;using System.Threading;

class Program{static void Main(){Thread monitorThread = new Thread(() =>{while (true){Console.WriteLine("状態を監視しています");Thread.Sleep(500);}});

    monitorThread.IsBackground = true;monitorThread.Start();Console.WriteLine("Enterキーを押すとメイン処理を終了します");Console.ReadLine();}

}

monitorThreadはバックグラウンドスレッドなので、メインスレッドが終了すると、監視処理の途中でもプロセスが終了できます。

実際のアプリケーションでは、無限ループを強制終了に任せるのではなく、終了要求を受け取って正常終了できるように設計することが重要です。

5-5. Thread使用時の例外処理の書き方

別スレッド内で発生した例外は、Startを呼び出した側のtrycatchでは捕捉できません。

次のコードでは、メインスレッド側のcatchに入りません。

try{Thread thread = new Thread(() =>{throw new InvalidOperationException("エラーが発生しました");});

thread.Start();

}catch (Exception ex){Console.WriteLine(ex.Message);}

例外は、スレッド内で捕捉する必要があります。

using System;using System.Threading;

class Program{static void Main(){Exception? workerException = null;

    Thread thread = new Thread(() =&gt;{try{throw new InvalidOperationException("別スレッド内のエラー");}catch (Exception ex){workerException = ex;}});thread.Start();thread.Join();if (workerException is not null){Console.WriteLine($"処理に失敗しました: {workerException.Message}");}}

}

このような例外の受け渡しを自分で実装する必要がある点も、ThreadよりTaskが使いやすい理由の1つです。Taskでは、awaitした場所で例外を捕捉できます。

6. ThreadとThreadPoolの違い

6-1. ThreadPoolとは何か

ThreadPoolは、あらかじめ用意されたワーカースレッドを複数の処理で再利用する仕組みです。

Threadを作るたびに、OSスレッドの作成コストやスタック用メモリが必要になります。短い処理のたびに新しいスレッドを作ると、オーバーヘッドが大きくなります。

ThreadPoolでは、既存のスレッドを再利用することで、スレッド作成のコストを抑えます。

C#のTaskも、一般的なケースでは内部でスレッドプールを利用します。

6-2. ThreadとThreadPoolの使い分け

Threadは、専用スレッドを作成します。スレッド名、優先度、バックグラウンド属性などを直接設定でき、スレッドの動作を細かく制御できます。

一方、ThreadPoolはスレッドをアプリケーション全体で共有します。短時間で終わる多数の処理を効率よく実行するのに向いています。

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

  • 長時間動き続ける専用処理:Threadを検討

  • 短時間のバックグラウンド処理:TaskThreadPool

  • 戻り値や例外、キャンセルを扱いたい:Task

  • スレッド固有の設定が必要:Thread

  • 一般的なアプリケーション開発:まずTaskを検討

ThreadPool上で長時間ブロックする処理を大量に実行すると、ほかの処理に使えるスレッドが不足する可能性があります。

6-3. ThreadPool.QueueUserWorkItemの基本

ThreadPool.QueueUserWorkItemを使うと、処理をスレッドプールのキューに登録できます。

using System;using System.Threading;

class Program{static void Main(){using ManualResetEventSlim completed = new(false);

    ThreadPool.QueueUserWorkItem(_ =&gt;{try{Console.WriteLine($"スレッドプールで実行: " +$"{Thread.CurrentThread.ManagedThreadId}");}finally{completed.Set();}});completed.Wait();}

}

登録された処理は、利用可能なスレッドプールスレッドによって実行されます。

ただし、QueueUserWorkItem自体には、処理結果を表すTaskがありません。結果、例外、完了待機を扱うには追加の実装が必要です。

そのため、通常は次のようにTask.Runを使うほうが扱いやすくなります。

await Task.Run(() =>{Console.WriteLine("バックグラウンド処理");});

6-4. ThreadPoolを使うメリットと注意点

ThreadPoolの主なメリットは、スレッドを再利用できることです。

  • スレッド作成のオーバーヘッドを抑えられる

  • 多数の短い処理を効率よく実行できる

  • スレッド数をランタイムが調整する

  • Taskなどの高レベルAPIの基盤として利用される

一方、次の点には注意が必要です。

  • スレッドの開始時刻は保証されない

  • スレッド名や優先度を処理ごとに自由に管理しにくい

  • 長時間のブロッキング処理はスレッドを占有する

  • 大量の処理を一度に登録すると、実行待ちが増える

  • プロセス終了時に処理完了が保証されるわけではない

  • スレッドローカルな状態を安易に引き継げない

スレッドプールは共有資源です。長時間の待機や無限ループを安易に登録しないようにしましょう。

6-5. 現代のC#でThreadPoolを直接使う場面

一般的なアプリケーションコードでは、ThreadPool.QueueUserWorkItemを直接使う機会は多くありません。

戻り値、例外、キャンセル、複数処理の待機などを扱えるTaskのほうが便利だからです。

ThreadPoolを直接使う場面としては、次のようなケースが考えられます。

  • 非常に低レベルなライブラリを実装する

  • Taskオブジェクトの生成を避けたい特殊な最適化を行う

  • 戻り値や完了通知が不要な短い処理を登録する

  • 既存のThreadPoolベースのコードを保守する

ただし、性能上の理由で直接利用する場合は、実際に計測し、Taskがボトルネックであることを確認してから判断する必要があります。

7. ThreadとTaskの違い

7-1. Taskとは何か

Taskは、実行中または将来完了する処理を表す高レベルな抽象化です。

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

await task;

戻り値を持つ処理は、Task<T>で表現できます。

Task<int> task = Task.Run(() =>{return 10 + 20;});

int result = await task;

Console.WriteLine(result);

Taskはスレッドそのものではありません。「処理の完了」を表すオブジェクトです。その処理がスレッドプール上で実行されることもあれば、I/O完了通知を待つだけで、待機中にスレッドを使用しないこともあります。

7-2. Threadは低レベル、Taskは高レベルな非同期処理

Threadは、OSスレッドを直接扱うための低レベルAPIです。

スレッドの開始、終了待機、共有データの同期、例外の受け渡しなどを、開発者が比較的細かく管理します。

Taskは、処理の実行と完了を扱う高レベルAPIです。

Taskには次の機能があります。

  • awaitによる非同期の完了待機

  • 戻り値の取得

  • 例外の伝達

  • キャンセル処理

  • 複数処理の待機

  • 継続処理

  • スレッドプールの利用

単にバックグラウンド処理を実行したい場合は、ThreadよりもTaskを使ったほうがコードを簡潔にできます。

7-3. Task.Runでバックグラウンド処理を実行する方法

Task.Runは、主にCPU負荷の高い同期処理をスレッドプール上で実行するために使います。

using System;using System.Threading.Tasks;

class Program{static async Task Main(){long result = await Task.Run(() =>{long total = 0;

        for (int i = 0; i &lt; 10_000_000; i++){total += i;}return total;});Console.WriteLine(result);}

}

awaitを使うと、処理の完了を非同期に待てます。Thread.Joinのように、待機しているスレッドをブロックする必要がありません。

例外も呼び出し側で捕捉できます。

try{await Task.Run(() =>{throw new InvalidOperationException("処理失敗");});}catch (InvalidOperationException ex){Console.WriteLine(ex.Message);}

7-4. ThreadよりTaskが推奨される理由

一般的なバックグラウンド処理でTaskが推奨される主な理由は、管理機能が充実しているためです。

  • 戻り値をTask<T>で受け取れる

  • awaitで完了を待てる

  • 例外を呼び出し元で処理できる

  • CancellationTokenでキャンセルできる

  • Task.WhenAllで複数処理を待てる

  • スレッドプールを効率よく利用できる

  • 非同期APIと組み合わせやすい

複数の処理を待つ場合も簡単です。

Task task1 = Task.Run(() => Work(1));Task task2 = Task.Run(() => Work(2));Task task3 = Task.Run(() => Work(3));

await Task.WhenAll(task1, task2, task3);

Threadを使う場合は、スレッドをそれぞれ作成し、すべてに対してJoinを呼び出す必要があります。

7-5. Threadを使うべきケースとTaskを使うべきケース

Threadを検討できるケースは次のとおりです。

  • 長時間動き続ける専用スレッドが必要

  • スレッドの優先度を設定したい

  • スレッド名やバックグラウンド属性を直接管理したい

  • 特定のスレッドに処理を固定する必要がある

  • 外部ライブラリが専用スレッドを要求する

  • スレッドアパートメント状態などの制御が必要

Taskが向いているケースは次のとおりです。

  • 一般的なバックグラウンド処理

  • CPU負荷の高い処理をUIスレッドから分離する

  • 戻り値を受け取りたい

  • 例外を呼び出し元で扱いたい

  • 複数処理をまとめて待ちたい

  • キャンセル可能にしたい

  • asyncawaitと組み合わせたい

特別なスレッド制御が必要でなければ、まずTaskを選ぶのが基本です。

8. Threadとasync/awaitの違い

8-1. async/awaitとは何か

asyncawaitは、非同期処理を同期処理に近い形で記述するためのC#の構文です。

using System;using System.Net.Http;using System.Threading.Tasks;

class Program{static async Task Main(){using HttpClient client = new();

    string content =await client.GetStringAsync("https://example.com");Console.WriteLine(content.Length);}

}

awaitは、対象の非同期処理が完了するまで、以降の処理を一時的に中断します。

重要なのは、待機中に現在のスレッドを占有し続ける必要がないことです。処理が完了すると、続きの処理が再開されます。

8-2. 非同期処理とマルチスレッドの違い

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

マルチスレッドは、複数のスレッドを使って処理を進める仕組みです。

非同期処理は、時間のかかる処理の完了を待っている間に、現在のスレッドを別の仕事に使えるようにする仕組みです。

たとえば、Web APIからの応答を待つ処理では、CPUが計算を続けているわけではありません。ネットワークからデータが届くまで待っているだけです。

この待ち時間のために専用スレッドを1つ占有するより、非同期I/Oを使って完了通知を待つほうが効率的です。

8-3. async/awaitは必ず別スレッドで動くわけではない

asyncメソッドを呼び出したからといって、自動的に新しいスレッドが作られるわけではありません。

static async Task SampleAsync(){Console.WriteLine($"開始: {Thread.CurrentThread.ManagedThreadId}");

await Task.Delay(1000);Console.WriteLine($"再開: {Thread.CurrentThread.ManagedThreadId}");

}

Task.Delayの待機中は、専用スレッドが1秒間眠っているわけではありません。タイマーによって完了が通知され、続きの処理が実行可能になります。

また、UIアプリケーションでは、await後の処理が元のUIスレッドで再開されることがあります。コンソールアプリケーションやASP.NET Coreなど、実行環境によって再開されるスレッドの扱いは異なります。

したがって、asyncawaitを「別スレッドで実行する機能」と理解するのは正確ではありません。

8-4. I/O待ち処理ではasync/awaitが向いている理由

I/Oバウンド処理とは、CPU計算よりも、外部処理の完了待ちが中心となる処理です。

代表例は次のとおりです。

  • HTTP通信

  • データベースアクセス

  • ファイルの読み書き

  • ソケット通信

  • クラウドストレージへのアクセス

このような処理では、非同期APIを直接awaitします。

string text =await File.ReadAllTextAsync("data.txt");

非同期APIを利用すれば、待機中にスレッドをブロックしません。多数のリクエストを処理するサーバーアプリケーションでは、スレッド資源を効率よく使えます。

I/O処理を次のようにTask.Runで包む必要は、通常ありません。

// 通常は不要string text = await Task.Run(() => File.ReadAllText("data.txt"));

非同期版のAPIが用意されている場合は、File.ReadAllTextAsyncのような非同期APIを直接使用します。

8-5. CPU負荷の高い処理ではTask.Runを使う理由

CPUバウンド処理とは、待ち時間ではなく、計算そのものにCPU時間を使う処理です。

代表例は次のとおりです。

  • 画像処理

  • 動画変換

  • 暗号化や圧縮

  • 大量データの集計

  • 複雑な数値計算

CPUバウンドな同期処理をUIスレッド上で実行すると、画面が固まります。この場合は、Task.Runを使ってスレッドプール上に移す方法があります。

int result = await Task.Run(() =>{return PerformHeavyCalculation();});

ただし、サーバーアプリケーションでは、CPU負荷の高い処理をTask.Runに移しても、必要なCPU量そのものが減るわけではありません。

大量のCPU処理を同時実行するとサーバー全体の応答性を下げる可能性があるため、キューイング、同時実行数の制限、別プロセスへの分離なども検討します。

9. C#のスレッド処理の使い分け早見表

9-1. Thread・ThreadPool・Task・async/awaitの比較

方法主な役割スレッドの扱い戻り値・例外主な用途
Thread専用スレッドを直接作る開発者が細かく管理自分で受け渡す長時間動く専用処理
ThreadPoolスレッドを再利用するランタイムが管理直接は扱いにくい低レベルな短時間処理
Task処理の完了を表す通常はスレッドプールを利用扱いやすいCPU処理、並行処理
asyncawait非同期処理を簡潔に待つ必ずしも別スレッドを使わない扱いやすい通信、DB、ファイルI/O

ThreadThreadPoolはスレッドを中心とした仕組みです。一方、Taskは処理の完了を表し、asyncawaitはその完了を扱いやすくする構文です。

9-2. CPUバウンド処理での選び方

CPU負荷の高い処理をバックグラウンドで実行する場合は、一般的にTask.Runを検討します。

Result result = await Task.Run(() => CalculateResult());

複数のCPUコアを活用して大量データを処理する場合は、Parallel.For、PLINQなども候補になります。

Parallel.For(0, 100, i =>{ProcessItem(i);});

ただし、並列化には管理コストがあります。処理が非常に短い場合やデータ量が少ない場合は、単一スレッドのほうが速いこともあります。

専用スレッドが必要な特別な要件がある場合のみ、Threadを検討します。

9-3. I/Oバウンド処理での選び方

I/Oバウンド処理では、非同期APIとasyncawaitを使うのが基本です。

HttpResponseMessage response =await httpClient.GetAsync(url, cancellationToken);

string content =await response.Content.ReadAsStringAsync(cancellationToken);

通信待ちのためにThreadを作成したり、同期I/OをTask.Runで包んだりするより、正式な非同期APIを利用するほうが効率的です。

9-4. UIアプリでの選び方

Windows Forms、WPF、.NET MAUIなどのUIアプリケーションでは、画面の更新は原則としてUIスレッドで行います。

I/O処理にはasyncawaitを使用します。

private async void LoadButton_Click(object sender,EventArgs e){LoadButton.Enabled = false;

try{string text =await File.ReadAllTextAsync("data.txt");ResultTextBox.Text = text;}finally{LoadButton.Enabled = true;}

}

CPU負荷の高い処理にはTask.Runを使えます。

private async void CalculateButton_Click(object sender,EventArgs e){int result =await Task.Run(() => HeavyCalculation());

ResultLabel.Text = result.ToString();

}

処理の完了後は、通常、awaitによってUIスレッドに戻った状態で画面を更新できます。

9-5. 初心者がまず覚えるべき使い分け

初心者は、次の順序で考えると分かりやすくなります。

  1. HTTP通信、DB、ファイル操作なら非同期APIをawaitする

  2. 重い計算でUIが固まるならTask.Runを検討する

  3. 複数のTaskを待つならTask.WhenAllを使う

  4. キャンセルにはCancellationTokenを使う

  5. 専用スレッドが本当に必要な場合だけThreadを使う

Threadの仕組みを理解することは重要ですが、日常的なC#開発ではTaskasyncawaitを中心に使うことが多くなります。

10. C#のスレッドでよくあるエラーと対処法

10-1. UIスレッド以外から画面を更新してエラーになる

多くのUIフレームワークでは、UIコントロールを作成したスレッド以外から、そのコントロールを直接更新できません。

Windows Formsで別スレッドから直接ラベルを変更すると、例外が発生する場合があります。

Thread thread = new Thread(() =>{// UIスレッド以外からの直接更新は問題になるresultLabel.Text = "完了";});

thread.Start();

Windows Formsでは、Invokeなどを使ってUIスレッドに処理を渡します。

Thread thread = new Thread(() =>{string result = "処理完了";

resultLabel.Invoke(() =&gt;{resultLabel.Text = result;});

});

thread.Start();

より一般的には、イベントハンドラー内でasyncawaitを使います。

private async void Button_Click(object sender,EventArgs e){string result = await Task.Run(() =>{return ExecuteHeavyWork();});

resultLabel.Text = result;

}

10-2. 共有データが意図しない値になる

複数スレッドから同じデータを更新すると、競合状態によって値が失われることがあります。

対策としては、次の方法があります。

  • lockで排他制御する

  • Interlockedを使う

  • 並行コレクションを使う

  • 共有データを不変にする

  • 各スレッドが個別のデータを処理し、最後に結果を集約する

単純にロックを追加するだけでなく、共有そのものを減らせないか検討することが重要です。

10-3. スレッドが終了しない

スレッドが終了しない主な原因には、次のようなものがあります。

  • 無限ループから抜ける条件がない

  • 終了フラグが正しく共有されていない

  • ロック待ちで停止している

  • I/O処理が終了しない

  • フォアグラウンドスレッドが残っている

  • デッドロックが発生している

終了可能なスレッドは、CancellationTokenなどを使って停止要求を受け取れるようにします。

using System;using System.Threading;

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

    Thread thread = new Thread(() =&gt;{while (!cts.Token.IsCancellationRequested){Console.WriteLine("実行中");if (cts.Token.WaitHandle.WaitOne(500)){break;}}Console.WriteLine("終了しました");});thread.Start();Console.ReadLine();cts.Cancel();thread.Join();}

}

この例では、キャンセル要求を送った後、Joinで正常終了を待っています。

10-4. 例外が呼び出し元に伝わらない

Thread内の例外は、自動的に呼び出し元へ返されません。スレッド内で捕捉し、共有変数やコールバックなどを使って通知する必要があります。

一方、Taskではawaitした場所に例外が伝わります。

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

例外処理を簡潔にしたい場合も、Taskを選ぶ大きなメリットがあります。

10-5. 処理順序が毎回変わる

複数スレッドの実行順序は、OSやランタイムのスケジューリング状況に左右されます。

Thread thread1 =new(() => Console.WriteLine("A"));

Thread thread2 =new(() => Console.WriteLine("B"));

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

この結果は、必ずしもABの順になるとは限りません。

順序が必要なら、偶然のタイミングに頼らず、明示的な同期を行います。

thread1.Start();thread1.Join();

thread2.Start();thread2.Join();

Taskであれば、順番にawaitできます。

await FirstAsync();await SecondAsync();

複数処理を並行実行しつつ、一部の順序だけ制御したい場合は、TaskCompletionSourceSemaphoreSlim、チャネルなどの仕組みを検討します。

11. C#のスレッド処理で守るべきベストプラクティス

11-1. 直接Threadを使いすぎない

Threadは強力ですが、作成コストや管理の複雑さがあります。

短い処理ごとに新しいスレッドを作ると、スレッドの作成・破棄やコンテキストスイッチの負荷が増えます。

一般的な処理では、次の優先順位で検討します。

  1. I/O処理なら非同期APIとasyncawait

  2. CPU処理ならTask.Run

  3. 大量データの並列処理ならParallelなど

  4. 専用スレッドが必要な場合だけThread

Threadを選ぶ場合は、「なぜTaskでは不十分なのか」を説明できる状態が理想です。

11-2. 可能ならTaskとasync/awaitを優先する

Taskasyncawaitを使うと、戻り値、例外、キャンセル、複数処理の完了待ちを統一的に扱えます。

public async Task<string> LoadDataAsync(CancellationToken cancellationToken){using HttpClient client = new();

return await client.GetStringAsync("https://example.com",cancellationToken);

}

呼び出し側では次のように扱えます。

try{string data =await LoadDataAsync(cancellationToken);}catch (OperationCanceledException){Console.WriteLine("キャンセルされました");}catch (HttpRequestException ex){Console.WriteLine($"通信エラー: {ex.Message}");}

このように、通常の制御構文で非同期処理を記述できる点が大きな利点です。

11-3. 共有状態を減らす

並行処理の問題の多くは、変更可能なデータを複数スレッドで共有することから発生します。

可能であれば、各処理が独立したデータを持つようにします。

Task<int>[] tasks = numbers.Select(number => Task.Run(() => Calculate(number))).ToArray();

int[] results = await Task.WhenAll(tasks);

Taskが個別の入力を処理し、結果を返す設計にすれば、共有変数を直接更新する必要がありません。

「共有データを安全にロックする」だけでなく、「そもそも共有しない」設計を優先しましょう。

11-4. lockの範囲を最小限にする

ロック中は、ほかのスレッドが同じロックを取得できません。ロック範囲が広いほど待機時間が増え、並行処理の効果が低下します。

次のように、時間のかかる計算までロック内で行うのは避けます。

lock (_syncRoot){Result result = HeavyCalculation();_results.Add(result);}

計算をロックの外で行い、共有データの更新だけをロックします。

Result result = HeavyCalculation();

lock (_syncRoot){_results.Add(result);}

また、ロック中にネットワーク通信、ファイルアクセス、ユーザー定義のコールバックなどを実行すると、長時間ロックを保持する可能性があります。

11-5. キャンセル可能な処理にする

長時間処理は、利用者やアプリケーションから停止できるように設計します。

TaskではCancellationTokenを使うのが一般的です。

static async Task ProcessAsync(CancellationToken cancellationToken){for (int i = 0; i < 100; i++){cancellationToken.ThrowIfCancellationRequested();

    await ProcessItemAsync(i, cancellationToken);}

}

呼び出し側はCancellationTokenSourceからキャンセル要求を送ります。

using CancellationTokenSource cts = new();

Task task = ProcessAsync(cts.Token);

// 必要になった時点でキャンセルcts.Cancel();

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

Threadを強制終了するのではなく、処理自身が安全なタイミングで終了する協調的キャンセルを利用します。

12. C#のスレッドに関するよくある質問

12-1. C#でThreadは非推奨なのか

Threadクラス自体が、すべての用途で非推奨になっているわけではありません。

専用スレッドが必要な処理、スレッドの優先度やアパートメント状態を制御したい処理、長時間動作する特殊なワーカーなどでは、Threadが適切な場合があります。

ただし、一般的なバックグラウンド処理や非同期処理では、Taskasyncawaitのほうが扱いやすく、推奨されることが多いという位置付けです。

Threadは使ってはいけない」のではなく、「必要な場合に限定して使う」と考えるとよいでしょう。

12-2. ThreadとTaskはどちらが速いのか

単純にどちらが速いとは決められません。

Threadは専用のOSスレッドを作るため、作成時のコストがあります。Task.Runは通常、スレッドプールの既存スレッドを再利用するため、短い処理では効率的なことが多くなります。

一方、非常に長時間動く処理や、スレッドプールを占有したくない特殊な処理では、専用のThreadが適する可能性があります。

性能は、次の条件によって変わります。

  • 処理時間

  • 同時実行数

  • CPUコア数

  • ブロッキングの有無

  • スレッド作成回数

  • スレッドプールの混雑状況

  • 共有データやロックの量

速度だけでなく、保守性、例外処理、キャンセル、リソース消費も含めて選択します。

12-3. async/awaitとThreadは同じ意味なのか

同じ意味ではありません。

Threadは、処理を実行するスレッドを直接作成・制御する仕組みです。

asyncawaitは、完了まで時間がかかる処理を、スレッドをブロックせずに待ちやすくする構文です。

asyncメソッドを実行しても、新しいスレッドが作られるとは限りません。非同期I/Oでは、待機中に実行用スレッドが存在しない時間もあります。

CPU処理を別スレッドで動かしたい場合は、Task.Runなどを組み合わせます。

12-4. スレッド数は多いほどよいのか

スレッド数は、多ければ多いほどよいわけではありません。

CPUコア数を大きく超える数のCPUバウンドスレッドを動かすと、スレッドの切り替えが増え、かえって処理が遅くなる可能性があります。

また、各スレッドにはスタック用メモリなどが必要です。大量のスレッドを作ると、メモリ使用量も増加します。

I/O待ちが多い場合でも、同期処理のために大量のスレッドを用意するより、非同期I/Oを使うほうが効率的です。

スレッド数を手動で決めるより、まずTaskThreadPoolParallelなど、ランタイムが実行数を管理する仕組みを検討しましょう。

12-5. 初心者はThread・Task・asyncのどれから学ぶべきか

まず、スレッドの基本概念を理解することが重要です。

そのうえで、実際のC#開発では次の順序で学ぶとよいでしょう。

  1. Taskの基本

  2. asyncawait

  3. Task.Run

  4. CancellationToken

  5. Task.WhenAll

  6. lockInterlocked

  7. 必要に応じてThreadThreadPool

Threadの使い方を学ぶと、競合状態、排他制御、デッドロックなど、並行処理の基礎を理解できます。

一方、実務で最初に使う可能性が高いのは、Taskasyncawaitです。概念としてスレッドを理解し、実装では高レベルなAPIを優先するのがおすすめです。

まとめ

C#のスレッドは、1つのプロセス内で複数の処理を並行して進めるための仕組みです。System.Threading.Threadを使うと、専用スレッドの作成、開始、終了待機、名前や優先度の設定などを細かく制御できます。

基本的な操作は、Threadオブジェクトを作成し、Startで開始し、必要に応じてJoinで終了を待つという流れです。

ただし、複数スレッドで共有データを扱う場合は、競合状態やデッドロックに注意しなければなりません。lockInterlockedSemaphoreSlim、並行コレクションなどを目的に応じて使い分けます。

現代のC#では、一般的なバックグラウンド処理にはTask、HTTP通信やファイル操作などのI/O待ちにはasyncawaitを使うのが基本です。

使い分けを簡単に整理すると、次のようになります。

  • 専用スレッドを細かく制御したい:Thread

  • 短い処理でスレッドを再利用したい:ThreadPool

  • CPU負荷の高い処理をバックグラウンドで実行したい:Task.Run

  • 通信、DB、ファイルなどの待ち時間を効率化したい:asyncawait

  • 共有データを安全に更新したい:lockInterlocked

  • 長時間処理を停止可能にしたい:CancellationToken

C#のスレッド処理では、単に別スレッドを作ることよりも、共有状態を減らし、適切な抽象化を選び、安全に終了・キャンセルできる設計にすることが重要です。