海外エンジニアがスレッド設計手法(CancellationTokenSource、Task.Cancel()関連)でつまずいたこと

  1. 海外エンジニア生活で最初にぶつかったスレッド設計の壁
    1. 海外プロジェクトでの初めての担当タスク:マルチスレッド対応画面の開発
    2. スレッド設計未経験の自分 vs 本番プロジェクトの現実
    3. 最初のキーワード:「CancellationTokenSource」
    4. Stack Overflow沼と英語ドキュメント地獄
    5. 「とりあえずやってみる」地獄の始まり
  2. キャンセルが効かない!?次々に湧き上がるバグ地獄と仕様の誤解
    1. 実案件で実装してみたら…まさかの「キャンセルできない」事件
    2. 原因不明。とりあえずググる。
    3. 少し冷静になってログを入れてみた
    4. 真犯人:「メインスレッドでRunImportを実行していた」
    5. 修正して非同期化成功!…と思いきや次の罠「スレッド間操作違反」
    6. スレッドセーフ設計って、こんなに大変なの?
    7. チームリーダーとの面談。そして気づき。
  3. 実践から学んだ「本当に動くスレッド設計」
    1. 「キャンセルできない原因」はそもそもの考え方にあった
    2. 「細かく分割して、こまめにチェックする」設計に方針転換
    3. UIスレッドの扱い方:「Dispatcher」と「Task Scheduler」
    4. CancellationTokenは「強制停止スイッチ」じゃない
    5. AggregateExceptionの正しい扱い方も学んだ
    6. 最終版:自分なりの「正しいキャンセル可能なWPF非同期設計」
    7. チームレビューでのフィードバック:初めての「よくやった」の一言
    8. 「スレッド設計?全然できないよ…」から「まあ、とりあえずやってみよう」に変わった瞬間
  4. スレッド設計で得た学びが、その後のキャリアを変えた話
    1. 「技術力」よりも「問題解決力」を手に入れた瞬間
    2. その後のプロジェクトで起きた「技術的デジャヴ」
    3. チームメンバーに「スレッド設計講座」をやる立場に
      1. 【新人向けスレッド設計・超ざっくり心得集】
    4. CancellationTokenSourceの「思想」が持つ大事な意味
    5. 今の自分ならどうするか?将来への布石
    6. この経験が「海外エンジニアキャリア」に与えた影響
    7. 最後に:今、同じように悩んでる人へ

海外エンジニア生活で最初にぶつかったスレッド設計の壁

「海外でITエンジニアとして働く」なんて、聞こえはかっこいいけど、実際に飛び込んでみると毎日が試行錯誤の連続だ。
しかも、言語の壁だけじゃない。技術の壁、文化の壁、考え方の壁…壁だらけ。

その中でも、今回話したいのは「スレッド設計」で盛大にハマったエピソード。
具体的には「CancellationTokenSource」と「Task.Cancel()」まわりで、人生初の深い挫折を味わった時の話だ。


海外プロジェクトでの初めての担当タスク:マルチスレッド対応画面の開発

時は数年前。オーストラリアのメルボルン。現地のソフトウェア開発会社に転職して、初めて正式にアサインされたプロジェクトは、WPFアプリケーションの「データインポート機能」のUI改善だった。

そのプロジェクト、表向きは「ちょっとしたUI改修」という軽いノリでアサインされたんだけど、実際にフタを開けたらぜんぜん違った。

内容はこうだ。

「インポート中にUIがフリーズするってクライアントからクレーム来てるから、非同期化してスムーズに動くようにして。それから、キャンセルボタンつけてね。」

え?非同期化?キャンセル?
しかも、既存コードはスレッド化されてない、完全なシングルスレッド設計。メインスレッドで全部動いてる。
UIスレッドが止まるのは当然だ。ボタン押したらアプリごと応答なし状態になる。
そりゃクライアント怒るわ…。


スレッド設計未経験の自分 vs 本番プロジェクトの現実

正直、当時の自分は「非同期」「マルチスレッド」「CancellationToken」なんて単語は知ってるけど、実戦投入した経験はゼロ。
大学でちょろっと勉強したくらいで、本格的に使ったことがなかった。

Visual StudioでTask.Run()書いて、「あ、非同期できたー」って喜んでたくらいのレベル。

でも、そんな言い訳は通じない。

現場リーダー:「できないなら勉強してやってね。デッドラインまでに。」

冷や汗が止まらない。
Slackでチームメンバーに質問するのも気が引ける。なぜなら、みんな忙しい。
しかも、言語は全部英語。技術用語がわからないだけじゃない。
文化的に「自分で調べてから聞け」って雰囲気が強い。

このままだと本当にヤバい…。
次の日、朝5時に起きて、とりあえずググりまくった。


最初のキーワード:「CancellationTokenSource」

最初にたどり着いたのが以下のサイトたち。

日本語記事も読みつつ、英語の公式ドキュメントも読む、みたいな感じで、頭をフル回転。
でも読めば読むほど、頭の中は「?」だらけだった。

特に混乱したのがこれ。

「Task.Cancel()」って何?これで止まるんじゃないの?

実際、CancellationTokenを渡してないタスクに「cancel()」みたいなメソッドは存在しない。
でもQiitaとかStack Overflowで「タスクキャンセル」「task cancel」で検索すると、CancellationTokenSource.Cancel()の例が山ほど出てくる。

この時点で既に情報の波に飲まれてた。
「Taskがキャンセルされる」って、どういうことなのか、感覚的に全く掴めてなかった。


Stack Overflow沼と英語ドキュメント地獄

英語がまだそこまで得意じゃなかったから、Stack Overflowの回答もかなりの確率で理解不能。
「この回答ベストアンサーって書いてあるけど、なに言ってるか分からん…」
「WaitHandle?AggregateException?何それ怖い…」

とにかく、頭の中は

  • CancellationTokenSource?
  • CancellationToken?
  • Task.Run()?
  • awaitとasync?
  • UIスレッドとバックグラウンドスレッド?

これ全部がぐちゃぐちゃになってた。

そして何より、「UIスレッドで操作するもの(例:WPFのUIコントロール)は、別スレッドから触っちゃいけない」っていう事実に気付いたのもこの頃。

これはもう、最初の一歩どころか、0.5歩くらいの地点で完全に立ち止まってた。


「とりあえずやってみる」地獄の始まり

とりあえず以下の流れで書き始めた。

var cts = new CancellationTokenSource();
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
// 長時間処理のふり
Thread.Sleep(100);
if (cts.Token.IsCancellationRequested)
{
// キャンセルされたらここで抜ける
return;
}
}
}, cts.Token);

最初は「うまくいってる!」って思った。

でも、このあとWPF画面側で「ボタンを押したらキャンセルする」ロジックを組んだ瞬間に地獄が始まる…。
InvokeRequired?Dispatcher?OperationCanceledException?

キャンセルが効かない!?次々に湧き上がるバグ地獄と仕様の誤解

さて、あの朝5時起きから数日。
CancellationTokenSourceを使ったサンプルはとりあえず「動いた風」だった。
でも本当の地獄はここからだった。


実案件で実装してみたら…まさかの「キャンセルできない」事件

まず最初にやったのは、WPF画面に「Cancel」ボタンをつけること。

UI側のコードはこんな感じで書いた。

private CancellationTokenSource _cts;

private void StartImport()
{
_cts = new CancellationTokenSource();
Task.Run(() => RunImport(_cts.Token));
}

private void CancelImport()
{
_cts.Cancel();
}

で、RunImportの中でTokenを監視するロジックも一応入れてた。

private void RunImport(CancellationToken token)
{
for (int i = 0; i < 10000; i++)
{
if (token.IsCancellationRequested)
{
return;
}
// ダミーの重い処理
Thread.Sleep(10);
}
}

…が、テスト中に気づく。

キャンセルボタン押しても、止まらない!!

何回押しても止まらない。
ウィンドウもフリーズする。
むしろ前より悪化してる。UI応答が戻ってこない。


原因不明。とりあえずググる。

慌ててまた検索。Stack Overflow、Qiita、Reddit…。
でも出てくる情報はだいたい「CancellationTokenをチェックしろ」とか「IsCancellationRequested使え」ばっかり。

いや、それはもうやってるんだって!

焦りすぎて、英語で質問も投稿しかけたけど、「質問の仕方が悪いとフルボッコにされる」って聞いてたからビビって投稿できず…。


少し冷静になってログを入れてみた

「とにかく原因を特定しよう」
そう思って、処理の中にログを大量に埋め込んだ。

private void RunImport(CancellationToken token)
{
for (int i = 0; i < 10000; i++)
{
Debug.WriteLine($"Loop {i}");

if (token.IsCancellationRequested)
{
Debug.WriteLine("Cancellation requested!");
return;
}
Thread.Sleep(10);
}
}

で、実行してみると…

あれ?ログ自体が出てこない…。

なぜ?

まさか、メソッドが呼ばれてない?
いや、デバッガーで見るとちゃんと呼ばれてる。

でも、UIがフリーズしてるってことは…
もしや、これ…


真犯人:「メインスレッドでRunImportを実行していた」

よくよく自分のStartImportメソッドを見返してみた。

private void StartImport()
{
_cts = new CancellationTokenSource();
RunImport(_cts.Token); // ←これ、まさかの同期呼び出し!!
}

…Task.Run()忘れてるやん。

完全に「メインスレッドで重い処理を実行してUIスレッドが止まる」という、初心者あるあるミスをかましてた。

やばい。
本当に恥ずかしい。
でもこれ、当時はガチで気づかなかった。


修正して非同期化成功!…と思いきや次の罠「スレッド間操作違反」

今度こそ正しく非同期に動かすため、以下のように修正。

private void StartImport()
{
_cts = new CancellationTokenSource();
Task.Run(() => RunImport(_cts.Token));
}

これでUIは固まらない!
やった!成功!

と思いきや…

次はこれ。

「InvalidOperationException: The calling thread cannot access this object because a different thread owns it.」

え?何これ。

ググる。

どうやら、WPFのUIコントロールは、UIスレッド以外から直接操作しちゃいけないらしい。
「Dispatcher.Invoke使え」っていろんなサイトで言われてる。

じゃあ、メッセージ出す部分だけ、こうやって書き直す。

Application.Current.Dispatcher.Invoke(() =>
{
MessageBox.Show("Import Completed!");
});

これで解決!と思ったら…

今度は**「DispatcherからUIスレッドが既にビジー状態」**みたいなエラー。

どうやら、非同期処理が終わる前にUI操作を無理やりやろうとしてクラッシュしてるっぽい。


スレッドセーフ設計って、こんなに大変なの?

この辺から頭の中が完全にパンク。

  • UIスレッド
  • バックグラウンドスレッド
  • Dispatcher
  • Task.Run()
  • CancellationToken
  • AggregateException

用語が多すぎる。
しかも、やっと理解できた頃にはデッドラインが目前。

正直、この時期は毎日寝不足だった。

「もっと早くからスレッド設計ちゃんと勉強しておけばよかった…」
そんな後悔ばっかりしてた。


チームリーダーとの面談。そして気づき。

デッドライン前日の夜。
ついにチームリーダーに正直に言った。

「正直、CancellationTokenとかスレッド制御とか、ちゃんと理解できてないです…」

リーダーは笑いながらこう言った。

「それ、最初はみんなそうだから大丈夫。ただ、一回本気で詰まったほうが、次から設計ちゃんとできるようになるよ。」

…なんか、肩の力が抜けた。


このあと、どうやって解決したか?
どんな設計変更をしたか?
実際にどんな「気づき」があったか?
どの設計パターンに行きついたか?

実践から学んだ「本当に動くスレッド設計」

このままじゃ本当にヤバい。プロジェクトの納期もギリギリ。
そんな状況で、ようやく自分が本気で「スレッド設計」というものに向き合いはじめた瞬間でもあった。


「キャンセルできない原因」はそもそもの考え方にあった

まず、前回までの自分のコードの致命的な問題点。
それは「CancellationTokenを渡してるだけで、途中でキャンセルするポイントが極端に少ないこと」。

処理の中で1ループごとに token.IsCancellationRequested を見てはいるけど、もしループの1回が「超重い処理」だったら?

そう、キャンセルが効くタイミングがない。

たとえば、以下みたいな長時間のIO処理や、DBクエリ、ネットワーク通信中だったら?

void LongProcess()
{
Thread.Sleep(10000); // ←ここが10秒止まる
}

この間、いくらキャンセルを要求しても、次にIsCancellationRequestedが評価されるのは「Sleepが終わった後」。
要するに、「キャンセルが効くタイミング」がないのが根本原因だった。


「細かく分割して、こまめにチェックする」設計に方針転換

ここで一つ、大きな気づきがあった。

それは、

「重い処理は、できるだけ小さく分割して、キャンセルポイントを増やす」

ということ。

つまり、例えばインポート処理が10000レコードあるなら、1レコードごとにキャンセルフラグを見る、という考え方。

こう書き換えた。

private void RunImport(CancellationToken token)
{
foreach (var record in GetRecords())
{
if (token.IsCancellationRequested)
{
Log("Import canceled by user.");
return;
}

ProcessRecord(record);
}
}

さらに、ProcessRecordの中もなるべく短く保つ。
場合によっては、その中でもさらにtoken.IsCancellationRequestedを見るようにした。


UIスレッドの扱い方:「Dispatcher」と「Task Scheduler」

次に苦戦したのが、UI更新問題
WPFでは、UIスレッドじゃないところから直接UIコントロールをいじると例外が出る。
これは前回痛感した。

だから、進捗更新とかメッセージボックス表示とか、UIに絡む部分は全部Dispatcher経由で書き直した。

Application.Current.Dispatcher.Invoke(() =>
{
progressBar.Value = progress;
statusText.Text = $"Imported {progress} records.";
});

さらに、

「Dispatcher.Invokeだと、UIスレッドが重いときに詰まるから、できればBeginInvokeがいいよ」

っていう同僚からのアドバイスももらって、一部はこうした。

Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
progressBar.Value = progress;
}));

ここでまた一つ学び。

「UIスレッドはなるべく軽く、バックグラウンドで重い処理、UIは進捗通知だけ」

これ、今なら当たり前だけど、当時はその感覚がゼロだった。


CancellationTokenは「強制停止スイッチ」じゃない

このあたりでようやく気づく。

最初、自分は「CancellationTokenSource.Cancel()を呼べば、裏側でタスクが強制終了する」って勘違いしてた。
でも実際は違う。

「あくまで『キャンセルしてください』っていうフラグを立てるだけで、止めるかどうかはタスク側のロジック次第」

これ、本当に盲点だった。

つまり、自分がちゃんと「止まる条件」をコードの中に書かない限り、どれだけCancel()しても意味がない。

完全に目からウロコだった。


AggregateExceptionの正しい扱い方も学んだ

次にハマったのが「例外処理」。

タスク中で何かエラーが発生した場合、特にTask.Wait()した瞬間にAggregateExceptionが飛んでくる。

当時は意味が分からず、

try
{
task.Wait();
}
catch (AggregateException ex)
{
// よくわからんけどとりあえずログ出す
Console.WriteLine(ex.Message);
}

って感じで適当に握りつぶしてた。

でもチームリーダーから「内部のInnerExceptions見ろ!」って指摘され、以下のようにちゃんと展開するように変更。

catch (AggregateException ex)
{
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine(inner.Message);
}
}

これでようやく、具体的なエラー内容がわかるようになった。


最終版:自分なりの「正しいキャンセル可能なWPF非同期設計」

最終的に、自分がたどり着いた設計パターンはこれ。

  1. 非同期処理は必ずTask.Runで分離
  2. UI更新は必ずDispatcher経由
  3. キャンセルはCancellationTokenを使い、処理の各ステップでこまめにチェック
  4. 例外はちゃんとキャッチ&InnerExceptionsを見る
  5. タスク終了時は、UIスレッドに戻ってユーザーに終了メッセージを出す

全体的には以下みたいな流れ。

private CancellationTokenSource _cts;

private async void StartImportAsync()
{
_cts = new CancellationTokenSource();
try
{
await Task.Run(() => RunImport(_cts.Token));
UpdateUIOnComplete();
}
catch (OperationCanceledException)
{
UpdateUIOnCancel();
}
catch (Exception ex)
{
ShowError(ex);
}
}

この頃には、async/awaitDispatcherCancellationToken、全部が頭の中でようやくつながってきた。


チームレビューでのフィードバック:初めての「よくやった」の一言

プロジェクト最終レビューの日。
リーダーがこう言ってくれた。

「最初の頃に比べたら、めちゃくちゃちゃんとした設計になったじゃん。」

もうその一言で全部報われた気がした。

もちろん、まだ改善点はあったし、ベストプラクティスには遠い部分もあったけど、
「自分で調べて、考えて、失敗して、学んで、形にした」このプロセスが何より大事だったんだと思う。


「スレッド設計?全然できないよ…」から「まあ、とりあえずやってみよう」に変わった瞬間

この経験以降、どんな新しい技術が来ても、
最初の一歩は怖くないと思えるようになった。

「分からなくても、まずは試して、ログ書いて、失敗して、ググって、また直す」

これが、今の自分のエンジニアライフのベースになっている。

スレッド設計で得た学びが、その後のキャリアを変えた話

あの時、海外プロジェクトのデッドラインに追われながら、スレッド設計の壁にぶつかった自分。
あの「CancellationTokenが効かない!」「UIが固まる!」「AggregateExceptionって何だよ!」の毎日。

それが、今振り返れば、自分のエンジニア人生における大きなターニングポイントだった。


「技術力」よりも「問題解決力」を手に入れた瞬間

あの案件を経験して、一番大きく変わったのは「問題にぶつかった時のメンタル」だ。

それまでの自分は…

  • ドキュメントを読んでも分からないとすぐ諦める
  • 「誰かに聞けば済む」とすぐ頼ろうとする
  • エラーメッセージが出ても、よく読まずにスルーする
  • 「どうせ無理」と思って、深入りせず逃げがち

そんなスタンスだった。

でも、あのスレッド設計地獄を乗り越えてからは違った。

「わからないことがあっても、まずは小さく試して、原因を切り分けて、少しずつ進めればいい」

「一発で正解を出す必要はない。まずは動くものを作って、そこから改善すればいい」

「ググるだけじゃダメ。英語の公式ドキュメントも、ちゃんと読む」

そんな当たり前のことを、実体験で体に叩き込めた。


その後のプロジェクトで起きた「技術的デジャヴ」

面白いことに、あのあとすぐ別プロジェクトで似たようなケースに遭遇した。

内容は「リアルタイムデータを表示するダッシュボード画面」。
これまた非同期とUIスレッド制御のオンパレード。

でもその時は、もうあの時みたいにパニックにはならなかった。

「まずはバックグラウンドスレッドでデータ取得して…」
「UI更新はDispatcherでラップして…」
「長い処理はキャンセルポイントをちゃんと入れて…」

自然とそんな思考ができるようになっていた。

しかも、チーム内で「スレッド周りに詳しい人」ってポジションにもなりつつあった。
(数ヶ月前の自分からは想像もできない…!)


チームメンバーに「スレッド設計講座」をやる立場に

最終的には、社内の新人向けに「WPF非同期処理とキャンセル設計」のミニ勉強会まで担当することに。

あの時、自分が苦しんで悩んで、ググって、失敗して学んだ内容が、
次の誰かの役に立つって、ちょっと感動すら覚えた。

自分がその勉強会で話した内容の一部を、ざっくりまとめるとこんな感じ。


【新人向けスレッド設計・超ざっくり心得集】

  • ✅ 「重い処理は必ずUIスレッドから切り離せ」
  • ✅ 「UI更新は必ずDispatcher経由で」
  • ✅ 「キャンセルは『フラグ立て→呼び出し側でこまめにチェック』の流れ」
  • ✅ 「Task.Wait()使う時は例外処理忘れるな」
  • ✅ 「UI応答性はエンドユーザーの最優先事項」
  • ✅ 「わからなかったら、まずは小さなサンプルで実験してから組み込め」

CancellationTokenSourceの「思想」が持つ大事な意味

ここでちょっと技術論を。

この経験を通じて、CancellationTokenSourceが「単なるAPIの一機能」じゃない、ということが身に染みてわかった。

CancellationTokenの設計思想って、つまるところこうだ。

「タスク側が自発的に止まる、フェアで安全な設計」

昔の自分は「外から強制停止できる方法ないのかよ!」って思ってた。
でもそれが危ないんだって、今なら分かる。

  • スレッド中断はリソースリークを生む
  • ロック中に中断するとデッドロックする
  • 未解放リソースの山でメモリリークする

だから、「呼び出し元は『お願いベース』でキャンセル要求する。止めるかどうかはタスクの中で責任を持って判断する」
これがCancellationToken設計思想の根っこにある哲学だって気付いた。


今の自分ならどうするか?将来への布石

もし今、同じような案件が来たら、自分は最初にこうする。

  • 仕様の段階で「キャンセル性が必要か?」を明確にする
  • 設計段階で「キャンセルチェックポイント」を一覧化する
  • UIスレッドとバックグラウンドスレッドの役割分担を明確にする
  • テスト用に「遅延処理」「強制例外」「大量データ」でシミュレーションケースを作る

そして何より、

「怖がらない」

これが一番大事。


この経験が「海外エンジニアキャリア」に与えた影響

最後に、これだけは言いたい。

もしこの経験がなかったら、たぶん今も自分は「海外エンジニアの下っ端ポジション」にいたままだったと思う。

でも、
「初めて本気で技術と向き合って」
「一回めちゃくちゃ失敗して」
「悩んで調べて試して修正して」
「最終的に形にした」

このプロセスが、次の案件、次の転職、次のキャリアステップで、めちゃくちゃ生きた。

その後、ヨーロッパの案件でも、アメリカのクライアント案件でも、スレッド設計が出るたびに、
「Oh, looks like you have solid threading knowledge!」って言われることが増えた。

(内心「いや、最初は地獄だったんだよ…」と思いながら笑ってるけどね)


最後に:今、同じように悩んでる人へ

もしこの記事を読んでる人が、

  • スレッド設計にビビってる人
  • CancellationTokenSourceがわからなくて頭抱えてる人
  • UIが固まってどうしていいかわからない人

だったとしたら、一つだけ言いたい。

「大丈夫。最初はみんなそうだった。」

でも一歩ずつやれば、必ずできるようになる。

自分がそうだったように、あなたも必ず超えられるから。

もし悩んだら、この記事を思い出してほしい。
(できれば、公式ドキュメントも読むのを忘れずにね!)

コメント

タイトルとURLをコピーしました