Threads を使って Delphi を扱う人は遅かれ早かれ TThread.Synchronize に行き当たる。そこで厄介な事態が起きる:断続的なハング、「UI が応答しない」、終了時やダイアログを開く際に見かけ上ランダムに発生するデッドロック。問題の核心が「Delphi が壊れている」ことにあることは稀で、ほとんどの場合は Synchronize、ブロッキングする待ち操作、および VCL のイベント処理である Message Loop を適切に処理できない UI スレッドという不利な組合せである。本稿は、レガシー文脈で実用的な、UI デッドロックを起こさない TThread と Synchronize の堅牢なパターンを示す──タイムアウト版、適切なエラー伝播、シャットダウンルール、および実際の既存アプリケーションで役立つデバッグ上の注意点を含む。
実務でなぜ Synchronize を巡るデッドロックが発生するのか
Synchronize は次を意味する:ワーカースレッドがプロシージャをキューに入れ、そのプロシージャが Main Thread 上で実行され、通常それが完了するまで 待機する。VCL アプリケーションでは Main Thread が UI スレッド(ウィンドウ、コントロール、イベント)と同一である。加えて、多くの環境ではそこに STA-Modell の COM オブジェクトが動作している(Single-Threaded Apartment:COM 呼び出しは同一スレッドで処理される必要がある)ため、正しく動作する Message Loop への依存はさらに強まる。
デッドロックは通常、次のような構成によって発生する:
- Main Thread での WaitFor: UI スレッドがワーカーを待機する(例:
MyThread.WaitFor)一方で、ワーカーがSynchronizeを介して UI スレッドを必要としている。両者が待機し、デッドロックになる。 - Lock-Inversion: ワーカーがロック(例:
TCriticalSectionやTMonitor)を保持したままSynchronizeを呼ぶ。同期された UI 側のプロシージャが同じロックを取得しようとする(直接または間接的に、ログ/キャッシュ/シングルトン経由で発生することが多い)──古典的なデッドロック。 - Shutdown/Destroy: フォームを閉じる際にスレッドが終了される一方で、まだ
Synchronizeのタスクが残っている。特に問題なのは、同期された呼び出しがちょうど破棄されつつあるコントロールを参照する場合である。 - Message Loop のブロック: モーダルダイアログ、長時間実行される UI 処理、ブロッキングする COM 呼び出し、あるいは「ちょっとだけ」DB/REST を行うハンドラが Main Thread を占有する。
Synchronizeのタスクは遅延するか、まったく処理されない。
アーキテクチャと運用における最も重要な帰結は次の通り:Synchronize はブロック境界である。インポート処理、BDE-Ablosung mit nativer Anbindung-クエリ、インターフェースジョブ、あるいは UI を持つバックグラウンドサービスを含む個別企業向けソフトウェアでは、この境界を意図的に制御すべきである──さもなければ「稀にしか起きない」問題が「急いでいるときに必ず起きる」問題に変わる。
基本ルール: Synchronize が絡む場合、UI スレッドをワーカーで待たせない
どこかでワーカーが Synchronize を使っているなら、Main Thread はそのワーカーを強制的にブロッキングして待つべきではない。これは自明に聞こえるが、レガシーコードでは「閉じるときに少し待とう」や「進捗ダイアログが終了を待つ」といった理由で頻繁に導入され、主要な原因になっている。
実務上の帰結:
- Worker に
Synchronizeを利用する経路が存在する場合、UI スレッドでのWaitFor呼び出しは行わないこと。 - スレッド終了は Event/Callback で通知する: UI は応答性を維持し、通知を受け取ってからクリーンアップする。
- UI の更新は原則として
TThread.Queueまたはディスパッチャー経由でポストし、Worker をブロックしないようにする。
TThread.Queue は多くの場合、デフォルトとして優れた選択です。Worker は作業を Main Thread にポストし、自身は継続して実行されてブロックしません。これにより多くのデッドロックを防げます。ただしすべての境界ケースを解決するわけではありません — 例えば Worker 内で どうしても Main Thread 側で生成される結果が必要な場合(UI に結び付いたリソースやスレッド制約のあるコンポーネントへのアクセスなど)は別です。
TThread und Synchronize ohne UI-Deadlocks: Denkmodell für saubere Übergaben
実用的な思考モデルはこうです: Main Thread への同期的な受け渡しは正当化されるケースがごく限られている。その他のすべてはステータス、表示、テレメトリであり、非同期で扱うべき、ということです。
レビューや既存プロジェクトの安定化では、単純な分類が役に立ちます:
- 「表示のみ」: 進捗、ログ行、カウンタ、状態表示(ランプ)、有効/無効 といった項目 — 常に
Queueを使う。 - 「状態を渡す」: Worker がデータオブジェクト/DTO を提供し、UI がレンダリングする —
Queueを用いるがコピー/イミュータビリティを保ち、共有で変更される構造は避ける。 - 「UI が判断する必要がある」: この場合に限り同期的なセマンティクスが必要(例: ユーザーへの問い合わせ)。ここでの本質的な問いは、Worker を本当に待たせる必要があるのか、それともワークフローを再設計できるか(ステートマシン、ジョブの中止と後続再開など)という点になる。
特に第三のカテゴリはデッドロックの罠になりやすい: Worker が UI の結果を待つと、UI が Worker を待つ(あるいはロックを介して間接的に待つ)状況を招きやすく、負荷時や遅いデータベース、リモートデスクトップ環境では顕在化しやすいです。
Source-Schnipsel: UI-Dispatcher mit Queue, optionalem Timeout und sauberem Shutdown
以下のパターンは UI への受け渡しを小さなヘルパークラスにカプセル化します。得られる機能は:
- Post:
TThread.Queueによる fire-and-forget(状態更新で典型的)。 - Call: 同期呼び出しだが Timeout を設ける(珍しいがレガシー状況で有用)、直接
Synchronizeをブロックポイントとして使わない実装。 - Shutdown-Schutz: 新規の UI ジョブを受け付けない、キューに入ったジョブはコントロールに触る前にフラグをチェックする。
技術的な位置付け: Queue と TEvent(カーネルイベント)を組み合わせて戻り値を扱います。Worker は Synchronize を直接待つのではなく、queued なアクションが実行された後に Main Thread 側でセットされる Event を待ちます。Timeout を設けることで、何らかの理由で UI スレッドが処理を進められなくなった場合の「永久ハング」を防ぎます。
unit UiDispatch;
interface
uses
System.SysUtils,
System.Classes,
System.SyncObjs;
type
EUiDispatchTimeout = class(Exception);
EUiDispatchShuttingDown = class(Exception);
/// <summary>
/// ワーカースレッドからのUI呼び出しをラップします。
/// Post: 非同期 (Queue)。
/// Call: タイムアウト付きの同期実行。TThread.Synchronizeを直接ブロックすることなく行います。
/// </summary>
TUiDispatcher = class
strict private
class var FShuttingDown: Integer;
public
class procedure BeginShutdown; static;
class function IsShuttingDown: Boolean; static;
class procedure Post(const AProc: TProc); static;
class procedure Call(const AProc: TProc; ATimeoutMs: Cardinal = 5000); static;
end;
implementation
{ TUiDispatcher }
class procedure TUiDispatcher.BeginShutdown;
begin
TInterlocked.Exchange(FShuttingDown, 1);
end;
class function TUiDispatcher.IsShuttingDown: Boolean;
begin
Result := TInterlocked.CompareExchange(FShuttingDown, 0, 0) = 1;
end;
class procedure TUiDispatcher.Post(const AProc: TProc);
begin
if not Assigned(AProc) then
Exit;
// シャットダウン時は新しいUIジョブを受け付けない。
if IsShuttingDown then
Exit;
// Queueはワーカーをブロックしない。
TThread.Queue(nil,
procedure
begin
if IsShuttingDown then
Exit;
AProc();
end);
end;
class procedure TUiDispatcher.Call(const AProc: TProc; ATimeoutMs: Cardinal);
var
DoneEvent: TEvent;
RaisedObj: TObject;
begin
if not Assigned(AProc) then
Exit;
if IsShuttingDown then
raise EUiDispatchShuttingDown.Create('UI-Dispatcherはシャットダウン中です。');
DoneEvent := TEvent.Create(nil, True, False, '');
try
RaisedObj := nil;
TThread.Queue(nil,
procedure
begin
try
if not IsShuttingDown then
AProc();
except
// 例外オブジェクトをスレッド境界を越えて渡す。
// 注意: ここで "raise" を使うと例外はメインスレッド側で発生する。
RaisedObj := AcquireExceptionObject;
end;
DoneEvent.SetEvent;
end);
case DoneEvent.WaitFor(ATimeoutMs) of
wrSignaled:
begin
if Assigned(RaisedObj) then
raise Exception(RaisedObj);
end;
wrTimeout:
raise EUiDispatchTimeout.CreateFmt(
'タイムアウト後 %d ms: メインスレッドが UI 呼び出しを処理していません。',
[ATimeoutMs]);
else
raise Exception.Create('UI-Dispatcherで予期しない WaitFor ステータス。');
end;
finally
DoneEvent.Free;
end;
end;
end.コードの目的と、意図的に「通常と異なる」点
このパターンは Synchronize を完全に置き換えるものではありませんが、同期的な引き渡しを制御可能にします:ワーカーはSynchronizeのメカニズム自体を待つのではなく、イベントを待ちます。これにより、タイムアウトを強制し、運用時にUIスレッドのハングを検出し、シャットダウン段階では新しいUIジョブを一貫して拒否することができます。
「通常と異なる」部分はイベント自体ではなく、同期的セマンティクスを Queue + Event で表現するという判断です。これは、既存アプリケーションに対して、すべてのSynchronize箇所を直ちにアーキテクチャ的に作り替えられない場合に、段階的に安定性を追加する際に有効です。
前提条件と注意点
- メモリ可視性:
DoneEventは同期の境界です。これによりWaitFor後のRaisedObjの読み出しは整合性が保たれます。それでもRaisedObjは各呼び出しごとにローカルにすべきで(ここでのように)、グローバルにしてはいけません。
AcquireExceptionObject は例外がメインスレッドで「消える」のを防ぎます。Workerで再スローするとスタックトレースは発生箇所と完全には一致しませんが、エラーメッセージはWorkerのログに残り、ジョブをきれいに失敗させることができます。BeginShutdown は中央のシャットダウンシーケンスに入れるべきです(例: メインフォームの OnCloseQuery の非常に早い段階)。さもないとウィンドウが既に破棄されている間にUIジョブがキューイングされます。ロック戦略: UIコールバックによるロックの反転を回避する方法
多くのデッドロックは WaitFor によってではなく、不明確なロック順序によって発生します。典型的な流れは: Workerが「データモデル」をロックし、Synchronize でUI更新を呼び出し、UI更新が再び「データモデル」にアクセスする、というものです。論理的には理解できても、技術的には致命的です。
チームで運用できる実践的なルール:
- スレッド境界をまたいでロックを保持しない: WorkerがUIへ何かをキュー/同期する前に、業務的なロックは解放されているべきです。
- UIはスナップショットを読む: UIコールバックはWorkerの構造を「ライブ」で参照すべきではなく、コピー/スナップショット(例: DTO、Record、単純な値)を表示すべきです。
- ロギングはロック候補になる: ロギングが内部でキュー、ファイルロック、またはシングルトンを使っている場合、それがデッドロックの一部になる可能性があります。UIコールバックはロギングを最小限にするか、別の非ブロッキングなログパイプラインへ書き込むべきです。
既に Layer-3 アーキテクチャ(UI、Services/ドメイン、データアクセスなどのインフラ)を採用している場合: UIコールバックは理想的にはUI動作のみを行うべきです。「Service」に該当する処理はコールバックに入れてはいけません。これにより再入可能性(reentrancy)による影響が大幅に減少します。
ハングしないシャットダウン: 「WaitForではなく協調的な停止」
終了時によく問題が起きます: UIが閉じ、スレッドを終了させようとするが、キューに入ったUIジョブがまだ残っている。きれいなシャットダウンは単にスレッドを強制終了することではなく、小さな協調手順です:
- シャットダウンフラグを立てる(例:
TUiDispatcher.BeginShutdown): 以降、新しいUIジョブは発行しない。 - Workerを協調的に停止する: Workerはキャンセルフラグ(例:
TEventまたはTCancellationTokenに類似したもの)を監視し、ループや待機を終了します。 - UIをブロックしない: メインスレッドでの強制的な待ちループは避けるべきです。どうしても「待つ」必要がある場合は、メッセージループを継続させながら(あるいは理想的にはコールバックで完了を処理して完全に回避する)行ってください。
- 最後のUIクリーンアップ作業はウィンドウ/コントロールが確実に存在する場合にのみ行うべきです。VCLではタイミングが重要です: ハンドルが失われた時点、遅くともそれ以降はキューに入ったジョブをコントロールに対して実行してはいけません。
この手順は運用とサポートにとって重要です: 「アプリケーションが終了時にハングする」は典型的な受け入れ問題であり、内部的にはすべて正しく処理されていても発生します。定義されたシャットダウン手順はここで実際の時間を節約します。
デバッグ: デッドロックを可視化する方法(推測を排する)
ハングした場合の核心的な問いは: 誰が誰を待っているのか? 既存プロジェクトで効果があったいくつかのアプローチ:
- Alle Wait-Stellen inventarisieren: Volltextsuche nach
WaitFor,Sleepin Schleifen,TEvent.WaitFor,INFINITE. Viele Probleme sind „versteckte“ Waits (auch in Bibliotheken). - Thread-Zustand im Log: Loggen Sie an Thread-Grenzen: „Job startet“, „queued UI“, „UI ausgeführt“, „Job fertig“. Damit sehen Sie, ob der Main Thread queued Jobs überhaupt abarbeitet.
- Message-Loop-Verdacht prüfen: Tritt der Hänger nur bei modalen Dialogen oder bestimmten COM-Interaktionen auf, ist die Message Loop oft der Flaschenhals. Dann ist das Ziel: UI-Handler entlasten, COM-Aufrufe isolieren, keine langen Operationen im UI.
- Locks sichtbar machen: Bei
TCriticalSection/TMonitorlohnt sich ein Debug-Build mit „Owner“-Metadaten (z. B. Thread-ID beim Enter) und zeitlicher Messung. So sehen Sie, welches Lock der Main Thread gerade hält, während Worker auf UI wartet.
Wichtig ist die Haltung: Deadlocks sind selten „zufällig“. Sie sind deterministische Zyklen, die nur selten ausgelöst werden. Wenn Sie den Zyklus einmal sauber identifiziert haben, ist die Behebung meist klar.
Varianten für Datenzugriff und Schnittstellen-Jobs (FireDAC, REST, Dateisystem)
Gerade bei FireDAC (oder anderen DB-Zugriffen) gilt: Verbindung, Transaktion und Datasets sind in der Praxis threadgebunden. Ein Worker-Thread sollte seinen DB-Kontext ausschließlich selbst besitzen. UI-Aufrufe sollten sich auf Darstellung beschränken, nicht auf DB-Operationen. Ein robustes Muster ist:
- Worker führt Query/REST-Call aus, berechnet Ergebnis, erzeugt DTO.
- Worker postet DTO via
Queue/TUiDispatcher.Postan die UI. - UI übernimmt DTO und aktualisiert Controls (ohne Rückgriff auf Worker-Objekte).
Wenn Sie historisch gewachsene Mischformen haben („UI triggert DB, DB-Callback triggert UI“), lohnt sich eine schrittweise Entkopplung: Erst Übergabepunkte isolieren (Dispatcher), dann Zustände in Services/Model verlagern. Das ist weniger riskant als ein großer Umbau, aber reduziert Deadlocks spürbar.
Fazit: Deadlocks vermeiden heißt Übergaben kontrollieren
TThread und Synchronize ohne UI-Deadlocks ist weniger eine einzelne Technik als Disziplin: Blockaden minimieren, Lock-Reihenfolgen sauber halten, Shutdown definieren und synchrone UI-Abhängigkeiten reduzieren. Der gezeigte UI-Dispatcher ist in Legacy-Situationen besonders nützlich, weil er Queue als Default nutzt, für notwendige synchrone Übergaben aber Timeout und klare Shutdown-Regeln nachrüstet.
Einsatzgrenzen bleiben: Wenn der Main Thread dauerhaft blockiert (durch schwergewichtige UI-Logik, modale Dialogketten oder COM-STA-Aufrufe), kann auch ein Dispatcher nur diagnostizieren und kontrolliert abbrechen. Die nachhaltige Lösung ist dann, die UI zu entlasten und Verantwortlichkeiten zu trennen. Wenn Sie dafür in einer bestehenden Delphi-Anwendung Unterstützung brauchen – von Threading-Fallen bis zur schrittweisen Stabilisierung – können Sie das Vorhaben hier einordnen: Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
Im fachlichen Umfeld spielen auch Delphi Multithreading und Synchronize Deadlock eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.