VB.NETにおけるCancellationTokenの極限:安全な非同期キャンセルとタイムアウト制御の真実
長年、Windowsおよびエンタープライズ領域の最前線でシステム構築を行ってきたエンジニアにとって、VB.NETによる非同期処理(`Async/Await`)の導入は、UIのスレッドハング(応答なし)を防ぐ強力な武器となった。しかし、その裏で「開始したタスクをいかにして安全に、かつ確実に仕留めるか」というライフサイクル管理を疎かにしているシステムが散見される。
タスクを放置することは、メモリリーク、データベース接続(コネクションプール)の枯渇、そしてファイルロックの残存を引き起こす。
本稿では、VB.NETにおける`CancellationTokenSource`(以下、CTS)および`CancellationToken`(以下、CT)を用いた、堅牢極まりない非同期キャンセルとタイムアウト制御の設計パターンを、アーキテクトの視点から徹底的に解剖する。
—
1. キャンセルの本質:なぜ「協調的キャンセル」なのか
レガシーなVB6や初期の.NETにおける `Thread.Abort` は、スレッドを任意のタイミングで強制終了させる暴力的な手段であった。これはロックの未解放や、オブジェクトが不整合な状態のまま放置される原因となり、現在では完全に非推奨、ないしは.NET Core/.NET 5以降ではプラットフォームによっては非サポート(`PlatformNotSupportedException`)となっている。
現代の.NETにおけるキャンセルモデルは「協調的キャンセル(Cooperative Cancellation)」である。
これは、呼び出し側が「キャンセルしてほしい」という意思(Token)を示し、実行側のタスクがそれを自発的に検知して安全に処理を中断するアプローチだ。
[呼び出し元] ——————- (CancellationTokenSource) ——————-> [非同期タスク]
| | |
|– 1. キャンセル要求 (Cancel()) —->| |
| |– 2. キャンセル状態 (IsCancellationRequested) |
| | または ThrowIfCancellationRequested() —->|
| |– 3. 安全なクリーンアップ
|<------------------ 4. OperationCanceledException のスロー -------------------------|
---
2. CancellationTokenSource のライフサイクルとメモリ最適化
多くの開発者が陥る最初の罠は、CTSのDispose忘れである。
CTS、特に一定時間後に自動キャンセルをトリガーする `CancelAfter` や、コンストラクタでタイムアウトを指定した `New CancellationTokenSource(TimeSpan)` は、内部でシステムタイマー(`System.Threading.Timer`)を確保する。これを明示的に `Dispose` しない場合、タイマーオブジェクトがガベージコレクション(GC)の対象から外れ、メモリリークの直接的な原因となる。
ライフサイクル管理の鉄則
- CTSを生成した者が、そのライフサイクルに責任を持つ。
- `Using` ステートメントを徹底し、スコープを抜ける際に確実に `Dispose` する。
‘ タイムアウト付きCTSの安全な生成パターン
Using cts As New CancellationTokenSource(TimeSpan.FromSeconds(10))
Dim token As CancellationToken = cts.Token
Await ExecuteHeavyTaskAsync(token)
End Using ‘ ここで確実にタイマーリソースが解放される
—
3. 実践:タイムアウトと手動キャンセルを統合したハイブリッド制御
実務における最も一般的な要求は、「ユーザーによる手動キャンセル」と「通信遅延などによるシステムタイムアウト」のハイブリッド制御である。
以下の実装は、社内基幹システムやデータベース、外部Web APIとの連携において、そのままプロダクション環境に適用できる極めて堅牢な非同期処理テンプレートである。
Imports System.Net.Http
Imports System.Threading
Public Class HeavyDataProcessor
‘ HttpClientはインスタンスを再利用するのが.NETの鉄則(ソケット枯渇防止)
Private Shared ReadOnly _httpClient As New HttpClient()
”’
”’
Public Async Function FetchAndProcessDataAsync(url As String, userToken As CancellationToken) As Task
‘ 1. ユーザーのキャンセル要求と、システム的な15秒タイムアウトを統合(リンクトークンの生成)
Using timeoutCts As New CancellationTokenSource(TimeSpan.FromSeconds(15))
Using linkedCts As CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(userToken, timeoutCts.Token)
Dim activeToken As CancellationToken = linkedCts.Token
Try
‘ 処理開始直後の事前チェック
activeToken.ThrowIfCancellationRequested()
‘ 2. I/Oバウンドな非同期処理(HttpClient)へのトークン伝達
‘ HttpClientの非同期メソッドはCancellationTokenをネイティブにサポートしている
Dim response As HttpResponseMessage = Await _httpClient.GetAsync(url, activeToken)
response.EnsureSuccessStatusCode()
Dim jsonResult As String = Await response.Content.ReadAsStringAsync()
‘ 3. CPUバウンドな重いループ処理のシミュレーション
Await ProcessRowsAsync(jsonResult, activeToken)
Catch ex As OperationCanceledException
‘ 4. キャンセル時の厳密な判定
If userToken.IsCancellationRequested Then
‘ ユーザーによる手動キャンセル
Console.WriteLine(“処理はユーザーによって明示的にキャンセルされました。”)
ElseIf timeoutCts.IsCancellationRequested Then
‘ タイムアウトによる自動キャンセル
Console.WriteLine(“処理が規定時間(15秒)内に完了しなかったため、タイムアウトしました。”)
End If
‘ 上位呼び出し元へキャンセル例外を再伝達(呼び出し元で状態管理を行うため)
Throw
Catch ex As HttpRequestException
‘ 通信エラーなどの個別ハンドリング
Console.WriteLine($”通信エラーが発生しました: {ex.Message}”)
Throw
End Try
End Using
End Using
End Function
”’
”’
Private Async Function ProcessRowsAsync(rawData As String, token As CancellationToken) As Task
‘ 実際は巨大なデータセットのパースなどを想定
For i As Integer = 1 To 100
‘ ループの各ステップでキャンセル要求をチェック
‘ ThrowIfCancellationRequestedは、キャンセルされていれば即座にOperationCanceledExceptionを投げる
token.ThrowIfCancellationRequested()
‘ 疑似的な重い処理(非同期待機)
‘ Task.Delayにトークンを渡すことで、待機中であっても即座にキャンセルを検知して制御を戻す
Await Task.Delay(100, token)
‘ パフォーマンス最適化:ループ内の特定の粒度でのみチェックする手法もあるが、
‘ 100回程度のループであれば毎回チェックしてもオーバーヘッドは無視できる
Next
End Function
End Class
このパターンの要諦
1. `CreateLinkedTokenSource` の活用: ユーザーが操作するUI側の「キャンセルボタン」から渡される `userToken` と、処理自体の「最大実行時間制限」を司る `timeoutCts.Token` を結合し、どちらか一方がトリガーされたら即座にキャンセルを検知する構造を構築している。
2. `ThrowIfCancellationRequested()`: ループ処理などの内部で、キャンセルが要求されているかを能動的に検証する。例外をスローすることで、呼び出し元へのロールバック処理(トランザクション破棄など)を自然に促す。
3. `Task.Delay` へのトークン伝達: `Task.Delay(100)` ではなく `Task.Delay(100, token)` とすることで、待機状態の最中であっても、キャンセルボタンが押された瞬間にミリ秒単位で処理が中断される。
—
4. ディープダイブ:Windows API(P/Invoke)やレガシーCOM連携時のキャンセル戦略
.NET Framework環境の保守、あるいはVBA/VB6で開発されたレガシーなCOMコンポーネント、Win32 APIをラップしたDLLをVB.NETから呼び出す(P/Invoke)場合、`CancellationToken` は直接的には機能しない。
アンマネージドコード(C++で書かれたDLLなど)は、.NETのスレッドコンテキストやトークンの状態を感知できないためである。この境界線を越えるためのテクニックが `CancellationToken.Register` である。
Win32 API:排他的なハンドルやイベントへのシグナル伝達
例えば、特定のWin32同期オブジェクト(Eventなど)の完了を待機している場合、CTのキャンセル時にそのイベントをWin32 API経由で強制的にセット(シグナル状態化)して、待機状態を脱出させることができる。
Imports System.Runtime.InteropServices
Imports System.Threading
Public Class LegacyApiWrapper
‘ Win32 APIの宣言
Private Shared Function SetEvent(hEvent As IntPtr) As Boolean
End Function
”’
”’
Public Async Function WaitForLegacySignalAsync(nativeEventHandle As IntPtr, token As CancellationToken) As Task
‘ トークンがキャンセルされたときに実行されるデリゲートを登録する
‘ CancellationTokenRegistrationは必ずUsingで囲むかDisposeすること
Using registration As CancellationTokenRegistration = token.Register(
Sub()
‘ キャンセル要求時に、Win32のイベントを強制的にセットして、待機を解除させる
If nativeEventHandle <> IntPtr.Zero Then
SetEvent(nativeEventHandle)
Console.WriteLine(“[Win32] キャンセル要求に伴い、ネイティブイベントを強制シグナル化しました。”)
End If
End Sub)
‘ ここでネイティブな待機処理、あるいは非同期ラッパーを呼び出す
‘ (登録したデリゲートがバックグラウンドで監視しているため、キャンセル時に即座に上記Blockが走る)
Await Task.Run(Sub()
‘ 実際のアンマネージド待機処理(疑似コード)
‘ WaitForSingleObject(nativeEventHandle, INFINITE)
End Sub, token)
End Using
End Function
End Class
`CancellationToken.Register` 使用時の致命的な罠
`token.Register` に渡すデリゲート(ラムダ式など)は、キャンセルのトリガーを実行したスレッド(多くの場合はUIスレッド、あるいはタイマースレッド)上で同期的に実行される可能性がある。
したがって、`Register` 内で重い同期処理や、デッドロックを引き起こすようなブロッキング操作(`.Wait()` や `.Result` の呼び出し)は絶対に避けるべきである。アンマネージドリソースの解放やシグナルの送信など、極めて軽量かつスレッドセーフな処理に限定しなければならない。
—
5. アーキテクトの戒律:システム設計におけるキャンセル・ポリシー
非同期キャンセルを正しく実装するためには、コードレベルの記述だけでなく、システム全体の整合性を保つための「ポリシー」が必要である。
1. 一度キャンセルされたCancellationTokenSourceは再利用できない
一度でも `Cancel()` を呼んだ、あるいはタイムアウトしたCTSは、内部状態が「キャンセル済み」のまま固定される。再度同じ一連の処理を実行する場合は、必ず新しいCTSインスタンスを生成すること。
2. `OperationCanceledException` は正常系として扱う
キャンセルによって発生した `OperationCanceledException`(およびその派生である `TaskCanceledException`)は、システムの「バグ」や「異常事態」ではない。ユーザーの意思、または設計された仕様通りの動作である。したがって、エラーログ(Fatal/Errorレベル)にスタックトレースを書き散らすような設計は避け、Informationレベルでの記録、あるいは正常系としてのハンドリングにとどめること。
3. 副作用を伴う処理(書き込み処理)のキャンセル
データの更新中にキャンセルが発生した場合、データの一部だけが更新される「不完全な状態」が発生し得る。これを防ぐため、データベース連携では必ず トランザクション(`SqlTransaction` など) を併用し、キャンセル例外をキャッチした `Catch` ブロックで確実にロールバックを実行すること。
VB.NETはその出自ゆえに、レガシーな記述(`On Error Resume Next` や、古い同期ブロッキング処理)が混在しやすい。しかし、現代の.NETアーキテクチャにおいては、CancellationTokenによるライフサイクル管理こそが、システムの信頼性を担保する最後の砦である。この協調的キャンセルの思想を正しく理解し、現場のコードに徹底して組み込んでいただきたい。
