C#デリゲートのコールバック設計:進捗・キャンセル・チェーンの例外

C#のコールバックを.NET 10で設計する。ActionとIProgress<T>、コンソールでProgress<T>の順序が崩れる理由、CancellationToken、チェーン途中の例外とGetInvocationList。

2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語向けに書き直したものです。例をすべて .NET 10.0.14(C# 14)でコンパイルし直したところ、完成した例4つのうち、原文のままコンパイルできたものは1つもありませんでした。デリゲートを MtDelegate で宣言して MyDelegate で使っていた(error CS0246)、Console を Comsole や Colsole と誤記していた(error CS0103)、ジェネリックの例に 3.3 f と空白入りのリテラルがあった、などです。宣言例の delegate void typeA(void); は C の書き方で、C# では error CS1536 になります。typeB の変数に new typeA(funcB) を代入していた箇所も error CS0123 の対象です。誤字を直すと出力は原文の説明どおりでした。デリゲートの基本は手短にまとめ、原文が「コールバック」という言葉で触れただけだった実務でのコールバック設計を中心に書き直しています。

この記事は、「メソッドにデリゲートを渡せる」ことは知っていて、進捗を知らせる・キャンセルを受け付ける・複数の相手に通知するといったコールバックの API を自分で設計する必要が出てきた人向けです。先に結論を書くと、進捗は IProgress<T>、キャンセルは CancellationToken で受け取り、チェーンについては2つだけ覚えておけば足ります。例外が出ると残りのハンドラーは実行されないこと、そして戻り値は最後のものしか残らないことです。どちらも GetInvocationList() で解決できます。デリゲートの基本と event キーワードは C#のデリゲートとイベント、Func/Action と呼び出しコストは FuncとActionデリゲート で扱っています。

2021年の例を直して動かし直す

デリゲート型は、参照するメソッドと同じシグネチャで宣言します。原文は宣言の例を C 風に書いていました。

delegate void typeA(void);   →  error CS1536: Invalid parameter type 'void'
delegate void typeB(int);    →  error CS1001: Identifier expected

C# に (void) という引数リストはなく、空のリストは () です。またデリゲートの引数には名前が必要です(delegate void typeB(int value);)。シグネチャの合わないメソッドはコンパイル時に弾かれます。int を受け取る funcB で new typeA(funcB) を作ると、error CS0123: No overload for 'funcB' matches delegate 'typeA' でした。

MtDelegate と Comsole を直した最初の例の出力です。

11 + 22 = 33
22 - 11 = 11

コールバックの例は、計算方法を Calculator に渡す構造です。今なら new MyDelegate(...) を書かず、メソッド名かラムダをそのまま渡します。

Calculator(11, 22, Plus);              // 33
Calculator(33, 22, (a, b) => a - b);   // 11
Calculator(11, 22, (a, b) => a * b);   // 242

static void Calculator(int a, int b, Func<int, int, int> op) => Console.WriteLine(op(a, b));
static int Plus(int a, int b) => a + b;

ジェネリックデリゲート delegate T MyDelegate<T>(T a, T b) の例は 33、7.7、12.1 を出力しました。原文になかった点をひとつ。plus が3つオーバーロードされていても、new MyDelegate<int>(plus) はデリゲートのシグネチャから正しいオーバーロードを選びます。現在はジェネリックデリゲートを自前で宣言するより Func<T, T, T> を使うのが普通です。

コールバック設計①:進捗の通知

原文はコールバックを受け取るメソッドを「コールバックメソッド」と呼んでいました。実務で一番よく出てくるコールバックは進捗の通知です。いちばん単純なのは Action<int> を受け取る形です。

static void Process(int items, Action<int> onProgress, CancellationToken ct)
{
    for (int i = 1; i <= items; i++)
    {
        ct.ThrowIfCancellationRequested();
        Thread.Sleep(1);                       // 実際の処理の代わり
        onProgress(i * 100 / items);
    }
}

Process(5, p => Console.Write($"{p}% "), CancellationToken.None);
Console.WriteLine("| done (Action)");
20% 40% 60% 80% 100% | done (Action)

コールバックは処理スレッド上で同期的に、順番どおり実行されます。コンソールツールなら問題ありませんが、UI アプリではバックグラウンドスレッドから画面のコントロールに触ることになってしまいます。

.NET の標準的な答えが IProgress<T> と、その実装の Progress<T> です。Progress<T> は生成時の SynchronizationContext を捕まえ、通知をそこへ Post します。WPF・WinForms・MAUI なら UI スレッドなので、まさに望みどおりです。ところがコンソールアプリや ASP.NET Core にはコンテキストがないので、通知はスレッドプールに回されます。同じループに Progress<T> を渡した結果です。

20% 40% 60% 80% | done (Progress<T>)
100%

メソッドが終わって done が出たあとで、最後の通知が実行されました。Thread.Sleep(1) を外して10回の通知を5回実行すると、もっとはっきりします。

IProgress<int> ip = new Progress<int>(p => Console.Write($"{p} "));
for (int i = 1; i <= 10; i++) ip.Report(i);
Console.WriteLine("| loop finished");
| loop finished
6 5 8 2 4 10 9 7 3 1
| loop finished
7 6 4 8 1 5 3 9 2 10
| loop finished
2 8 4 7 3 1 6 9 10 5

実行のたびに順番が変わり、通知はすべてループが終わってから届きました。それでも引数の型は IProgress<T> で受けるのが正解です。UI 側は Progress<T> を、コンソールやテスト側は小さな同期実装(class SyncProgress<T>(Action<T> a) : IProgress<T> { public void Report(T v) => a(v); })を渡せばよいからです。このクラスで5回通知すると、1 2 3 4 5 | done と順番どおりに出ました。

通知が多すぎるときのコストも測りました。1つのループから100万回通知し、すべてのコールバックが終わるまでの時間です。条件:.NET 10.0.14、Release ビルド、Stopwatch、ウォームアップ1回のあと3回計測、Apple Silicon Mac(M1 Pro)。

コールバック 1回目 2回目 3回目
Action<int> 100万回 2.91 ms 2.23 ms 3.82 ms
Progress<T> 100万回、全部終わるまで 250.7 ms 246.9 ms 214.8 ms
Progress<T>、% が変わったときだけ(100回) 1.74 ms 1.12 ms 1.07 ms
棒グラフ:普通の Action<int> コールバックは100万回で3.0ms。Progress<T> はスレッドプールで100万回のコールバックが終わるまで237msかかり、通知1回あたり約64バイトを割り当てた。パーセントが変わったときだけ通知した Progress<T>(100回)は1.3ms。
1つのループから進捗を100万回通知し、すべてのコールバックが終わるまでの時間(3回の平均)。.NET 10.0.14、Release ビルド、Apple Silicon Mac(M1 Pro)。短いほど速い。

Progress<T> は通知1回あたり約64バイトを割り当て(1回目は89バイト、GC.GetTotalAllocatedBytes で計測)、直接呼び出しの約80倍かかりました。対策はデリゲートとは関係なく簡単で、画面に見える値が変わったときだけ通知することです。

コールバック設計②:CancellationToken でキャンセル

呼び出し側が処理を止める手段も必要です。標準的なのは CancellationToken を受け取り、ループの中で確認するやり方です。次は進捗コールバック自身が40%でキャンセルをかける例です。

using var cts = new CancellationTokenSource();
try
{
    Process(5, p => { Console.Write($"{p}% "); if (p >= 40) cts.Cancel(); }, cts.Token);
}
catch (OperationCanceledException e)
{
    Console.WriteLine($"| {e.GetType().Name}: {e.Message}");
}
20% 40% | OperationCanceledException: The operation was canceled.

守っておきたい慣習は3つです。トークンは最後の引数で受け取る(公開 API なら既定値 default)。処理の単位ごとに ThrowIfCancellationRequested() で確認する。呼び出す非同期 API(Task.Delay(ms, ct)、HttpClient.GetAsync(url, ct))にそのまま渡す。Func<bool> shouldStop のような独自コールバックを作る必要はありません。トークンはタイムアウト(cts.CancelAfter)やリンクしたソースとそのまま組み合わせられます。

デリゲートチェーン:途中で例外が出たら

誤字を直した2021年のチェーンの例は、原文の説明どおりに出力しました(出力文字列は英語に置き換えています)。

first second third
 second

原文になかった点が2つあります。1つは、-= で最後のメソッドまで外すと変数が null になることです。確かめると null になっており、null 許容が有効なプロジェクトではその途中で警告 CS8601、CS8602 が出ます。チェーンは ?.Invoke で呼び出します。

もっと大事なのは、ハンドラーの1つが例外を投げたときです。ログ、失敗するハンドラー、キャッシュの無効化をつないだ「ファイル保存済み」のチェーンを作りました。

Action<string> onSaved = name => Console.WriteLine($"  [log]   saved {name}");
onSaved += name => throw new IOException("disk full");
onSaved += name => Console.WriteLine($"  [cache] invalidate {name}");

try { onSaved("a.txt"); }
catch (Exception e) { Console.WriteLine($"  caught {e.GetType().Name}: {e.Message}"); }
  [log]   saved a.txt
  caught IOException: disk full

キャッシュは無効化されませんでした。マルチキャストの呼び出しは try のない単純なループなので、最初の例外がループを抜けて呼び出し元へ行き、残りのハンドラーは飛ばされます。書いたログが取り消されることもありません。実際のアプリでは、原因から遠く離れたところで「ときどきキャッシュが古い」という症状として現れます。

2段の図。上段、普通の onSaved(name):ハンドラー1(ログ)が実行され、ハンドラー2が IOException を投げ、ハンドラー3(キャッシュ)は飛ばされて例外が呼び出し元へ届く。下段、GetInvocationList を回して1件ずつ try/catch:ハンドラー1が実行され、ハンドラー2の例外は記録され、ハンドラー3も実行され、最後に AggregateException を1つ投げる。
2番目のハンドラーが例外を投げたときの、普通のマルチキャスト呼び出しと GetInvocationList ループの違い。普通の呼び出しは3番目に届かず、ループは3つとも実行してから失敗をまとめて報告する。

全ハンドラーを必ず実行したいなら、GetInvocationList() を自分で回して失敗を集めます。

static void InvokeAll<T>(Action<T>? handlers, T arg)
{
    if (handlers is null) return;
    List<Exception>? errors = null;
    foreach (Action<T> h in handlers.GetInvocationList())
    {
        try { h(arg); }
        catch (Exception e) { (errors ??= []).Add(e); }
    }
    if (errors is not null) throw new AggregateException(errors);
}
  [log]   saved a.txt
  [cache] invalidate a.txt
  1 failure(s): disk full

3つとも実行され、呼び出し側も失敗があったことを知ることができます。GetInvocationList() は呼ぶたびに新しい配列を作り、そのコストは デリゲートとイベントの記事 で計測しました。「ファイル保存済み」程度の通知なら気にする必要はありません。

GetInvocationList で全部の戻り値を受け取る

Func のチェーンは全メソッドを実行しますが、戻り値は最後のものだけです。検証関数ではこれが原因でエラーが黙って消えます。次の検証関数は、エラーメッセージか null を返します。

Func<string, string?> validate = s => s.Length < 8 ? "too short" : null;
validate += s => s.Any(char.IsDigit) ? null : "no digit";
validate += s => s.Contains(' ') ? "contains space" : null;

Console.WriteLine(validate("ab c") ?? "null");
var errors = validate.GetInvocationList()
    .Cast<Func<string, string?>>()
    .Select(v => v("ab c"))
    .OfType<string>()
    .ToList();
Console.WriteLine(string.Join(", ", errors));
Console.WriteLine(validate("abcdefgh") ?? "null");
contains space
too short, no digit, contains space
null

危ないのは最後の行です。"abcdefgh" には数字がないので2番目の検証は "no digit" を返しましたが、最後の検証が通ったため、普通の呼び出しの結果は null でした。validate(s) is null で判定するコードは、不正なパスワードを通してしまいます。全部の結果が必要なら上のように GetInvocationList() を回すか、最初から List<Func<string, string?>> に入れておくほうが意図がはっきりします。

コールバックの形の選び方

状況 選ぶもの
呼び出し側から関数を1つ受け取る(演算、フィルター) Func<…> / Action<…> 引数
長い処理の進捗を知らせる IProgress<T> 引数
コンソールやテストで順番どおり受けたい 自作の同期 IProgress<T>
呼び出し側が処理を止められるように 最後の引数に CancellationToken
購読者が複数、発行側は相手を知らなくてよい event(基本、応用)
1つ失敗しても全部実行したい GetInvocationList() + try、最後に AggregateException
すべての戻り値が要る GetInvocationList() のループか List<Func<…>>
1秒あたりの通知が多い 見える値が変わったときだけ通知

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple Silicon、M1 Pro)。

よくある質問

時間のかかるメソッドの進捗コールバックは Action<int> と IProgress<int> のどちらで受けるべき?

IProgress<int> で受けるのがおすすめです。呼び出し側は new Progress<int>(...) を渡しても、自作の小さなクラスを渡しても構いません。ただし Progress<T> は SynchronizationContext を捕まえる仕組みで、コンソールアプリやサーバーにはそれがないため、通知はスレッドプールに回されます。検証では10回の通知が実行のたびに違う順番で表示され、メソッドが終わったあとに最後の通知が届きました。

デリゲートチェーンの途中で例外が出たら、残りのメソッドはどうなる?

例外を投げたところでチェーンが止まり、それ以降のハンドラーは実行されないまま例外が呼び出し元に届きます。すでに実行されたハンドラーが取り消されることもありません。全部実行したいなら GetInvocationList() を回して1件ずつ try/catch し、最後に AggregateException にまとめて投げます。

マルチキャストデリゲートにつないだ全メソッドの戻り値を受け取るには?

普通に呼ぶと最後のメソッドの値しか返りません。GetInvocationList() で1つずつ呼び出します。検証用の関数を3つつないだとき、普通の呼び出しは "contains space" だけを返し、ループは3つのメッセージをすべて返しました。2番目の検証がエラーを見つけていたのに、普通の呼び出しが null を返したケースもありました。

  • #C#
  • #デリゲート
  • #コールバック
  • #IProgress
  • #CancellationToken
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑