C# 非同期処理の基本を図解で完全理解|async/await・Taskの使い方とよくある落とし穴

はじめに

C#の非同期処理は、Web API呼び出し、データベースアクセス、ファイル読み書き、UIアプリの画面フリーズ防止など、実務でほぼ必ず出てくる重要なテーマです。

しかし、asyncawaitTaskTask.RunConfigureAwait(false).Result.Wait() など、似たような用語や書き方が多く、最初は混乱しやすい分野でもあります。

特に初心者がつまずきやすいのは、次のような点です。

  • asyncを付けると別スレッドで動くのか

  • awaitは処理を止めているのか

  • TaskThreadは何が違うのか

  • Task.Runはいつ使うべきなのか

  • .Result.Wait()がなぜ危険なのか

  • 非同期処理にすると必ず速くなるのか

この記事では、C#の非同期処理を「待ち時間中にスレッドを塞がない仕組み」として整理し、async/awaitTaskの基本から、実務でよく使う書き方、例外処理、キャンセル、デッドロック、ベストプラクティスまでを図解とコードで解説します。

1. C#の非同期処理とは?まず検索ユーザーが知りたい全体像

1-1. 非同期処理を一言でいうと「待ち時間中にスレッドを塞がない仕組み」

C#の非同期処理を一言でいうと、時間のかかる処理の待ち時間中に、スレッドを無駄に占有しない仕組みです。

たとえば、Web APIを呼び出す処理を考えてみます。

C#
var response = await httpClient.GetAsync("https://example.com/api/users");

この処理では、ネットワーク越しにサーバーから応答が返ってくるまで待ち時間が発生します。

同期処理の場合、その待ち時間中もスレッドはずっとブロックされます。一方、非同期処理では、応答を待っている間にスレッドを解放できます。そして、処理が完了したタイミングで続きの処理を再開します。

重要なのは、**非同期処理は「待ち時間を消す仕組み」ではなく「待ち時間中にスレッドを塞がない仕組み」**だという点です。

そのため、非同期処理は特にI/O待ちが発生する処理で効果を発揮します。

I/O待ちとは、次のような外部リソースの応答を待つ時間です。

  • Web APIのレスポンス待ち

  • データベースの結果待ち

  • ファイルの読み書き待ち

  • ネットワーク通信の完了待ち

1-2. 同期処理と非同期処理の違いを図解で理解する

同期処理では、処理が完了するまで呼び出し元のスレッドが待ち続けます。

同期処理

スレッド
|
|--- API呼び出し開始
| |
| | 応答待ち中もスレッドを占有
| |
|--- API呼び出し完了
|
|--- 次の処理へ

この間、スレッドは他の仕事ができません。

一方、非同期処理では、待ち時間に入った時点でスレッドを解放できます。

非同期処理

スレッド
|
|--- API呼び出し開始
|
|--- awaitでいったん呼び出し元へ戻る
|
| 応答待ち中、スレッドは解放される
|
|--- API呼び出し完了後、続きを再開
|
|--- 次の処理へ

awaitは「ここで完全にスレッドを止める」という意味ではありません。

正確には、await対象のTaskが完了していなければ、現在のメソッドの続きを一時保存して、呼び出し元へ制御を返す仕組みです。

そのため、UIアプリでは画面が固まりにくくなり、Webアプリでは限られたスレッドを効率よく使えるようになります。

1-3. C#で非同期処理が必要になる代表例:Web API・DB・ファイル・UIアプリ

C#で非同期処理がよく使われる代表例は、次のとおりです。

まず、Web API呼び出しです。

C#
var json = await httpClient.GetStringAsync("https://example.com/api/products");

外部サービスからレスポンスが返るまで時間がかかるため、非同期処理に向いています。

次に、データベースアクセスです。

C#
var users = await dbContext.Users.ToListAsync();

データベースへの問い合わせは、クエリ実行やネットワーク通信、ディスクI/Oを伴うため、非同期処理と相性がよい処理です。

ファイル読み書きでも非同期処理は使われます。

C#
var text = await File.ReadAllTextAsync("sample.txt");

大きなファイルを扱う場合、同期的に読み書きするとその間スレッドを占有してしまいます。

UIアプリでも非同期処理は重要です。

C#
private async void Button_Click(object sender, EventArgs e)
{
var data = await LoadDataAsync();
label.Text = data;
}

時間のかかる処理をUIスレッドで同期的に実行すると、ボタンが押せない、画面が再描画されない、ウィンドウが固まるといった問題が発生します。

非同期処理を使うことで、待ち時間中もUIスレッドをブロックせず、画面の応答性を保てます。

1-4. 非同期・並行・並列・マルチスレッドの違い

C#の非同期処理を理解するうえで、混同しやすい言葉があります。

それが、非同期並行並列マルチスレッドです。

非同期:
待ち時間中にスレッドを塞がない仕組み

並行:
複数の処理を同じ時間帯に進めること

並列:
複数の処理を実際に同時実行すること

マルチスレッド:
複数のスレッドを使って処理すること

たとえば、非同期処理は必ずしも別スレッドで動くわけではありません。

C#
await Task.Delay(1000);

このコードは1秒待ちますが、その間に専用のスレッドが1秒間眠っているわけではありません。タイマーによって完了が通知され、完了後に続きを再開します。

一方、CPU負荷の高い処理を複数コアで同時に実行する場合は、並列処理やマルチスレッドの話になります。

C#
await Task.Run(() => HeavyCalculation());

この場合、Task.Runによってスレッドプール上でCPU処理を実行します。

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

1-5. 非同期処理が得意な処理と不得意な処理

非同期処理が得意なのは、待ち時間が多い処理です。

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

  • Web API呼び出し

  • データベースアクセス

  • ファイル読み書き

  • ネットワーク通信

  • 外部サービスとの連携

これらは、CPUがずっと計算しているというより、外部からの応答やI/O完了を待っている時間が長い処理です。

一方で、非同期処理がそのまま得意とは限らないのが、CPU負荷の高い処理です。

たとえば、次のような処理です。

  • 大量データの集計

  • 画像処理

  • 暗号化処理

  • 複雑な計算

  • 動画変換

これらは待ち時間ではなくCPUを使う時間が長いため、単にasync/awaitを付けるだけでは速くなりません。

CPU負荷の高い処理をUIスレッドから逃がしたい場合は、Task.Runを使うことがあります。ただし、ASP.NET Coreなどサーバーアプリで安易にTask.Runを多用すると、スレッドプールを圧迫して逆効果になる場合があります。

2. async/await・Taskの基本を図解で理解する

2-1. asyncとは何か:非同期メソッドを定義するキーワード

asyncは、C#で非同期メソッドを定義するためのキーワードです。

C#
public async Task DoWorkAsync()
{
await Task.Delay(1000);
}

asyncを付けると、そのメソッドの中でawaitを使えるようになります。

ただし、ここで重要なのは、asyncを付けただけでは処理が自動的に別スレッドで実行されるわけではないという点です。

たとえば、次のコードはasyncが付いていますが、awaitがありません。

C#
public async Task DoWorkAsync()
{
Console.WriteLine("Hello");
}

このようなメソッドは、実質的には同期的に実行されます。コンパイラからも「awaitがない」という警告が出ます。

asyncは「このメソッドは非同期処理を含む可能性があり、awaitを使って途中で呼び出し元へ制御を返せる」という宣言です。

2-2. awaitとは何か:Taskの完了を待って続きを再開する仕組み

awaitは、Taskの完了を待ち、完了後にその続きを再開するためのキーワードです。

C#
public async Task<string> GetMessageAsync()
{
await Task.Delay(1000);
return "完了しました";
}

このコードでは、Task.Delay(1000)が完了するまで待ちます。

ただし、同期的なThread.Sleep(1000)とは違います。

C#
Thread.Sleep(1000);       // スレッドを1秒間ブロックする
await Task.Delay(1000); // スレッドを塞がずに1秒後に続きを再開する

図にすると次のようになります。

await前

非同期処理を開始

まだ完了していない

現在のメソッドの続きを保存

呼び出し元へ戻る

Task完了

保存していた続きから再開

awaitは「処理を止める」のではなく、「続きの処理を予約して、いったん戻る」と考えると理解しやすくなります。

2-3. Taskとは何か:非同期処理の結果や状態を表す型

Taskは、非同期処理の状態を表す型です。

戻り値がない非同期処理では、Taskを返します。

C#
public async Task SaveAsync()
{
await Task.Delay(1000);
Console.WriteLine("保存しました");
}

Taskは、処理そのものの完了状態や例外情報を持っています。

主な状態は次のようなものです。

  • 実行中

  • 完了

  • キャンセル

  • 失敗

つまり、Taskは「将来完了する処理を表すオブジェクト」と考えると分かりやすいです。

Task
├─ 処理は完了したか
├─ 結果はあるか
├─ 例外は発生したか
└─ キャンセルされたか

awaitは、このTaskを監視し、完了したら結果を取り出したり例外を再スローしたりします。

2-4. Task<T>とは何か:戻り値がある非同期処理

戻り値がある非同期処理では、Task<T>を使います。

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

このメソッドは、最終的にintを返します。

呼び出し側では、awaitするとTask<int>の中身であるintを受け取れます。

C#
int count = await GetCountAsync();
Console.WriteLine(count);

図にすると次のようなイメージです。

GetCountAsync()

Task<int> を返す

awaitで完了を待つ

int の結果を取り出す

Task<T>は、「今すぐTが返る」のではなく「将来Tが得られる処理」を表します。

2-5. async Task・async Task<T>・async voidの違い

C#の非同期メソッドでは、主に次の3つの戻り値があります。

C#
async Task
async Task<T>
async void

async Taskは、戻り値がない非同期処理に使います。

C#
public async Task SaveAsync()
{
await Task.Delay(1000);
}

async Task<T>は、戻り値がある非同期処理に使います。

C#
public async Task<string> LoadAsync()
{
await Task.Delay(1000);
return "データ";
}

async voidは、基本的にイベントハンドラでのみ使います。

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

async voidが危険な理由は、呼び出し側が完了を待てず、例外も通常のtry-catchで扱いにくくなるためです。

たとえば、次のような書き方は避けるべきです。

C#
public async void SaveAsync()
{
await Task.Delay(1000);
throw new Exception("失敗");
}

呼び出し側はこの処理をawaitできません。

C#
SaveAsync(); // 完了も例外も管理しづらい

原則として、非同期メソッドはTaskまたはTask<T>を返すようにします。

2-6. awaitしたとき内部で何が起きるのかを図解する

awaitを書いたとき、内部ではおおよそ次のような流れになります。

asyncメソッド開始

await対象のTaskを取得

Taskは完了済み?
├─ はい → そのまま続きへ
└─ いいえ

続きの処理をコールバックとして登録

呼び出し元へ戻る

Task完了

登録していた続きの処理を再開

コードで見ると、次のような処理です。

C#
public async Task RunAsync()
{
Console.WriteLine("1");

await Task.Delay(1000);

Console.WriteLine("2");
}

実行イメージは次のとおりです。

RunAsync開始

"1"を出力

Task.Delay開始

awaitでいったん戻る

1秒後

"2"を出力

awaitの後の処理は、非同期処理が完了してから再開されます。

この「続きを再開する」という考え方が、C#の非同期処理を理解するうえで非常に重要です。

3. C#非同期処理の最小コードで基本構文を理解する

3-1. 最小のasync/awaitサンプル

まずは、最小構成のasync/awaitサンプルを見てみましょう。

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

class Program
{
static async Task Main()
{
Console.WriteLine("開始");

await DoSomethingAsync();

Console.WriteLine("終了");
}

static async Task DoSomethingAsync()
{
await Task.Delay(1000);
Console.WriteLine("非同期処理が完了しました");
}
}

実行結果は次のようになります。

開始
非同期処理が完了しました
終了

DoSomethingAsyncは、1秒待ってからメッセージを表示します。

ポイントは、DoSomethingAsyncの戻り値がTaskであり、呼び出し側がawaitしていることです。

C#
await DoSomethingAsync();

これにより、DoSomethingAsyncが完了してから次の行に進みます。

3-2. Task.Delayで「待つだけ」の非同期処理を書く

非同期処理の学習では、Task.Delayがよく使われます。

C#
await Task.Delay(1000);

これは「1秒待つ」処理ですが、Thread.Sleepとは大きく違います。

C#
Thread.Sleep(1000);     // スレッドをブロックする
await Task.Delay(1000); // スレッドをブロックしない

比較すると次のようになります。

Thread.Sleep
スレッドを占有したまま待つ

Task.Delay
タイマーに任せて、スレッドを解放する

そのため、UIアプリやサーバーアプリでは、単に待ちたい場合でもThread.SleepではなくTask.Delayを使うのが基本です。

C#
public async Task WaitAsync()
{
Console.WriteLine("待機開始");

await Task.Delay(2000);

Console.WriteLine("待機終了");
}

3-3. 戻り値のある非同期メソッドを書く

戻り値のある非同期メソッドは、Task<T>を返します。

C#
public async Task<string> GetUserNameAsync()
{
await Task.Delay(1000);
return "田中";
}

呼び出し側では、awaitして結果を受け取ります。

C#
string name = await GetUserNameAsync();
Console.WriteLine(name);

awaitしない場合は、結果そのものではなくTask<string>が返ります。

C#
Task<string> task = GetUserNameAsync();

この時点では、stringではありません。

GetUserNameAsync() の戻り値
= string ではなく Task<string>

await GetUserNameAsync() の結果
= string

戻り値のある非同期メソッドでは、戻り値の型をそのまま書くのではなく、Task<T>で包むと覚えておきましょう。

3-4. 呼び出し側もasyncにする理由

非同期メソッドを呼び出す側も、多くの場合asyncにする必要があります。

C#
public async Task ExecuteAsync()
{
string name = await GetUserNameAsync();
Console.WriteLine(name);
}

なぜなら、awaitを使うためには、そのメソッド自体にasyncを付ける必要があるからです。

GetUserNameAsync が async

呼び出し側で await したい

呼び出し側も async にする

さらにその呼び出し側も await される

このように、非同期処理は呼び出し元へ連鎖していきます。

これをよく async all the way と呼びます。

悪い例は、非同期メソッドを同期的に待ってしまうことです。

C#
string name = GetUserNameAsync().Result;

このような書き方は、デッドロックやスレッドブロックの原因になります。

できる限り、上位の呼び出し元までasync/awaitでつなぐことが大切です。

3-5. メソッド名にAsyncを付けるべき理由

C#では、非同期メソッドの末尾にAsyncを付けるのが一般的です。

C#
GetUserAsync()
SaveFileAsync()
LoadDataAsync()
SendEmailAsync()

これは単なる好みではありません。

呼び出し側が、そのメソッドが非同期処理であることをすぐに理解できるようにするためです。

C#
var user = await GetUserAsync();

Asyncが付いていれば、呼び出し側は「このメソッドはawaitして使うものだ」と判断しやすくなります。

逆に、次のような名前だと分かりにくくなります。

C#
GetUser()
SaveFile()
LoadData()

内部で非同期処理を行っているなら、名前にも明示することで可読性が上がります。

3-6. Mainメソッドでasync/awaitを使う方法

C#では、Mainメソッドも非同期にできます。

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

class Program
{
static async Task Main(string[] args)
{
await RunAsync();
}

static async Task RunAsync()
{
await Task.Delay(1000);
Console.WriteLine("完了");
}
}

昔のコードでは、Mainで非同期処理を呼ぶために次のような書き方を見かけることがあります。

C#
RunAsync().Wait();

または、

C#
RunAsync().GetAwaiter().GetResult();

ただし、基本的にはasync Task Mainを使える環境であれば、素直にawaitする書き方が分かりやすく安全です。

C#
static async Task Main()
{
await RunAsync();
}

コンソールアプリでも、非同期処理はasync/awaitで自然に書けます。

4. 実務でよく使うC#非同期処理の使い方

4-1. HttpClientでWeb APIを非同期に呼び出す

Web API呼び出しは、C#非同期処理の代表的な用途です。

C#
using System.Net.Http;

public class ApiClient
{
private readonly HttpClient _httpClient;

public ApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}

public async Task<string> GetUsersAsync()
{
var response = await _httpClient.GetAsync("https://example.com/api/users");

response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync();
}
}

GetAsyncReadAsStringAsyncも非同期メソッドです。

HTTPリクエスト送信

サーバー応答待ち

レスポンス受信

本文読み取り

この待ち時間中にスレッドを塞がないため、WebアプリやUIアプリで効率よく動作します。

実務では、HttpClientを毎回newするのではなく、DIコンテナやIHttpClientFactoryを使って管理することが多いです。

4-2. ファイル読み書きを非同期で行う

ファイル読み込みも非同期で書けます。

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

ファイル書き込みは次のように書けます。

C#
public async Task WriteFileAsync(string path, string content)
{
await File.WriteAllTextAsync(path, content);
}

大きなファイルを扱う場合や、UIアプリでファイル操作を行う場合は、非同期メソッドを使うことで応答性を保ちやすくなります。

複数行を読み込む場合も同様です。

C#
public async Task<string[]> ReadLinesAsync(string path)
{
return await File.ReadAllLinesAsync(path);
}

ポイントは、ファイルI/Oの待ち時間中にスレッドをブロックしないことです。

4-3. データベースアクセスを非同期で行う

Entity Framework Coreなどを使う場合、データベースアクセスも非同期で書くのが一般的です。

C#
public async Task<List<User>> GetUsersAsync()
{
return await _dbContext.Users
.Where(user => user.IsActive)
.ToListAsync();
}

1件だけ取得する場合は、次のように書けます。

C#
public async Task<User?> GetUserAsync(int id)
{
return await _dbContext.Users
.FirstOrDefaultAsync(user => user.Id == id);
}

保存処理も非同期で行えます。

C#
public async Task AddUserAsync(User user)
{
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
}

データベース処理は、アプリケーションから見ると結果が返るまで待つ処理です。

そのため、同期的に待つのではなく、非同期メソッドを使うことでスレッドを有効活用できます。

4-4. UIアプリで画面フリーズを防ぐ

Windows FormsやWPFなどのUIアプリでは、UIスレッドが画面の描画や操作イベントを担当しています。

このUIスレッドで時間のかかる処理を同期的に実行すると、画面が固まります。

悪い例です。

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

このコードでは、5秒間UIスレッドがブロックされます。

非同期にすると、次のように書けます。

C#
private async void Button_Click(object sender, EventArgs e)
{
label.Text = "処理中...";

await Task.Delay(5000);

label.Text = "完了";
}

この場合、待機中もUIスレッドはブロックされないため、画面の再描画や操作が可能です。

CPU負荷の高い処理をUIスレッドから逃がす場合は、Task.Runを使うことがあります。

C#
private async void Button_Click(object sender, EventArgs e)
{
label.Text = "計算中...";

var result = await Task.Run(() => HeavyCalculation());

label.Text = result.ToString();
}

4-5. ASP.NET Coreで非同期処理を使う

ASP.NET Coreでは、ControllerやMinimal APIでも非同期処理を自然に使えます。

C#
[HttpGet("{id}")]
public async Task<IActionResult> GetUserAsync(int id)
{
var user = await _dbContext.Users
.FirstOrDefaultAsync(x => x.Id == id);

if (user is null)
{
return NotFound();
}

return Ok(user);
}

ASP.NET Coreでは、リクエストごとに処理が行われます。

データベースや外部APIの応答待ちでスレッドをブロックすると、同時に処理できるリクエスト数に影響します。

非同期処理を使うことで、I/O待ちの間にスレッドを解放し、より効率よくリクエストを処理できます。

ただし、CPU負荷の高い処理をむやみにTask.Runで包むのは避けるべきです。

サーバー側では、スレッドプールのスレッドを追加で使うことになり、負荷が高い状況では逆効果になる可能性があります。

4-6. I/O待ち処理ではTask.Runを使わない理由

初心者がよくやりがちなのが、I/O処理を何でもTask.Runで包む書き方です。

C#
var result = await Task.Run(() =>
{
return httpClient.GetStringAsync(url).Result;
});

この書き方はおすすめできません。

理由は、もともとGetStringAsyncが非同期I/Oに対応しているため、Task.Runで別スレッドに逃がす必要がないからです。

正しくは次のように書きます。

C#
var result = await httpClient.GetStringAsync(url);

図にすると、違いは明確です。

悪い例:
Task.Runでスレッドを使う

その中でI/O待ち

スレッドを無駄に占有

良い例:
非同期I/Oを直接await

待ち時間中にスレッドを解放

Task.Runは、主にCPU負荷の高い同期処理を別スレッドで実行したいときに使います。

I/O処理に対しては、ライブラリが提供しているxxxAsyncメソッドを直接awaitするのが基本です。

5. 複数の非同期処理を扱う方法

5-1. awaitを順番に書いた場合の動き

複数の非同期処理を順番にawaitすると、処理は直列に実行されます。

C#
var user = await GetUserAsync();
var orders = await GetOrdersAsync();
var points = await GetPointsAsync();

このコードでは、次のように動きます。

GetUserAsync 開始

完了

GetOrdersAsync 開始

完了

GetPointsAsync 開始

完了

それぞれ1秒かかる場合、合計で約3秒かかります。

処理同士に依存関係がある場合は、この書き方で問題ありません。

たとえば、ユーザー情報を取得して、そのユーザーIDを使って注文履歴を取得する場合です。

C#
var user = await GetUserAsync();
var orders = await GetOrdersAsync(user.Id);

この場合、GetOrdersAsyncuser.Idが必要なので、順番に実行する必要があります。

5-2. Task.WhenAllで複数処理を同時に待つ

互いに依存しない非同期処理であれば、Task.WhenAllを使ってまとめて待てます。

C#
var userTask = GetUserAsync();
var ordersTask = GetOrdersAsync();
var pointsTask = GetPointsAsync();

await Task.WhenAll(userTask, ordersTask, pointsTask);

var user = await userTask;
var orders = await ordersTask;
var points = await pointsTask;

この場合、3つの処理を先に開始してからまとめて待ちます。

GetUserAsync   開始 ───── 完了
GetOrdersAsync 開始 ───── 完了
GetPointsAsync 開始 ───── 完了

すべて完了したら続きへ

それぞれ1秒かかる処理であれば、合計時間は約1秒に近づきます。

ただし、外部APIやデータベースに対して大量のリクエストを一気に投げると、相手側や自分のアプリに負荷がかかります。

必要に応じて、同時実行数を制御することが重要です。

5-3. Task.WhenAnyで最初に終わった処理を受け取る

Task.WhenAnyは、複数のTaskのうち最初に完了したものを受け取るためのメソッドです。

C#
var task1 = GetFromServer1Async();
var task2 = GetFromServer2Async();

var completedTask = await Task.WhenAny(task1, task2);

var result = await completedTask;

使いどころとしては、複数の候補のうち最初に応答したものを使いたい場合などがあります。

Server1: 開始 ───────── 完了
Server2: 開始 ── 完了

先に終わったServer2を使う

注意点として、WhenAnyは「最初に完了したTask」を返すだけです。

そのTaskが成功したとは限りません。失敗やキャンセルで完了している可能性もあります。

そのため、結果を取り出すときは、返ってきたTaskをさらにawaitして例外を適切に扱います。

C#
try
{
var completedTask = await Task.WhenAny(task1, task2);
var result = await completedTask;
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

5-4. 直列実行と並行実行の違いを図解する

直列実行は、1つずつ順番に処理します。

直列実行

処理A: ─────
処理B: ─────
処理C: ─────

合計時間: A + B + C

並行実行は、複数の処理を同じ時間帯に進めます。

並行実行

処理A: ─────
処理B: ─────
処理C: ─────

合計時間: 最も遅い処理に近い

C#では、非同期メソッドを呼び出した時点で処理が開始されるケースが多いです。

そのため、次のように書くと並行実行になります。

C#
var taskA = ProcessAAsync();
var taskB = ProcessBAsync();
var taskC = ProcessCAsync();

await Task.WhenAll(taskA, taskB, taskC);

一方、次のように書くと直列実行です。

C#
await ProcessAAsync();
await ProcessBAsync();
await ProcessCAsync();

違いは、awaitする前に複数のTaskを開始しているかどうかです。

5-5. 複数APIを呼び出す実践コード例

複数のAPIを呼び出して、結果をまとめる例を見てみましょう。

C#
public async Task<DashboardViewModel> GetDashboardAsync(int userId)
{
var userTask = GetUserAsync(userId);
var ordersTask = GetOrdersAsync(userId);
var notificationsTask = GetNotificationsAsync(userId);

await Task.WhenAll(userTask, ordersTask, notificationsTask);

return new DashboardViewModel
{
User = await userTask,
Orders = await ordersTask,
Notifications = await notificationsTask
};
}

この例では、ユーザー情報、注文履歴、通知一覧を並行して取得しています。

それぞれが独立しているなら、順番に待つより効率的です。

ただし、次のように依存関係がある場合は直列にする必要があります。

C#
var user = await GetUserAsync(userId);
var orders = await GetOrdersAsync(user.Id);

並行化できるかどうかは、「後続の処理が前の処理の結果に依存しているか」で判断します。

5-6. 並行数を制御したいときの考え方

大量の非同期処理を一気に開始すると、外部API、データベース、ファイルシステム、自分のアプリに負荷がかかります。

たとえば、1000件のURLに対して一斉にHTTPリクエストを送るのは危険です。

C#
var tasks = urls.Select(url => httpClient.GetStringAsync(url));
var results = await Task.WhenAll(tasks);

この書き方では、URLの数だけ一気に処理が走る可能性があります。

並行数を制御したい場合は、SemaphoreSlimを使う方法があります。

C#
public async Task<List<string>> FetchAllAsync(IEnumerable<string> urls)
{
using var semaphore = new SemaphoreSlim(5);
var results = new List<string>();

var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync();

try
{
var result = await _httpClient.GetStringAsync(url);

lock (results)
{
results.Add(result);
}
}
finally
{
semaphore.Release();
}
});

await Task.WhenAll(tasks);

return results;
}

この例では、同時に実行されるHTTPリクエストを最大5件に制限しています。

実務では、外部サービスのレート制限やデータベース接続数を考慮して、適切な並行数を設定することが重要です。

6. Task.Runの正しい使い方と注意点

6-1. Task.Runは何をするメソッドなのか

Task.Runは、指定した処理をスレッドプール上で実行するためのメソッドです。

C#
var result = await Task.Run(() =>
{
return HeavyCalculation();
});

主な用途は、CPU負荷の高い同期処理を、現在のスレッドから切り離して実行することです。

特にUIアプリでは、重い計算をUIスレッドで実行すると画面が固まります。

C#
private async void Button_Click(object sender, EventArgs e)
{
var result = await Task.Run(() => HeavyCalculation());

label.Text = result.ToString();
}

このように書くと、重い計算を別スレッドで実行し、完了後にUIスレッドへ戻って画面を更新できます。

ただし、Task.Runは万能ではありません。

I/O待ち処理には、基本的にTask.Runではなく、非同期APIを直接使います。

6-2. CPU負荷の高い処理を別スレッドで実行する

CPU負荷の高い処理とは、CPUを使って計算し続ける処理です。

たとえば、次のような処理です。

C#
public int HeavyCalculation()
{
var total = 0;

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

return total;
}

これをUIスレッドで直接実行すると、処理が終わるまで画面が反応しません。

C#
private void Button_Click(object sender, EventArgs e)
{
var result = HeavyCalculation();
label.Text = result.ToString();
}

Task.Runを使うと、UIスレッドをブロックせずに実行できます。

C#
private async void Button_Click(object sender, EventArgs e)
{
label.Text = "計算中...";

var result = await Task.Run(() => HeavyCalculation());

label.Text = $"結果: {result}";
}

図にすると次のようになります。

UIスレッド
|--- ボタンクリック
|--- Task.Runで計算を別スレッドへ
|--- UI操作を受け付け可能
|--- 計算完了後に結果を表示

スレッドプール
|--- HeavyCalculationを実行

6-3. I/O処理にTask.Runを使うべきではない理由

I/O処理に対してTask.Runを使うのは、多くの場合不要です。

悪い例です。

C#
var json = await Task.Run(() =>
{
return httpClient.GetStringAsync(url).Result;
});

このコードでは、Task.Runでスレッドプールのスレッドを使い、その中で.Resultにより非同期処理を同期的に待っています。

これは、非同期処理の利点を壊してしまう書き方です。

正しくは次のように書きます。

C#
var json = await httpClient.GetStringAsync(url);

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

C#
var text = await File.ReadAllTextAsync(path);

データベースアクセスも同様です。

C#
var users = await _dbContext.Users.ToListAsync();

I/O処理では、ライブラリが提供する非同期メソッドをそのままawaitするのが基本です。

6-4. asyncを付けただけでは別スレッドにならない

よくある誤解が、「asyncを付けると別スレッドで実行される」というものです。

これは正しくありません。

C#
public async Task DoWorkAsync()
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);

await Task.Delay(1000);

Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
}

asyncは、メソッドを別スレッドに移動するキーワードではありません。

asyncは、awaitを使って非同期的に中断・再開できるメソッドを作るためのキーワードです。

別スレッドで処理したい場合は、Task.Runなどを使います。

C#
await Task.Run(() =>
{
HeavyCalculation();
});

ただし、何でも別スレッドにすればよいわけではありません。

I/O待ちなら非同期APIを直接awaitし、CPU負荷が高い処理なら必要に応じてTask.Runを検討します。

6-5. Task.Runを使うべきケース・使わないケース

Task.Runを使うべきケースは、主に次のような場合です。

  • UIアプリで重いCPU処理を画面から切り離したい

  • 同期的なCPU処理しか提供されていない

  • バックグラウンドで計算処理を行いたい

例です。

C#
var result = await Task.Run(() => HeavyCalculation());

一方、使わない方がよいケースは次のとおりです。

  • Web API呼び出し

  • データベースアクセス

  • ファイルI/O

  • すでにAsyncメソッドが用意されている処理

  • ASP.NET Coreでの通常のリクエスト処理

悪い例です。

C#
await Task.Run(async () =>
{
await httpClient.GetStringAsync(url);
});

この場合、GetStringAsync自体が非同期なので、Task.Runで包む意味はほとんどありません。

6-6. Task.Runとasync/awaitの関係を図解する

Task.Runasync/awaitは、役割が違います。

Task.Run:
処理をスレッドプールで実行する

async/await:
非同期処理の完了を待ち、続きを再開する

組み合わせると、次のようになります。

C#
var result = await Task.Run(() => HeavyCalculation());

図にすると次のとおりです。

呼び出し元

Task.Runで重い処理を開始

awaitで完了を待つ

待っている間、呼び出し元スレッドは解放

計算完了

結果を受け取って続きへ

Task.Runは「どこで実行するか」に関係します。

awaitは「完了をどう待つか」に関係します。

この2つを混同しないことが大切です。

7. C#非同期処理で必ず押さえる例外処理

7-1. await中に例外が発生した場合の動き

非同期処理中に例外が発生した場合、その例外はTaskに保持されます。

そして、呼び出し側がawaitしたタイミングで再スローされます。

C#
public async Task RunAsync()
{
await FailAsync();
}

public async Task FailAsync()
{
await Task.Delay(1000);
throw new InvalidOperationException("失敗しました");
}

呼び出し側では、通常の同期処理と同じようにtry-catchで捕捉できます。

C#
try
{
await RunAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

図にすると次のようになります。

FailAsync開始

await Task.Delay

例外発生

Taskが失敗状態になる

呼び出し側がawait

例外が再スローされる

catchで捕捉

7-2. try-catchで非同期処理の例外を捕捉する

非同期処理の例外は、awaitする場所でtry-catchすれば捕捉できます。

C#
public async Task ExecuteAsync()
{
try
{
var result = await GetDataAsync();
Console.WriteLine(result);
}
catch (HttpRequestException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"予期しないエラー: {ex.Message}");
}
}

ポイントは、awaittryブロックの中に入れることです。

悪い例です。

C#
try
{
var task = GetDataAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

この書き方では、GetDataAsync内で後から発生した例外を捕捉できない場合があります。

正しくは、次のようにawaitします。

C#
try
{
var data = await GetDataAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

7-3. Task.WhenAllで複数例外が発生した場合

Task.WhenAllで複数の非同期処理を待つ場合、複数のTaskで例外が発生する可能性があります。

C#
var task1 = Process1Async();
var task2 = Process2Async();
var task3 = Process3Async();

try
{
await Task.WhenAll(task1, task2, task3);
}
catch (Exception ex)
{
Console.WriteLine($"例外: {ex.Message}");
}

await Task.WhenAll(...)では例外を捕捉できますが、複数の例外をすべて確認したい場合は、それぞれのTaskの状態を見る必要があります。

C#
var tasks = new[]
{
Process1Async(),
Process2Async(),
Process3Async()
};

try
{
await Task.WhenAll(tasks);
}
catch
{
foreach (var task in tasks.Where(t => t.IsFaulted))
{
foreach (var ex in task.Exception!.InnerExceptions)
{
Console.WriteLine(ex.Message);
}
}
}

実務では、複数処理を並行実行する場合、どの処理が失敗したかをログに残すことが重要です。

7-4. 例外が握りつぶされる危険な書き方

非同期処理で危険なのは、Taskを開始したのにawaitしない書き方です。

C#
public void Execute()
{
DoWorkAsync();
}

このコードでは、DoWorkAsyncの完了を待っていません。

もしDoWorkAsyncの中で例外が発生しても、呼び出し側で自然に捕捉できません。

C#
public async Task DoWorkAsync()
{
await Task.Delay(1000);
throw new Exception("失敗");
}

正しくは、呼び出し側も非同期にしてawaitします。

C#
public async Task ExecuteAsync()
{
await DoWorkAsync();
}

どうしても待たない処理が必要な場合でも、例外を内部で必ず処理するべきです。

C#
public void StartBackgroundWork()
{
_ = RunSafelyAsync();
}

private async Task RunSafelyAsync()
{
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
Console.WriteLine($"バックグラウンド処理で例外: {ex.Message}");
}
}

7-5. fire-and-forgetで例外を見失う問題

fire-and-forgetとは、「非同期処理を開始するが、完了を待たない」書き方です。

C#
_ = SendEmailAsync();

この書き方は一見便利ですが、次の問題があります。

  • 完了したか分からない

  • 失敗しても呼び出し側で気づけない

  • キャンセルできない

  • アプリ終了時に途中で失われる可能性がある

特にASP.NET Coreのリクエスト処理中にfire-and-forgetを使うのは注意が必要です。

リクエスト終了後に必要なサービススコープが破棄され、バックグラウンド処理でエラーになることがあります。

安全に行うなら、バックグラウンドキューやホステッドサービスなど、管理された仕組みを使うべきです。

単純なfire-and-forgetを書く場合でも、最低限、例外処理を含めます。

C#
_ = Task.Run(async () =>
{
try
{
await SendEmailAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
});

ただし、これは万能な解決策ではありません。実務では、処理の完了、失敗、リトライ、キャンセルをどう扱うかまで設計する必要があります。

7-6. ログ出力とエラーハンドリングの実務パターン

実務では、非同期処理の例外を単にcatchするだけでなく、ログ出力やリトライ、ユーザーへの通知も考慮します。

C#
public async Task<User?> GetUserSafeAsync(int id)
{
try
{
return await _userRepository.GetUserAsync(id);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "ユーザー情報の取得に失敗しました。UserId={UserId}", id);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "予期しないエラーが発生しました。UserId={UserId}", id);
throw;
}
}

ポイントは、握りつぶしてよい例外と、上位に伝えるべき例外を分けることです。

復旧可能な例外
→ ログを出して代替値を返す

復旧不能な例外
→ ログを出して再スローする

呼び出し側で判断すべき例外
→ そのまま上位へ伝える

catch (Exception)で何でも握りつぶすと、障害の原因が分からなくなります。

非同期処理でも、例外処理の基本は同期処理と同じです。

「どこで捕捉し、どこでログを出し、どこでユーザーに伝えるか」を設計することが重要です。

8. キャンセル・タイムアウト・進捗表示の実装方法

8-1. CancellationTokenとは何か

CancellationTokenは、非同期処理にキャンセル要求を伝えるための仕組みです。

非同期処理では、時間のかかる処理を途中でやめたいケースがあります。

たとえば、次のような場合です。

  • ユーザーがキャンセルボタンを押した

  • HTTPリクエストが中断された

  • タイムアウトになった

  • アプリケーションが終了する

CancellationTokenを使うと、呼び出し側から処理側へ「キャンセルしてほしい」という通知を渡せます。

C#
public async Task LoadAsync(CancellationToken cancellationToken)
{
await Task.Delay(5000, cancellationToken);
}

キャンセルは、強制終了ではありません。

処理側がCancellationTokenを確認し、適切なタイミングで中断する協調的な仕組みです。

8-2. 非同期処理をキャンセル可能にする基本コード

キャンセル可能な処理を書くには、メソッドの引数にCancellationTokenを追加します。

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

Console.WriteLine($"処理中: {i}");

await Task.Delay(1000, cancellationToken);
}
}

呼び出し側では、CancellationTokenSourceを作成します。

C#
using var cts = new CancellationTokenSource();

var task = DoWorkAsync(cts.Token);

// 途中でキャンセル
cts.Cancel();

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

図にすると次のようになります。

呼び出し側

CancellationTokenSourceを作成

Tokenを非同期メソッドへ渡す

Cancelを呼ぶ

処理側がTokenを確認

OperationCanceledExceptionで中断

8-3. タイムアウトを実装する方法

一定時間を超えたらキャンセルしたい場合は、CancellationTokenSourceにタイムアウトを設定できます。

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

try
{
await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("タイムアウトしました");
}

または、後からCancelAfterを呼ぶこともできます。

C#
using var cts = new CancellationTokenSource();

cts.CancelAfter(TimeSpan.FromSeconds(5));

await DoWorkAsync(cts.Token);

HTTPリクエストに対しても、CancellationTokenを渡せます。

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

var response = await httpClient.GetAsync(url, cts.Token);

タイムアウト処理では、「キャンセルされた理由」がユーザー操作なのかタイムアウトなのかを区別したい場合があります。

その場合は、別々のCancellationTokenSourceを管理する設計を検討します。

8-4. HttpClientでキャンセルを扱う

HttpClientの非同期メソッドには、CancellationTokenを渡せるものがあります。

C#
public async Task<string> GetJsonAsync(
string url,
CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(url, cancellationToken);

response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync(cancellationToken);
}

呼び出し側です。

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

try
{
var json = await GetJsonAsync("https://example.com/api/users", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("HTTPリクエストがキャンセルされました");
}

ASP.NET Coreでは、HTTPリクエストがクライアントから切断されたときにキャンセル通知を受け取れる場合があります。

Controllerでは、アクションメソッドの引数にCancellationTokenを追加できます。

C#
[HttpGet]
public async Task<IActionResult> GetAsync(CancellationToken cancellationToken)
{
var users = await _dbContext.Users.ToListAsync(cancellationToken);

return Ok(users);
}

これにより、クライアントが切断した後も無駄な処理を続けるリスクを減らせます。

8-5. UIアプリでキャンセルボタンを実装する

UIアプリでは、キャンセルボタンとCancellationTokenSourceを組み合わせます。

C#
private CancellationTokenSource? _cts;

private async void StartButton_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();

try
{
statusLabel.Text = "処理中...";

await LongRunningAsync(_cts.Token);

statusLabel.Text = "完了";
}
catch (OperationCanceledException)
{
statusLabel.Text = "キャンセルされました";
}
finally
{
_cts.Dispose();
_cts = null;
}
}

private void CancelButton_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}

private async Task LongRunningAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
cancellationToken.ThrowIfCancellationRequested();

await Task.Delay(1000, cancellationToken);
}
}

この例では、開始ボタンで処理を開始し、キャンセルボタンでCancelを呼びます。

ポイントは、長時間処理の中で定期的にCancellationTokenを確認することです。

キャンセル要求は、処理を強制的に止める命令ではありません。処理側がキャンセルに対応して初めて中断できます。

8-6. IProgress<T>で進捗を表示する

非同期処理の進捗をUIに表示したい場合は、IProgress<T>を使えます。

C#
public async Task DownloadAsync(
IProgress<int> progress,
CancellationToken cancellationToken)
{
for (int i = 0; i <= 100; i += 10)
{
cancellationToken.ThrowIfCancellationRequested();

await Task.Delay(300, cancellationToken);

progress.Report(i);
}
}

UI側では、Progress<T>を作成します。

C#
private async void StartButton_Click(object sender, EventArgs e)
{
using var cts = new CancellationTokenSource();

var progress = new Progress<int>(value =>
{
progressBar.Value = value;
statusLabel.Text = $"{value}%";
});

await DownloadAsync(progress, cts.Token);
}

Progress<T>を使うと、UIスレッドへの反映を扱いやすくなります。

進捗表示の基本形は次のとおりです。

長時間処理

progress.Report(進捗値)

UI側のProgress<T>が受け取る

プログレスバーやラベルを更新

進捗表示、キャンセル、例外処理を組み合わせると、実務で使いやすい非同期処理になります。

9. C#非同期処理でよくある落とし穴

9-1. .Resultや.Wait()でデッドロックする

C#非同期処理で最も有名な落とし穴が、.Result.Wait()です。

悪い例です。

C#
var result = GetDataAsync().Result;

または、

C#
GetDataAsync().Wait();

これらは、非同期処理を同期的に待つ書き方です。

UIアプリや古いASP.NETなど、特定のコンテキストではデッドロックの原因になります。

安全な書き方は、awaitすることです。

C#
var result = await GetDataAsync();

.Result.Wait()は、スレッドをブロックします。

非同期処理の利点を失うだけでなく、後述するSynchronizationContextとの組み合わせで処理が止まることがあります。

9-2. async voidを多用して例外処理できなくなる

async voidは、イベントハンドラ以外では基本的に使わないようにします。

悪い例です。

C#
public async void SaveAsync()
{
await Task.Delay(1000);
throw new Exception("保存に失敗しました");
}

このメソッドは呼び出し側でawaitできません。

C#
SaveAsync();

そのため、完了を待てず、例外も扱いにくくなります。

正しくはTaskを返します。

C#
public async Task SaveAsync()
{
await Task.Delay(1000);
throw new Exception("保存に失敗しました");
}

呼び出し側では、次のように例外を捕捉できます。

C#
try
{
await SaveAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

async voidは、UIイベントハンドラなど、戻り値の型がvoidに決まっている場合だけに限定しましょう。

9-3. awaitを書き忘れて処理順序が崩れる

awaitを書き忘れると、処理の完了を待たずに次へ進んでしまいます。

悪い例です。

C#
SaveAsync();
Console.WriteLine("保存完了");

このコードでは、SaveAsyncが完了する前に「保存完了」と表示される可能性があります。

正しくは次のように書きます。

C#
await SaveAsync();
Console.WriteLine("保存完了");

また、戻り値がある場合も注意が必要です。

C#
var task = GetUserAsync();

これはユーザー情報ではなく、Task<User>です。

C#
var user = await GetUserAsync();

awaitを書き忘れると、処理順序、例外処理、戻り値の扱いが崩れやすくなります。

9-4. Taskを返すだけで非同期化できたと誤解する

Taskを返しているからといって、必ずしも本当に非同期処理になっているとは限りません。

たとえば、次のようなコードです。

C#
public Task<int> CalculateAsync()
{
var result = HeavyCalculation();
return Task.FromResult(result);
}

このコードでは、HeavyCalculationは同期的に実行されています。

Task.FromResultは、すでに計算済みの結果をTaskとして包んでいるだけです。

HeavyCalculationを実行

結果が出る

Task.FromResultでTask化

つまり、呼び出し側からはTask<int>に見えても、重い処理はすでに同期的に終わっています。

本当に非同期I/Oを行うなら、非同期APIを使います。

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

CPU負荷の高い処理を別スレッドで実行したいなら、必要に応じてTask.Runを使います。

C#
public Task<int> CalculateAsync()
{
return Task.Run(() => HeavyCalculation());
}

ただし、サーバーアプリでのTask.Run多用は注意が必要です。

9-5. Task.Runを何でも使って逆に遅くなる

Task.Runを使えば速くなると思って、何でも包むのは間違いです。

悪い例です。

C#
var user = await Task.Run(async () =>
{
return await GetUserFromApiAsync();
});

GetUserFromApiAsyncがすでに非同期I/Oであれば、Task.Runで包む必要はありません。

正しくは次のように書きます。

C#
var user = await GetUserFromApiAsync();

Task.Runを使うと、スレッドプールへのスケジューリングなどのオーバーヘッドが発生します。

CPU処理では有効な場合がありますが、I/O待ちでは逆に無駄になることがあります。

判断基準は次のとおりです。

I/O待ちが中心
→ Asyncメソッドを直接await

CPU計算が中心
→ 必要に応じてTask.Run

すでに非同期APIがある
→ Task.Runで包まない

9-6. UIスレッドを直接更新して例外になる

UIアプリでは、UI部品を更新できるのは基本的にUIスレッドです。

Task.Runの中から直接UIを更新すると、例外になることがあります。

悪い例です。

C#
await Task.Run(() =>
{
label.Text = "更新";
});

Task.Run内の処理は、UIスレッドではなくスレッドプールで実行されます。

正しくは、重い処理だけをTask.Runに渡し、UI更新はawait後に行います。

C#
var result = await Task.Run(() => HeavyCalculation());

label.Text = result.ToString();

図にすると次のようになります。

UIスレッド

Task.Runで重い処理を開始

awaitで完了待ち

UIスレッドに戻る

label.Textを更新

WPFやWindows Formsでは、UI更新のスレッドに注意しましょう。

9-7. ConfigureAwait(false)を何となく使う

ConfigureAwait(false)は、await後に元のコンテキストへ戻る必要がない場合に使われます。

C#
await SomeAsync().ConfigureAwait(false);

ライブラリコードでは、UIスレッドなど特定のコンテキストに戻る必要がないことが多いため、ConfigureAwait(false)が使われる場合があります。

一方、UIアプリでawait後に画面を更新したい場合は、元のUIスレッドに戻る必要があります。

C#
await LoadAsync();

label.Text = "完了";

ここで安易にConfigureAwait(false)を使うと、await後にUIスレッドへ戻らず、UI更新で問題が起きる可能性があります。

UI更新が必要
→ ConfigureAwait(false)を安易に使わない

ライブラリ内部処理
→ コンテキスト不要なら検討する

ConfigureAwait(false)は、意味を理解して使うべきものです。

9-8. fire-and-forgetで完了・例外・キャンセルを管理できない

fire-and-forgetは、非同期処理を開始して待たない書き方です。

C#
_ = ProcessAsync();

この書き方は、次の点で危険です。

完了したか分からない
例外を捕捉しづらい
キャンセルできない
リトライできない
アプリ終了時に中断される可能性がある

どうしても使う場合は、少なくとも例外処理を内部に入れます。

C#
_ = RunBackgroundAsync();

async Task RunBackgroundAsync()
{
try
{
await ProcessAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}

ただし、実務では専用のバックグラウンド処理基盤を使う方が安全です。

特にサーバーアプリでは、キュー、ワーカー、ホステッドサービスなどを使って、処理のライフサイクルを管理する設計が望ましいです。

10. デッドロックを図解で理解する

10-1. デッドロックとは何か

デッドロックとは、複数の処理がお互いの完了を待ち続け、どちらも先に進めなくなる状態です。

C#の非同期処理では、.Result.Wait()を使って非同期処理を同期的に待つことで発生することがあります。

特にUIアプリでは典型的です。

UIスレッド

非同期処理を開始

.ResultでUIスレッドをブロック

非同期処理はawait後にUIスレッドへ戻りたい

しかしUIスレッドは.Resultで塞がっている

お互い待ち続ける

これが非同期処理における典型的なデッドロックです。

10-2. .Resultや.Wait()で止まる典型パターン

次のコードは危険です。

C#
private void Button_Click(object sender, EventArgs e)
{
var result = LoadDataAsync().Result;
label.Text = result;
}

private async Task<string> LoadDataAsync()
{
await Task.Delay(1000);
return "完了";
}

UIスレッドで.Resultを呼ぶと、UIスレッドがブロックされます。

一方、LoadDataAsyncawait Task.Delay(1000)の後、元のUIスレッドに戻って続きを実行しようとします。

しかし、UIスレッドは.Resultで塞がっています。

Button_Click

LoadDataAsync().Result

UIスレッドをブロック

LoadDataAsyncのawait後の続きがUIスレッドに戻ろうとする

UIスレッドが空かない

デッドロック

正しくは、イベントハンドラをasyncにしてawaitします。

C#
private async void Button_Click(object sender, EventArgs e)
{
var result = await LoadDataAsync();
label.Text = result;
}

10-3. SynchronizationContextとUIスレッドの関係

SynchronizationContextは、「await後の続きをどこで実行するか」に関係する仕組みです。

UIアプリでは、UIスレッドに戻る必要があるため、await後の続きを元のUIスレッドで実行しようとします。

C#
private async void Button_Click(object sender, EventArgs e)
{
await Task.Delay(1000);

label.Text = "完了";
}

このコードでは、await後にUIスレッドへ戻るため、label.Textを安全に更新できます。

図にすると次のようになります。

UIスレッドで開始

await Task.Delay

一時中断

Task完了

UIスレッドに戻る

label.Textを更新

しかし、UIスレッドを.Result.Wait()で塞ぐと、戻る場所が塞がれてしまいます。

これがデッドロックの原因になります。

10-4. 「async all the way」が重要な理由

デッドロックを避けるためには、非同期処理を途中で同期的に待たないことが重要です。

つまり、呼び出し元までずっとasync/awaitでつなぎます。

悪い流れ:
asyncメソッド

途中で.Result

スレッドブロック

デッドロックの可能性

良い流れ:
asyncメソッド

await

呼び出し元もasync

さらにawait

これが async all the way です。

悪い例です。

C#
public string GetData()
{
return GetDataAsync().Result;
}

良い例です。

C#
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}

非同期処理は、途中で無理に同期処理へ戻そうとすると問題が起きやすくなります。

10-5. デッドロックを避ける安全な書き方

デッドロックを避ける基本は、次のとおりです。

  • .Resultを使わない

  • .Wait()を使わない

  • GetAwaiter().GetResult()を安易に使わない

  • 呼び出し元までasync/awaitでつなぐ

  • UIスレッドをブロックしない

安全な書き方です。

C#
public async Task ExecuteAsync()
{
var data = await LoadDataAsync();
Console.WriteLine(data);
}

UIイベントハンドラでは、次のようにします。

C#
private async void Button_Click(object sender, EventArgs e)
{
var data = await LoadDataAsync();
label.Text = data;
}

ASP.NET CoreのControllerでも同様です。

C#
public async Task<IActionResult> GetAsync()
{
var data = await _service.LoadDataAsync();
return Ok(data);
}

同期メソッドの中で非同期メソッドを無理に呼び出す設計は、できる限り避けましょう。

10-6. ConfigureAwait(false)が有効な場面

ConfigureAwait(false)は、await後に元のコンテキストへ戻らなくてよい場合に使えます。

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

ライブラリコードでは、UIスレッドに戻る必要がないことが多いため、有効な場合があります。

一方、UI更新を行うアプリケーションコードでは注意が必要です。

C#
await LoadAsync().ConfigureAwait(false);

label.Text = "完了"; // UIスレッドではない可能性がある

判断基準は次のとおりです。

await後にUI更新がある
→ ConfigureAwait(false)は避ける

await後に特定のコンテキストが不要
→ ConfigureAwait(false)を検討する

ライブラリコード
→ 呼び出し元のコンテキストに依存しない設計にする

ConfigureAwait(false)はデッドロック回避に役立つ場合がありますが、根本的には.Result.Wait()を使わず、async all the wayで書くことが重要です。

11. C#非同期処理のベストプラクティス

11-1. 非同期メソッドは末尾にAsyncを付ける

非同期メソッドには、末尾にAsyncを付けるのが基本です。

C#
LoadAsync()
SaveAsync()
SendAsync()
GetUserAsync()

これにより、呼び出し側は非同期メソッドだとすぐに分かります。

C#
var user = await GetUserAsync();

名前から使い方が分かるコードは、保守しやすくなります。

特に、同期版と非同期版が混在する場合は重要です。

C#
GetUser()
GetUserAsync()

チーム開発では、命名規則を統一することでレビューや保守の負担を減らせます。

11-2. async voidはイベントハンドラ以外で使わない

async voidは、イベントハンドラ以外では使わないのが原則です。

イベントハンドラでは、シグネチャがvoidに決まっているため例外的に使います。

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

通常の非同期メソッドでは、TaskまたはTask<T>を返します。

C#
public async Task SaveAsync()
{
await Task.Delay(1000);
}
C#
public async Task<User> GetUserAsync()
{
await Task.Delay(1000);
return new User();
}

Taskを返せば、呼び出し側がawaitでき、例外やキャンセルも扱いやすくなります。

11-3. 可能な限りawaitで待つ

非同期メソッドを呼び出したら、基本的にはawaitします。

C#
await SaveAsync();

awaitしない場合は、完了、例外、キャンセルを管理できません。

C#
SaveAsync(); // 危険

意図的に待たない場合は、なぜ待たないのかを明確にし、例外処理も設計します。

C#
_ = RunSafelyAsync();
C#
private async Task RunSafelyAsync()
{
try
{
await SaveAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "保存に失敗しました");
}
}

ただし、このようなfire-and-forgetは慎重に使うべきです。

11-4. 同期処理と非同期処理を混ぜない

非同期処理の途中で、同期的な待機を混ぜると問題が起きやすくなります。

避けたい書き方です。

C#
var result = GetDataAsync().Result;
C#
GetDataAsync().Wait();
C#
Thread.Sleep(1000);

代わりに、次のように書きます。

C#
var result = await GetDataAsync();
C#
await Task.Delay(1000);

同期処理と非同期処理を混ぜると、デッドロックやスレッドブロックの原因になります。

非同期処理を使うなら、呼び出し元まで一貫して非同期にするのが基本です。

11-5. CancellationTokenを引数で受け取れる設計にする

時間のかかる非同期メソッドでは、CancellationTokenを受け取れるようにしておくと実務で扱いやすくなります。

C#
public async Task<List<User>> GetUsersAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext.Users.ToListAsync(cancellationToken);
}

呼び出し側は必要に応じてキャンセルできます。

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

var users = await GetUsersAsync(cts.Token);

メソッド内部で他の非同期メソッドを呼ぶ場合も、CancellationTokenを渡していきます。

C#
await _httpClient.GetAsync(url, cancellationToken);
await File.WriteAllTextAsync(path, content, cancellationToken);

キャンセル可能な設計にしておくと、タイムアウト、ユーザーキャンセル、アプリ終了処理に対応しやすくなります。

11-6. ライブラリコードとアプリケーションコードで考え方を分ける

非同期処理では、ライブラリコードとアプリケーションコードで考え方を分けると整理しやすくなります。

アプリケーションコードとは、UI、Controller、画面処理など、実際の実行環境に近いコードです。

C#
private async void Button_Click(object sender, EventArgs e)
{
var data = await _service.LoadAsync();
label.Text = data;
}

このようなコードでは、await後にUIスレッドへ戻る必要がある場合があります。

一方、ライブラリコードは、特定のUIや実行環境に依存しない処理です。

C#
public async Task<string> LoadAsync()
{
return await _repository.GetDataAsync().ConfigureAwait(false);
}

ライブラリコードでは、呼び出し元のコンテキストに戻る必要がない場合があります。

ただし、ConfigureAwait(false)を機械的に入れるのではなく、そのコードがどの環境で使われるかを考えて判断することが大切です。

11-7. 可読性を落とさない非同期コードの書き方

非同期コードは、書き方によっては読みにくくなります。

可読性を保つためには、次の点を意識します。

まず、処理を詰め込みすぎないことです。

悪い例です。

C#
var result = await ProcessAsync(await LoadAsync(await GetIdAsync()));

処理の流れが分かりにくくなります。

分けて書くと読みやすくなります。

C#
var id = await GetIdAsync();
var data = await LoadAsync(id);
var result = await ProcessAsync(data);

また、並行実行する場合は、Task変数に分けると意図が明確になります。

C#
var userTask = GetUserAsync();
var ordersTask = GetOrdersAsync();

await Task.WhenAll(userTask, ordersTask);

var user = await userTask;
var orders = await ordersTask;

非同期コードでは、処理順序が重要です。

「どこで開始し、どこで待つのか」が読み取れるように書きましょう。

12. 実践:よくある処理を非同期化してみる

12-1. 同期コードをasync/awaitに書き換える手順

同期コードを非同期化するときは、次の手順で考えると整理しやすくなります。

1. 時間のかかる処理を見つける
2. 非同期APIがあるか確認する
3. メソッドの戻り値をTaskまたはTask<T>にする
4. メソッドにasyncを付ける
5. 非同期APIをawaitする
6. 呼び出し側もasync/awaitにする

同期コードの例です。

C#
public string LoadText(string path)
{
return File.ReadAllText(path);
}

非同期化すると、次のようになります。

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

呼び出し側も変更します。

C#
var text = await LoadTextAsync("sample.txt");

ポイントは、内部だけでなく呼び出し側も含めて非同期にすることです。

12-2. API呼び出しを非同期化するサンプル

同期的にAPIを呼び出しているコードを考えます。

悪い例です。

C#
public string GetJson(string url)
{
return _httpClient.GetStringAsync(url).Result;
}

.Resultで非同期処理を同期的に待っているため、避けるべきです。

非同期化すると、次のようになります。

C#
public async Task<string> GetJsonAsync(string url)
{
return await _httpClient.GetStringAsync(url);
}

呼び出し側です。

C#
var json = await GetJsonAsync("https://example.com/api/users");

例外処理も含めるなら、次のように書けます。

C#
public async Task<string?> GetJsonSafeAsync(string url)
{
try
{
return await _httpClient.GetStringAsync(url);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "API呼び出しに失敗しました。Url={Url}", url);
return null;
}
}

12-3. 複数API呼び出しをTask.WhenAllで高速化するサンプル

複数のAPIを順番に呼び出すコードです。

C#
public async Task<Summary> GetSummaryAsync()
{
var users = await GetUsersAsync();
var orders = await GetOrdersAsync();
var products = await GetProductsAsync();

return new Summary(users, orders, products);
}

これらが互いに依存していないなら、並行実行できます。

C#
public async Task<Summary> GetSummaryAsync()
{
var usersTask = GetUsersAsync();
var ordersTask = GetOrdersAsync();
var productsTask = GetProductsAsync();

await Task.WhenAll(usersTask, ordersTask, productsTask);

return new Summary(
await usersTask,
await ordersTask,
await productsTask);
}

図にすると次のとおりです。

Before:
Users → Orders → Products

After:
Users ─────
Orders ─────
Products ─────

並行実行により、合計時間を短縮できる可能性があります。

ただし、APIのレート制限やサーバー負荷には注意が必要です。

12-4. UIが固まる処理を改善するサンプル

UIが固まる同期コードです。

C#
private void Button_Click(object sender, EventArgs e)
{
statusLabel.Text = "処理中...";

var result = HeavyCalculation();

statusLabel.Text = result.ToString();
}

HeavyCalculationが重い処理だと、画面が固まります。

改善例です。

C#
private async void Button_Click(object sender, EventArgs e)
{
statusLabel.Text = "処理中...";

var result = await Task.Run(() => HeavyCalculation());

statusLabel.Text = result.ToString();
}

この場合、重い計算はスレッドプールで実行され、UIスレッドはブロックされません。

ただし、Task.Runの中でUI部品を更新してはいけません。

悪い例です。

C#
await Task.Run(() =>
{
statusLabel.Text = "更新";
});

UI更新はawait後に行います。

12-5. キャンセル可能な長時間処理のサンプル

キャンセル可能な長時間処理の例です。

C#
public async Task<int> CountAsync(
IProgress<int> progress,
CancellationToken cancellationToken)
{
var count = 0;

for (int i = 1; i <= 100; i++)
{
cancellationToken.ThrowIfCancellationRequested();

await Task.Delay(100, cancellationToken);

count++;
progress.Report(i);
}

return count;
}

UI側の呼び出し例です。

C#
private CancellationTokenSource? _cts;

private async void StartButton_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();

var progress = new Progress<int>(value =>
{
progressBar.Value = value;
});

try
{
var result = await CountAsync(progress, _cts.Token);
statusLabel.Text = $"完了: {result}";
}
catch (OperationCanceledException)
{
statusLabel.Text = "キャンセルされました";
}
finally
{
_cts.Dispose();
_cts = null;
}
}

private void CancelButton_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}

このように、キャンセル、進捗、例外処理を組み合わせることで、実務的な非同期処理になります。

12-6. 例外処理まで含めた完成形コード

最後に、API呼び出し、タイムアウト、キャンセル、例外処理を含めた例を見てみましょう。

C#
public class UserService
{
private readonly HttpClient _httpClient;
private readonly ILogger<UserService> _logger;

public UserService(HttpClient httpClient, ILogger<UserService> logger)
{
_httpClient = httpClient;
_logger = logger;
}

public async Task<string?> GetUserJsonAsync(
int userId,
CancellationToken cancellationToken = default)
{
var url = $"https://example.com/api/users/{userId}";

try
{
using var response = await _httpClient.GetAsync(url, cancellationToken);

response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (OperationCanceledException)
{
_logger.LogWarning("ユーザー情報の取得がキャンセルされました。UserId={UserId}", userId);
throw;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "HTTPリクエストに失敗しました。UserId={UserId}", userId);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "予期しないエラーが発生しました。UserId={UserId}", userId);
throw;
}
}
}

呼び出し側です。

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

try
{
var json = await userService.GetUserJsonAsync(1, cts.Token);

if (json is null)
{
Console.WriteLine("ユーザー情報を取得できませんでした");
return;
}

Console.WriteLine(json);
}
catch (OperationCanceledException)
{
Console.WriteLine("処理がキャンセルまたはタイムアウトしました");
}

このコードでは、次の要素を含めています。

非同期API呼び出し
CancellationToken
タイムアウト
HTTPエラー処理
ログ出力
キャンセル処理
予期しない例外の再スロー

実務では、このように正常系だけでなく、失敗時の扱いまで含めて設計することが大切です。

13. C#非同期処理のFAQ

13-1. asyncを付けると必ず非同期になりますか?

いいえ。

asyncを付けただけでは、必ず非同期になるわけではありません。

C#
public async Task DoWorkAsync()
{
Console.WriteLine("Hello");
}

このコードにはawaitがないため、実質的には同期的に実行されます。

asyncは、メソッド内でawaitを使えるようにするためのキーワードです。

非同期処理になるかどうかは、実際にawaitしている処理が非同期的に完了するかどうかに依存します。

13-2. awaitしないとどうなりますか?

awaitしない場合、処理の完了を待たずに次へ進みます。

C#
SaveAsync();
Console.WriteLine("完了");

この場合、SaveAsyncが終わる前に「完了」と表示される可能性があります。

また、例外を呼び出し側で捕捉しにくくなります。

正しくは次のように書きます。

C#
await SaveAsync();
Console.WriteLine("完了");

意図的に待たない場合は、例外処理や完了管理を別途設計する必要があります。

13-3. TaskとThreadの違いは何ですか?

Threadは実行単位そのものに近い概念です。

一方、Taskは処理の完了や結果を表す抽象的な型です。

Thread:
実際のスレッド

Task:
非同期処理や作業の結果・状態を表すもの

Taskは、必ずしも専用スレッドを意味しません。

たとえば、Task.Delayや非同期I/Oでは、待ち時間中に専用スレッドが動き続けるわけではありません。

C#
await Task.Delay(1000);

一方、Task.Runを使うと、スレッドプール上で処理を実行します。

C#
await Task.Run(() => HeavyCalculation());

13-4. Task.Runはいつ使うべきですか?

Task.Runは、主にCPU負荷の高い同期処理を別スレッドで実行したいときに使います。

C#
var result = await Task.Run(() => HeavyCalculation());

特にUIアプリで、重い計算による画面フリーズを防ぎたい場合に有効です。

一方、Web API、データベース、ファイルI/Oなど、すでに非同期メソッドが用意されている処理では、基本的にTask.Runを使いません。

C#
var json = await httpClient.GetStringAsync(url);

I/O処理は、非同期APIを直接awaitするのが基本です。

13-5. ValueTaskは使うべきですか?

通常は、まずTaskまたはTask<T>を使えば十分です。

ValueTaskは、パフォーマンス最適化のために使われる型です。

たとえば、結果が同期的に返ることが多く、割り当てを減らしたい場合に検討されます。

C#
public ValueTask<int> GetValueAsync()
{
return new ValueTask<int>(10);
}

ただし、ValueTaskは扱いに注意が必要です。

初心者や一般的なアプリケーション開発では、まずTaskを使うのが安全です。

ライブラリ開発や高パフォーマンスが必要な場面で、必要性を理解したうえで使うのがよいでしょう。

13-6. ConfigureAwait(false)は毎回必要ですか?

毎回必要ではありません。

ConfigureAwait(false)は、await後に元のコンテキストへ戻る必要がない場合に使います。

C#
await LoadAsync().ConfigureAwait(false);

ライブラリコードでは有効な場合があります。

一方、UIアプリでawait後に画面を更新する場合は、元のUIスレッドへ戻る必要があります。

C#
await LoadAsync();

label.Text = "完了";

このような場面で安易にConfigureAwait(false)を使うと、UI更新で問題になる可能性があります。

判断基準は、「await後に元のコンテキストが必要か」です。

13-7. 非同期処理は必ず速くなりますか?

いいえ。

非同期処理にしても、処理そのものの時間が短くなるとは限りません。

たとえば、1秒かかるAPI呼び出しは、非同期にしても基本的には1秒かかります。

ただし、その1秒の待ち時間中にスレッドを塞がないため、アプリ全体の効率や応答性が向上します。

非同期処理で改善しやすいもの:
UIの応答性
サーバーのスレッド効率
複数I/O処理の待ち時間

非同期処理だけでは改善しにくいもの:
CPU計算そのものの処理時間
遅いアルゴリズム
外部サービス自体の遅延

非同期処理は、単純な高速化の魔法ではありません。

「待ち時間中にスレッドを塞がない」「複数のI/O処理を効率よく待つ」ための仕組みです。

まとめ

C#の非同期処理は、async/awaitTaskを使って、待ち時間中にスレッドを塞がずに処理を進めるための仕組みです。

特に、Web API呼び出し、データベースアクセス、ファイル読み書き、UIアプリの画面フリーズ防止、ASP.NET Coreのリクエスト処理などで重要になります。

基本は次のとおりです。

C#
public async Task<string> LoadAsync()
{
var result = await SomeAsyncOperation();
return result;
}

押さえるべきポイントは、次のように整理できます。

async:
awaitを使える非同期メソッドを定義する

await:
Taskの完了を待ち、完了後に続きを再開する

Task:
非同期処理の状態や完了を表す

Task<T>:
戻り値がある非同期処理を表す

Task.Run:
主にCPU負荷の高い処理を別スレッドで実行する

CancellationToken:
非同期処理にキャンセル要求を伝える

また、C#非同期処理では、次の落とし穴を避けることが重要です。

  • .Result.Wait()で同期的に待たない

  • async voidをイベントハンドラ以外で使わない

  • awaitを書き忘れない

  • I/O処理をTask.Runで包まない

  • fire-and-forgetを安易に使わない

  • UIスレッドをブロックしない

  • ConfigureAwait(false)を意味なく使わない

非同期処理の基本方針は、async all the wayです。

非同期メソッドを呼んだら、途中で同期的に待たず、呼び出し元までasync/awaitでつなげることが安全で分かりやすい書き方です。

C#の非同期処理は最初こそ難しく見えますが、「待ち時間中にスレッドを塞がない」「Taskは将来完了する処理を表す」「awaitは完了後に続きを再開する」という3点を押さえれば、全体像がつかみやすくなります。