C#のTask ContinueWithとは?使い方・awaitとの違い・例外処理まで初心者向けに解説

はじめに

C#で非同期処理を学び始めると、Taskawaitasync、そしてContinueWithという言葉をよく見かけます。特にContinueWithは、名前のとおり「あるTaskが終わったあとに、続きの処理を実行する」ためのメソッドです。

たとえば、時間のかかる処理を別スレッドで実行し、その処理が終わったら結果を使ってログを出力したり、画面を更新したり、次の計算を実行したりできます。このような「前の処理が終わったあとに実行する処理」を継続処理と呼びます。

ただし、現在のC#では多くの場合、ContinueWithよりもasync/awaitを使う方が読みやすく、安全で、例外処理もしやすいです。そのため、ContinueWithは「非同期処理を書くための基本手段」というよりも、「Taskの継続処理を細かく制御したいときに使うメソッド」と考えると理解しやすくなります。

この記事では、C#のTask ContinueWithについて、基本的な使い方、awaitとの違い、例外処理、TaskContinuationOptionsTaskSchedulerとの関係、実践的なコード例まで初心者向けに解説します。

1. C#のTask ContinueWithとは?

1-1. ContinueWithの意味:Task完了後に続きの処理を実行するメソッド

ContinueWithは、Taskが完了したあとに実行する処理を登録するメソッドです。

基本的なイメージは次のとおりです。

C#
Task task = Task.Run(() =>
{
Console.WriteLine("最初の処理");
});

task.ContinueWith(t =>
{
Console.WriteLine("続きの処理");
});

このコードでは、まずTask.Runで「最初の処理」を実行します。そのTaskが完了すると、ContinueWithに渡した処理が実行されます。

ContinueWithの「Continue」は「続ける」という意味です。つまり、C#のTask ContinueWithは「Taskのあとに処理を続けるための仕組み」と考えるとよいでしょう。

1-2. Task・非同期処理・継続処理の関係

C#のTaskは、非同期処理や並列処理の結果を表す型です。

たとえば、次のような処理は時間がかかる可能性があります。

C#
Task<int> task = Task.Run(() =>
{
Thread.Sleep(1000);
return 100;
});

このtaskは、すぐに100を返すわけではありません。裏側で処理が進み、完了したあとに結果を取得できます。

ContinueWithは、そのTaskが完了したタイミングで実行したい処理を登録します。

C#
task.ContinueWith(t =>
{
Console.WriteLine($"結果: {t.Result}");
});

このように、Taskは「実行中または完了済みの処理」を表し、ContinueWithは「そのTaskが終わったあとの続き」を表します。

1-3. ContinueWithが使われる主な場面

ContinueWithは、次のような場面で使われます。

C#
Task.Run(() =>
{
return "データ取得完了";
})
.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

主な用途は、Task完了後の後続処理です。

たとえば、ファイル読み込みが終わったら内容を解析する、API呼び出しが終わったらログを出す、計算が終わったら次の計算を行う、といったケースです。

また、TaskContinuationOptionsを使うことで、「成功したときだけ実行」「失敗したときだけ実行」「キャンセルされたときだけ実行」といった条件付きの継続処理も書けます。

C#
task.ContinueWith(t =>
{
Console.WriteLine("成功時だけ実行");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

ただし、現在のC#では単純な非同期処理の流れを書く場合、ContinueWithよりもawaitが使われることの方が多いです。

1-4. 初心者が混同しやすい「await」との位置づけの違い

初心者が混同しやすいのが、ContinueWithawaitの違いです。

どちらも「Taskの完了を待って、そのあとに処理を続ける」という点では似ています。

awaitで書くと、次のようになります。

C#
int result = await Task.Run(() =>
{
return 100;
});

Console.WriteLine(result);

一方、ContinueWithで書くと次のようになります。

C#
Task.Run(() =>
{
return 100;
})
.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

awaitは、非同期処理を通常の順番どおりのコードに近い形で書くための構文です。
ContinueWithは、Taskに対して「完了後に実行する処理」を登録するメソッドです。

初心者が新しくコードを書く場合は、基本的にasync/awaitを優先し、ContinueWithは必要な場面で使うと考えるのがおすすめです。

2. ContinueWithの基本的な使い方

2-1. ContinueWithの基本構文

ContinueWithの基本構文は次のとおりです。

C#
task.ContinueWith(previousTask =>
{
// taskが完了したあとに実行される処理
});

ContinueWithに渡すラムダ式の引数には、前のTaskが渡されます。

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

Task continuation = task.ContinueWith(previousTask =>
{
Console.WriteLine("前のTaskが完了しました");
});

ここで重要なのは、ContinueWithの中に渡されるpreviousTaskは「前のTaskそのもの」だという点です。

前のTaskが成功したのか、失敗したのか、キャンセルされたのかは、previousTaskの状態を確認することで判断できます。

C#
task.ContinueWith(previousTask =>
{
Console.WriteLine(previousTask.Status);
});

2-2. 前のTaskの結果を受け取る方法

前のTaskがTask<T>の場合、ContinueWithの中でResultプロパティを使って結果を取得できます。

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

task.ContinueWith(previousTask =>
{
int result = previousTask.Result;
Console.WriteLine($"結果は {result} です");
});

この例では、前のTaskが10を返し、ContinueWithの中でその値を受け取っています。

ただし、Resultを使うときには注意が必要です。前のTaskが例外で失敗していた場合、Resultにアクセスすると例外が発生します。

そのため、実際のコードでは次のように状態を確認してからResultを使うことがあります。

C#
task.ContinueWith(previousTask =>
{
if (previousTask.IsCompletedSuccessfully)
{
Console.WriteLine(previousTask.Result);
}
});

2-3. Task<T>で戻り値を扱う例

ContinueWith自体も戻り値を返すことができます。

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

Task<int> nextTask = task.ContinueWith(previousTask =>
{
return previousTask.Result * 2;
});

Console.WriteLine(nextTask.Result);

このコードでは、最初のTaskが10を返し、ContinueWithの中でその値を2倍にしています。そのため、nextTask.Result20になります。

ContinueWithは単に後続処理を実行するだけでなく、前のTaskの結果を使って新しい結果を返すこともできます。

C#
Task<string> messageTask = Task.Run(() =>
{
return "C#";
})
.ContinueWith(previousTask =>
{
return $"{previousTask.Result}のTask ContinueWithを学習中";
});

このように、Task<T>ContinueWithを組み合わせると、処理結果を次の処理へ渡すことができます。

2-4. 複数の処理を順番につなげる例

ContinueWithはチェーンのようにつなげることもできます。

C#
Task.Run(() =>
{
Console.WriteLine("1. データを取得");
return 10;
})
.ContinueWith(t =>
{
Console.WriteLine("2. データを加工");
return t.Result * 2;
})
.ContinueWith(t =>
{
Console.WriteLine("3. 結果を表示");
Console.WriteLine(t.Result);
});

このコードでは、次の順番で処理が実行されます。

1. データを取得
2. データを加工
3. 結果を表示

ただし、ContinueWithを何段もつなげると、コードが読みにくくなりやすいです。

同じ処理をawaitで書くと、次のように自然な流れになります。

C#
int value = await Task.Run(() =>
{
Console.WriteLine("1. データを取得");
return 10;
});

Console.WriteLine("2. データを加工");
int result = value * 2;

Console.WriteLine("3. 結果を表示");
Console.WriteLine(result);

処理の順番を読みやすく書きたい場合は、awaitの方が向いています。

2-5. ContinueWithの戻り値もTaskになる点に注意

ContinueWithの戻り値は、通常の値ではなくTaskです。

C#
Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("続きの処理");
});

戻り値を返すContinueWithの場合は、Task<T>になります。

C#
Task<int> nextTask = task.ContinueWith(t =>
{
return 123;
});

この点は非常に重要です。ContinueWithを呼び出したからといって、その場ですぐに中の処理結果が返ってくるわけではありません。ContinueWithも非同期的に実行される可能性があり、その完了を表すTaskが返されます。

そのため、後続処理の完了を待ちたい場合は、返されたTaskに対してawaitするか、コンソールアプリの簡単な検証ではWaitResultを使います。

C#
Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("続きの処理");
});

await continuation;

ただし、実務コードではWaitResultよりもawaitを使う方が推奨されます。

3. ContinueWithのサンプルコードで動きを理解する

3-1. 最もシンプルなContinueWithの例

まずは最もシンプルな例です。

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

class Program
{
static void Main()
{
Task task = Task.Run(() =>
{
Console.WriteLine("最初のTaskを実行");
});

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("ContinueWithで続きの処理を実行");
});

continuation.Wait();
}
}

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

最初のTaskを実行
ContinueWithで続きの処理を実行

taskが完了したあとに、ContinueWithで登録した処理が実行されています。

ここでcontinuation.Wait()を書いているのは、コンソールアプリがすぐ終了してしまうのを防ぐためです。実際の非同期コードでは、可能であればawait continuation;のように書く方が自然です。

3-2. Task.RunとContinueWithを組み合わせる例

Task.Runは、処理をスレッドプール上で実行したいときによく使われます。

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

class Program
{
static void Main()
{
Task task = Task.Run(() =>
{
Console.WriteLine("重い処理を開始");
Thread.Sleep(1000);
Console.WriteLine("重い処理が完了");
});

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("重い処理のあとにログを出力");
});

continuation.Wait();
}
}

この例では、Task.Runで時間のかかる処理を実行し、その完了後にContinueWithでログを出力しています。

処理の流れは次のようになります。

重い処理を開始
重い処理が完了
重い処理のあとにログを出力

ContinueWithを使うと、前のTaskが終わるまで後続処理が実行されないことが分かります。

3-3. 計算結果を次の処理に渡す例

次に、前のTaskの戻り値を次の処理で使う例です。

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

class Program
{
static void Main()
{
Task<int> calculationTask = Task.Run(() =>
{
return 50 + 20;
});

Task<string> messageTask = calculationTask.ContinueWith(t =>
{
int result = t.Result;
return $"計算結果は {result} です";
});

Console.WriteLine(messageTask.Result);
}
}

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

計算結果は 70 です

calculationTaskTask<int>です。そのため、ContinueWithの中でt.Resultを使うと、前のTaskの計算結果である70を受け取れます。

そして、ContinueWithの中で文字列を返しているため、messageTaskTask<string>になります。

3-4. コンソールアプリで実行順序を確認する例

ContinueWithの動きを理解するには、実行順序を表示してみると分かりやすいです。

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

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

Task task = Task.Run(() =>
{
Console.WriteLine("Task開始");
Thread.Sleep(1000);
Console.WriteLine("Task終了");
});

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("ContinueWith実行");
});

Console.WriteLine("Mainの途中");

continuation.Wait();

Console.WriteLine("Main終了");
}
}

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

Main開始
Mainの途中
Task開始
Task終了
ContinueWith実行
Main終了

Task.Runで開始した処理は別スレッドで動く可能性があるため、Mainの途中が先に表示される場合があります。

一方で、ContinueWith実行Task終了のあとに表示されます。これがContinueWithの基本的な役割です。

3-5. ContinueWithが別スレッドで実行される場合の確認方法

ContinueWithがどのスレッドで実行されるかを確認するには、Thread.CurrentThread.ManagedThreadIdを表示します。

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

class Program
{
static void Main()
{
Console.WriteLine($"Main Thread: {Thread.CurrentThread.ManagedThreadId}");

Task task = Task.Run(() =>
{
Console.WriteLine($"Task Thread: {Thread.CurrentThread.ManagedThreadId}");
});

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine($"ContinueWith Thread: {Thread.CurrentThread.ManagedThreadId}");
});

continuation.Wait();
}
}

実行結果の例です。

Main Thread: 1
Task Thread: 4
ContinueWith Thread: 5

このように、ContinueWithの処理は必ずしも元のスレッドで実行されるわけではありません。

特にWPF、Windows Forms、MAUIなどのUIアプリでは、UIスレッド以外から画面部品を更新しようとするとエラーになることがあります。その場合は、TaskScheduler.FromCurrentSynchronizationContext()などを使ってUIスレッドに戻す必要があります。

4. ContinueWithとawaitの違い

4-1. awaitは非同期処理を自然に書くための構文

awaitは、Taskの完了を待ち、その結果を受け取るための構文です。

C#
int result = await Task.Run(() =>
{
return 100;
});

Console.WriteLine(result);

このコードは、見た目としては通常の同期処理に近い形で読めます。

Taskを実行する
結果を待つ
結果を表示する

awaitを使うと、Taskが完了するまで現在の非同期メソッドの実行は一時停止されます。ただし、スレッドを単純にブロックするわけではありません。そのため、UIアプリやWebアプリでも効率よく非同期処理を書くことができます。

4-2. ContinueWithはTaskに継続処理を登録するメソッド

ContinueWithは構文ではなく、Taskクラスのメソッドです。

C#
Task.Run(() =>
{
return 100;
})
.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

awaitが「Taskの完了を待って次の行へ進む」書き方なのに対し、ContinueWithは「Taskが完了したら、この処理を実行してほしい」と登録する書き方です。

そのため、ContinueWithでは処理がネストしたり、チェーンが長くなったりしやすいです。

C#
Task.Run(() => 1)
.ContinueWith(t => t.Result + 1)
.ContinueWith(t => t.Result + 1)
.ContinueWith(t => Console.WriteLine(t.Result));

短い処理なら問題ありませんが、実務で複雑な条件分岐や例外処理が入ると読みにくくなりやすいです。

4-3. コードの読みやすさ・例外処理・戻り値の違い

awaitContinueWithには、主に次のような違いがあります。

C#
// await
try
{
int result = await GetValueAsync();
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

awaitでは、通常のtry-catchに近い形で例外を扱えます。コードの流れも上から下に読みやすいです。

一方、ContinueWithでは、前のTaskの状態を確認して処理を分ける必要があります。

C#
GetValueAsync().ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine(t.Exception?.GetBaseException().Message);
}
else
{
Console.WriteLine(t.Result);
}
});

ContinueWithでは、成功、失敗、キャンセルといったTaskの状態を明示的に扱える一方で、コードが複雑になりやすいです。

戻り値についても違いがあります。awaitではTaskの中身を自然に取り出せます。

C#
int value = await GetValueAsync();

ContinueWithでは、前のTaskのResultを参照し、さらにContinueWith自身もTaskを返すことを意識する必要があります。

C#
Task<int> nextTask = GetValueAsync().ContinueWith(t => t.Result + 1);

4-4. awaitで書いた場合のコード例

次のような処理を考えてみます。

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

awaitで書くと、次のようになります。

C#
static async Task ExecuteAsync()
{
int number = await GetNumberAsync();

int result = number * 2;

Console.WriteLine(result);
}

処理の流れが非常に分かりやすいです。

数値を取得する
2倍する
表示する

例外処理も自然に書けます。

C#
static async Task ExecuteAsync()
{
try
{
int number = await GetNumberAsync();
Console.WriteLine(number);
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}
}

初心者にとっても、awaitの方が直感的に理解しやすいでしょう。

4-5. ContinueWithで書いた場合のコード例

同じ処理をContinueWithで書くと、次のようになります。

C#
static Task ExecuteAsync()
{
return GetNumberAsync()
.ContinueWith(t =>
{
int number = t.Result;

int result = number * 2;

Console.WriteLine(result);
});
}

さらに例外処理を入れると、次のようになります。

C#
static Task ExecuteAsync()
{
return GetNumberAsync()
.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine($"エラー: {t.Exception?.GetBaseException().Message}");
return;
}

int number = t.Result;
Console.WriteLine(number);
});
}

ContinueWithの場合、前のTaskが成功したかどうかを自分で確認しながら書く必要があります。

単純な処理であれば問題ありませんが、処理が増えるほど可読性が落ちやすくなります。

4-6. 基本的にはawaitが推奨される理由

現在のC#では、新規コードを書く場合、基本的にはContinueWithよりもasync/awaitを使うことが推奨されます。

理由は、主に次の点です。

awaitは上から下に処理を読めるため、コードの流れが分かりやすくなります。また、例外処理をtry-catchで自然に書けるため、初心者でもミスを減らしやすいです。

さらに、awaitは戻り値の扱いも簡単です。Task<int>awaitすると、intとして結果を受け取れます。

C#
int value = await GetValueAsync();

一方、ContinueWithでは、Taskの状態、ResultException、戻り値が再びTaskになる点などを意識する必要があります。

そのため、C#のTask ContinueWithは「非同期処理の基本形」として覚えるより、「Taskの継続処理を細かく制御したいときに使う方法」として理解するとよいでしょう。

5. ContinueWithを使うべきケース・使わない方がよいケース

5-1. ContinueWithが向いているケース

ContinueWithが向いているのは、Taskの状態に応じて継続処理を細かく制御したい場合です。

たとえば、成功時だけ実行したい処理を登録する場合です。

C#
task.ContinueWith(t =>
{
Console.WriteLine("成功しました");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

失敗時だけログを出したい場合にも使えます。

C#
task.ContinueWith(t =>
{
Console.WriteLine(t.Exception?.GetBaseException().Message);
}, TaskContinuationOptions.OnlyOnFaulted);

また、複数の継続処理をTaskに対して登録したい場合や、TaskSchedulerを明示して実行場所を制御したい場合にもContinueWithが使われることがあります。

C#
task.ContinueWith(t =>
{
// UIスレッドで実行したい処理
}, TaskScheduler.FromCurrentSynchronizationContext());

このように、ContinueWithは細かい制御をしたいときに便利です。

5-2. awaitの方が向いているケース

通常の非同期処理では、ほとんどの場合awaitの方が向いています。

たとえば、APIを呼び出して結果を受け取り、その結果を使って画面表示を更新するような処理です。

C#
var data = await GetDataAsync();
Display(data);

これをContinueWithで書くと、次のようにやや回りくどくなります。

C#
GetDataAsync().ContinueWith(t =>
{
Display(t.Result);
});

さらに例外処理が入ると、awaitの方が明らかに読みやすくなります。

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

業務アプリケーションでは、読みやすさと保守しやすさが重要です。そのため、単純に「Taskのあとに処理を続けたい」だけなら、ContinueWithではなくawaitを使うのが基本です。

5-3. TaskContinuationOptionsを細かく制御したい場合

ContinueWithの大きな特徴は、TaskContinuationOptionsで継続処理の条件を指定できることです。

C#
task.ContinueWith(t =>
{
Console.WriteLine("成功時のみ実行");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

代表的なオプションには、次のようなものがあります。

C#
TaskContinuationOptions.OnlyOnRanToCompletion
TaskContinuationOptions.OnlyOnFaulted
TaskContinuationOptions.OnlyOnCanceled
TaskContinuationOptions.NotOnFaulted
TaskContinuationOptions.NotOnCanceled
TaskContinuationOptions.ExecuteSynchronously

これらを使うと、Taskの状態に応じて処理を分けることができます。

C#
task.ContinueWith(t =>
{
Console.WriteLine("失敗時のみログ出力");
}, TaskContinuationOptions.OnlyOnFaulted);

成功時、失敗時、キャンセル時で別々の処理を登録したい場合には、ContinueWithが役立ちます。

5-4. 古いコードやライブラリでContinueWithを見かけるケース

async/awaitが一般的になる前のC#コードでは、ContinueWithが使われていることがあります。

古いプロジェクトやライブラリでは、次のようなコードを見かけるかもしれません。

C#
SomeAsyncOperation()
.ContinueWith(t =>
{
if (t.IsFaulted)
{
Log(t.Exception);
}
else
{
Process(t.Result);
}
});

このようなコードを読むときには、ContinueWithが「前のTaskが完了したあとに実行される処理」だと分かっていれば理解しやすくなります。

また、ライブラリ内部では、Taskの継続処理を細かく制御するためにContinueWithが使われることもあります。

初心者がすぐに多用する必要はありませんが、既存コードを読むためには理解しておく価値があります。

5-5. 初心者が新規コードで使う場合の判断基準

初心者が新しくC#の非同期処理を書く場合、判断基準はシンプルです。

通常の処理の流れを書きたいなら、async/awaitを使います。

C#
var result = await GetResultAsync();
Console.WriteLine(result);

Taskの状態ごとに継続処理を細かく登録したいなら、ContinueWithを検討します。

C#
task.ContinueWith(t =>
{
Console.WriteLine("失敗時のみ実行");
}, TaskContinuationOptions.OnlyOnFaulted);

ただし、ContinueWithを使う場合は、前のTaskが成功したのか、失敗したのか、キャンセルされたのかを必ず意識しましょう。

特に、何も確認せずにt.Resultを使うコードは、例外発生時に思わぬエラーにつながります。

6. ContinueWithの例外処理

6-1. ContinueWith内で前のTaskの例外を確認する方法

ContinueWithでは、前のTaskが例外で失敗しても、継続処理自体は実行される場合があります。

次の例を見てください。

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

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("ContinueWithが実行されました");
});

前のTaskは例外で失敗していますが、ContinueWith自体は登録されているため実行されます。

例外を確認するには、IsFaultedExceptionを使います。

C#
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine($"例外: {t.Exception?.GetBaseException().Message}");
}
});

IsFaultedは、Taskが未処理の例外によって失敗したことを示します。

6-2. task.IsFaultedとtask.Exceptionの使い方

task.IsFaultedは、Taskが例外で失敗したかどうかを表すプロパティです。

C#
if (task.IsFaulted)
{
// 例外で失敗している
}

task.Exceptionには、Task内で発生した例外情報が入ります。

C#
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
Exception? exception = t.Exception;
Console.WriteLine(exception?.GetBaseException().Message);
}
});

ただし、task.Exceptionの型はAggregateExceptionです。これは、Taskの中で複数の例外が発生する可能性があるためです。

単純に元の例外メッセージを見たい場合は、GetBaseException()を使うと分かりやすいです。

C#
Console.WriteLine(t.Exception?.GetBaseException().Message);

また、前のTaskが失敗しているのにt.Resultへアクセスすると、例外が発生します。

C#
task.ContinueWith(t =>
{
// 前のTaskが失敗していると危険
Console.WriteLine(t.Result);
});

そのため、Resultを使う前にTaskの状態を確認することが重要です。

6-3. AggregateExceptionとは何か

AggregateExceptionは、複数の例外をまとめて扱うための例外型です。

Taskでは、並列処理や複数の非同期処理の中で、複数の例外が発生する可能性があります。そのため、TaskのExceptionプロパティはAggregateExceptionになります。

C#
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
AggregateException? aggregateException = t.Exception;

foreach (Exception ex in aggregateException!.InnerExceptions)
{
Console.WriteLine(ex.Message);
}
}
});

単一の例外だけを扱う場合でも、TaskではAggregateExceptionに包まれることがあります。

ただし、awaitを使う場合は、通常は元の例外としてcatchできます。

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

この点も、awaitの例外処理が分かりやすい理由のひとつです。

6-4. OnlyOnFaultedで失敗時だけ処理する方法

TaskContinuationOptions.OnlyOnFaultedを使うと、前のTaskが失敗したときだけContinueWithを実行できます。

C#
Task task = Task.Run(() =>
{
throw new InvalidOperationException("エラーが発生しました");
});

Task errorTask = task.ContinueWith(t =>
{
Console.WriteLine("失敗時の処理");
Console.WriteLine(t.Exception?.GetBaseException().Message);
}, TaskContinuationOptions.OnlyOnFaulted);

errorTask.Wait();

このコードでは、前のTaskが例外で失敗した場合だけ、ContinueWith内の処理が実行されます。

成功時には、この継続処理は実行されません。

C#
Task successTask = Task.Run(() =>
{
Console.WriteLine("成功");
});

successTask.ContinueWith(t =>
{
Console.WriteLine("ここは実行されません");
}, TaskContinuationOptions.OnlyOnFaulted);

エラー時だけログを出したい場合などに便利です。

6-5. OnlyOnRanToCompletionで成功時だけ処理する方法

TaskContinuationOptions.OnlyOnRanToCompletionを使うと、前のTaskが正常完了したときだけ継続処理を実行できます。

C#
Task<int> task = Task.Run(() =>
{
return 100;
});

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine($"成功しました: {t.Result}");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

continuation.Wait();

前のTaskが成功しているため、t.Resultを安全に使いやすくなります。

ただし、OnlyOnRanToCompletionを指定した継続処理は、前のTaskが失敗した場合やキャンセルされた場合には実行されません。

C#
Task<int> task = Task.Run<int>(() =>
{
throw new Exception("失敗");
});

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine(t.Result);
}, TaskContinuationOptions.OnlyOnRanToCompletion);

この場合、continuationは実行されません。継続処理が実行されない可能性があることを考慮して設計する必要があります。

6-6. awaitのtry-catchとの違い

awaitでは、例外処理を通常の同期コードに近い形で書けます。

C#
try
{
int result = await Task.Run(() =>
{
throw new InvalidOperationException("失敗しました");
#pragma warning disable CS0162
return 100;
#pragma warning restore CS0162
});

Console.WriteLine(result);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"例外: {ex.Message}");
}

ContinueWithでは、Taskの状態を確認しながら書きます。

C#
Task.Run<int>(() =>
{
throw new InvalidOperationException("失敗しました");
})
.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine($"例外: {t.Exception?.GetBaseException().Message}");
return;
}

Console.WriteLine(t.Result);
});

awaitは例外処理が自然で、読みやすいです。
ContinueWithは、Taskの状態を細かく見ながら処理を分けられる反面、コードが複雑になりやすいです。

6-7. 例外を見落としやすいContinueWithの注意点

ContinueWithでよくある失敗は、前のTaskの例外を確認せずに放置してしまうことです。

C#
Task.Run(() =>
{
throw new Exception("エラー");
})
.ContinueWith(t =>
{
Console.WriteLine("続きの処理");
});

このコードでは、前のTaskで例外が発生していますが、ContinueWith内でt.Exceptionを確認していません。

また、失敗しているTaskに対してResultを呼び出すと、例外が発生します。

C#
Task<int> task = Task.Run<int>(() =>
{
throw new Exception("エラー");
});

task.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

安全に書くには、成功時と失敗時を分けるのがおすすめです。

C#
task.ContinueWith(t =>
{
Console.WriteLine($"成功: {t.Result}");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

task.ContinueWith(t =>
{
Console.WriteLine($"失敗: {t.Exception?.GetBaseException().Message}");
}, TaskContinuationOptions.OnlyOnFaulted);

ContinueWithを使う場合は、「成功した前提」で書かないことが重要です。

7. TaskContinuationOptionsの使い方

7-1. TaskContinuationOptionsとは

TaskContinuationOptionsは、ContinueWithの動作を制御するための列挙型です。

たとえば、次のように指定します。

C#
task.ContinueWith(t =>
{
Console.WriteLine("成功時だけ実行");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

これにより、前のTaskが正常完了した場合だけ継続処理が実行されます。

TaskContinuationOptionsを使うと、次のような条件を指定できます。

C#
OnlyOnRanToCompletion
OnlyOnFaulted
OnlyOnCanceled
NotOnFaulted
NotOnCanceled
ExecuteSynchronously

ContinueWithを安全に使うには、このオプションを理解しておくことが大切です。

7-2. OnlyOnRanToCompletion:成功時のみ実行

OnlyOnRanToCompletionは、前のTaskが正常に完了したときだけ継続処理を実行します。

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

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine($"成功: {t.Result}");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

continuation.Wait();

成功時だけ実行されるため、t.Resultを扱いやすくなります。

ただし、前のTaskが失敗またはキャンセルされた場合、この継続処理は実行されません。

C#
Task<int> task = Task.Run<int>(() =>
{
throw new Exception("失敗");
});

task.ContinueWith(t =>
{
Console.WriteLine(t.Result);
}, TaskContinuationOptions.OnlyOnRanToCompletion);

この例では、継続処理は実行されません。

7-3. OnlyOnFaulted:例外発生時のみ実行

OnlyOnFaultedは、前のTaskが例外で失敗したときだけ継続処理を実行します。

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

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("エラー処理を実行");
Console.WriteLine(t.Exception?.GetBaseException().Message);
}, TaskContinuationOptions.OnlyOnFaulted);

continuation.Wait();

失敗時だけログを出力したい場合に便利です。

成功時の処理と失敗時の処理を分けたい場合は、次のように複数のContinueWithを登録できます。

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

task.ContinueWith(t =>
{
Console.WriteLine($"成功: {t.Result}");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

task.ContinueWith(t =>
{
Console.WriteLine($"失敗: {t.Exception?.GetBaseException().Message}");
}, TaskContinuationOptions.OnlyOnFaulted);

7-4. OnlyOnCanceled:キャンセル時のみ実行

OnlyOnCanceledは、前のTaskがキャンセルされたときだけ継続処理を実行します。

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

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task task = Task.Run(() =>
{
token.ThrowIfCancellationRequested();
}, token);

cts.Cancel();

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine("Taskがキャンセルされました");
}, TaskContinuationOptions.OnlyOnCanceled);

キャンセル処理を扱う場合は、CancellationTokenと一緒に使うことが多いです。

ただし、Taskがキャンセル状態になるには、適切にキャンセルを通知する必要があります。単に例外を投げるだけではなく、OperationCanceledExceptionThrowIfCancellationRequestedを正しく使うことが重要です。

7-5. NotOnFaulted・NotOnCanceledの使い方

NotOnFaultedは、前のTaskが例外で失敗していない場合に継続処理を実行します。

C#
task.ContinueWith(t =>
{
Console.WriteLine("失敗していない場合に実行");
}, TaskContinuationOptions.NotOnFaulted);

NotOnCanceledは、前のTaskがキャンセルされていない場合に継続処理を実行します。

C#
task.ContinueWith(t =>
{
Console.WriteLine("キャンセルされていない場合に実行");
}, TaskContinuationOptions.NotOnCanceled);

これらは、成功時だけではなく「失敗していない場合」「キャンセルされていない場合」といった条件で使います。

ただし、条件の意味を誤解すると、意図しないタイミングで継続処理が実行される可能性があります。

たとえば、NotOnFaultedは「失敗していない場合」なので、キャンセル時にどう扱われるかを意識する必要があります。成功時だけに限定したいなら、OnlyOnRanToCompletionを使う方が分かりやすいです。

7-6. ExecuteSynchronouslyの意味と注意点

ExecuteSynchronouslyは、可能であれば継続処理を同期的に実行するためのオプションです。

C#
task.ContinueWith(t =>
{
Console.WriteLine("可能であれば同期的に実行");
}, TaskContinuationOptions.ExecuteSynchronously);

名前だけ見ると「必ず同じスレッドで実行される」と思うかもしれませんが、そうとは限りません。あくまで、Taskスケジューラが可能と判断した場合に、継続処理を効率よく実行するための指定です。

長時間かかる処理をExecuteSynchronouslyで実行すると、前の処理の完了処理を妨げたり、予期しない場所で重い処理が走ったりする可能性があります。

そのため、初心者が安易に指定する必要はありません。パフォーマンスやスケジューリングを意識する場面で検討するオプションです。

7-7. 条件付きで継続処理を分ける実装例

成功、失敗、キャンセルで処理を分ける例です。

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

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task<int> task = Task.Run(() =>
{
token.ThrowIfCancellationRequested();
return 100;
}, token);

task.ContinueWith(t =>
{
Console.WriteLine($"成功: {t.Result}");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

task.ContinueWith(t =>
{
Console.WriteLine($"失敗: {t.Exception?.GetBaseException().Message}");
}, TaskContinuationOptions.OnlyOnFaulted);

task.ContinueWith(t =>
{
Console.WriteLine("キャンセルされました");
}, TaskContinuationOptions.OnlyOnCanceled);

このように、TaskContinuationOptionsを使うと、状態ごとに処理を分けられます。

ただし、処理が複雑になる場合は、awaittry-catchを使った方が読みやすいことも多いです。

C#
try
{
int result = await task;
Console.WriteLine($"成功: {result}");
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました");
}
catch (Exception ex)
{
Console.WriteLine($"失敗: {ex.Message}");
}

どちらが読みやすいかを考えて使い分けることが大切です。

8. ContinueWithで非同期メソッドを扱うときの注意点

8-1. ContinueWithにasyncラムダを渡すとTask<Task>になる理由

ContinueWithasyncラムダを渡すと、戻り値が二重のTaskになることがあります。

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

C#
Task<Task> task = Task.Run(() =>
{
return 100;
})
.ContinueWith(async t =>
{
await Task.Delay(1000);
Console.WriteLine(t.Result);
});

asyncラムダはTaskを返します。
そして、ContinueWith自体もTaskを返します。

そのため、結果としてTask<Task>のようなネストしたTaskになることがあります。

これは初心者が非常に混乱しやすいポイントです。

C#
var continuation = task.ContinueWith(async t =>
{
await SomeAsyncMethod();
});

この場合、continuationが表しているのは「ContinueWithに渡したasyncラムダを開始するところまで」であり、その中のawait後の処理完了まで自然に待てていない可能性があります。

8-2. Unwrapを使うケース

Task<Task>を平らなTaskにしたい場合は、Unwrapを使います。

C#
Task continuation = Task.Run(() =>
{
return 100;
})
.ContinueWith(async t =>
{
await Task.Delay(1000);
Console.WriteLine(t.Result);
})
.Unwrap();

Unwrapを使うことで、ネストしたTaskを1つのTaskとして扱えます。

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

C#
Task<int> continuation = Task.Run(() =>
{
return 100;
})
.ContinueWith(async t =>
{
await Task.Delay(1000);
return t.Result * 2;
})
.Unwrap();

ただし、初心者にとってはContinueWithasyncラムダ、Unwrapの組み合わせは複雑です。特別な理由がない限り、awaitで書いた方が安全です。

8-3. awaitと組み合わせる場合の書き方

ContinueWithの戻り値をawaitすることはできます。

C#
await Task.Run(() =>
{
return 100;
})
.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

ただし、この書き方は「awaitを使っているのに、あえてContinueWithも使っている」状態です。

同じ処理は、次のように書いた方が分かりやすいです。

C#
int result = await Task.Run(() =>
{
return 100;
});

Console.WriteLine(result);

非同期メソッドを続けて呼びたい場合も、awaitなら自然に書けます。

C#
int result = await GetNumberAsync();
string message = await CreateMessageAsync(result);

Console.WriteLine(message);

ContinueWithで書くとネストしやすくなるため、読みやすさを優先するならawaitを使いましょう。

8-4. 非同期処理のネストを避ける書き方

ContinueWithで非同期処理をネストすると、コードが分かりにくくなります。

C#
GetNumberAsync()
.ContinueWith(async t =>
{
int number = t.Result;
string message = await CreateMessageAsync(number);
Console.WriteLine(message);
});

このようなコードは、Task<Task>になったり、例外処理が複雑になったりする可能性があります。

改善するなら、次のようにawaitで書くのがおすすめです。

C#
int number = await GetNumberAsync();
string message = await CreateMessageAsync(number);

Console.WriteLine(message);

処理の流れが上から下に読めるため、バグを見つけやすくなります。

複数の非同期処理を順番に実行する場合は、無理にContinueWithでチェーンを作るより、async/awaitで書く方が保守しやすいです。

8-5. 初心者がやりがちな失敗例

初心者がやりがちな失敗のひとつは、asyncラムダをContinueWithに渡して、その完了を正しく待てていないケースです。

C#
Task task = Task.Run(() => 100)
.ContinueWith(async t =>
{
await Task.Delay(1000);
Console.WriteLine(t.Result);
});

このコードでは、見た目上はTaskに代入できているように見えますが、実際には内側の非同期処理の扱いに注意が必要です。

正しく扱うなら、Unwrapを使います。

C#
Task task = Task.Run(() => 100)
.ContinueWith(async t =>
{
await Task.Delay(1000);
Console.WriteLine(t.Result);
})
.Unwrap();

しかし、より簡単なのはawaitに書き換えることです。

C#
int result = await Task.Run(() => 100);
await Task.Delay(1000);
Console.WriteLine(result);

初心者が新規コードを書くなら、このようにawaitを使う方が失敗しにくいです。

9. ContinueWithとTaskScheduler・スレッドの関係

9-1. ContinueWithがどのスレッドで実行されるか

ContinueWithの処理は、必ず前のTaskと同じスレッドで実行されるわけではありません。

次のコードで確認できます。

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

Console.WriteLine($"Main: {Thread.CurrentThread.ManagedThreadId}");

Task task = Task.Run(() =>
{
Console.WriteLine($"Task: {Thread.CurrentThread.ManagedThreadId}");
});

Task continuation = task.ContinueWith(t =>
{
Console.WriteLine($"ContinueWith: {Thread.CurrentThread.ManagedThreadId}");
});

continuation.Wait();

実行結果の例です。

Main: 1
Task: 4
ContinueWith: 5

Task.Runの処理とContinueWithの処理が別のスレッドで実行される場合があります。

そのため、ContinueWithの中でスレッドに依存する処理を書くときは注意が必要です。

9-2. TaskScheduler.Defaultとは

TaskSchedulerは、Taskをどこでどのように実行するかを管理する仕組みです。

TaskScheduler.Defaultは、通常、スレッドプールを使ってTaskを実行します。

C#
task.ContinueWith(t =>
{
Console.WriteLine("スレッドプール上で実行される可能性があります");
}, TaskScheduler.Default);

明示的にTaskScheduler.Defaultを指定すると、既定のTaskスケジューラで継続処理が実行されます。

コンソールアプリやサーバーサイドの処理では、この挙動で問題ないことが多いです。

しかし、UIアプリでは注意が必要です。UI部品は通常、UIスレッドからしか操作できません。TaskScheduler.Default上で実行されたContinueWithから直接UIを更新すると、エラーになる可能性があります。

9-3. UIアプリで注意すべきスレッド問題

WPFやWindows FormsなどのUIアプリでは、画面の部品を操作できるのは基本的にUIスレッドだけです。

次のようなコードは問題になる可能性があります。

C#
Task.Run(() =>
{
return "完了";
})
.ContinueWith(t =>
{
label.Text = t.Result; // Windows Formsなどではエラーになる可能性
});

ContinueWithがUIスレッド以外で実行されると、UI部品を直接更新できません。

このような場合は、UIスレッドに戻してから更新する必要があります。

awaitを使うと、UIアプリでは多くの場合、元のコンテキストに戻って続きの処理を実行できます。

C#
string result = await Task.Run(() =>
{
return "完了";
});

label.Text = result;

そのため、UIアプリでは特にContinueWithよりawaitの方が安全で分かりやすいことが多いです。

9-4. TaskScheduler.FromCurrentSynchronizationContextの使い方

ContinueWithでUIスレッドに戻したい場合は、TaskScheduler.FromCurrentSynchronizationContext()を使います。

C#
TaskScheduler uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();

Task.Run(() =>
{
return "処理完了";
})
.ContinueWith(t =>
{
label.Text = t.Result;
}, uiScheduler);

このコードでは、ContinueWithの継続処理を現在の同期コンテキスト、つまりUIスレッド上で実行するように指定しています。

WPFの場合も同じ考え方です。

C#
TaskScheduler uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();

Task.Run(() =>
{
return "完了";
})
.ContinueWith(t =>
{
TextBlockMessage.Text = t.Result;
}, uiScheduler);

ただし、このメソッドはUIスレッド上で呼び出す必要があります。バックグラウンドスレッドから呼び出すと、意図した同期コンテキストを取得できないことがあります。

9-5. デッドロックやUI更新エラーを避けるポイント

UIアプリでデッドロックやUI更新エラーを避けるには、次の点を意識します。

まず、UIスレッドで.Result.Wait()を安易に使わないことです。

C#
// UIスレッドでは避けたい例
var result = GetDataAsync().Result;

このようなコードは、非同期処理の完了待ちでUIスレッドをブロックし、状況によってはデッドロックや画面フリーズの原因になります。

次に、UI更新はUIスレッドで行うことです。

ContinueWithを使う場合は、TaskScheduler.FromCurrentSynchronizationContext()を指定します。

C#
task.ContinueWith(t =>
{
label.Text = "完了";
}, TaskScheduler.FromCurrentSynchronizationContext());

しかし、シンプルに書くならawaitの方が分かりやすいです。

C#
await task;
label.Text = "完了";

UIアプリでは、基本的にawaitを使い、必要な場合だけContinueWithTaskSchedulerを使うのがおすすめです。

10. ContinueWithと他のTask関連メソッドの比較

10-1. ContinueWithとTask.Runの違い

Task.Runは、新しいTaskとして処理を開始するためのメソッドです。

C#
Task task = Task.Run(() =>
{
Console.WriteLine("処理を実行");
});

一方、ContinueWithは、すでにあるTaskの完了後に実行する処理を登録するメソッドです。

C#
task.ContinueWith(t =>
{
Console.WriteLine("完了後の処理");
});

つまり、Task.Runは「処理を始める」、ContinueWithは「処理が終わったあとに続ける」という違いがあります。

次のように組み合わせて使うこともできます。

C#
Task.Run(() =>
{
return 100;
})
.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

ただし、通常の非同期処理では、次のようにawaitで書く方が読みやすいです。

C#
int result = await Task.Run(() => 100);
Console.WriteLine(result);

10-2. ContinueWithとTask.WhenAllの違い

Task.WhenAllは、複数のTaskがすべて完了するのを待つためのメソッドです。

C#
Task task1 = Task.Delay(1000);
Task task2 = Task.Delay(2000);

await Task.WhenAll(task1, task2);

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

ContinueWithは、1つのTaskの完了後に処理を続けるために使われることが多いです。

C#
task1.ContinueWith(t =>
{
Console.WriteLine("task1が完了");
});

複数のTaskがすべて終わったあとに処理したい場合は、Task.WhenAllを使う方が自然です。

C#
Task.WhenAll(task1, task2)
.ContinueWith(t =>
{
Console.WriteLine("すべて完了");
});

ただし、これもawaitで書けばより分かりやすくなります。

C#
await Task.WhenAll(task1, task2);
Console.WriteLine("すべて完了");

10-3. ContinueWithとTask.WhenAnyの違い

Task.WhenAnyは、複数のTaskのうち、どれか1つが完了するのを待つためのメソッドです。

C#
Task task1 = Task.Delay(1000);
Task task2 = Task.Delay(2000);

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

Console.WriteLine("どれか1つが完了");

ContinueWithは、特定のTaskが完了したあとに処理を登録します。

C#
task1.ContinueWith(t =>
{
Console.WriteLine("task1が完了");
});

複数のTaskの中で最初に完了したものに応じて処理したい場合は、Task.WhenAnyが向いています。

C#
Task completed = await Task.WhenAny(task1, task2);

if (completed == task1)
{
Console.WriteLine("task1が先に完了");
}
else
{
Console.WriteLine("task2が先に完了");
}

一方、単一のTaskの完了後に処理をつなげたい場合は、ContinueWithを使うこともできます。

10-4. ContinueWithとasync/awaitの使い分け

ContinueWithasync/awaitの使い分けは、次のように考えると分かりやすいです。

処理の流れを自然に書きたい場合は、async/awaitを使います。

C#
var result = await GetDataAsync();
Console.WriteLine(result);

Taskの完了状態に応じて継続処理を細かく登録したい場合は、ContinueWithを検討します。

C#
task.ContinueWith(t =>
{
Console.WriteLine("成功時のみ実行");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

ただし、例外処理や戻り値の扱いやすさを考えると、アプリケーションコードではasync/awaitの方が向いていることが多いです。

ContinueWithは、Taskの仕組みを深く理解したいときや、古いコードを読むとき、細かな制御が必要なときに重要になります。

10-5. 複数タスクの完了後に処理したい場合の選び方

複数のTaskを扱う場合は、目的に応じてメソッドを選びます。

すべてのTaskが完了したあとに処理したいなら、Task.WhenAllを使います。

C#
await Task.WhenAll(task1, task2, task3);
Console.WriteLine("すべて完了");

どれか1つが完了した時点で処理したいなら、Task.WhenAnyを使います。

C#
Task first = await Task.WhenAny(task1, task2, task3);
Console.WriteLine("最初のTaskが完了");

特定のTaskのあとに処理をつなげたいなら、ContinueWithを使えます。

C#
task1.ContinueWith(t =>
{
Console.WriteLine("task1の後続処理");
});

ただし、可読性を重視するなら、Task.WhenAllTask.WhenAnyawaitを組み合わせるのが一般的です。

11. ContinueWithのよくあるエラーと解決方法

11-1. Resultを呼び出して例外が発生する

前のTaskが失敗している状態でResultを呼び出すと、例外が発生します。

C#
Task<int> task = Task.Run<int>(() =>
{
throw new Exception("失敗");
});

task.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

このコードでは、t.Resultにアクセスした時点で例外が発生します。

解決方法は、Taskの状態を確認することです。

C#
task.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
{
Console.WriteLine(t.Result);
}
else if (t.IsFaulted)
{
Console.WriteLine(t.Exception?.GetBaseException().Message);
}
});

または、成功時だけ実行するようにします。

C#
task.ContinueWith(t =>
{
Console.WriteLine(t.Result);
}, TaskContinuationOptions.OnlyOnRanToCompletion);

11-2. 例外がContinueWithでうまく捕捉できない

ContinueWithの外側でtry-catchしても、非同期処理内の例外を期待どおり捕捉できないことがあります。

C#
try
{
Task.Run(() =>
{
throw new Exception("エラー");
})
.ContinueWith(t =>
{
Console.WriteLine("続き");
});
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

このtry-catchは、Taskの中で非同期的に発生した例外をそのまま捕捉できるわけではありません。

ContinueWithで例外を見るなら、前のTaskのIsFaultedExceptionを確認します。

C#
Task.Run(() =>
{
throw new Exception("エラー");
})
.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine(t.Exception?.GetBaseException().Message);
}
});

または、awaitを使ってtry-catchする方が分かりやすいです。

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

11-3. 継続処理が実行されない

TaskContinuationOptionsを指定している場合、条件に合わないと継続処理は実行されません。

C#
Task task = Task.Run(() =>
{
throw new Exception("失敗");
});

task.ContinueWith(t =>
{
Console.WriteLine("成功時のみ実行");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

このコードでは、前のTaskが失敗しているため、OnlyOnRanToCompletionの継続処理は実行されません。

解決方法は、指定したオプションが目的に合っているか確認することです。

失敗時に実行したいなら、OnlyOnFaultedを使います。

C#
task.ContinueWith(t =>
{
Console.WriteLine("失敗時のみ実行");
}, TaskContinuationOptions.OnlyOnFaulted);

成功、失敗、キャンセルをすべて扱いたい場合は、それぞれの継続処理を用意するか、オプションなしで状態を確認します。

C#
task.ContinueWith(t =>
{
Console.WriteLine(t.Status);
});

11-4. asyncラムダで処理が二重のTaskになる

ContinueWithasyncラムダを渡すと、Taskがネストすることがあります。

C#
var task = Task.Run(() => 10)
.ContinueWith(async t =>
{
await Task.Delay(1000);
return t.Result * 2;
});

この場合、taskTask<int>ではなく、Task<Task<int>>のような構造になることがあります。

解決方法は、Unwrapを使うことです。

C#
Task<int> task = Task.Run(() => 10)
.ContinueWith(async t =>
{
await Task.Delay(1000);
return t.Result * 2;
})
.Unwrap();

ただし、よりおすすめなのはawaitで書き換えることです。

C#
int value = await Task.Run(() => 10);
await Task.Delay(1000);

int result = value * 2;

この方がシンプルで、例外処理も分かりやすくなります。

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

UIアプリでよくあるエラーが、ContinueWithの中から直接画面を更新してしまうケースです。

C#
Task.Run(() =>
{
return "完了";
})
.ContinueWith(t =>
{
label.Text = t.Result;
});

このContinueWithがUIスレッド以外で実行されると、画面更新でエラーになることがあります。

解決方法は、UIスレッド上で継続処理を実行することです。

C#
TaskScheduler uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();

Task.Run(() =>
{
return "完了";
})
.ContinueWith(t =>
{
label.Text = t.Result;
}, uiScheduler);

または、awaitを使う方が簡単です。

C#
string result = await Task.Run(() =>
{
return "完了";
});

label.Text = result;

UIアプリでは、原則としてawaitを使う方が安全です。

11-6. 処理順序が想定と違う

ContinueWithを使っていると、処理順序が想定と違うように見えることがあります。

C#
Task.Run(() =>
{
Console.WriteLine("Task");
})
.ContinueWith(t =>
{
Console.WriteLine("ContinueWith");
});

Console.WriteLine("Main");

実行結果は次のようになる可能性があります。

Main
Task
ContinueWith

これは、Task.Runが非同期的に実行されるためです。ContinueWithは前のTaskのあとに実行されますが、メインスレッドの処理はその完了を待たずに先へ進みます。

後続処理の完了まで待ちたい場合は、返されたTaskを待ちます。

C#
Task continuation = Task.Run(() =>
{
Console.WriteLine("Task");
})
.ContinueWith(t =>
{
Console.WriteLine("ContinueWith");
});

await continuation;
Console.WriteLine("完了");

コンソールアプリのMainで検証する場合は、static async Task Main()を使うと分かりやすいです。

C#
static async Task Main()
{
Task continuation = Task.Run(() =>
{
Console.WriteLine("Task");
})
.ContinueWith(t =>
{
Console.WriteLine("ContinueWith");
});

await continuation;

Console.WriteLine("Main終了");
}

12. ContinueWithの実践的なコード例

12-1. 成功時・失敗時・キャンセル時で処理を分ける例

次の例では、Taskの状態に応じて成功、失敗、キャンセルの処理を分けています。

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

class Program
{
static void Main()
{
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task<int> task = Task.Run(() =>
{
token.ThrowIfCancellationRequested();

return 100;
}, token);

Task success = task.ContinueWith(t =>
{
Console.WriteLine($"成功: {t.Result}");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

Task failure = task.ContinueWith(t =>
{
Console.WriteLine($"失敗: {t.Exception?.GetBaseException().Message}");
}, TaskContinuationOptions.OnlyOnFaulted);

Task canceled = task.ContinueWith(t =>
{
Console.WriteLine("キャンセルされました");
}, TaskContinuationOptions.OnlyOnCanceled);

try
{
Task.WaitAll(success, failure, canceled);
}
catch
{
// 条件に合わなかった継続Taskがキャンセル扱いになることがあるため、
// サンプルでは簡略化しています。
}
}
}

実務では、成功、失敗、キャンセルを分けたい場合でも、awaittry-catchを使った方が読みやすいケースが多いです。

C#
try
{
int result = await task;
Console.WriteLine($"成功: {result}");
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました");
}
catch (Exception ex)
{
Console.WriteLine($"失敗: {ex.Message}");
}

12-2. API呼び出し後にログ出力する例

API呼び出し後にログを出す例です。

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

class Program
{
static async Task<string> GetApiResultAsync()
{
using HttpClient client = new HttpClient();
return await client.GetStringAsync("https://example.com");
}

static void Main()
{
Task<string> apiTask = GetApiResultAsync();

Task logTask = apiTask.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine($"API呼び出し失敗: {t.Exception?.GetBaseException().Message}");
}
else
{
Console.WriteLine("API呼び出し成功");
Console.WriteLine($"文字数: {t.Result.Length}");
}
});

logTask.Wait();
}
}

このコードでは、API呼び出しが成功した場合は文字数を出力し、失敗した場合は例外メッセージを出力しています。

ただし、HttpClientを使う実際のアプリケーションでは、awaitで書く方が一般的です。

C#
try
{
string result = await GetApiResultAsync();
Console.WriteLine("API呼び出し成功");
Console.WriteLine($"文字数: {result.Length}");
}
catch (Exception ex)
{
Console.WriteLine($"API呼び出し失敗: {ex.Message}");
}

12-3. ファイル読み込み後に後続処理を実行する例

ファイル読み込み後に内容を処理する例です。

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

class Program
{
static void Main()
{
Task<string> readTask = File.ReadAllTextAsync("sample.txt");

Task continuation = readTask.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine($"ファイル読み込み失敗: {t.Exception?.GetBaseException().Message}");
return;
}

string content = t.Result;
int lineCount = content.Split(Environment.NewLine).Length;

Console.WriteLine($"行数: {lineCount}");
});

continuation.Wait();
}
}

この例では、ファイル読み込みが終わったあとに行数を数えています。

awaitで書くと次のようになります。

C#
try
{
string content = await File.ReadAllTextAsync("sample.txt");
int lineCount = content.Split(Environment.NewLine).Length;

Console.WriteLine($"行数: {lineCount}");
}
catch (Exception ex)
{
Console.WriteLine($"ファイル読み込み失敗: {ex.Message}");
}

こちらの方が、処理の流れが自然で読みやすいです。

12-4. UI更新を含むContinueWithの例

Windows FormsやWPFなどのUIアプリでContinueWithを使う場合は、UIスレッドを意識する必要があります。

Windows Formsの例です。

C#
private void button1_Click(object sender, EventArgs e)
{
TaskScheduler uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();

Task.Run(() =>
{
Thread.Sleep(1000);
return "処理が完了しました";
})
.ContinueWith(t =>
{
label1.Text = t.Result;
}, uiScheduler);
}

この例では、TaskScheduler.FromCurrentSynchronizationContext()を使って、ContinueWithの処理をUIスレッドで実行しています。

これを指定しないと、label1.Textの更新時にエラーになる可能性があります。

ただし、同じ処理はawaitを使うとより簡単に書けます。

C#
private async void button1_Click(object sender, EventArgs e)
{
string message = await Task.Run(() =>
{
Thread.Sleep(1000);
return "処理が完了しました";
});

label1.Text = message;
}

UIアプリでは、このようにawaitを使う方が分かりやすく、安全です。

12-5. awaitに書き換えた改善例

ContinueWithで書かれたコードをawaitに書き換える例です。

まず、ContinueWithで書いたコードです。

C#
GetUserAsync()
.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine(t.Exception?.GetBaseException().Message);
return;
}

User user = t.Result;
Console.WriteLine(user.Name);
});

これをawaitに書き換えると、次のようになります。

C#
try
{
User user = await GetUserAsync();
Console.WriteLine(user.Name);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

awaitを使うことで、成功時の処理と例外処理が自然に分かれます。

さらに、複数の処理を順番に実行する場合も分かりやすくなります。

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

Console.WriteLine($"{user.Name} の注文数: {orders.Length}");
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}

複雑なContinueWithチェーンを見つけた場合は、async/awaitに書き換えることで読みやすさが大きく改善することがあります。

13. ContinueWithを使う際のベストプラクティス

13-1. 新規コードでは基本的にasync/awaitを優先する

新しくC#の非同期処理を書く場合は、基本的にasync/awaitを優先しましょう。

C#
var result = await GetDataAsync();
Console.WriteLine(result);

この書き方は、処理の流れが上から下に読めるため、初心者にも分かりやすく、保守もしやすいです。

ContinueWithは便利なメソッドですが、例外処理、戻り値、Taskの状態、スレッドの扱いなど、意識する点が多くなります。

特別な理由がない限り、アプリケーションコードではawaitを使うのがおすすめです。

13-2. ContinueWithを使う場合はTaskContinuationOptionsを明示する

ContinueWithを使う場合は、できるだけ意図を明確にしましょう。

成功時だけ実行したいなら、OnlyOnRanToCompletionを指定します。

C#
task.ContinueWith(t =>
{
Console.WriteLine(t.Result);
}, TaskContinuationOptions.OnlyOnRanToCompletion);

失敗時だけ実行したいなら、OnlyOnFaultedを指定します。

C#
task.ContinueWith(t =>
{
Console.WriteLine(t.Exception?.GetBaseException().Message);
}, TaskContinuationOptions.OnlyOnFaulted);

条件を明示することで、「どの状態のときに実行される処理なのか」が分かりやすくなります。

13-3. 例外・キャンセル・成功の状態を必ず確認する

ContinueWith内で前のTaskを扱う場合は、成功、失敗、キャンセルの状態を意識しましょう。

C#
task.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
{
Console.WriteLine($"成功: {t.Result}");
}
else if (t.IsFaulted)
{
Console.WriteLine($"失敗: {t.Exception?.GetBaseException().Message}");
}
else if (t.IsCanceled)
{
Console.WriteLine("キャンセル");
}
});

特に、状態確認なしでt.Resultにアクセスするのは避けた方が安全です。

C#
// 注意が必要
Console.WriteLine(t.Result);

前のTaskが失敗している場合、Resultの取得時に例外が発生します。

13-4. asyncラムダを使う場合はTaskのネストに注意する

ContinueWithasyncラムダを渡すと、Task<Task>Task<Task<T>>のようにTaskがネストすることがあります。

C#
var task = Task.Run(() => 10)
.ContinueWith(async t =>
{
await Task.Delay(1000);
return t.Result * 2;
});

このような場合は、Unwrapが必要になることがあります。

C#
Task<int> task = Task.Run(() => 10)
.ContinueWith(async t =>
{
await Task.Delay(1000);
return t.Result * 2;
})
.Unwrap();

しかし、初心者にとっては複雑になりやすいので、可能であればawaitで書き換えましょう。

C#
int value = await Task.Run(() => 10);
await Task.Delay(1000);

int result = value * 2;

13-5. UIアプリではTaskSchedulerを意識する

UIアプリでContinueWithを使う場合は、どのスレッドで継続処理が実行されるかを必ず意識しましょう。

UI部品を更新する場合は、UIスレッド上で処理する必要があります。

C#
TaskScheduler uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();

Task.Run(() =>
{
return "完了";
})
.ContinueWith(t =>
{
label.Text = t.Result;
}, uiScheduler);

TaskScheduler.FromCurrentSynchronizationContext()を使うことで、現在のUIコンテキストに戻して継続処理を実行できます。

ただし、UIアプリではawaitを使った方が簡単です。

C#
string result = await Task.Run(() => "完了");
label.Text = result;

13-6. 読みやすさを重視して複雑なチェーンを避ける

ContinueWithはチェーンできるため、次のようなコードを書くこともできます。

C#
Task.Run(() => 1)
.ContinueWith(t => t.Result + 1)
.ContinueWith(t => t.Result + 1)
.ContinueWith(t => Console.WriteLine(t.Result));

短いコードなら問題ありませんが、実務で処理が増えると読みづらくなります。

例外処理や条件分岐が入ると、さらに複雑になります。

C#
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
Log(t.Exception);
return;
}

Process(t.Result);
})
.ContinueWith(t =>
{
// さらに後続処理
});

このような場合は、async/awaitに書き換えた方が読みやすくなることが多いです。

C#
try
{
var result = await task;
Process(result);

await NextProcessAsync();
}
catch (Exception ex)
{
Log(ex);
}

C#のTask ContinueWithは便利ですが、読みやすさを犠牲にしてまで使う必要はありません。保守しやすいコードを書くことを優先しましょう。

まとめ

C#のTask ContinueWithは、Taskが完了したあとに続きの処理を実行するためのメソッドです。

基本的な使い方は、Taskに対してContinueWithを呼び出し、前のTaskを引数として受け取るラムダ式を渡します。

C#
task.ContinueWith(t =>
{
Console.WriteLine("Task完了後の処理");
});

前のTaskがTask<T>の場合は、t.Resultで結果を受け取ることができます。

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

task.ContinueWith(t =>
{
Console.WriteLine(t.Result);
});

ただし、前のTaskが失敗している状態でResultにアクセスすると例外が発生するため、IsFaultedExceptionを確認することが重要です。

C#
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
Console.WriteLine(t.Exception?.GetBaseException().Message);
}
});

また、TaskContinuationOptionsを使うと、成功時だけ、失敗時だけ、キャンセル時だけといった条件付きの継続処理を書けます。

C#
task.ContinueWith(t =>
{
Console.WriteLine("成功時のみ実行");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

一方で、現在のC#では、多くの場面でContinueWithよりもasync/awaitの方が推奨されます。

C#
try
{
int result = await GetValueAsync();
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

awaitはコードの流れが自然で、例外処理も書きやすく、戻り値の扱いも簡単です。

そのため、初心者が新しく非同期処理を書く場合は、基本的にasync/awaitを使いましょう。ContinueWithは、Taskの継続処理を細かく制御したい場合、古いコードを読む場合、TaskContinuationOptionsTaskSchedulerを明示的に扱いたい場合に使うと理解しやすいです。

C#のTask ContinueWithを正しく理解しておくと、非同期処理の仕組みやTaskの状態管理がより深く分かるようになります。新規コードではawaitを中心に使いながら、必要な場面でContinueWithを適切に使い分けることが大切です。