C# Taskとは?async/awaitの使い方・戻り値・エラー処理を初心者向けに徹底解説

はじめに

C#で時間のかかる処理を実装するときに欠かせないのが、Taskasyncawaitです。これらを使うと、HTTP通信やファイル操作の完了を待っている間も、画面やアプリケーション全体を停止させずに処理できます。

しかし、初心者にとっては次のような疑問が生じやすいでしょう。

  • Taskは別スレッドを作る機能なのか

  • asyncawaitは何が違うのか

  • 非同期メソッドから戻り値を受け取るにはどうするのか

  • Task.Runはどのような場面で使うのか

  • 複数のTaskを同時に実行するにはどうするのか

  • エラーやキャンセルをどのように処理するのか

本記事では、C#のTaskの基本から、asyncawait、戻り値、例外処理、キャンセル、タイムアウト、複数処理の並行実行まで、サンプルコードを交えて初心者向けに解説します。

1. C#のTaskとは?非同期処理の基本を初心者向けに解説

1-1. Taskは「完了を待てる処理」を表すクラス

C#のTaskは、進行中または将来完了する処理を表すクラスです。

たとえば、Web APIからデータを取得する処理には時間がかかります。データを要求した直後に結果が返るとは限らないため、「現在処理中であり、後で完了する」という状態を表す必要があります。その役割を担うのがTaskです。

戻り値を持たない処理はTask、戻り値を持つ処理はTask<T>で表します。

C#
Task SaveAsync()
{
// 戻り値がない非同期処理
}

Task<string> GetMessageAsync()
{
// stringを返す非同期処理
}

Task<string>は、「処理が完了したらstring型の値を取得できる」という意味です。

Taskには、処理の状態も記録されます。代表的な状態には、実行待ち、実行中、正常終了、失敗、キャンセルなどがあります。

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

同期処理では、ある処理が終わるまで、呼び出し元の処理もその場で待機します。

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

GetDataが5秒かかる場合、次のConsole.WriteLineは5秒後まで実行されません。

一方、非同期処理では、待ち時間中にスレッドを占有し続けない形で完了を待てます。

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

このコードでも、Console.WriteLineが実行されるのはデータ取得後です。ただし、await中は単純にスレッドを止めて待ち続けるのではなく、処理の完了後に続きから再開できるよう制御が戻されます。

特にGUIアプリケーションでは、同期処理で長時間待つと画面が固まる原因になります。非同期処理を使えば、通信やファイル操作の待ち時間中も、画面操作を受け付けやすくなります。

1-3. Taskを使うメリット

Taskを使う主なメリットは、アプリケーションの応答性と処理効率を高められることです。

HTTP通信やデータベースアクセスでは、処理時間の多くが外部からの応答待ちになります。この待ち時間中にスレッドを占有しないことで、サーバーはほかのリクエストを処理でき、デスクトップアプリケーションは画面操作に応答できます。

また、Taskは次のような機能と組み合わせられます。

  • awaitによる完了待ち

  • Task.WhenAllによる複数処理の待機

  • Task.WhenAnyによる最初に終わった処理の取得

  • CancellationTokenによるキャンセル

  • try-catchによる例外処理

  • Task.RunによるCPU負荷の高い処理の実行

非同期処理を構造化して扱える点が、Taskの大きな利点です。

1-4. Taskとスレッドの違い

Taskとスレッドは同じものではありません。

スレッドは、CPU上でプログラムを実行するための実行単位です。一方、Taskは処理の進行や完了を表す抽象的なオブジェクトです。

Task.Runで実行した処理は、通常、スレッドプール上のスレッドで動作します。しかし、HTTP通信やファイル読み込みなどの非同期I/Oでは、待機中に専用スレッドが使われ続けるとは限りません。

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

この処理では、ファイルの読み込み完了を待つ間、呼び出し元のスレッドをブロックせずに済みます。

つまり、次のように考えると理解しやすいでしょう。

  • Thread:処理を実行するための実行基盤

  • Task:処理の完了や結果を管理する仕組み

通常のアプリケーション開発では、スレッドを直接作成するより、Taskasyncawaitを利用する方が安全で簡潔です。

1-5. Taskが向いている処理・向いていない処理

Taskを使った非同期処理が特に向いているのは、待ち時間が発生するI/O処理です。

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

  • HTTP APIへのアクセス

  • ファイルの読み書き

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

  • ネットワーク通信

  • 一定時間の待機

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

一方、単純な計算や一瞬で終わる処理を、何でも非同期化すればよいわけではありません。

C#
int Add(int x, int y)
{
return x + y;
}

このような軽い処理をTask.Runで包むと、タスクの生成やスケジューリングによる余分なコストが発生します。

CPU負荷の高い計算にはTask.Runが役立つことがありますが、軽量な同期処理まで無理にTask化する必要はありません。

2. C#のTask・async・awaitの関係

2-1. asyncキーワードの役割

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

C#
static async Task ShowMessageAsync()
{
await Task.Delay(1000);
Console.WriteLine("1秒経過しました");
}

asyncを付けると、そのメソッドは非同期処理として記述できます。ただし、asyncを付けただけで処理が別スレッドになるわけではありません。

asyncメソッドは、awaitに到達するまでは通常の同期処理と同じように実行されます。

C#
static async Task ExampleAsync()
{
Console.WriteLine("開始");
await Task.Delay(1000);
Console.WriteLine("終了");
}

このメソッドを呼び出すと、まず「開始」がすぐに表示されます。その後、Task.Delayの完了を非同期に待ち、約1秒後に「終了」が表示されます。

2-2. awaitキーワードの役割

awaitは、Taskの完了を非同期に待つためのキーワードです。

C#
await Task.Delay(1000);

awaitを使うと、処理が完了するまで後続コードの実行を待ちます。ただし、Thread.Sleepのように現在のスレッドを占有したまま停止するわけではありません。

戻り値のあるTask<T>awaitすると、完了後の値を直接取得できます。

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

int number = await GetNumberAsync();
Console.WriteLine(number);

GetNumberAsyncの戻り値はTask<int>ですが、awaitした結果はintになります。

2-3. await中に処理が止まらない仕組み

awaitに未完了のTaskが渡されると、非同期メソッドはその時点で一度呼び出し元へ制御を返します。そして、待っている処理が完了したら、awaitの後ろから処理を再開します。

概念的には、次のような流れです。

  1. 非同期メソッドを実行する

  2. awaitまで同期的に進む

  3. 対象のTaskが未完了なら、呼び出し元へ制御を返す

  4. Taskの完了後、残りの処理を再開する

そのため、UIスレッドやサーバーの処理スレッドを、待ち時間だけ占有し続けずに済みます。

なお、awaitする時点ですでにTaskが完了している場合は、処理を中断せずにそのまま後続コードへ進む場合があります。

2-4. asyncだけ、awaitだけでは使えない?

通常、asyncawaitは組み合わせて使います。

awaitは原則としてasyncメソッド内で使用します。

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

一方、asyncメソッド内にawaitがなくてもコンパイルはできますが、警告が発生します。

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

このメソッドは実際には同期的に実行されます。非同期に待つ処理がないなら、通常はasyncを外し、Task.FromResultなどを利用できます。

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

また、asyncを付けずにTaskをそのまま返す実装も可能です。

C#
static Task WaitAsync()
{
return Task.Delay(1000);
}

2-5. 非同期メソッド名に「Async」を付ける理由

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

C#
GetDataAsync();
SaveFileAsync();
SendMessageAsync();

Asyncを付けることで、呼び出し側は次の点を判断しやすくなります。

  • Taskを返すメソッドである

  • awaitして呼び出すことを想定している

  • 同期版とは異なる処理である

  • 完了までに待ち時間が発生する可能性がある

たとえば、ReadReadAsyncが並んでいれば、同期版と非同期版を簡単に区別できます。

これはコンパイラーが要求する規則ではありませんが、可読性を高めるために広く利用されている命名規則です。

3. C#でTaskを使う基本的な書き方

3-1. 戻り値がない非同期メソッドはTaskを返す

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

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

呼び出し元ではawaitを付けます。

C#
await SaveAsync();

Taskを返すことで、呼び出し側は処理の完了を待ったり、例外を捕捉したりできます。

3-2. 戻り値がある非同期メソッドはTask<T>を返す

非同期メソッドから値を返す場合は、Task<T>を使用します。

C#
static async Task<string> GetNameAsync()
{
await Task.Delay(500);
return "山田太郎";
}

Tには、最終的に返したい値の型を指定します。

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

Task<int>ならintTask<string>ならstringTask<User>ならUserを受け取れます。

3-3. 呼び出し元でawaitして完了を待つ

非同期メソッドを呼び出しただけでは、呼び出し元はその完了を待ちません。

C#
Task task = SaveAsync();
Console.WriteLine("次の処理");

この場合、「次の処理」が保存完了より先に表示される可能性があります。

完了後に次の処理を実行したい場合は、awaitします。

C#
await SaveAsync();
Console.WriteLine("保存後の処理");

非同期処理を安全に扱う基本は、「必要なTaskを必ずawaitする」ことです。

3-4. Task.Delayで非同期処理を試す

Task.Delayは、指定時間が経過するまで非同期に待機するメソッドです。

C#
static async Task CountdownAsync()
{
Console.WriteLine("開始");
await Task.Delay(2000);
Console.WriteLine("2秒経過");
}

Task.Delayは学習用だけでなく、再試行までの待機や一定間隔の処理にも使えます。

似た機能にThread.Sleepがありますが、両者には重要な違いがあります。

C#
Thread.Sleep(2000);

Thread.Sleepは現在のスレッドをブロックします。一方、Task.Delayは待機中に現在のスレッドを占有し続けません。非同期メソッドでは、基本的にTask.Delayを使用します。

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

C#では、Mainメソッドをasync Task Mainとして定義できます。

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

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

戻り値として終了コードを返す場合は、Task<int>も利用できます。

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

トップレベルステートメントを利用するプロジェクトでは、そのままawaitを書けます。

C#
await Task.Delay(1000);
Console.WriteLine("完了");

3-6. asyncメソッド内にawaitがない場合の注意点

次のコードにはasyncがありますが、awaitがありません。

C#
static async Task<int> GetValueAsync()
{
return 123;
}

このメソッドは同期的に実行され、コンパイラーから警告が出ます。asyncメソッドを生成するための余分なコストも発生する可能性があります。

非同期処理が本当に不要なら、同期メソッドにする方法があります。

C#
static int GetValue()
{
return 123;
}

インターフェースなどの都合でTask<int>を返す必要がある場合は、Task.FromResultを利用できます。

C#
static Task<int> GetValueAsync()
{
return Task.FromResult(123);
}

戻り値のない完了済みTaskが必要なら、Task.CompletedTaskを使います。

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

4. Taskから戻り値を受け取る方法

4-1. Task<T>の型引数に戻り値の型を指定する

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

C#
static async Task<int> CalculateAsync()
{
await Task.Delay(300);
return 10 + 20;
}

このメソッドの戻り値はTask<int>です。

独自クラスも指定できます。

C#
public class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}

static async Task<User> GetUserAsync()
{
await Task.Delay(300);

return new User
{
Id = 1,
Name = "佐藤"
};
}

4-2. awaitを使って戻り値を取得する

Task<T>から値を受け取る基本的な方法はawaitです。

C#
int result = await CalculateAsync();
Console.WriteLine(result);

awaitの右側にはTask<int>がありますが、await後の値はintです。

次のように、タスクを変数に保存してから待つこともできます。

C#
Task<int> calculationTask = CalculateAsync();

// ここで別の処理を実行できる
Console.WriteLine("計算中です");

int result = await calculationTask;
Console.WriteLine(result);

先にTaskを取得しておくと、非同期処理の進行中に別の処理を行えます。

4-3. 複数の値をタプルやクラスで返す

複数の値を返したい場合は、タプルを利用できます。

C#
static async Task<(string Name, int Age)> GetProfileAsync()
{
await Task.Delay(300);
return ("鈴木", 30);
}

呼び出し側では分解して受け取れます。

C#
(string name, int age) = await GetProfileAsync();

Console.WriteLine(name);
Console.WriteLine(age);

値が多い場合や、業務上の意味を明確にしたい場合は、専用クラスやレコードを定義する方が適しています。

C#
public record UserProfile(string Name, int Age, string Email);

static async Task<UserProfile> GetProfileAsync()
{
await Task.Delay(300);
return new UserProfile(
"鈴木",
30,
"suzuki@example.com"
);
}

4-4. Task.Resultで戻り値を取得する際の注意点

Task<T>にはResultプロパティがあり、次のように値を取得できます。

C#
Task<int> task = CalculateAsync();
int result = task.Result;

ただし、Resultは処理が終わるまで現在のスレッドをブロックします。

特にUIアプリケーションや従来のASP.NETなど、同期コンテキストを持つ環境では、Resultがデッドロックの原因になることがあります。また、スレッドをブロックするため、非同期処理の利点も失われます。

原則として、次のようにawaitを使いましょう。

C#
int result = await CalculateAsync();

非同期処理は、途中だけ同期的に待つのではなく、呼び出し元まで非同期にすることが重要です。

4-5. 戻り値が不要な場合にTaskを使う理由

戻り値がない場合でも、Taskを返す意味があります。

C#
static async Task SendEmailAsync()
{
await Task.Delay(1000);
}

Taskを返すことで、呼び出し側は次の操作を行えます。

  • 処理の完了を待つ

  • 例外を捕捉する

  • キャンセルを管理する

  • 複数の処理と組み合わせる

  • 実行状態を確認する

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

単に値を返さないという理由でvoidにすると、これらの制御が難しくなります。

4-6. async voidを原則避けるべき理由

async voidは原則として、通常の非同期メソッドでは使用しません。

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

async voidには、次の問題があります。

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

  • Task.WhenAllに渡せない

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

  • テストが難しい

  • キャンセルや実行状態を管理しにくい

通常はasync Taskを使います。

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

async voidが必要になる代表的な場面は、GUIのイベントハンドラーです。

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

イベントハンドラーは仕組み上voidを要求されるため、例外をメソッド内で適切に処理する必要があります。

5. Task.Runの使い方と利用すべき場面

5-1. Task.Runで同期処理を別のタスクとして実行する

Task.Runは、指定した処理をスレッドプール上で実行し、その処理を表すTaskを返します。

C#
await Task.Run(() =>
{
Console.WriteLine("別のタスクで実行");
});

主にCPU負荷の高い同期処理を、UIスレッドなどから切り離して実行するときに使います。

ただし、Task.Runを使えば処理そのものが速くなるわけではありません。実行する場所をスレッドプールへ移すことで、呼び出し元のスレッドを占有しないようにする仕組みです。

5-2. 戻り値なしの処理をTask.Runで実行する

戻り値のない処理は、ラムダ式で渡せます。

C#
static void HeavyWork()
{
for (int i = 0; i < 1_000_000; i++)
{
Math.Sqrt(i);
}
}

await Task.Run(() => HeavyWork());

メソッドグループを直接渡すこともできます。

C#
await Task.Run(HeavyWork);

awaitを付けることで、処理中に発生した例外を呼び出し元で捕捉できます。

5-3. 戻り値ありの処理をTask.Runで実行する

ラムダ式が値を返す場合、Task.Runの戻り値はTask<T>になります。

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

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

return total;
});

Console.WriteLine(result);

既存の同期メソッドも実行できます。

C#
static int Calculate()
{
return Enumerable.Range(1, 100).Sum();
}

int result = await Task.Run(Calculate);

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

Task.Runが役立つ代表例は、CPU負荷の高い処理です。

  • 大量データの計算

  • 画像変換

  • 圧縮や展開

  • 暗号化

  • 複雑な解析

  • 大規模な並べ替え

たとえば、デスクトップアプリケーションで重い計算をUIスレッド上で実行すると、計算が終わるまで画面が応答しなくなります。

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

try
{
long result = await Task.Run(() => HeavyCalculation());
ResultLabel.Text = result.ToString();
}
finally
{
CalculateButton.Enabled = true;
}
}

Task.Runにより、計算中もUIスレッドが画面操作を処理できます。

一方、ASP.NET Coreのサーバー処理では、リクエストごとにCPU処理を安易にTask.Runへ移しても、サーバー全体のCPU負荷は減りません。用途や実行環境を考慮する必要があります。

5-5. I/O処理でTask.Runを多用しない理由

HTTP通信やファイルアクセスに非同期APIが用意されている場合、通常はTask.Runで包みません。

避けたい例は次のとおりです。

C#
string text = await Task.Run(() => File.ReadAllText("sample.txt"));

非同期APIを直接使います。

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

HTTP通信も同様です。

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

非同期I/Oは、待機中にスレッドを占有しないよう設計されています。それをTask.Runで包むと、余分なスレッドプール処理が追加されるだけになる場合があります。

基本的な使い分けは次のとおりです。

  • I/O待ち:提供されている非同期APIを使う

  • CPU負荷の高い同期処理:必要に応じてTask.Runを使う

5-6. Task.RunとTask.Delayの違い

Task.RunTask.Delayは用途が異なります。

Task.Runは、指定したコードをスレッドプール上で実行します。

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

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

C#
await Task.Delay(1000);

Task.Delayは重い処理を実行する機能ではなく、スレッドをブロックせずに時間を待つ機能です。

次のコードは、1秒間CPU処理を行うものではありません。

C#
await Task.Delay(1000);

単に約1秒後に後続処理を再開します。

5-7. Task.Factory.StartNewとの違い

Task.Factory.StartNewでもタスクを開始できます。

C#
Task task = Task.Factory.StartNew(() =>
{
Console.WriteLine("処理");
});

ただし、通常のアプリケーションコードではTask.Runの方が使いやすく、安全です。

特に非同期ラムダ式を扱う場合、StartNewは注意が必要です。

C#
Task<Task> nestedTask = Task.Factory.StartNew(async () =>
{
await Task.Delay(1000);
});

このコードではTask<Task>という入れ子のタスクが返されます。外側だけを待つと、内側の非同期処理を正しく待てない可能性があります。

Task.Runなら、非同期ラムダ式のタスクを自動的に展開します。

C#
Task task = Task.Run(async () =>
{
await Task.Delay(1000);
});

await task;

特別なスケジューラーや細かな生成オプションが必要でなければ、基本的にはTask.Runを使用します。

6. 複数のTaskを実行する方法

6-1. awaitを順番に書く逐次実行

複数の非同期処理を順番にawaitすると、逐次実行になります。

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

ProcessAAsyncが完了してからProcessBAsyncを開始し、その後でProcessCAsyncを開始します。

各処理が1秒ずつかかる場合、全体では約3秒かかります。

C#
static async Task ProcessAAsync()
{
await Task.Delay(1000);
}

static async Task ProcessBAsync()
{
await Task.Delay(1000);
}

static async Task ProcessCAsync()
{
await Task.Delay(1000);
}

処理Bが処理Aの結果に依存する場合など、実行順序が必要なときは逐次実行が適しています。

6-2. 複数のTaskを同時に開始する並行実行

互いに依存しない処理なら、先にすべて開始してから待つことで並行実行できます。

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

await taskA;
await taskB;
await taskC;

この場合、3つの処理は順番に完了を待つのではなく、ほぼ同時に開始されます。

各処理が1秒なら、全体もおおむね1秒程度で完了します。ただし、実行環境や処理内容によって所要時間は変わります。

6-3. Task.WhenAllですべての処理を待つ

複数のTaskがすべて完了するまで待つには、Task.WhenAllを使います。

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

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

Console.WriteLine("すべて完了しました");

配列やコレクションも渡せます。

C#
List<Task> tasks = new();

for (int i = 0; i < 5; i++)
{
tasks.Add(ProcessAsync(i));
}

await Task.WhenAll(tasks);

Task.WhenAllは、渡された処理を開始するメソッドではありません。各非同期メソッドを呼び出した時点で処理が開始され、その完了をまとめて待つためのメソッドです。

6-4. Task.WhenAllで複数の戻り値を受け取る

Task<T>の集合をTask.WhenAllで待つと、結果を配列として取得できます。

C#
static async Task<int> GetNumberAsync(int number)
{
await Task.Delay(500);
return number * 10;
}

Task<int> task1 = GetNumberAsync(1);
Task<int> task2 = GetNumberAsync(2);
Task<int> task3 = GetNumberAsync(3);

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

foreach (int result in results)
{
Console.WriteLine(result);
}

結果は、通常、Task.WhenAllに渡したタスクの順番に並びます。完了した順番ではありません。

コレクションからタスクを作成する場合は、LINQも利用できます。

C#
int[] numbers = { 1, 2, 3, 4, 5 };

IEnumerable<Task<int>> tasks =
numbers.Select(number => GetNumberAsync(number));

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

6-5. Task.WhenAnyで最初に完了した処理を取得する

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

C#
Task<string> taskA = GetDataAsync("A", 3000);
Task<string> taskB = GetDataAsync("B", 1000);
Task<string> taskC = GetDataAsync("C", 2000);

Task<string> completedTask =
await Task.WhenAny(taskA, taskB, taskC);

string result = await completedTask;

Console.WriteLine(result);

サンプルのメソッドは次のとおりです。

C#
static async Task<string> GetDataAsync(
string name,
int delayMilliseconds)
{
await Task.Delay(delayMilliseconds);
return $"{name}が完了";
}

この例では、約1秒で完了するtaskBが最初に返されます。

Task.WhenAny自体をawaitすると、完了したタスクが得られます。そのタスクの結果を受け取るため、さらにawait completedTaskを行います。

6-6. 逐次実行と並行実行を使い分けるポイント

逐次実行が適しているのは、処理間に依存関係がある場合です。

C#
User user = await GetUserAsync();
Order[] orders = await GetOrdersAsync(user.Id);

注文情報を取得するにはユーザーIDが必要なので、順番に実行します。

一方、互いに独立した処理は並行実行できます。

C#
Task<User> userTask = GetUserAsync();
Task<Product[]> productsTask = GetProductsAsync();
Task<News[]> newsTask = GetNewsAsync();

await Task.WhenAll(userTask, productsTask, newsTask);

User user = await userTask;
Product[] products = await productsTask;
News[] news = await newsTask;

使い分けの基準は、「前の処理結果が次の処理に必要かどうか」です。

6-7. 並行実行数が多すぎる場合の注意点

大量のタスクを一度に開始すると、外部サービスやシステム資源に負荷をかける可能性があります。

たとえば、1万件のURLへ同時にアクセスするコードは危険です。

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

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

同時接続数やメモリ使用量が増え、次の問題につながります。

  • 接続数の上限に達する

  • APIのレート制限を超える

  • タイムアウトが増える

  • メモリ使用量が増える

  • サーバーに過剰な負荷をかける

並行数を制限するには、SemaphoreSlimを利用できます。

C#
static async Task<string> GetWithLimitAsync(
HttpClient httpClient,
string url,
SemaphoreSlim semaphore)
{
await semaphore.WaitAsync();

try
{
return await httpClient.GetStringAsync(url);
}
finally
{
semaphore.Release();
}
}

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

C#
using SemaphoreSlim semaphore = new(initialCount: 5);

Task<string>[] tasks = urls
.Select(url => GetWithLimitAsync(httpClient, url, semaphore))
.ToArray();

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

この例では、同時実行数を最大5件に制限できます。

7. Taskのエラー・例外を処理する方法

7-1. async/awaitではtry-catchで例外を捕捉する

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

C#
try
{
await ExecuteAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"処理エラー: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"予期しないエラー: {ex.Message}");
}

非同期メソッド内で例外が発生すると、その例外は返されるTaskに記録されます。そして、そのTaskawaitした位置で例外が再度送出されます。

C#
static async Task ExecuteAsync()
{
await Task.Delay(500);
throw new InvalidOperationException("処理に失敗しました");
}

7-2. awaitしないTaskの例外を見逃す原因

次のように非同期メソッドを呼び出してもawaitしない場合、例外を適切に処理できない可能性があります。

C#
ExecuteAsync();
Console.WriteLine("次の処理");

ExecuteAsyncで例外が発生しても、呼び出し元のtry-catchで期待どおりに捕捉できません。

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

このtry-catchが囲んでいるのは、非同期処理の開始部分だけです。await後に発生した例外は、戻されたTaskに保存されます。

正しくは、awaitします。

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

7-3. Task.WhenAllで複数の例外を扱う方法

Task.WhenAllに渡した複数のタスクが失敗した場合、WhenAllが返したタスクには複数の例外情報が保存されることがあります。

C#
Task task1 = FailedAsync("エラー1");
Task task2 = FailedAsync("エラー2");

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);
}
}
}

例外を発生させるメソッドは次のとおりです。

C#
static async Task FailedAsync(string message)
{
await Task.Delay(100);
throw new InvalidOperationException(message);
}

await allTaskで送出される例外だけでなく、allTask.Exception.InnerExceptionsを確認すると、記録された複数の例外を調査できます。

7-4. AggregateExceptionとは

AggregateExceptionは、複数の例外をまとめて保持する例外クラスです。

複数のタスクを待つ処理や、WaitResultなどの同期的な待機を行った場合に登場します。

C#
try
{
Task.WhenAll(task1, task2).Wait();
}
catch (AggregateException ex)
{
foreach (Exception innerException in ex.InnerExceptions)
{
Console.WriteLine(innerException.Message);
}
}

ただし、非同期コードではWaitではなくawaitを使うのが基本です。

C#
await Task.WhenAll(task1, task2);

awaitでは例外が扱いやすい形で再送出されますが、複数の失敗内容をすべて調べたい場合は、WhenAllが返したタスクのExceptionプロパティを確認します。

7-5. TaskのIsFaulted・Exceptionプロパティを確認する

Taskには、失敗したかどうかを確認するIsFaultedプロパティがあります。

C#
Task task = ExecuteAsync();

try
{
await task;
}
catch
{
if (task.IsFaulted)
{
Console.WriteLine("タスクが失敗しました");
Console.WriteLine(task.Exception);
}
}

主な状態確認プロパティには、次のものがあります。

  • IsCompleted:処理が完了している

  • IsCompletedSuccessfully:正常に完了している

  • IsFaulted:例外によって失敗している

  • IsCanceled:キャンセルされている

ただし、通常の制御では状態を何度も調べるより、awaittry-catchを使う方が読みやすくなります。状態プロパティは、診断や複雑なタスク管理で役立ちます。

7-6. fire-and-forgetで発生する問題

非同期処理を開始し、完了を待たずに処理を続ける方式を、fire-and-forgetと呼ぶことがあります。

C#
_ = SendNotificationAsync();

この方式には、次の問題があります。

  • 例外を見逃しやすい

  • 完了したか確認できない

  • アプリケーション終了時に処理が中断される

  • 使用中の依存オブジェクトが先に破棄される

  • テストが難しくなる

  • 再試行やログ管理が複雑になる

可能であれば、呼び出し元までTaskを返してawaitします。

C#
await SendNotificationAsync();

バックグラウンド処理が本当に必要な場合は、アプリケーションの種類に応じた専用の仕組みを利用します。たとえば、ASP.NET Coreではホステッドサービスやメッセージキューなどを検討します。

7-7. finallyを使って後処理を確実に実行する

成功、失敗、キャンセルにかかわらず実行したい後処理は、finallyに記述します。

C#
try
{
await SaveAsync();
}
catch (Exception ex)
{
Console.WriteLine($"保存失敗: {ex.Message}");
}
finally
{
Console.WriteLine("後処理を実行します");
}

UIのボタン状態を戻す場合にも使えます。

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

try
{
await SaveAsync();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
finally
{
SaveButton.Enabled = true;
}
}

ファイルや通信オブジェクトなど、破棄が必要なリソースにはusingawait usingも活用します。

8. Taskをキャンセル・タイムアウトする方法

8-1. CancellationTokenとは

CancellationTokenは、処理にキャンセル要求が出されたことを伝えるための値です。

キャンセルは、処理を外部から強制終了する仕組みではありません。実行中の処理がトークンを確認し、自ら安全な位置で終了する協調的キャンセルです。

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

Console.WriteLine(i);
await Task.Delay(500, cancellationToken);
}
}

ThrowIfCancellationRequestedは、キャンセル要求があるとOperationCanceledExceptionを送出します。

8-2. CancellationTokenSourceでキャンセルを通知する

CancellationTokenSourceは、キャンセル要求を発行するオブジェクトです。

C#
using CancellationTokenSource cancellationTokenSource = new();

Task task = WorkAsync(cancellationTokenSource.Token);

// 2秒後にキャンセルを要求
await Task.Delay(2000);
cancellationTokenSource.Cancel();

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

処理側にはTokenを渡し、キャンセルしたいタイミングでCancelを呼び出します。

8-3. 非同期メソッドにCancellationTokenを渡す

キャンセル可能な非同期メソッドでは、通常、最後の引数としてCancellationTokenを受け取ります。

C#
static async Task<string> DownloadAsync(
HttpClient httpClient,
string url,
CancellationToken cancellationToken)
{
return await httpClient.GetStringAsync(
url,
cancellationToken
);
}

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

C#
using CancellationTokenSource cts = new();

try
{
string text = await DownloadAsync(
httpClient,
"https://example.com",
cts.Token
);
}
catch (OperationCanceledException)
{
Console.WriteLine("ダウンロードを中止しました");
}

独自メソッドから別の非同期メソッドを呼ぶ場合も、受け取ったトークンを下位メソッドへ渡すことが重要です。

8-4. OperationCanceledExceptionを処理する

キャンセルされた非同期処理は、通常、OperationCanceledExceptionまたはその派生クラスであるTaskCanceledExceptionを送出します。

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

一般的には、TaskCanceledExceptionだけでなくOperationCanceledExceptionを捕捉すると、幅広いキャンセル処理に対応できます。

キャンセルはシステム障害とは限らないため、通常のエラーとは分けて処理するのが適切です。

C#
try
{
await WorkAsync(cancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセル");
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}

8-5. 一定時間後にTaskをキャンセルする

CancellationTokenSource.CancelAfterを使うと、指定時間後にキャンセル要求を送れます。

C#
using CancellationTokenSource cts = new();

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

try
{
await WorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("5秒を超えたためキャンセルしました");
}

生成時にタイムアウト時間を指定する方法もあります。

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

呼び出す非同期APIがCancellationTokenを受け取れる場合は、この方法でタイムアウトを実装しやすくなります。

ただし、キャンセル要求を出しても、処理側がトークンを確認しなければすぐには停止しません。

8-6. Task.WhenAnyでタイムアウトを実装する

キャンセルトークンに対応していないタスクでは、Task.WhenAnyを使ってタイムアウトを判定できます。

C#
static async Task<T> WithTimeoutAsync<T>(
Task<T> task,
TimeSpan timeout)
{
Task timeoutTask = Task.Delay(timeout);

Task completedTask =
await Task.WhenAny(task, timeoutTask);

if (completedTask == timeoutTask)
{
throw new TimeoutException(
"処理がタイムアウトしました"
);
}

return await task;
}

使用例は次のとおりです。

C#
try
{
string result = await WithTimeoutAsync(
GetDataAsync(),
TimeSpan.FromSeconds(3)
);

Console.WriteLine(result);
}
catch (TimeoutException ex)
{
Console.WriteLine(ex.Message);
}

ただし、この方法では呼び出し側が待つのをやめるだけで、元の処理自体が停止するとは限りません。可能であれば、CancellationTokenを利用して元の処理にもキャンセルを通知します。

8-7. キャンセルと例外を区別する方法

キャンセルと処理失敗は意味が異なります。

  • キャンセル:利用者やタイムアウトによって中止された

  • 例外:通信障害やプログラム上の問題などで失敗した

そのため、例外処理を分けます。

C#
try
{
await ProcessAsync(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("要求によりキャンセルされました");
}
catch (HttpRequestException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"予期しないエラー: {ex.Message}");
}

Taskの状態を確認する場合は、IsCanceledIsFaultedを区別できます。

C#
if (task.IsCanceled)
{
Console.WriteLine("キャンセル");
}
else if (task.IsFaulted)
{
Console.WriteLine("失敗");
}

9. C#のTaskで初心者がつまずきやすいポイント

9-1. awaitを付け忘れる

非同期メソッドを呼び出しただけで、処理が完了したと思い込まないようにしましょう。

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

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

正しくは次のとおりです。

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

戻り値を利用しない場合でも、処理の完了や例外を管理する必要があればawaitします。

9-2. ResultやWaitで処理をブロックする

ResultWaitは、非同期処理が終わるまで現在のスレッドをブロックします。

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

これでは非同期処理の利点が失われ、環境によってはデッドロックも発生します。

代わりにawaitを使います。

C#
string result = await GetDataAsync();
C#
await GetDataAsync();

「非同期処理を同期メソッドから無理に呼ぶ」のではなく、呼び出し元もasyncにして、非同期処理を上位へ伝播させるのが基本です。

9-3. ResultやWaitによるデッドロック

UIアプリケーションなどでは、ResultWaitによってデッドロックが発生する可能性があります。

概念的には次の状況です。

  1. UIスレッドがResultで非同期処理の完了を待つ

  2. 非同期処理がawait後にUIスレッドへ戻ろうとする

  3. UIスレッドはResultでブロックされている

  4. 双方が待ち続ける

問題になりやすいコードは次のとおりです。

C#
private void Button_Click(object sender, EventArgs e)
{
string result = GetDataAsync().Result;
ResultLabel.Text = result;
}

イベントハンドラーを非同期化します。

C#
private async void Button_Click(
object sender,
EventArgs e)
{
string result = await GetDataAsync();
ResultLabel.Text = result;
}

デッドロックを避ける最も基本的な方法は、async処理を最後までawaitでつなぐことです。

9-4. async voidを通常のメソッドで使う

通常の非同期メソッドをasync voidにすると、呼び出し側が完了を待てません。

C#
static async void UpdateAsync()
{
await Task.Delay(1000);
}

原則としてTaskを返します。

C#
static async Task UpdateAsync()
{
await Task.Delay(1000);
}

async voidはイベントハンドラーなど、戻り値の型をvoidにする必要がある場面に限定します。

9-5. Taskを作成しただけで実行したと思い込む

Taskの生成方法によって、開始タイミングが異なります。

次のコードは、タスクを作成しただけで実行していません。

C#
Task task = new Task(() =>
{
Console.WriteLine("実行");
});

開始するにはStartが必要です。

C#
task.Start();
await task;

ただし、通常はTask.Runを利用する方が簡単です。

C#
await Task.Run(() =>
{
Console.WriteLine("実行");
});

一方、asyncメソッドは呼び出した時点で処理を開始し、最初の未完了のawaitまで進みます。

C#
Task task = ProcessAsync();

Task.WhenAllawaitがタスクを開始するのではなく、非同期メソッドの呼び出し時に開始される点を理解しておきましょう。

9-6. 非同期処理と並列処理を混同する

非同期処理と並列処理は異なります。

非同期処理は、待ち時間中に呼び出し元をブロックしない仕組みです。必ずしも複数の処理が同時にCPU上で動くわけではありません。

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

これは非同期I/Oですが、CPU計算を複数スレッドで並列実行しているわけではありません。

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

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

また、複数の非同期I/Oを同時に進める処理は「並行実行」と表現できます。

C#
await Task.WhenAll(
DownloadAsync(url1),
DownloadAsync(url2)
);

用途に応じて区別することが重要です。

9-7. 共有データを複数のTaskから同時に変更する

複数のタスクから同じ変数やコレクションを変更すると、競合状態が発生する可能性があります。

C#
int count = 0;

Task[] tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(() => count++))
.ToArray();

await Task.WhenAll(tasks);

Console.WriteLine(count);

count++は単一の不可分な操作ではないため、結果が1000にならない可能性があります。

整数の加算にはInterlockedを利用できます。

C#
int count = 0;

Task[] tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(() =>
Interlocked.Increment(ref count)))
.ToArray();

await Task.WhenAll(tasks);

複雑な共有データでは、lockやスレッドセーフなコレクションを検討します。

ただし、lockの中ではawaitを使用できません。非同期対応の排他制御が必要なら、SemaphoreSlim.WaitAsyncなどを利用します。

9-8. 不要なTask.Runで性能を低下させる

すべてのメソッドをTask.Runで包むと、性能が上がるわけではありません。

不要な例は次のとおりです。

C#
int result = await Task.Run(() => 1 + 2);

単純な計算では、タスク生成とスレッドプールへの登録の方が大きなコストになります。

すでに非同期APIがある処理を包む必要もありません。

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

次のように直接待ちます。

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

Task.Runは、CPU負荷の高い同期処理を呼び出し元のスレッドから切り離す必要がある場合に使います。

10. Taskの実践的な使用例

10-1. HTTP APIからデータを取得する

HttpClientを使うと、HTTP APIへ非同期にアクセスできます。

C#
using System.Net.Http;

static async Task<string> GetApiDataAsync(
HttpClient httpClient,
string url,
CancellationToken cancellationToken = default)
{
using HttpResponseMessage response =
await httpClient.GetAsync(
url,
cancellationToken
);

response.EnsureSuccessStatusCode();

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

呼び出し側は次のとおりです。

C#
using HttpClient httpClient = new();

try
{
string json = await GetApiDataAsync(
httpClient,
"https://example.com/api/data"
);

Console.WriteLine(json);
}
catch (HttpRequestException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}

実際のアプリケーションでは、HttpClientをリクエストごとに大量生成するのではなく、再利用したり、DI環境で適切に管理したりします。

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

ファイルを非同期に読み込むには、File.ReadAllTextAsyncを使います。

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

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

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

使用例は次のとおりです。

C#
try
{
await WriteFileAsync(
"sample.txt",
"C# Taskのサンプル"
);

string text =
await ReadFileAsync("sample.txt");

Console.WriteLine(text);
}
catch (IOException ex)
{
Console.WriteLine(
$"ファイル操作に失敗しました: {ex.Message}"
);
}

10-3. 複数のAPIをTask.WhenAllで同時に呼び出す

互いに独立した複数のAPIは、Task.WhenAllで並行して待てます。

C#
static async Task<(string Users, string Products)>
GetAllDataAsync(
HttpClient httpClient,
CancellationToken cancellationToken = default)
{
Task<string> usersTask =
httpClient.GetStringAsync(
"https://example.com/api/users",
cancellationToken
);

Task<string> productsTask =
httpClient.GetStringAsync(
"https://example.com/api/products",
cancellationToken
);

await Task.WhenAll(usersTask, productsTask);

return (
await usersTask,
await productsTask
);
}

順番に待つコードと比較すると、全体の待ち時間を短縮できる可能性があります。

C#
string users = await httpClient.GetStringAsync(usersUrl);
string products = await httpClient.GetStringAsync(productsUrl);

ただし、APIのレート制限や同時接続数を考慮し、無制限に並行実行しないことが重要です。

10-4. 時間のかかる計算をTask.Runで実行する

CPU負荷の高い計算を、UIスレッドなどから切り離して実行する例です。

C#
static long CalculateSum(int max)
{
long total = 0;

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

return total;
}

static async Task<long> CalculateSumAsync(int max)
{
return await Task.Run(
() => CalculateSum(max)
);
}

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

C#
long result =
await CalculateSumAsync(100_000_000);

Console.WriteLine(result);

ライブラリ側の同期計算を何でもTask.Runで包むのではなく、どの実行環境で、どのスレッドを解放したいのかを考えて利用します。

10-5. キャンセル可能な処理を実装する

キャンセル可能な繰り返し処理は、適切な位置でトークンを確認します。

C#
static async Task ProcessItemsAsync(
IEnumerable<string> items,
CancellationToken cancellationToken)
{
foreach (string item in items)
{
cancellationToken.ThrowIfCancellationRequested();

Console.WriteLine($"{item}を処理中");

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

呼び出し側は次のとおりです。

C#
using CancellationTokenSource cts = new();

Task processTask = ProcessItemsAsync(
new[] { "A", "B", "C", "D", "E" },
cts.Token
);

// 1.5秒後にキャンセル
cts.CancelAfter(TimeSpan.FromSeconds(1.5));

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

10-6. タイムアウト付きの非同期処理を実装する

キャンセルトークンを使ったタイムアウト処理は次のように実装できます。

C#
static async Task<string> GetDataWithTimeoutAsync(
HttpClient httpClient,
string url,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
using CancellationTokenSource timeoutCts =
new(timeout);

using CancellationTokenSource linkedCts =
CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
timeoutCts.Token
);

try
{
return await httpClient.GetStringAsync(
url,
linkedCts.Token
);
}
catch (OperationCanceledException)
when (timeoutCts.IsCancellationRequested
&& !cancellationToken.IsCancellationRequested)
{
throw new TimeoutException(
$"処理が{timeout.TotalSeconds}秒でタイムアウトしました"
);
}
}

外部からのキャンセルと、内部タイムアウトを区別している点がポイントです。

C#
try
{
string result = await GetDataWithTimeoutAsync(
httpClient,
"https://example.com/api/data",
TimeSpan.FromSeconds(5)
);
}
catch (TimeoutException ex)
{
Console.WriteLine(ex.Message);
}
catch (OperationCanceledException)
{
Console.WriteLine("ユーザーが処理を中止しました");
}

10-7. 例外処理を含む非同期メソッドを作成する

通信、キャンセル、予期しない例外を区別する例です。

C#
static async Task<string?> LoadDataAsync(
HttpClient httpClient,
string url,
CancellationToken cancellationToken)
{
try
{
using HttpResponseMessage response =
await httpClient.GetAsync(
url,
cancellationToken
);

response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync(
cancellationToken
);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("処理がキャンセルされました");
throw;
}
catch (HttpRequestException ex)
{
Console.WriteLine(
$"HTTP通信に失敗しました: {ex.Message}"
);

return null;
}
finally
{
Console.WriteLine("通信処理を終了しました");
}
}

例外をその場で解決できない場合は、ログだけ記録して握りつぶすのではなく、throw;で呼び出し元へ伝えることも検討します。

C#
catch (Exception ex)
{
LogError(ex);
throw;
}

11. C#のTaskに関するよくある質問

11-1. Taskは必ず別スレッドで実行される?

いいえ。Taskは必ず別スレッドで実行されるわけではありません。

Task.Runに渡した処理は通常、スレッドプール上で実行されます。一方、HTTP通信やファイル操作などの非同期I/Oでは、待機中に専用スレッドを使用し続けるとは限りません。

また、asyncメソッドは、最初の未完了のawaitに到達するまでは呼び出し元のスレッド上で実行されます。

Taskはスレッドそのものではなく、処理の完了や結果を表すオブジェクトです。

11-2. TaskとThreadはどちらを使うべき?

一般的なアプリケーション開発では、まずTaskを使用します。

Taskには、次の利点があります。

  • asyncawaitと組み合わせられる

  • 戻り値を扱いやすい

  • 例外を伝播できる

  • キャンセルを実装しやすい

  • 複数処理を組み合わせやすい

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

Threadを直接使うのは、専用スレッドが必要な場合や、スレッドの優先度、バックグラウンド設定などを細かく制御したい場合です。

通常はTaskを選び、明確な理由がある場合にのみThreadを検討します。

11-3. asyncメソッドは必ずTaskを返す?

一般的な非同期メソッドは、TaskまたはTask<T>を返します。

C#
async Task SaveAsync()
C#
async Task<string> GetDataAsync()

イベントハンドラーではasync voidを使う場合があります。

C#
async void Button_Click(
object sender,
EventArgs e)

非同期ストリームではIAsyncEnumerable<T>を返すこともあります。

C#
async IAsyncEnumerable<int> GetNumbersAsync()

また、性能上の理由などからValueTaskValueTask<T>が使われる場合もあります。初心者は、まずTaskTask<T>を基本として理解するとよいでしょう。

11-4. awaitを使うと処理は並列になる?

いいえ。awaitを付けただけで処理が並列になるわけではありません。

次のコードは逐次実行です。

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

ProcessAAsyncが完了してからProcessBAsyncが始まります。

並行して進めたい場合は、先に両方のタスクを開始します。

C#
Task taskA = ProcessAAsync();
Task taskB = ProcessBAsync();

await Task.WhenAll(taskA, taskB);

ただし、並行実行は必ずしもCPU上の並列実行を意味しません。非同期I/Oの場合は、複数の待ち処理を同時に進めている状態です。

11-5. Task.Runをawaitする必要はある?

基本的にはawaitします。

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

awaitしないと、次の問題が起こる可能性があります。

  • 完了前に後続処理が進む

  • 例外を見逃す

  • アプリケーション終了時に処理が中断される

  • 処理結果を受け取れない

戻り値がある場合も同様です。

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

明確な設計のもとでバックグラウンド処理として管理する場合を除き、開始したタスクは適切にawaitします。

11-6. Task.CompletedTaskはどのような場面で使う?

Task.CompletedTaskは、すでに正常完了している戻り値なしのTaskを表します。

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

インターフェースに合わせてTaskを返す必要があるものの、実装内に非同期処理がない場合に利用できます。

C#
public interface ILogger
{
Task LogAsync(string message);
}

public class NullLogger : ILogger
{
public Task LogAsync(string message)
{
return Task.CompletedTask;
}
}

不要なasyncと状態管理オブジェクトの生成を避けられます。

11-7. Task.FromResultはどのような場面で使う?

Task.FromResultは、指定した結果を持つ完了済みのTask<T>を作成します。

C#
static Task<int> GetNumberAsync()
{
return Task.FromResult(100);
}

キャッシュにデータがある場合にも利用できます。

C#
static Task<string> GetNameAsync(int id)
{
if (id == 1)
{
return Task.FromResult("山田");
}

return LoadNameFromDatabaseAsync(id);
}

ただし、同期処理しか行わないメソッドを、理由なく非同期メソッドに見せるために使う必要はありません。インターフェースとの整合性や、同期的に結果を返せる経路がある場合に役立ちます。

11-8. ConfigureAwaitは初心者も使うべき?

ConfigureAwait(false)は、await後に元の同期コンテキストへ戻ることを要求しない設定です。

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

主に、特定のUIコンテキストへ戻る必要がないライブラリコードなどで利用されます。

一方、UIアプリケーションでは、await後に画面部品を操作するため、元のUIコンテキストへ戻る必要があります。

C#
string text = await GetDataAsync();
ResultLabel.Text = text;

ASP.NET Coreなど、通常は専用の同期コンテキストを持たない環境では、ConfigureAwait(false)による効果や必要性が従来のASP.NETとは異なります。

初心者は、まず通常のawaitを正しく使うことを優先しましょう。利用するフレームワークやライブラリ設計を理解してから、必要な場面でConfigureAwaitを検討するのが安全です。

11-9. ValueTaskとTaskの違いは?

ValueTask<T>も、将来取得できる結果を表します。

C#
static ValueTask<int> GetValueAsync()
{
return ValueTask.FromResult(100);
}

Task<T>は参照型ですが、ValueTask<T>は値型です。結果が同期的に返ることが非常に多い処理では、Taskオブジェクトの割り当てを減らせる可能性があります。

ただし、ValueTaskには次のような注意点があります。

  • 原則として複数回awaitしない

  • 複数箇所で共有しにくい

  • Task.WhenAllなどと組み合わせにくい場合がある

  • 使用方法を誤るとコードが複雑になる

  • 常にTaskより高速とは限らない

通常のアプリケーションコードでは、まずTaskを使用します。計測によって割り当てが性能上の問題になっており、同期完了する割合が高い場合にValueTaskを検討します。

まとめ

C#のTaskは、進行中または将来完了する処理を表すクラスです。asyncawaitを組み合わせることで、HTTP通信やファイル操作などの待ち時間中に、呼び出し元のスレッドをブロックせずに処理できます。

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

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

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

呼び出し側では、原則としてawaitを使います。

C#
await SaveAsync();

int number = await GetNumberAsync();

CPU負荷の高い同期処理ではTask.Runが役立ちますが、HTTP通信やファイル操作など、すでに非同期APIが用意されているI/O処理を安易にTask.Runで包む必要はありません。

複数の独立した処理を待つ場合は、Task.WhenAllを利用できます。

C#
Task task1 = ProcessAAsync();
Task task2 = ProcessBAsync();

await Task.WhenAll(task1, task2);

例外はtry-catch、キャンセルはCancellationTokenで処理します。

C#
try
{
await ProcessAsync(cancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました");
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}

C#のTaskを正しく使うために、特に次の点を意識しましょう。

  • Taskはスレッドそのものではない

  • asyncを付けただけでは別スレッドにならない

  • 必要なTaskawaitする

  • ResultWaitを安易に使わない

  • 通常のメソッドではasync voidを避ける

  • I/O処理では既存の非同期APIを使う

  • CPU負荷の高い処理では必要に応じてTask.Runを使う

  • 独立した処理はTask.WhenAllで並行実行する

  • 例外、キャンセル、タイムアウトを区別して扱う

  • 大量のタスクを無制限に同時実行しない

まずはTask.Delayを使った簡単なコードから試し、次にHTTP通信、ファイル操作、Task.WhenAll、キャンセル処理へ進むと、C#の非同期処理を段階的に理解できます。