Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】The CancellationToken That Never Propagated: A Subtle ASP.NET Core Timeout Bug I Missed in Code Review

2026年09月19日に「Dev.to」が公開したITニュース「The CancellationToken That Never Propagated: A Subtle ASP.NET Core Timeout Bug I Missed in Code Review」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

ASP.NET Coreで非同期のバックグラウンドタスクを「ファイアアンドフォーゲット」で実行する際、CancellationTokenを伝播させないと、クライアントが切断されても処理が継続し、リソース枯渇やシステム遅延を招く。HttpClientのタイムアウト設定だけでは不十分で、タスクにCancellationTokenを渡し、処理中にキャンセル要求をチェックする実装が重要となる。

ITニュース解説

多くのシステムエンジニアが開発初期に、HTTPリクエストにタイムアウトを設定すればシステムは安全だと考えることがある。しかし、これは間違いであることが少なくない。最近、ある.NET Coreアプリケーションで、高負荷時に原因不明の処理遅延が発生し、最終的には応答が遅くなり、アプリケーションを動かすためのスレッド(処理の単位)が尽きてしまう問題が起きた。この問題は、CancellationTokenという仕組みが適切に利用されていなかったことが原因で、単純なタイムアウト設定だけでは防げない種類のバグである。

問題が起きたのは、ASP.NET CoreのAPIエンドポイントで、リクエストを受け取った後、すぐに「202 Accepted」(受け付けました)という応答をクライアントに返すものだった。本来の重い処理は、ユーザーが結果を待たずに別の作業に移れるように、バックグラウンドの別タスクとして「起動したらあとはお任せ」という形で実行されていた。具体的なコードは、クライアントからのリクエストを受け付けるメソッドが、すぐに検証処理を終え、その後に重い処理を担う別の非同期メソッドを呼び出し、その結果を待たずにすぐにAccepted応答を返す、という形だった。この重い処理は、例えば外部の遅いシステム(サードパーティAPI)を呼び出したり、その結果を使ってさらに複雑な計算(CPU負荷の高い作業)を行ったりする。

一見すると、この実装はメインの処理をブロックせず、非同期処理を適切に使っているように見える。しかし、ここには隠れた落とし穴があった。それは、「キャンセルの文脈」(cancellation context)が欠けていることだ。例えば、クライアントがリクエストを送った後、APIがすぐに202 Acceptedを返し、バックグラウンドタスクが重い処理を開始したとする。ところが、外部サービスが非常に遅く30秒かかるような場合、その間にユーザーがブラウザのタブを閉じたり、途中のネットワーク機器(ロードバランサーなど)が接続をタイムアウトさせてしまったりすることがある。

従来の同期的な処理であれば、クライアントとの接続が切れた時点でサーバー側もそのリクエストに関連する処理を中断することが一般的だった。しかし、この非同期のバックグラウンドタスクは、一度開始されるとクライアントとの接続から「切り離された」状態になる。つまり、クライアントがもう結果を必要としていないにもかかわらず、バックグラウンドタスクはスレッドプール上で処理を継続してしまうのだ。これが一度や二度なら問題にならないが、高負荷状態になると深刻な連鎖反応を引き起こす。

例えば、毎秒1,000件のリクエストが来るようなピーク時には、1,000個のバックグラウンドジョブが同時に起動する。それぞれのジョブが遅い外部サービスの応答を30秒間待つとどうなるだろうか。システムには限られた数のスレッドしか存在しない。非同期処理の仕組みにより、HTTP待ちの間はスレッドが一度プールに返却されるとはいえ、外部サービスへの接続数にも上限がある(通常、HttpClientではホストあたり50など)。もし1,000個のジョブが同時に同じ遅い外部サービスにアクセスしようとすると、すぐに接続プールの上限に達してしまう。さらに、この問題は単にHTTP呼び出しの待ち時間だけではない。重い処理の中にはHTTP呼び出し後にCPUを多く使う処理も含まれていた。CancellationTokenが適切に伝播されていないため、これらのCPU負荷の高い処理は、たとえクライアントがすでに接続を切断していたとしても、停止すべきかどうかを確認せず実行し続ける。結果として、接続プールの飽和と、キューに溜まったCPU処理が相まって、スレッドプールが限界まで拡張され、最終的には新しいリクエストを受け付けるためのスレッドすら確保できなくなり、システム全体が停止してしまう事態に陥ったのである。

この問題の解決策は、CancellationTokenを適切に「伝播」させ、タスク内でそのトークンを「尊重する」ことにあった。具体的には、まずクライアントからのリクエストと同時に生成されるCancellationTokenを、APIエンドポイントのメソッド引数として受け取るように変更する。このトークンは、クライアントが切断したり、サーバーがシャットダウンしたりするとキャンセルされる信号を出す。次に、この受け取ったCancellationTokenを、バックグラウンドで実行される重い処理のメソッドの引数として渡す。そして、重い処理の内部で、外部サービスへのHTTP呼び出しを行う際や、CPU負荷の高い処理を実行する前に、CancellationTokenの状態をチェックし、「キャンセルが要求されているか?」を確認する。もしキャンセルが要求されていれば、それ以上処理を進めずに早期に終了するようにコードを修正するのだ。例えば、HttpClient.GetAsyncメソッドにはCancellationTokenを渡せる引数があり、これによりHTTPリクエスト自体をキャンセルできる。また、CPU負荷の高い処理の前には、if (token.IsCancellationRequested) のように明示的にチェックを入れ、処理を中断させる。

多くの開発者は「タイムアウトを設定すればよい」と考えがちだが、HttpClientのタイムアウトは、あくまでそのHTTPクライアントが外部サービスからの応答をいつまで待つかを定義するものであり、サーバー側で開始されたバックグラウンド処理そのものを停止させるものではない。CancellationTokenは、より強力で、アプリケーションのすべての層に対して「このコンテキストはもう無効なので、今行っている作業を停止してください」と協調的に伝えるための信号である。CancellationTokenを適切に伝播させない場合、様々な問題が発生する可能性がある。例えば、バックグラウンドジョブが不要なリソース(メモリやスレッド)を消費し続ける「リソースリーク」、ユーザーがすでに興味を失った後にデータベースを更新してしまいデータの整合性を損なう「古いデータ」の問題、そしてアプリケーションが正常にシャットダウンしようとしたときに、これらの「ゾンビ」タスクの完了を待ってしまい、シャットダウンが遅延したりハングしたりする問題などだ。

この経験から得られる実用的な教訓は、常に「この作業は誰がキャンセルすることを許可されているのか?」という問いを自分に投げかけることである。もし「起動したらあとはお任せ」のような処理を行うのであれば、必ずキャンセル戦略を検討する必要がある。その処理がクライアントのリクエストに強く結びついているなら、リクエスト固有のCancellationToken(ASP.NET CoreではHttpContext.RequestAbortedとして利用できることが多い)を使うべきだ。もしアプリケーション全体のライフサイクルに結びついているなら、アプリケーション全体に渡されるCancellationToken(例えばHostedServiceに渡されるもの)を使う。コードレビューを行う際には、引数を全く受け取らないTask.Runや非同期メソッドを見かけたら、それがクライアントの切断やアプリケーションのシャットダウン時にどうなるかを必ず確認するべきだ。もしその処理が「ずっと実行され続ける」という答えになるのであれば、それは高負荷時にバグとして顕在化する可能性が高い。

関連コンテンツ

関連IT用語