C#スレッド入門:Thread・Task・async/awaitの違いと安全な使い方

はじめに

C#でアプリケーションを作っていると、「画面が固まる」「API通信を待っている間に何もできない」「大量データの処理が遅い」といった問題に直面します。こうした場面で重要になるのが、C# スレッド処理、Task、そしてasync/awaitです。

ただし、C#のスレッド処理は便利な反面、使い方を誤るとデッドロック、競合状態、UIスレッドのエラー、パフォーマンス低下を引き起こします。特に初心者は、ThreadTaskasync/awaitを同じものとして捉えがちですが、それぞれ役割が異なります。

この記事では、C# スレッドの基本から、ThreadTaskasync/awaitの違い、安全な同期制御、実用的なコード例までを順番に解説します。

1. C#のスレッドとは何か

1-1. スレッドの基本概念と役割

スレッドとは、プログラム内で処理を実行するための流れです。通常、C#のコンソールアプリやWebアプリ、デスクトップアプリは、少なくとも1つのスレッド上で動いています。

たとえば、1本のスレッドで次の処理を順番に実行するとします。

C#
Console.WriteLine("開始");
Thread.Sleep(3000);
Console.WriteLine("終了");

この場合、Thread.Sleep(3000)の間、そのスレッドは次の処理へ進めません。画面アプリでこれをUIスレッド上で実行すると、ボタン操作や画面描画が止まり、「アプリが固まった」ように見えます。

C# スレッド処理を使う目的は、時間のかかる処理を別の実行単位に分け、アプリの応答性や処理効率を高めることです。

1-2. プロセス・スレッド・CPUコアの違い

プロセスは、実行中のアプリケーションの単位です。たとえば、1つのコンソールアプリを起動すると1つのプロセスが作られます。

スレッドは、そのプロセスの中で実行される処理の流れです。1つのプロセスは複数のスレッドを持つことができます。

CPUコアは、実際に命令を処理するハードウェア上の実行資源です。複数コアを持つCPUでは、複数のスレッドを同時に実行しやすくなります。ただし、スレッド数を増やせば必ず速くなるわけではありません。スレッドの切り替え、同期、メモリ競合などのコストが発生するためです。

1-3. C#でスレッド処理が必要になる場面

C#でスレッド処理が必要になる代表的な場面は、次のようなケースです。

場面向いている方法
CPU負荷が高い処理画像処理、集計、暗号化、大量計算Task.Run、並列処理
待ち時間が長い処理API通信、DBアクセス、ファイル読み書きasync/await
UIを固めたくない処理WPF、WinForms、MAUIなどasync/await
常駐処理監視、キュー処理、バックグラウンドワーカーTask、Hosted Serviceなど
専用スレッドが必要な処理STA、スレッド固有状態、長時間専有処理Thread

特に、Webアクセスやファイル操作などの待機を伴う処理では、非同期処理によってアプリの応答性を高められます。MicrosoftのC#非同期プログラミングの説明でも、Webアクセスやファイル操作、UIアプリの応答性改善が代表例として挙げられています。

1-4. マルチスレッド化で得られるメリットと注意点

マルチスレッド化のメリットは、主に次の3つです。

1つ目は、UIの応答性を保てることです。重い処理をUIスレッドから切り離せば、画面が固まりにくくなります。

2つ目は、待ち時間を有効活用できることです。API通信やDBアクセスの完了を待つ間、スレッドをブロックせずに他の処理へ制御を戻せます。

3つ目は、CPUコアを活用して処理時間を短縮できることです。独立した計算処理であれば、複数の処理を並列に実行できます。

一方で、共有データを複数スレッドから更新すると、競合状態が発生します。また、lockの取り方を誤るとデッドロックが発生します。C# スレッド処理では、「速くすること」だけでなく、「安全に扱うこと」が同じくらい重要です。

2. C#におけるThread・Task・async/awaitの全体像

2-1. Thread・Task・async/awaitの違いを比較

ThreadTaskasync/awaitは、どれも「同時実行」や「非同期処理」と関係しますが、抽象度が異なります。

種類役割主な用途
ThreadOSスレッドに近い低レベルな実行単位を直接扱う専用スレッド、特殊な制御
Task非同期・並列処理の作業単位を表すCPU処理、複数処理の管理
async/await非同期処理を読みやすく書くための構文I/O待ち、UI応答性、Web処理

Threadクラスは、スレッドの作成、制御、優先度、状態の取得などを扱うためのクラスです。 一方、Task.Runは処理をスレッドプールへキューに入れ、その作業を表すTaskを返します。 awaitは非同期操作の完了までメソッドの実行を一時停止しますが、その間にスレッドをブロックしません。

2-2. それぞれの使い分け早見表

やりたいこと推奨
API通信を待ちたいasync/await
ファイルを非同期に読み書きしたいasync/await
CPU負荷の高い処理をUIから逃がしたいTask.Run + await
複数の非同期処理をまとめて待ちたいTask.WhenAll
最初に終わった処理だけ使いたいTask.WhenAny
専用スレッドを明示的に作りたいThread
共有変数を安全に更新したいlockInterlocked、スレッドセーフコレクション
処理をキャンセル可能にしたいCancellationToken

2-3. 現代のC#でTaskとasync/awaitが推奨される理由

現在のC#では、Threadを直接使うより、まずTaskasync/awaitを検討するのが一般的です。

理由は、Taskがスレッドプール、例外、キャンセル、複数処理の待機などを扱いやすくしてくれるからです。また、async/awaitを使うと、コールバックだらけの読みにくいコードではなく、同期処理に近い見た目で非同期処理を書けます。MicrosoftのTAP、つまりTask-based Asynchronous Patternの説明でも、C#コンパイラが複雑な作業を担い、同期コードに似た論理構造を保てることが利点として説明されています。

2-4. 初心者が混同しやすい「非同期」と「並列処理」の違い

非同期処理と並列処理は似ていますが、目的が違います。

非同期処理は、「待っている間にスレッドを塞がない」ための考え方です。API通信、DBアクセス、ファイル読み書きなど、外部処理の完了待ちに向いています。

並列処理は、「複数の処理を同時に進めて処理時間を短くする」ための考え方です。CPUを使う計算、画像処理、集計などに向いています。

重要なのは、async/awaitを書いたからといって必ず別スレッドが作られるわけではないことです。awaitは非同期操作の完了までメソッドを一時停止しますが、待機中に現在のスレッドをブロックしない仕組みです。

3. Threadクラスの基本的な使い方

3-1. Threadを使って処理を別スレッドで実行する

Threadクラスを使うと、明示的に新しいスレッドを作成できます。

C#
using System;
using System.Threading;

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

thread.Start();

Console.WriteLine("メインスレッドで実行中");
}
}

Thread.Start()を呼ぶと、指定した処理が別スレッドで実行されます。ただし、実行順序は保証されません。「メインスレッドで実行中」が先に表示される場合もあれば、「別スレッドで実行中」が先に表示される場合もあります。

3-2. Thread.StartとThread.Joinの使い方

Startはスレッドを開始するメソッドです。Joinは、対象スレッドが終了するまで呼び出し元スレッドを待機させるメソッドです。Thread.Joinは、対象スレッドが終了するまで呼び出し元をブロックします。

C#
using System;
using System.Threading;

class Program
{
static void Main()
{
Thread thread = new Thread(() =>
{
Thread.Sleep(1000);
Console.WriteLine("作業完了");
});

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

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

このコードでは、別スレッドの作業が終わってから「すべて完了」が表示されます。

3-3. 引数を渡してスレッドを開始する方法

Threadに引数を渡すには、ラムダ式を使う方法が簡単です。

C#
using System;
using System.Threading;

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

Thread thread = new Thread(() =>
{
Console.WriteLine(message);
});

thread.Start();
}
}

古い書き方ではParameterizedThreadStartを使えますが、型がobjectになるためキャストが必要です。現在はラムダ式で必要な値をキャプチャする方が読みやすいでしょう。

3-4. Thread.Sleepの使い方と注意点

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

C#
Thread.Sleep(1000);

ただし、Thread.Sleepは「非同期に待つ」ものではありません。現在のスレッドを止めます。UIスレッドで使うと画面が固まり、ASP.NETなどのサーバーアプリで多用するとスレッドを無駄に占有します。

非同期メソッドでは、通常はThread.SleepではなくTask.Delayを使います。

C#
await Task.Delay(1000);

3-5. Threadを直接使うべきケース・避けるべきケース

Threadを直接使うべきケースは限られています。たとえば、長時間動き続ける専用スレッドが必要な場合、スレッドのアパートメント状態を明示的に制御したい場合、スレッドごとの状態を厳密に管理したい場合です。

一方、短い処理をたくさん実行するためにThreadを毎回作るのは避けるべきです。スレッド作成にはコストがあり、スレッド数が増えすぎるとコンテキストスイッチやメモリ消費が増えます。通常のC# スレッド処理では、まずTaskThreadPoolを利用する設計を検討しましょう。

4. Taskの基本的な使い方

4-1. Taskとは何か

Taskは、非同期または並列に実行される「作業」を表すオブジェクトです。Threadが実際の実行単位に近いのに対し、Taskは「完了するかもしれない処理」「結果を返すかもしれない処理」を抽象化したものです。

Taskには、戻り値がないTaskと、戻り値があるTask<T>があります。

C#
Task task = Task.Run(() => Console.WriteLine("作業"));

Task<int> taskWithResult = Task.Run(() => 123);

4-2. Task.Runで別スレッド処理を実行する

Task.Runは、指定した処理をスレッドプールにキュー登録し、その作業を表すTaskを返します。

C#
using System;
using System.Threading.Tasks;

class Program
{
static async Task Main()
{
await Task.Run(() =>
{
Console.WriteLine("重い処理を実行中");
});

Console.WriteLine("完了");
}
}

Task.Runは、CPU負荷の高い処理をUIスレッドから逃がしたいときに便利です。ただし、API通信やDBアクセスのようなI/O待ち処理では、まずHttpClient.GetAsyncFile.ReadAllTextAsyncなどの非同期APIをそのままawaitする方が適しています。

4-3. Task.WaitとResultの使い方と注意点

Task.Wait()は、Taskが完了するまで現在のスレッドをブロックします。Task<T>.Resultも、結果が出るまで現在のスレッドをブロックします。

C#
Task<int> task = Task.Run(() => 10);

// ブロックする
int result = task.Result;

コンソールアプリの学習用コードでは動くこともありますが、UIアプリや古いASP.NETではデッドロックの原因になることがあります。基本的には、WaitResultではなくawaitを使いましょう。

C#
int result = await task;

awaitは待機中に現在のスレッドをブロックしないため、非同期処理では自然な書き方です。

4-4. Task.WhenAll・Task.WhenAnyで複数処理を扱う

複数の非同期処理をまとめて待つにはTask.WhenAllを使います。Task.WhenAllは、渡されたすべてのTaskが完了したときに完了するTaskを作成します。

C#
using System.Net.Http;

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

Task<string> task1 = client.GetStringAsync("https://example.com");
Task<string> task2 = client.GetStringAsync("https://example.org");

string[] results = await Task.WhenAll(task1, task2);

Console.WriteLine(results.Length);
}

最初に完了した処理を扱いたい場合はTask.WhenAnyを使います。Task.WhenAnyは、渡された処理のいずれかが完了したときに完了し、最初に完了したTaskを結果として返します。

C#
Task<string> fastest = await Task.WhenAny(task1, task2);
string result = await fastest;

4-5. ThreadよりTaskを使うべき理由

Taskを使うと、スレッドを直接管理せずに非同期・並列処理を書けます。スレッドプールを利用でき、戻り値、例外、キャンセル、複数処理の待機も扱いやすくなります。

Threadは低レベルな制御には便利ですが、通常のアプリケーション開発ではコードが複雑になりがちです。C# スレッド処理の入門段階では、「専用スレッドが必要な理由が明確でない限り、まずTaskを使う」と考えるとよいでしょう。

5. async/awaitによる非同期処理の基本

5-1. async/awaitとは何か

async/awaitは、非同期処理を読みやすく書くためのC#の構文です。asyncはメソッド内でawaitを使えるようにし、awaitは非同期処理の完了を待ちます。

重要なのは、awaitは現在のスレッドをブロックしないことです。非同期操作の完了までメソッドの実行を一時停止し、制御を呼び出し元へ戻します。

5-2. asyncメソッドの書き方

基本形は次のとおりです。

C#
public async Task<string> LoadTextAsync(string path)
{
string text = await File.ReadAllTextAsync(path);
return text;
}

asyncメソッド名には、慣習としてAsyncを付けることが多いです。これにより、呼び出し側が非同期メソッドだと分かりやすくなります。

5-3. awaitで処理を待機する仕組み

awaitは、対象のTaskが完了していなければメソッドの続きを後で実行するように予約し、いったん呼び出し元に制御を返します。処理が完了すると、await以降のコードが再開されます。

C#
Console.WriteLine("開始");

await Task.Delay(1000);

Console.WriteLine("1秒後");

このコードは見た目には上から順番に読めますが、Task.Delayの待機中にスレッドを占有しません。これがThread.Sleepとの大きな違いです。

5-4. 戻り値Task・Task<T>・voidの違い

asyncメソッドの戻り値は、基本的にTaskまたはTask<T>にします。

C#
public async Task SaveAsync()
{
await File.WriteAllTextAsync("sample.txt", "text");
}

public async Task<int> GetCountAsync()
{
await Task.Delay(100);
return 10;
}

async voidは原則として避けます。例外を呼び出し側で捕捉しにくく、完了を待つこともできないためです。ただし、WinFormsやWPFのボタンクリックなど、イベントハンドラーではasync voidが使われます。

C#
private async void Button_Click(object sender, EventArgs e)
{
await LoadAsync();
}

5-5. UIアプリやWebアプリでasync/awaitが重要な理由

UIアプリでは、画面描画やユーザー操作を処理するUIスレッドをブロックすると、アプリが固まります。非同期メソッドを使えば、時間のかかる処理を待つ間もUIの応答性を保ちやすくなります。Microsoftの説明でも、UI関連の活動は通常1つのスレッドを共有するため、同期処理でブロックするとアプリ全体が応答しなくなるとされています。

Webアプリでは、スレッドをブロックしないことがスケーラビリティに関係します。リクエストごとにスレッドを長時間占有すると、同時アクセスが増えたときに処理能力が落ちます。DBアクセスや外部API呼び出しでは、非同期APIをawaitする設計が重要です。

6. Thread・Task・async/awaitの実践的な使い分け

6-1. CPU負荷の高い処理に向いている書き方

CPU負荷の高い処理は、Task.Runでバックグラウンドに逃がすのが分かりやすい方法です。

C#
public async Task<int> CalculateAsync()
{
int result = await Task.Run(() =>
{
int total = 0;

for (int i = 0; i < 100_000_000; i++)
{
total += i % 10;
}

return total;
});

return result;
}

UIアプリでは、このようにTask.Runで重い計算をUIスレッドから分離すると、画面のフリーズを避けやすくなります。

6-2. ファイル読み書き・API通信・DBアクセスに向いている書き方

I/O待ち処理では、Task.Runで無理に別スレッドへ逃がすより、非同期APIをそのままawaitするのが基本です。

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

ファイル読み込みも同様です。

C#
public async Task<string> ReadFileAsync(string path)
{
return await File.ReadAllTextAsync(path);
}

I/O処理はCPUで計算し続けているわけではなく、外部リソースの応答を待つ時間が中心です。そのため、スレッドをブロックしないasync/awaitが向いています。

6-3. UIを固めないための非同期処理

WinFormsの例です。

C#
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;

try
{
string result = await Task.Run(() =>
{
Thread.Sleep(3000);
return "完了";
});

label1.Text = result;
}
finally
{
button1.Enabled = true;
}
}

await後にUIスレッドへ戻る環境では、label1.Textの更新を自然に書けます。ただし、ConfigureAwait(false)を使うとUIスレッドに戻らない可能性があるため、UI更新を続けて行うコードでは注意が必要です。

6-4. バックグラウンド処理を実装する場合

単発のバックグラウンド処理ならTask.Runで十分なことが多いです。

C#
_ = Task.Run(async () =>
{
while (true)
{
await Task.Delay(5000);
Console.WriteLine("定期処理");
}
});

ただし、このような「投げっぱなし」の処理は例外が見逃されやすく、終了制御も難しくなります。実用では、キャンセル、例外処理、ログ、アプリ終了時の停止を設計しましょう。ASP.NET CoreではBackgroundServiceやキュー処理を使う方が適している場合もあります。

6-5. 判断に迷ったときの選び方

迷ったときは、次の順番で考えると整理しやすくなります。

まず、その処理は「待ち」が中心か、「計算」が中心かを考えます。待ちが中心ならasync/await、計算が中心ならTask.Runや並列処理を検討します。

次に、専用スレッドが本当に必要かを考えます。必要性が明確でなければ、ThreadではなくTaskを選ぶのが無難です。

最後に、共有データがあるかを確認します。共有データがあるなら、lockInterlockedConcurrentDictionaryなどを使って安全性を確保します。

7. C#スレッド処理でよくある問題と原因

7-1. デッドロックが発生する原因

デッドロックとは、複数の処理が互いに相手の完了やロック解放を待ち続け、永遠に先へ進めなくなる状態です。

典型例は、UIスレッドで非同期処理を.Result.Wait()で待つケースです。

C#
// 避けたい例
string text = LoadAsync().Result;

LoadAsyncの続きがUIスレッドに戻ろうとしているのに、UIスレッド自身が.Resultでブロックされていると、処理が進まなくなります。ConfigureAwaitのドキュメントでも、元の非同期コンテキストへ戻る挙動はUIスレッドでデッドロックにつながる可能性があると説明されています。

7-2. 競合状態とデータ不整合

競合状態とは、複数スレッドが同じデータを同時に読み書きし、実行タイミングによって結果が変わる問題です。

C#
int count = 0;

Parallel.For(0, 10000, _ =>
{
count++;
});

Console.WriteLine(count);

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

7-3. UIスレッドへのアクセスエラー

UI部品は、基本的にUIスレッドから操作する必要があります。バックグラウンドスレッドから直接UIを更新すると、例外が発生することがあります。

C#
Task.Run(() =>
{
// UIコントロールを直接更新すると危険
// label1.Text = "完了";
});

UI更新は、awaitでUIスレッドに戻ってから行うか、DispatcherInvokeを使ってUIスレッドへ処理を渡します。

7-4. 例外が正しく捕捉できない問題

Task内で発生した例外は、awaitすれば通常のtry-catchで捕捉できます。

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

しかし、Taskawaitせずに放置すると、例外の扱いが難しくなります。非同期処理は「開始したら必ず待つ」または「監視する」ことを意識しましょう。

7-5. スレッド数を増やしすぎる問題

スレッドを増やしすぎると、メモリ消費やコンテキストスイッチが増え、逆に遅くなることがあります。特に、リクエストごとに新しいThreadを作るような実装は避けるべきです。

大量の処理を同時に走らせる場合は、SemaphoreSlimで同時実行数を制限する、キューを使う、ParallelOptionsで並列度を制御するなどの設計が重要です。

8. 安全なスレッド処理のための同期制御

8-1. lockによる排他制御

lockは、共有リソースに同時にアクセスできるスレッドを1つに制限するための構文です。lock文は、指定オブジェクトの相互排他ロックを取得してブロックを実行し、終了後にロックを解放します。また、同時にその本体を実行できるスレッドを最大1つにします。

C#
private readonly object _lock = new object();
private int _count = 0;

public void Increment()
{
lock (_lock)
{
_count++;
}
}

lockの対象には、専用のオブジェクトを使います。thistypeof(...)、文字列リテラルをロック対象にするのは避けましょう。Microsoftのガイドラインでも、専用インスタンスを使い、thisType、文字列を避けること、ロック保持時間を短くすることが推奨されています。

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

lockは内部的には主にMonitorを使った排他制御です。通常の同一プロセス内の排他制御では、まずlockを使えば十分です。

Mutexは、プロセスをまたいだ排他制御が必要な場合に使われます。単一アプリ内の通常の排他制御にはやや重めです。

SemaphoreSlimは、同時にアクセスできる数を制限したいときに便利です。たとえば、API呼び出しを同時に5件までに制限する、といった用途です。SemaphoreSlimは、同時にリソースへアクセスできるスレッド数を制限する軽量なSemaphore代替として説明されています。

C#
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(5);

public async Task CallApiAsync()
{
await _semaphore.WaitAsync();

try
{
await Task.Delay(1000);
}
finally
{
_semaphore.Release();
}
}

8-3. ConcurrentDictionaryなどスレッドセーフなコレクション

複数スレッドからコレクションを更新する場合、Dictionary<TKey,TValue>List<T>をそのまま使うと危険です。

C#
using System.Collections.Concurrent;

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

counts.AddOrUpdate("apple", 1, (_, oldValue) => oldValue + 1);

System.Collections.Concurrent名前空間には、スレッドセーフでスケーラブルなコレクションが含まれており、複数スレッドから安全かつ効率的に追加・削除できます。

代表的な型は次のとおりです。

用途
ConcurrentDictionary<TKey,TValue>スレッドセーフな辞書
ConcurrentQueue<T>FIFOキュー
ConcurrentStack<T>LIFOスタック
ConcurrentBag<T>順序を問わないコレクション
BlockingCollection<T>Producer-Consumerパターン

8-4. volatileとInterlockedの使いどころ

volatileは、複数スレッドから読み書きされるフィールドについて、最適化や読み書き順序に関する問題を避けるために使われます。ただし、count++のような複合操作を安全にするものではありません。

単純な数値の加算や交換にはInterlockedが向いています。Interlocked.IncrementInterlocked.Addは、値の更新をアトミックに実行できます。Microsoftの説明でも、Interlockedは複数スレッドが同じ変数を更新するときに発生するエラーから保護するためのメソッド群として説明されています。

C#
private int _count = 0;

public void Increment()
{
Interlocked.Increment(ref _count);
}

8-5. 同期処理でパフォーマンスを落とさない考え方

同期制御は安全性を高めますが、使いすぎるとパフォーマンスを落とします。

lockの範囲はできるだけ短くします。I/O、API通信、DBアクセスのような時間のかかる処理をlock内で行うのは避けましょう。

共有状態そのものを減らすことも重要です。スレッドごとにローカル変数で計算し、最後に結果を集約する設計にすれば、ロックの必要性を減らせます。

9. async/awaitで失敗しないための注意点

9-1. async voidを避けるべき理由

async voidは、呼び出し側が完了を待てず、例外も扱いにくくなります。

C#
// 避けたい
public async void SaveAsync()
{
await File.WriteAllTextAsync("a.txt", "text");
}

通常はTaskを返します。

C#
public async Task SaveAsync()
{
await File.WriteAllTextAsync("a.txt", "text");
}

例外として、イベントハンドラーではasync voidを使います。

9-2. .Resultや.Waitでデッドロックが起きる理由

.Result.Wait()は現在のスレッドをブロックします。UIスレッドや同期コンテキストを持つ環境では、非同期処理の続きが元のコンテキストに戻ろうとしているのに、そのコンテキストがブロックされているため、デッドロックになることがあります。

C#
// 避けたい
var result = GetDataAsync().Result;

// 推奨
var result = await GetDataAsync();

非同期処理は、呼び出し元までasync/awaitでつなげるのが基本です。

9-3. ConfigureAwaitの基本的な考え方

ConfigureAwait(false)は、await後の続き処理を元のコンテキストに戻す必要がないことを示すために使います。ConfigureAwaitの引数continueOnCapturedContextは、trueならキャプチャした元のコンテキストへ継続をマーシャリングし、falseならそうしない設定です。

ライブラリコードでは、UIスレッドや特定の同期コンテキストに戻る必要がないことが多いため、ConfigureAwait(false)を検討できます。

C#
public async Task<string> LoadAsync()
{
return await File.ReadAllTextAsync("sample.txt")
.ConfigureAwait(false);
}

一方、UIアプリでawait後に画面部品を更新する場合は、安易にConfigureAwait(false)を付けない方が安全です。

9-4. 例外処理をtry-catchで正しく書く

awaitを使うと、非同期処理の例外も同期処理に近い形で捕捉できます。

C#
try
{
string text = await File.ReadAllTextAsync("notfound.txt");
}
catch (FileNotFoundException)
{
Console.WriteLine("ファイルが見つかりません");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

複数のTaskTask.WhenAllで待つ場合、いずれかのタスクが失敗すると例外が発生します。必要に応じて、各タスクの失敗を個別に記録する設計も検討しましょう。

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

長時間処理では、キャンセルできる設計が重要です。.NETのキャンセルモデルは協調的キャンセルであり、CancellationTokenSourceからトークンを渡し、処理側がキャンセル要求を検知して適切に終了します。

C#
public async Task WorkAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
cancellationToken.ThrowIfCancellationRequested();

await Task.Delay(1000, cancellationToken);
Console.WriteLine($"{i + 1}回目");
}
}

呼び出し側は次のようにキャンセルできます。

C#
using CancellationTokenSource cts = new();

Task task = WorkAsync(cts.Token);

cts.CancelAfter(3000);

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

10. 実用コードで学ぶC#スレッド処理

10-1. 時間のかかる処理をTask.Runで実行するサンプル

C#
public async Task<int> DoHeavyWorkAsync()
{
return await Task.Run(() =>
{
int result = 0;

for (int i = 0; i < 100_000_000; i++)
{
result += i % 7;
}

return result;
});
}

CPU負荷の高い処理をTask.Runで実行し、呼び出し側ではawaitします。UIアプリでは、画面を固めずに重い計算を実行できます。

10-2. 複数APIを並列実行するサンプル

C#
using System.Net.Http;

public async Task<string[]> DownloadAllAsync(string[] urls)
{
using HttpClient client = new HttpClient();

Task<string>[] tasks = urls
.Select(url => client.GetStringAsync(url))
.ToArray();

return await Task.WhenAll(tasks);
}

独立したAPI呼び出しであれば、1つずつawaitするより、先にすべて開始してTask.WhenAllで待つ方が効率的です。

10-3. 共有変数をlockで安全に更新するサンプル

C#
private readonly object _sync = new object();
private int _count = 0;

public void Add()
{
lock (_sync)
{
_count++;
}
}

public int GetCount()
{
lock (_sync)
{
return _count;
}
}

複数スレッドから_countを更新する可能性がある場合、lockで保護します。読み取りも同じロックで保護すると、整合性を保ちやすくなります。

10-4. CancellationTokenで処理を中断するサンプル

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

Console.WriteLine($"処理中: {i}");
await Task.Delay(200, cancellationToken);
}
}
C#
using CancellationTokenSource cts = new();

Task task = ProcessAsync(cts.Token);

await Task.Delay(1000);
cts.Cancel();

try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("処理を中断しました");
}

キャンセルは強制終了ではありません。処理側がCancellationTokenを確認し、適切なタイミングで終了します。協調的キャンセルでは、リスナー側がキャンセル要求を検知して応答する責任を持ちます。

10-5. UIを固めずに処理を実行するサンプル

WinFormsの例です。

C#
private async void buttonStart_Click(object sender, EventArgs e)
{
buttonStart.Enabled = false;
labelStatus.Text = "処理中...";

try
{
int result = await Task.Run(() =>
{
Thread.Sleep(3000);
return 123;
});

labelStatus.Text = $"結果: {result}";
}
catch (Exception ex)
{
labelStatus.Text = ex.Message;
}
finally
{
buttonStart.Enabled = true;
}
}

ポイントは、時間のかかる処理をTask.Runに渡し、UI側ではawaitすることです。これにより、待機中もUIスレッドをブロックしにくくなります。

11. C#スレッド処理のベストプラクティス

11-1. Threadを直接使う前にTaskを検討する

C# スレッド処理では、まずTaskを検討しましょう。Threadは低レベルな制御が必要な場合に使うものです。

一般的なアプリケーションでは、Task.Runasync/awaitTask.WhenAllCancellationTokenを組み合わせることで、多くの要件を満たせます。

11-2. 非同期処理はasync/awaitで最後までつなぐ

非同期メソッドを呼ぶなら、途中で.Result.Wait()に戻さず、呼び出し元までawaitを伝播させるのが基本です。

C#
public async Task ControllerAsync()
{
string data = await ServiceAsync();
Console.WriteLine(data);
}

「async all the way」と考えると、デッドロックやスレッドブロックを避けやすくなります。

11-3. 共有状態をできるだけ減らす

スレッドセーフなコードを書く最も簡単な方法は、共有データを減らすことです。

複数スレッドから同じ変数を更新する代わりに、各タスクでローカルに計算し、最後に集約します。

C#
int[] results = await Task.WhenAll(
Enumerable.Range(0, 10)
.Select(i => Task.Run(() => i * i))
);

int total = results.Sum();

共有状態がなければ、lockも競合状態も大幅に減ります。

11-4. キャンセルとタイムアウトを最初から設計する

時間のかかる処理には、キャンセルとタイムアウトを用意しましょう。

C#
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));

await LongRunningOperationAsync(cts.Token);

キャンセルできない処理は、ユーザー体験や運用性を悪化させます。特にAPI通信、DB処理、バッチ処理では、キャンセル可能な設計が重要です。

11-5. スレッド数ではなく同時実行数を制御する

パフォーマンス改善のために、むやみにスレッド数を増やすのは危険です。重要なのは、スレッド数ではなく同時実行数の制御です。

C#
SemaphoreSlim semaphore = new SemaphoreSlim(3);

async Task RunLimitedAsync(Func<Task> work)
{
await semaphore.WaitAsync();

try
{
await work();
}
finally
{
semaphore.Release();
}
}

外部API、DB、ファイルシステムなどには処理能力の限界があります。同時実行数を制限することで、全体の安定性を高められます。

11-6. lock内でawaitしない

lockブロック内ではawaitを使えません。C#のlock文の説明でも、lock文の本体でawait式は使用できないとされています。

C#
lock (_sync)
{
// await SomeAsync(); // コンパイル不可
}

非同期処理で排他制御が必要な場合は、SemaphoreSlim.WaitAsyncを使う方法があります。

C#
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

public async Task SafeAsync()
{
await _semaphore.WaitAsync();

try
{
await Task.Delay(1000);
}
finally
{
_semaphore.Release();
}
}

まとめ

C# スレッド処理を理解するには、まずThreadTaskasync/awaitの役割を分けて考えることが重要です。

Threadは、スレッドを直接作成・制御する低レベルな方法です。専用スレッドが必要な特殊なケースでは有効ですが、通常の開発では使いすぎない方が安全です。

Taskは、非同期・並列に実行される作業を表す高レベルな仕組みです。Task.RunTask.WhenAllTask.WhenAnyを使うことで、複数の処理を扱いやすくなります。

async/awaitは、非同期処理を読みやすく安全に書くための構文です。API通信、DBアクセス、ファイル操作、UIアプリの応答性改善では、現代のC#開発に欠かせません。

一方で、マルチスレッドにはデッドロック、競合状態、UIスレッドアクセスエラー、例外処理漏れといった落とし穴があります。共有データにはlockInterlockedConcurrentDictionaryを使い、長時間処理にはCancellationTokenを用意しましょう。

C# スレッド処理の基本方針は、「I/O待ちはasync/await」「CPU負荷はTask.Runや並列処理」「専用スレッドが必要なときだけThread」「共有状態は最小限にして同期制御する」です。この考え方を押さえれば、安全で読みやすく、応答性の高いC#アプリケーションを作りやすくなります。