C#非同期処理とは?async/awaitとTaskの使い方を初心者向けに徹底解説

はじめに

C#でWeb APIへのアクセス、ファイルの読み書き、データベース処理などを実装すると、処理が完了するまでアプリケーションが待たされることがあります。特にデスクトップアプリでは画面が固まり、Webアプリでは処理できるリクエスト数が減る原因になります。

そこで利用されるのが、C#の非同期処理です。

C#では、主に次の3つを組み合わせて非同期処理を実装します。

  • async

  • await

  • Task

これらを使うと、時間のかかる処理を待っている間にスレッドを占有し続けることなく、読みやすいコードで処理を記述できます。

本記事では、C#の非同期処理について、基本概念からasyncawaitTaskの使い方、複数処理の実行、例外処理、キャンセル、デッドロック対策まで初心者向けに解説します。

1. C#の非同期処理とは?初心者向けに基本をわかりやすく解説

1-1. 非同期処理とは「処理の完了を待たずに次へ進める仕組み」

非同期処理とは、時間のかかる処理を開始したあと、その処理が完了するまで実行スレッドを占有せず、ほかの作業を進められるようにする仕組みです。

たとえば、Web APIからデータを取得する処理を考えてみましょう。

Web APIにリクエストを送信してから結果が返るまで、ネットワーク通信の待ち時間が発生します。この間、CPUが計算し続けているわけではありません。

非同期処理を使えば、通信結果を待っている間、実行スレッドを別の作業に利用できます。処理が完了したら、awaitの次の行から実行を再開します。

C#の非同期処理は、コンパイラによって状態を管理する仕組みに変換されるため、開発者は同期処理に近い書き方で処理の中断と再開を表現できます。Microsoft Learn+1

1-2. 同期処理と非同期処理の違い

同期処理では、ある処理が完了するまで次の処理に進みません。

C#
string data = DownloadData();
Console.WriteLine(data);

DownloadDataの実行に5秒かかる場合、呼び出したスレッドは5秒間待機します。

非同期処理では、非同期メソッドが返すTaskawaitします。

C#
string data = await DownloadDataAsync();
Console.WriteLine(data);

await中は、メソッドの実行が一時的に中断されます。ただし、通常はスレッドそのものをブロックし続けるわけではありません。

違いを整理すると、次のようになります。

項目同期処理非同期処理
待機中のスレッドブロックされる解放できる
UIの応答性固まりやすい維持しやすい
Webアプリの処理効率低下しやすい向上しやすい
コード単純asyncawaitが必要
適した処理短時間の処理通信、ファイル、DBなど

1-3. C#で非同期処理が必要になる場面

C#の非同期処理は、主に次のような場面で利用します。

  • Web APIやWebサイトへのアクセス

  • データベースへの問い合わせ

  • ファイルの読み書き

  • ネットワーク通信

  • ユーザー操作を受け付けるUIアプリ

  • 長時間かかる処理のキャンセル

  • 複数の外部サービスへの同時アクセス

特に、通信やストレージの応答を待つI/O処理では、非同期処理が効果的です。

1-4. 非同期処理でアプリの応答性が向上する理由

デスクトップアプリでは、画面の描画やボタン操作をUIスレッドが担当しています。

UIスレッド上で時間のかかる同期処理を実行すると、処理が終わるまでクリック、スクロール、再描画などができません。その結果、ユーザーからは「画面が固まった」ように見えます。

非同期APIをawaitすれば、待機中にUIスレッドへ制御を戻せます。通信などが完了したあと、必要に応じてUIスレッド上で後続処理を再開できます。

Webアプリでも、外部サービスやデータベースを待っている間にスレッドを解放できるため、そのスレッドで別のリクエストを処理しやすくなります。Microsoft Learn+1

1-5. 非同期処理は「必ず高速化する」とは限らない

非同期処理の主な目的は、待機中のスレッドを有効利用して、応答性やスループットを向上させることです。

1件の処理だけを見た場合、非同期化しても完了時間が短くなるとは限りません。通信に5秒かかるなら、非同期処理にしても通信自体は基本的に5秒かかります。

また、短時間で終わる処理を無理に非同期化すると、状態管理やタスク生成のオーバーヘッドによって、かえって処理が複雑になることがあります。

非同期処理は「処理速度を必ず上げる技術」ではなく、「待ち時間にスレッドを占有しないための技術」と考えると理解しやすいでしょう。

2. C#非同期処理で使うasync/await/Taskの基礎知識

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

asyncは、メソッド内でawaitを使用できるようにするキーワードです。

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

asyncを付けただけで、処理が自動的に別スレッドへ移動するわけではありません。

asyncの主な役割は、メソッド内でawaitを使えるようにし、コンパイラが処理の中断と再開を管理できるようにすることです。

2-2. awaitとは?Taskの完了を待つためのキーワード

awaitは、Taskが完了するまで現在の非同期メソッドを一時中断するためのキーワードです。

C#
await Task.Delay(1000);
Console.WriteLine("1秒後に実行");

awaitで待機している間、呼び出し元へ制御を戻せます。タスクが完了すると、原則としてawaitの次の処理から再開します。

awaitは、単に「その場で処理全体を停止する命令」ではありません。待機対象が未完了であれば、現在のメソッドを中断し、タスクの完了後に続きの処理を実行する仕組みです。Microsoft Learn+1

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

Taskは、進行中または完了した処理を表す型です。

Taskオブジェクトには、次のような状態があります。

  • 実行待ち

  • 実行中

  • 正常終了

  • 失敗

  • キャンセル

非同期メソッドを呼び出すと、通常はすぐにTaskが返されます。呼び出し側は、そのTaskawaitして完了を待ちます。

C#
Task task = ExecuteAsync();
await task;

多くの場合は、変数に保存せず直接awaitします。

C#
await ExecuteAsync();

2-4. TaskとTask<T>の違い

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

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

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

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

呼び出し側では、awaitの結果としてT型の値を受け取れます。

C#
int number = await GetNumberAsync();

非同期メソッドの代表的な戻り値は、値を返さないTask、値を返すTask<T>、イベントハンドラーなどで使うvoidです。Microsoft Learn+1

2-5. async voidを使ってよい場面と避けるべき理由

async voidは、原則としてUIイベントなどのイベントハンドラーだけで使います。

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

通常の非同期メソッドでは、次のようにasync Taskを使います。

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

async voidを避ける主な理由は、次のとおりです。

  • 呼び出し側が完了を待てない

  • 戻り値としてTaskを受け取れない

  • 呼び出し側で例外を捕捉しにくい

  • 単体テストが難しくなる

  • 複数の非同期処理と組み合わせにくい

イベントハンドラーは戻り値をvoidにする必要があるため、例外的にasync voidが使われます。

2-6. メソッド名にAsyncを付ける命名ルール

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

C#
GetUserAsync()
SaveFileAsync()
DownloadDataAsync()

同期版と非同期版がある場合も、違いが分かりやすくなります。

C#
GetUser()
GetUserAsync()

Asyncという名前を付けることで、呼び出し側は戻り値がTaskであり、awaitが必要なメソッドだと判断できます。

3. C#のasync/awaitの基本的な書き方

3-1. async/awaitを使った最小構成のサンプルコード

最小構成のサンプルは次のとおりです。

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

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

await WaitAsync();

Console.WriteLine("終了");
}

static async Task WaitAsync()
{
await Task.Delay(1000);
Console.WriteLine("待機が完了しました");
}
}

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

開始
待機が完了しました
終了

3-2. 戻り値がない非同期メソッドの書き方

戻り値がない非同期メソッドでは、戻り値の型をTaskにします。

C#
public async Task SendMessageAsync()
{
await Task.Delay(500);
Console.WriteLine("メッセージを送信しました");
}

呼び出し側ではawaitします。

C#
await SendMessageAsync();

3-3. 戻り値がある非同期メソッドの書き方

戻り値がある場合は、Task<T>を使います。

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

呼び出し側では、awaitした結果を変数に代入できます。

C#
string message = await GetMessageAsync();
Console.WriteLine(message);

3-4. awaitを書いた後の処理の流れ

次のコードを見てみましょう。

C#
static async Task SampleAsync()
{
Console.WriteLine("A");

await Task.Delay(1000);

Console.WriteLine("B");
}

処理の流れは次のとおりです。

  1. Aを表示する

  2. Task.Delayを開始する

  3. タスクが未完了なら、呼び出し元へ制御を戻す

  4. タスクが完了したら処理を再開する

  5. Bを表示する

awaitより前の部分は、メソッドを呼び出した時点で同期的に実行されます。最初の未完了なawaitに到達すると、メソッドはTaskを返します。

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

コンソールアプリのMainメソッドでも、戻り値をTaskにすればawaitを使用できます。

C#
static async Task Main(string[] args)
{
string result = await GetDataAsync();
Console.WriteLine(result);
}

static async Task<string> GetDataAsync()
{
await Task.Delay(500);
return "データを取得しました";
}

終了コードを返す場合は、Task<int>も利用できます。

C#
static async Task<int> Main()
{
await Task.Delay(500);
return 0;
}

3-6. よくあるコンパイルエラーと対処法

asyncではないメソッド内にawaitを書くとエラーになります。

C#
public void Execute()
{
await Task.Delay(1000);
}

次のように修正します。

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

また、Task<T>をそのままT型へ代入することはできません。

C#
int number = GetNumberAsync();

awaitを付けます。

C#
int number = await GetNumberAsync();

一方、asyncを付けたのにawaitが1つもない場合は、警告が発生します。

C#
public async Task<int> GetNumberAsync()
{
return 10;
}

このような場合は、asyncを外して完了済みタスクを返す方法があります。

C#
public Task<int> GetNumberAsync()
{
return Task.FromResult(10);
}

4. Taskの使い方をサンプルコードで理解する

4-1. Task.Runで処理を別スレッドに移す方法

Task.Runは、処理をスレッドプールへ登録するために使います。

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

for (int i = 0; i < 1_000_000; i++)
{
total += i;
}

return total;
});

主に、計算量の多いCPUバウンド処理をUIスレッドから切り離したい場合に利用します。

ただし、Task.Runが処理ごとに専用スレッドを作るわけではありません。通常は.NETが管理するスレッドプール上で実行されます。

4-2. Task.Delayで待機処理を非同期にする方法

Task.Delayは、指定した時間が経過するまで非同期に待機します。

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

await Task.Delay(2000);

Console.WriteLine("2秒経過");

同期的なThread.Sleepは、指定時間中スレッドをブロックします。

C#
Thread.Sleep(2000);

非同期メソッド内では、基本的にTask.Delayを使います。

C#
await Task.Delay(2000);

キャンセルトークンも渡せます。

C#
await Task.Delay(2000, cancellationToken);

4-3. Task.FromResultで完了済みTaskを返す方法

Task.FromResultは、すでに結果が決まっているTask<T>を返します。

C#
public Task<int> GetCachedValueAsync()
{
int cachedValue = 100;
return Task.FromResult(cachedValue);
}

キャッシュに値がある場合など、実際の非同期待機が不要な処理で利用できます。

C#
public Task<string> GetUserNameAsync(int userId)
{
if (userId == 1)
{
return Task.FromResult("山田太郎");
}

return LoadUserNameAsync(userId);
}

4-4. Task.CompletedTaskの使いどころ

Task.CompletedTaskは、正常終了済みのTaskを表します。

C#
public Task DoNothingAsync()
{
return Task.CompletedTask;
}

インターフェース上はTaskを返す必要があるものの、特定の実装では非同期処理が不要な場合に便利です。

C#
public Task WriteLogAsync(string message)
{
Console.WriteLine(message);
return Task.CompletedTask;
}

4-5. Task.ResultやWaitを安易に使ってはいけない理由

Task.Resultは結果を同期的に取得し、Task.Waitはタスクの完了を同期的に待ちます。

C#
string result = GetDataAsync().Result;
C#
GetDataAsync().Wait();

これらは呼び出しスレッドをブロックします。UIアプリなどでは、非同期処理の続きがUIスレッドへ戻ろうとしているのに、そのUIスレッドがResultWaitで待機していると、デッドロックが発生する可能性があります。

また、WaitResultでは例外がAggregateExceptionに包まれる場合があり、awaitより例外処理が複雑になります。非同期メソッド内では、原則としてawaitを使用します。Microsoft Learn

C#
string result = await GetDataAsync();

4-6. Taskを使った処理の実行順序を確認する

次のコードを確認してみましょう。

C#
static async Task Main()
{
Console.WriteLine("1");

Task task = WorkAsync();

Console.WriteLine("2");

await task;

Console.WriteLine("3");
}

static async Task WorkAsync()
{
Console.WriteLine("A");

await Task.Delay(1000);

Console.WriteLine("B");
}

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

1
A
2
B
3

WorkAsyncを呼び出すと、最初の未完了なawaitまで処理されます。そのため、A2より先に表示されます。

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

5-1. 複数のTaskを順番にawaitする方法

次のコードでは、1つ目の処理が完了してから2つ目を開始します。

C#
string user = await GetUserAsync();
string orders = await GetOrdersAsync();

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

処理に依存関係がある場合は、この書き方が適切です。

C#
User user = await GetUserAsync();
List<Order> orders = await GetOrdersAsync(user.Id);

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

処理同士に依存関係がなければ、先にすべてのタスクを開始してからTask.WhenAllで待てます。

C#
Task<string> userTask = GetUserAsync();
Task<string> ordersTask = GetOrdersAsync();
Task<string> noticesTask = GetNoticesAsync();

string[] results = await Task.WhenAll(
userTask,
ordersTask,
noticesTask
);

3つの処理がそれぞれ2秒かかる場合、順番に待てば約6秒ですが、同時に進められれば全体は約2秒で完了する可能性があります。

Task.WhenAllは、渡したすべてのタスクが完了したときに完了するタスクを返します。Task<T>を渡した場合、結果は入力したタスクと同じ順序の配列で返されます。Microsoft Learn+1

5-3. Task.WhenAnyで最初に完了した処理を受け取る方法

Task.WhenAnyは、複数のタスクのうち最初に完了したタスクを返します。

C#
Task<string> server1 = GetFromServer1Async();
Task<string> server2 = GetFromServer2Async();

Task<string> completedTask = await Task.WhenAny(server1, server2);
string result = await completedTask;

Console.WriteLine(result);

Task.WhenAnyで得たタスクに対して、もう一度awaitしている点が重要です。

最初のawaitは「どのタスクが完了したか」を受け取るためのものです。2回目のawaitで、実際の結果を取得し、発生した例外も適切に伝播させます。Microsoft Learn+1

5-4. 並列処理と非同期処理の違い

非同期処理と並列処理は同じ意味ではありません。

非同期処理は、待ち時間にスレッドを占有しないための仕組みです。

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

並列処理は、複数のCPUコアやスレッドを利用して、複数の計算を同時に進める処理です。

C#
Parallel.For(0, 100, i =>
{
Calculate(i);
});

Web通信をawaitしても、待機中にCPU上で処理が実行され続けるわけではありません。一方、CPU負荷の高い計算を並列化すると、複数のスレッドが同時にCPUを使用します。

5-5. I/Oバウンド処理とCPUバウンド処理の違い

I/Oバウンド処理は、外部からの応答を待つ時間が長い処理です。

  • HTTP通信

  • データベースアクセス

  • ファイルアクセス

  • ソケット通信

I/Oバウンド処理では、提供されている非同期APIを直接awaitします。

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

CPUバウンド処理は、CPUによる計算時間が長い処理です。

  • 画像加工

  • 動画変換

  • 暗号化

  • 大量データの集計

  • 複雑な数値計算

UIアプリでCPUバウンド処理を実行する場合は、Task.Runを使ってUIスレッドから切り離す方法があります。

C#
Result result = await Task.Run(() => Calculate());

I/Oバウンド処理とCPUバウンド処理では、推奨される非同期化の方法が異なります。Microsoft Learn

5-6. Task.Runを使うべきケースと使わないほうがよいケース

Task.Runを検討できるのは、主に次のようなケースです。

  • UIスレッド上で重い計算を実行したくない

  • 既存の同期的なCPU処理をバックグラウンドで実行したい

  • 処理がCPUバウンドである

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

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

代わりに、非同期APIを直接awaitします。

C#
await httpClient.GetStringAsync(url);

ASP.NET Coreのリクエスト処理でも、I/O処理を安易にTask.Runへ移すと、スレッドプールを余計に消費する可能性があります。

6. C#非同期処理の実践例

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

HttpClientでは、HTTPリクエストを非同期に実行できます。

C#
using System.Net.Http;

public class ApiClient
{
private static readonly HttpClient HttpClient = new();

public async Task<string> GetDataAsync(
string url,
CancellationToken cancellationToken = default)
{
string json = await HttpClient.GetStringAsync(
url,
cancellationToken
);

return json;
}
}

GetStringAsyncは、HTTP GETリクエストを送信し、レスポンス本文を文字列として非同期に取得します。Microsoft Learn+1

レスポンスの状態コードを確認したい場合は、GetAsyncを使います。

C#
public async Task<string> GetDataAsync(
string url,
CancellationToken cancellationToken = default)
{
using HttpResponseMessage response =
await HttpClient.GetAsync(url, cancellationToken);

response.EnsureSuccessStatusCode();

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

6-2. ファイル読み書きを非同期で実行する

ファイル全体を文字列として読み込む場合は、File.ReadAllTextAsyncを使えます。

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

書き込みにはFile.WriteAllTextAsyncを使います。

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

File.ReadAllTextAsyncは、ファイルを非同期に開いて内容を読み込み、Task<string>として結果を返します。Microsoft Learn+1

6-3. データベースアクセスを非同期化する

Entity Framework Coreでは、データベースへアクセスする処理に非同期版が用意されています。

C#
using Microsoft.EntityFrameworkCore;

public async Task<List<User>> GetActiveUsersAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext.Users
.Where(user => user.IsActive)
.ToListAsync(cancellationToken);
}

保存処理ではSaveChangesAsyncを使います。

C#
public async Task AddUserAsync(
User user,
CancellationToken cancellationToken = default)
{
_dbContext.Users.Add(user);

await _dbContext.SaveChangesAsync(cancellationToken);
}

同じDbContextインスタンスで複数のデータベース処理を同時実行することは避け、それぞれの処理を適切にawaitします。Microsoft Learn

6-4. UIアプリで画面が固まらないようにする

WPFなどのUIアプリでは、イベントハンドラー内で非同期メソッドをawaitします。

C#
private async void LoadButton_Click(
object sender,
RoutedEventArgs e)
{
LoadButton.IsEnabled = false;

try
{
string data = await LoadDataAsync();
ResultTextBox.Text = data;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
finally
{
LoadButton.IsEnabled = true;
}
}

待機中はUIスレッドが解放されるため、画面の再描画などを続けられます。

6-5. 時間のかかる処理をキャンセル可能にする

非同期処理をキャンセル可能にするには、CancellationTokenをメソッドへ渡します。

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

await Task.Delay(100, cancellationToken);

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

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

C#
using CancellationTokenSource cts = new();

Task task = ProcessAsync(cts.Token);

// 必要なタイミングでキャンセルを要求
cts.Cancel();

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

6-6. CancellationTokenの基本的な使い方

CancellationTokenによるキャンセルは、処理を強制終了する仕組みではありません。

呼び出し側がキャンセルを要求し、処理側がトークンを確認して協調的に終了します。

基本的な流れは次のとおりです。

C#
using CancellationTokenSource cts = new();

CancellationToken token = cts.Token;

Task task = ExecuteAsync(token);

cts.Cancel();

await task;

処理側では、次のいずれかを使って要求を確認します。

C#
if (cancellationToken.IsCancellationRequested)
{
return;
}
C#
cancellationToken.ThrowIfCancellationRequested();

可能であれば、呼び出す非同期APIにもトークンを渡します。

C#
await Task.Delay(1000, cancellationToken);

CancellationTokenSource.Cancelはキャンセル要求を通知するものであり、受け取った処理がトークンを監視して初めてキャンセルが反映されます。Microsoft Learn+1

7. 非同期処理の例外処理とキャンセル処理

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

awaitした非同期処理の例外は、通常の同期処理と同じようにtrycatchで捕捉できます。

C#
try
{
string result = await GetDataAsync();
Console.WriteLine(result);
}
catch (HttpRequestException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}

非同期メソッド内で発生した例外は、返されたTaskに記録され、awaitした時点で呼び出し側へ伝播します。

7-2. Task内で発生した例外の扱い方

次のタスクは例外で終了します。

C#
Task task = Task.Run(() =>
{
throw new InvalidOperationException("処理に失敗しました");
});

awaitすれば、元の例外を捕捉できます。

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

タスクを開始したままawaitせず、Task.Exceptionも確認しないと、例外に気づきにくくなります。

C#
_ = ExecuteAsync();

意図的に完了を待たない処理でも、例外をログへ記録する仕組みを用意する必要があります。

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

複数のタスクをTask.WhenAllで待つ場合、複数の処理が失敗する可能性があります。

C#
Task task1 = Execute1Async();
Task task2 = Execute2Async();

Task allTask = Task.WhenAll(task1, task2);

try
{
await allTask;
}
catch
{
if (allTask.Exception is not null)
{
foreach (Exception ex in allTask.Exception.InnerExceptions)
{
Console.WriteLine(ex.Message);
}
}
}

await allTaskでは代表となる例外が再スローされます。すべての例外を調査したい場合は、WhenAllが返したタスクのException.InnerExceptionsを確認します。

WhenAllに渡したタスクのいずれかが失敗すると、返されたタスクも失敗状態になります。複数の失敗は、そのタスクの例外情報に集約されます。Microsoft Learn+1

7-4. OperationCanceledExceptionの扱い方

キャンセルされた非同期処理をawaitすると、OperationCanceledExceptionまたはその派生型が発生します。

C#
try
{
await ProcessAsync(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("ユーザー操作によりキャンセルされました");
}

キャンセルは、必ずしもシステム障害を意味しません。

ユーザーがキャンセルボタンを押した場合や、アプリケーション終了時に処理を中断した場合など、想定された制御フローとして扱うケースがあります。

7-5. タイムアウト処理を実装する方法

近年の.NETでは、Task.WaitAsyncを利用してタイムアウトを設定できます。

C#
try
{
await ExecuteAsync()
.WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
Console.WriteLine("処理がタイムアウトしました");
}

キャンセルトークンも同時に渡せます。

C#
await ExecuteAsync()
.WaitAsync(
TimeSpan.FromSeconds(5),
cancellationToken
);

WaitAsyncは、元のタスクが完了するか、指定した時間が経過するか、キャンセルが要求されるまで非同期に待機します。Microsoft Learn

CancellationTokenSource.CancelAfterを使う方法もあります。

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

await ExecuteAsync(cts.Token);

7-6. 例外を握りつぶさないための実装ポイント

次のように、何もせず例外を無視する実装は避けます。

C#
try
{
await ExecuteAsync();
}
catch
{
}

少なくとも、エラーの内容や発生場所をログへ記録します。

C#
try
{
await ExecuteAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "非同期処理に失敗しました");
throw;
}

その場で回復できない例外は、ログを記録したうえで再スローする方法があります。

再スローでは、スタックトレースを維持するためにthrow ex;ではなくthrow;を使います。

8. 初心者がつまずきやすいC#非同期処理の注意点

8-1. awaitを付け忘れるとどうなるか

非同期メソッドを呼び出しても、awaitしなければ完了を待ちません。

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

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

次のように修正します。

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

意図的に待たない場合は、そのことが分かるように記述し、例外処理も考える必要があります。

C#
_ = SaveWithLoggingAsync();

8-2. asyncを付けただけでは非同期にならない

次のメソッドにはasyncが付いていますが、非同期待機はありません。

C#
public async Task<int> CalculateAsync()
{
return Calculate();
}

asyncは、処理を自動的に別スレッドへ移すキーワードではありません。

CPU負荷の高い同期処理をUIスレッドから切り離すなら、呼び出し側でTask.Runを検討します。

C#
int result = await Task.Run(() => Calculate());

8-3. Task.Runを多用すると逆にパフォーマンスが悪化する

すべての処理をTask.Runで包むと、スレッドプールへの登録やスケジューリングが増えます。

特に次のようなコードは不要です。

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

非同期APIを直接呼び出します。

C#
await httpClient.GetStringAsync(url);

Task.Runは、非同期APIが存在しないCPUバウンド処理など、目的を限定して使うことが重要です。

8-4. デッドロックが起きる原因と回避策

代表的なデッドロックの原因は、非同期処理を同期的に待つことです。

C#
string result = GetDataAsync().Result;

GetDataAsync内のawait後の処理がUIスレッドへ戻ろうとしている場合、次の状態になることがあります。

  1. UIスレッドがResultで完了を待つ

  2. 非同期処理がUIスレッドで再開しようとする

  3. UIスレッドは待機中なので再開できない

  4. 両方が待ち続ける

最も基本的な回避方法は、呼び出し元まで非同期にすることです。

C#
string result = await GetDataAsync();

「非同期処理は最後まで非同期でつなぐ」ことが重要です。

8-5. ConfigureAwaitとは何か

通常のawaitは、環境によっては現在の同期コンテキストを取得し、処理完了後に元のコンテキストへ戻ろうとします。

C#
string result = await GetDataAsync();

ライブラリ内部など、元のコンテキストへ戻る必要がない場合は、ConfigureAwait(false)を検討できます。

C#
string result = await GetDataAsync()
.ConfigureAwait(false);

falseを指定すると、取得したコンテキストで後続処理を実行することを要求しません。これにより、不要なコンテキスト復帰を避けられる場合があります。Microsoft Learn

ただし、UIを更新する処理ではUIスレッドへ戻る必要があります。アプリケーションコードで無条件に付けるのではなく、実行環境やコードの役割を考慮して使います。

8-6. 非同期メソッドを同期的に呼び出さない

次の呼び出しは、可能な限り避けます。

C#
GetDataAsync().Wait();
C#
string result = GetDataAsync().Result;
C#
string result = GetDataAsync()
.GetAwaiter()
.GetResult();

基本は、呼び出し元もasyncにしてawaitします。

C#
public async Task ExecuteAsync()
{
string result = await GetDataAsync();
}

8-7. 例外が発生しているのに気づきにくいパターン

次のように、戻されたタスクを保存もawaitもしない場合、処理の失敗を検知しにくくなります。

C#
ExecuteAsync();

特にasync voidでは、呼び出し側がタスクを受け取れないため、例外処理が難しくなります。

通常の処理ではTaskを返し、呼び出し側でawaitします。

C#
await ExecuteAsync();

バックグラウンド処理として実行する必要がある場合は、処理内で例外を記録します。

C#
private async Task ExecuteWithLoggingAsync()
{
try
{
await ExecuteAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "バックグラウンド処理に失敗しました");
}
}

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

9-1. 基本はasync Taskを使う

戻り値がない非同期メソッドでは、基本的にasync Taskを使います。

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

値を返す場合はasync Task<T>です。

C#
public async Task<int> CalculateAsync()
{
return await LoadNumberAsync();
}

async voidはイベントハンドラーに限定します。

9-2. I/O処理は可能な限り非同期APIを使う

同期APIをTask.Runで包むよりも、提供されている非同期APIを優先します。

C#
string text = await File.ReadAllTextAsync(path);
C#
string json = await httpClient.GetStringAsync(url);
C#
List<User> users = await dbContext.Users.ToListAsync();

非同期APIは、待機中にスレッドを占有しないよう設計されているため、I/O処理に適しています。

9-3. ライブラリ側ではConfigureAwait(false)を検討する

汎用ライブラリでは、await後に呼び出し元のUIコンテキストへ戻る必要がないことが一般的です。

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

ただし、対象の.NETバージョン、実行環境、プロジェクトの方針を踏まえて判断します。

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

時間がかかる可能性のあるメソッドは、CancellationTokenを受け取れるようにすると再利用しやすくなります。

C#
public async Task<string> DownloadAsync(
string url,
CancellationToken cancellationToken = default)
{
return await _httpClient.GetStringAsync(
url,
cancellationToken
);
}

一般的には、CancellationTokenを引数の末尾に配置し、必要に応じて既定値を設定します。

9-5. 非同期処理の責務を小さく分ける

1つの非同期メソッドに、通信、変換、保存、ログなどを詰め込みすぎると、例外処理やテストが難しくなります。

C#
public async Task ImportAsync(
CancellationToken cancellationToken)
{
string json = await DownloadAsync(cancellationToken);
List<Item> items = Parse(json);
await SaveAsync(items, cancellationToken);
}

役割ごとに分けることで、各処理を単独でテストしやすくなります。

9-6. ログ出力と例外処理をセットで考える

外部サービスやデータベースへアクセスする処理では、失敗時の調査に必要な情報を記録します。

C#
try
{
await ImportAsync(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation("インポート処理がキャンセルされました");
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "インポート処理に失敗しました");
throw;
}

パスワード、アクセストークン、個人情報などをログへ出力しないことも重要です。

9-7. 読みやすい非同期コードを書くための命名と設計

読みやすいコードにするため、次のポイントを意識します。

  • 非同期メソッドにはAsyncを付ける

  • 戻り値は原則としてTaskまたはTask<T>にする

  • CancellationTokenを適切に受け取る

  • 不要なTask.Runを避ける

  • ResultWaitで同期的に待たない

  • 並行実行できる処理だけをWhenAllにまとめる

  • 例外とキャンセルを区別する

  • 非同期処理の責務を小さくする

非同期コードは、単にasyncawaitを追加するだけでなく、呼び出し元から末端のI/O処理まで一貫して設計することが大切です。

10. C#非同期処理に関するよくある質問

10-1. asyncとawaitは必ずセットで使う必要がある?

必ずしもセットではありません。

asyncメソッドでは通常awaitを使用しますが、すでに存在するタスクをそのまま返す場合は、asyncを付けずに書けます。

C#
public Task<string> GetDataAsync()
{
return _httpClient.GetStringAsync("https://example.com");
}

処理の前後に追加のロジックが必要なら、asyncawaitを使うと読みやすくなります。

C#
public async Task<string> GetDataAsync()
{
Console.WriteLine("取得開始");

string result =
await _httpClient.GetStringAsync("https://example.com");

Console.WriteLine("取得終了");

return result;
}

10-2. TaskとThreadの違いは?

Threadは、OSスレッドを直接表す型です。

Taskは、完了予定の処理や結果を表す、より抽象度の高い型です。

1つのTaskが必ず1つの専用スレッドを持つわけではありません。I/Oの待機中にはスレッドを使用していない場合もあります。

通常のアプリケーション開発では、スレッドを直接管理するより、Taskasyncawaitを使う方法が適しています。

10-3. Task.Runを使えば必ず別スレッドで動く?

Task.Runは、処理を通常スレッドプールへ登録します。

そのため、呼び出し元とは別のタイミングやスレッドで実行されますが、処理専用の新しいスレッドを毎回作るわけではありません。

また、Taskを返すすべての処理が別スレッドで動くわけでもありません。HTTP通信などの非同期I/Oは、待機中にスレッドを占有せずに完了通知を待てます。

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

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

非同期処理は、処理の完了を待つ間に呼び出し元をブロックしない仕組みです。

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

非同期I/Oは、待機中にスレッドを使わないことがあります。一方、CPUバウンド処理を並列化する場合は複数スレッドが使われます。

10-5. async voidはなぜ避けるべき?

async voidでは、呼び出し側が次の操作を行えません。

  • 完了を待つ

  • 結果を受け取る

  • タスクの状態を確認する

  • 通常の方法で例外を捕捉する

  • Task.WhenAllなどへ渡す

そのため、イベントハンドラー以外ではasync Taskを使います。

10-6. awaitすると処理は止まるのか?

現在の非同期メソッドは、待機対象のタスクが未完了なら一時中断されます。

ただし、通常はスレッド全体をブロックするわけではありません。呼び出し元へ制御が戻り、スレッドはほかの作業に利用できます。

タスクが完了すると、awaitの後から処理が再開します。

なお、待機対象のタスクがすでに完了している場合は、中断せずにそのまま次の処理へ進むことがあります。

10-7. 非同期処理はどのタイミングで使うべき?

主に、次のような処理で使います。

  • Web APIへのアクセス

  • データベースへの問い合わせや保存

  • ファイルの読み書き

  • ネットワーク通信

  • UIを固めたくない処理

  • 複数の独立したI/O処理

  • キャンセルやタイムアウトが必要な長時間処理

反対に、単純な文字列処理や短時間で終わる計算まで、すべて非同期化する必要はありません。

処理に待ち時間があるか、呼び出しスレッドをブロックすると問題があるかを基準に判断します。

まとめ

C#の非同期処理は、時間のかかる処理を待っている間にスレッドを占有せず、アプリケーションの応答性や処理効率を高めるための仕組みです。

基本となる役割は次のとおりです。

  • asyncは、メソッド内でawaitを使えるようにする

  • awaitは、Taskの完了までメソッドを非同期に待機させる

  • Taskは、非同期処理の状態や完了を表す

  • Task<T>は、非同期処理の結果を表す

  • Task.WhenAllは、複数のタスクがすべて完了するまで待つ

  • Task.WhenAnyは、最初に完了したタスクを受け取る

  • CancellationTokenは、協調的なキャンセルを実現する

非同期処理を安全に使うには、Task.ResultWaitによる同期的な待機を避け、呼び出し元までasyncawaitでつなぐことが重要です。

まずはWeb API、ファイルアクセス、データベースアクセスなどのI/O処理から非同期APIを利用し、処理の流れや例外の伝わり方を確認しながら理解を深めていきましょう。