海外エンジニア生活で最初にぶつかったスレッド設計の壁
「海外でITエンジニアとして働く」なんて、聞こえはかっこいいけど、実際に飛び込んでみると毎日が試行錯誤の連続だ。
しかも、言語の壁だけじゃない。技術の壁、文化の壁、考え方の壁…壁だらけ。
その中でも、今回話したいのは「スレッド設計」で盛大にハマったエピソード。
具体的には「CancellationTokenSource」と「Task.Cancel()」まわりで、人生初の深い挫折を味わった時の話だ。
海外プロジェクトでの初めての担当タスク:マルチスレッド対応画面の開発
時は数年前。オーストラリアのメルボルン。現地のソフトウェア開発会社に転職して、初めて正式にアサインされたプロジェクトは、WPFアプリケーションの「データインポート機能」のUI改善だった。
そのプロジェクト、表向きは「ちょっとしたUI改修」という軽いノリでアサインされたんだけど、実際にフタを開けたらぜんぜん違った。
内容はこうだ。
「インポート中にUIがフリーズするってクライアントからクレーム来てるから、非同期化してスムーズに動くようにして。それから、キャンセルボタンつけてね。」
え?非同期化?キャンセル?
しかも、既存コードはスレッド化されてない、完全なシングルスレッド設計。メインスレッドで全部動いてる。
UIスレッドが止まるのは当然だ。ボタン押したらアプリごと応答なし状態になる。
そりゃクライアント怒るわ…。
スレッド設計未経験の自分 vs 本番プロジェクトの現実
正直、当時の自分は「非同期」「マルチスレッド」「CancellationToken」なんて単語は知ってるけど、実戦投入した経験はゼロ。
大学でちょろっと勉強したくらいで、本格的に使ったことがなかった。
Visual StudioでTask.Run()書いて、「あ、非同期できたー」って喜んでたくらいのレベル。
でも、そんな言い訳は通じない。
現場リーダー:「できないなら勉強してやってね。デッドラインまでに。」
冷や汗が止まらない。
Slackでチームメンバーに質問するのも気が引ける。なぜなら、みんな忙しい。
しかも、言語は全部英語。技術用語がわからないだけじゃない。
文化的に「自分で調べてから聞け」って雰囲気が強い。
このままだと本当にヤバい…。
次の日、朝5時に起きて、とりあえずググりまくった。
最初のキーワード:「CancellationTokenSource」
最初にたどり着いたのが以下のサイトたち。
- Microsoft Learn: Cancellation in managed threads
- Task-based Asynchronous Programming (TAP)
- C#でのキャンセルトークンの使い方 Qiita記事
日本語記事も読みつつ、英語の公式ドキュメントも読む、みたいな感じで、頭をフル回転。
でも読めば読むほど、頭の中は「?」だらけだった。
特に混乱したのがこれ。
「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非同期設計」
最終的に、自分がたどり着いた設計パターンはこれ。
- 非同期処理は必ずTask.Runで分離
- UI更新は必ずDispatcher経由
- キャンセルはCancellationTokenを使い、処理の各ステップでこまめにチェック
- 例外はちゃんとキャッチ&InnerExceptionsを見る
- タスク終了時は、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/await、Dispatcher、CancellationToken、全部が頭の中でようやくつながってきた。
チームレビューでのフィードバック:初めての「よくやった」の一言
プロジェクト最終レビューの日。
リーダーがこう言ってくれた。
「最初の頃に比べたら、めちゃくちゃちゃんとした設計になったじゃん。」
もうその一言で全部報われた気がした。
もちろん、まだ改善点はあったし、ベストプラクティスには遠い部分もあったけど、
「自分で調べて、考えて、失敗して、学んで、形にした」このプロセスが何より大事だったんだと思う。
「スレッド設計?全然できないよ…」から「まあ、とりあえずやってみよう」に変わった瞬間
この経験以降、どんな新しい技術が来ても、
最初の一歩は怖くないと思えるようになった。
「分からなくても、まずは試して、ログ書いて、失敗して、ググって、また直す」
これが、今の自分のエンジニアライフのベースになっている。
スレッド設計で得た学びが、その後のキャリアを変えた話
あの時、海外プロジェクトのデッドラインに追われながら、スレッド設計の壁にぶつかった自分。
あの「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が固まってどうしていいかわからない人
だったとしたら、一つだけ言いたい。
「大丈夫。最初はみんなそうだった。」
でも一歩ずつやれば、必ずできるようになる。
自分がそうだったように、あなたも必ず超えられるから。
もし悩んだら、この記事を思い出してほしい。
(できれば、公式ドキュメントも読むのを忘れずにね!)

コメント