雑誌のテーマからプロジェクト実践へ
該当記事に関連するサービス・技術ページ
Wer in Delphi rechenintensive oder I/O-lastige Jobs parallelisieren will, landet schnell bei der Parallel Programming Library (PPL) und konkret bei TParallel.For. Der Effekt ist oft sofort messbar – bis man im selben Atemzug eine Progress-UI „mal eben“ aktualisieren möchte. Genau hier entstehen die typischen Hänger: scheinbar zufällige UI-Freezes, eine ProgressBar, die rückwärts springt, oder ein kompletter Deadlock, sobald man im Debugger Schritt für Schritt läuft.
In diesem Beitrag geht es um TParallel.For threadsichere Progress-UI: ein robustes Muster, das in VCL und FMX funktioniert, UI-Updates über TThread.Queue sauber bündelt, Cancel/Abort berücksichtigt und die häufigsten Deadlock-Fallen konsequent umgeht. Der Fokus liegt auf Betriebsrealität: reproduzierbares Verhalten, verständliche Zuständigkeiten und Debugging-Hinweise, die auch dann helfen, wenn der Fehler nur „bei Kunden“ auftritt.
Warum Progress-UI bei TParallel.For so oft schiefgeht
TParallel.For läuft typischerweise auf Worker-Threads aus dem Delphi-Threadpool. Diese Threads dürfen keine VCL- oder FMX-Controls direkt anfassen, weil die UI-Frameworks (Message Loop, Window Handles, Rendering) an den Main Thread gebunden sind. Schon ein scheinbar harmloses ProgressBar.Position := … aus einem Worker kann zu undefiniertem Verhalten führen: sporadische AVs, eingefrorene Fenster oder „flackernde“ Updates.
Die naheliegende Reparatur ist oft TThread.Synchronize. Das behebt zwar die Thread-Safety, führt aber in parallelen Schleifen schnell zu einem anderen Problem: Sie erzeugen einen seriellen Engpass. Jeder Worker wartet auf den UI-Thread, der wiederum mit dem Rendern und dem Abarbeiten von Synchronize-Aufrufen beschäftigt ist. Unter Last sieht das wie ein Deadlock aus – auch wenn es „nur“ ein starvation/lockstep-Effekt ist.
Und dann gibt es die echte Deadlock-Klasse: Der Main Thread wartet (z. B. per WaitFor, Task.Wait oder indirekt über blockierende Aufrufe) auf das Ende der Parallel-Operation, während Worker-Threads versuchen, per Synchronize oder ungünstiger Queue-Nutzung Arbeit in den Main Thread zu schicken. Ergebnis: Main Thread wartet auf Worker, Worker warten auf Main Thread.
TThread.Queue vs. TThread.Synchronize: der praktische Unterschied
Beide Mechanismen dienen dazu, Code sicher im Main Thread auszuführen. Der Unterschied ist die Wartesemantik:
- TThread.Synchronize: Der aufrufende Worker wartet, bis der Main Thread den Code ausgeführt hat. Das ist „synchron“, erhöht Latenzen und ist eine klassische Zutat für Deadlocks, wenn der Main Thread gerade blockiert.
- TThread.Queue: ワーカーはコードをメインスレッド用のキューに入れて処理を継続します。これは「非同期」であり、スレッドを切り離すため、並列処理のシナリオでは更新頻度を制御できる限りほとんど常にデフォルトで推奨される選択肢です。
Wichtig: Queue ist kein Freifahrtschein. Wenn Sie in jeder Iteration einer Schleife ein Queue-Update schicken, überfluten Sie die Main-Thread-Queue. Dann hängt die UI zwar nicht wegen Deadlock, aber wegen schierer Menge an Nachrichten. Die UI wirkt „zäh“, und das Ende der Verarbeitung verzögert sich, weil noch hunderte oder tausende UI-Updates nachlaufen.
Der Randfall, der wirklich wehtut: Warten im UI-Thread
In Unternehmensanwendungen sieht man häufig folgenden Ablauf: Button „Start“ klickt, UI wird deaktiviert, ProgressDialog geht auf, dann wird synchron „gewartet“, bis alles fertig ist, um anschließend wieder zu aktivieren. Dieses Muster ist der Kern vieler Deadlocks.
Typische Varianten (je nach Codebasis):
- Main Thread startet TParallel.For und ruft danach eine blockierende Wartelogik auf (direkt oder indirekt).
- Ein ProgressDialog ruft im Constructor oder OnShow eine Routine auf, die intern wartet.
- Ein Abbrechen-Button setzt zwar ein Flag, aber der Main Thread bleibt trotzdem in einem Warteloop hängen.
Wenn Worker-Threads in dieser Zeit Synchronize benutzen, ist der Deadlock praktisch garantiert. Mit Queue kann es ebenfalls hängen, wenn der Main Thread blockiert und keine Messages pumpt – denn dann wird auch die Queue nicht abgearbeitet.
Die betriebsfeste Konsequenz lautet: Der Main Thread darf nicht blockierend auf die Parallel-Schleife warten, wenn parallel UI-Updates benötigt werden. Stattdessen muss die Verarbeitung entweder vollständig in einen Hintergrund-Task ausgelagert werden, oder man organisiert ein „asynchrones Ende“ (Callback/Queued Abschlussaktion), das die UI am Schluss wieder freigibt.
Sauberer Ansatz: Progress nur aggregiert, UI-Updates gedrosselt
Ein robustes Muster besteht aus drei klar getrennten Verantwortlichkeiten:
- Worker-Threads machen die eigentliche Arbeit pro Element/Index. Sie melden nur Fortschritt in einer threadsicheren Form (Zähler, Queue, Thread-safe Queue).
- Aggregator (oft: Main Thread oder ein dedizierter Timer im UI) berechnet aus dem Fortschritt einen UI-Status (Position, Text, ETA) und aktualisiert Controls. So vermeiden Sie 1:1-Updates pro Iteration.
- Abschluss (ebenfalls im Main Thread): UI reaktivieren, Ergebnis anzeigen, Fehler zusammenfassen, Ressourcen freigeben.
なぜこの分離がうまく機能するか: ワークロードは高頻度(数千の要素)になり得るが、UIは1秒あたりわずかな更新しか必要としない。実務では5–10回/秒の更新で足り、非常に高速なジョブでは2–4回で十分だ。それ以上はたいてい視覚的なノイズであり、Message PumpのCPU時間を消費するだけである。
スレッドセーフなカウント: ロックではなくアトミック
単純なProgressBarにはアトミックなカウンタで十分なことが多い。「アトミック」とは、インクリメントと読み取りがレースコンディションなしで行われることを指し、典型的には TInterlocked を使う。これによりループのホットパスでのロック(クリティカルセクション)を回避できる。
実用的な最小構成:
- 総件数が事前に分かっている(例:レコード数、ファイル数、IDの件数)。
- 各イテレーションで DoneCounter をアトミックに増やす。
- UIタイマが定期的にカウンタを読み取り、ProgressBar.Position を設定する。
利点: 要素ごとに TThread.Queue を呼ばず、UIの氾濫が起きない。欠点: 要素ごとの詳細メッセージ(例:ファイル名)は得られない。その代わりに、もう一つ低頻度のステータスメッセージを補うことができる(次の節参照)。
スパムのないステータスメッセージ: 「最後のステータスが勝つ」
さらに短いテキスト(現在の要素、フェーズ、エラーメッセージ)を表示したい場合も、各ワーカーステップでUIを氾濫させないパターンが必要だ。実務では「最後のステータスが勝つ」が非常に有効だ:
- Workerはステータス情報をスレッドセーフな構造に書き込む(例:アトミックに差し替え可能な文字列、または小さなロックで保護された構造)。
- UIタイマは定期的に最後に見たステータスをラベルに反映する。
これによりUIは応答性を保ちつつ「何かが起きている」ことを示せる。重要なのは文字列そのものではなくそのライフタイムだ:ワーカースレッドの短命なオブジェクトへの参照をUIスレッドに持ち込まないこと。オブジェクトを渡す場合は所有権を明確にする。
TParallel.For によるスレッドセーフな進捗UIと TThread.Queue:堅牢なパターン
UIタイマだけでは不十分なシナリオがある。例えば、最終的に確実に一度だけ「完了」更新を発行したい場合や、UI更新がより複雑な処理(例:ログウィンドウへの書き込みだがレート制限する)である場合だ。そのときは TThread.Queue が適切だが、要素ごとではなく狙って使うべきである。
実用的なアプローチは、低頻度イベントのみを扱うキューを用意することだ:
- Start-Event(UIの準備、ボタンを無効化)
- 周期的な進捗イベント(最大で X ミリ秒ごと)
- エラーイベント(任意で収集)
- 完了イベント(UIをリセットし、結果を表示)
周期制御はUIスレッドで行うのではなく、ワーカーコンテキスト側で行う:ワーカーは最後のUI更新から十分な時間が経過している場合にのみUI更新をqueueする。これには単調増加する時刻ソース(例:TickCount)とアトミックな「最終更新」値が適している。
重要: UI更新自体は「高速」でなければならない。高コストの計算、ファイルI/O、データベースアクセスはキューされたUIコールバックに入れてはいけない。コールバックは状態を読み取り、コントロールを設定するだけにするべきだ。
キャンセル処理:ハングしない中断
実アプリケーションでは中止は任意ではない。重要なのは:Cancelは「Kill」ではなく 協調的 な終了である。ワーカーは定期的に中止シグナルが立っているかをチェックし、あればきれいに終了する必要がある。 Delphi にはそのための複数の手段がある(PPLの構造による):独自の Volatile フラグ、アトミックな Boolean、あるいは Tasks を用いたキャンセレーション設計(Delphi のバージョンと構造に依存)。
運用上重要なのは次の2つのルールです:
- キャンセルはすぐに認識できること: 中断フラグは、イテレーションの終わりだけでなく、イテレーションが数秒かかる場合でも意味のある箇所で確認してください。
- キャンセルはクリーンアップを行うこと: 開いたハンドル、テンポラリファイル、トランザクション、またはロックが残っていてはなりません。つまり、リソースが関与する場合は各ワーカーのイテレーションでtry/finallyブロックが必須です。
UI側ではキャンセルはシグナルをセットし、UIを「Stopping…」状態にするだけにすべきです。実際の終了処理とUIの再活性化はクリック直後ではなく、Done-Eventで行われるべきです。
Deadlocks vermeiden: die häufigsten Fallen in der Praxis
Falle 1: WaitFor/Task.Wait im Main Thread
Main Thread がブロックされると、Queue-コールバックを実行できず、メッセージを処理できません。ワーカーが正しく動作していてもデッドロックのように見えます。解決策:UI スレッドでブロッキングな Wait を行わないこと。代わりに終了アクションを TThread.Queue で実行するか、イベント制御(例:タイマーが「fertig」を確認する)を使ってください。
Falle 2: Synchronize innerhalb eines Locks
定番の問題です:ワーカーがクリティカルセクションを保持したまま Synchronize を呼び、UI コールバック内で(直接的または間接的に)同じクリティカルセクションが再び必要とされる場合。結果は循環待ちです。ルールはシンプルです:保持中のロックの内部から UI に渡す(Synchronize/Queue)な。ロックが必要な場合は、まず必要なデータをローカル変数に取り出し、ロックを解放してから queue してください。
Falle 3: UI-Callback triggert Reentrancy
場合によっては UI 更新自体が「無害」ではありません:プロパティの設定がイベント(OnChange、OnResize)を発生させ、それがワーカーの状態にアクセスするロジックを呼び出すことがあります。これは厳密な意味でのデッドロックではありませんが、説明の難しいハングやレースコンディションを引き起こします。対策:UI 更新を「静かな」経路に置く(イベントを一時的に無効化する)か、再入防止ガードを利用する(例:更新フェーズ用のアトミックガード)。
Falle 4: Zu viele queued Updates
Wait がなくても、何万ものキューされたコールバックを生成すると UI が「固まる」ことがあります。症状:ProgressBar が長時間動き続ける、ウィンドウの応答が遅い、Main Thread の CPU 使用率が高い。解決策:スロットリング(時間窓)、集約(カウンタ)、あるいは単一の UI 更新のみが「pending」になれる本物の Producer/Consumer 構造(Coalescing)を採用することです。
Wenn es komplizierter wird: Ergebnisse sammeln, Fehler bündeln, Reihenfolgen garantieren
TParallel.For はイテレーションが独立している場合に理想的です。しかしビジネスソフトウェアではイテレーションは多くの場合「概ね」独立しています:ファイルを読み、REST-APIs を呼び、データベース行を書きます。その場合は以下の3点をきちんと設計する必要があります:
- スレッドセーフな結果の収集: スレッドごとにローカルバッファを用意して(最後に統合する)、あるいはスレッドセーフな Queue/Collection を使用してください。ホットパスでのロックは避けてください.
- エラー処理: ワーカースレッドからの例外は収集する必要があります。実務上は次のいずれかが有効です:最初の例外を記憶してキャンセルを発行する、またはすべての例外を収集して最後にまとめて表示する。
- 順序: 出力に安定した順序が必要な場合(例: インデックス順のログ)、後でソートする並列処理は「スレッドセーフな順序付き挿入」を実装するよりも簡単なことが多いです。
UIに対しては、個々のエラーメッセージをその都度表示しないでください。それはモーダルダイアログ地獄を招きます。エラーを収集する(例: 文字列のリスト)し、最後にまとめ表示するかエクスポート可能なログとして提示してください。
デバッグ: デッドロックを本当に可視化する方法
並列コードのデッドロックは厄介です。デバッガ上の見え方がリリース時と異なることがあるためです。それでも、実用的な対処法がいくつかあります:
Threadsウィンドウとコールスタックの活用
UIがハングしている場合、すべてのスレッドを確認してください: メインスレッドはどこにありますか?待機状態ですか?メッセージループにいますか?ワーカースレッドはどこにありますか?ワーカーが Synchronize で止まっている場合、原因はほとんどが「メインスレッドがブロックされている」か「メインスレッドがロックを必要としている」です。
キュー/Synchronize の箇所にマーキングする
受け渡し箇所(キューの前、キューコールバック内、イテレーションの最後など)に狙ってログを仕込みます。本番環境ではタイミングが重要なため、ブレークポイントより有用なことが多いです。ログ出力自体がスレッドセーフで非ブロッキングであることに注意してください(例: ワーカースレッドから直接UIにログを出力しない)。
最終更新時刻を測定する
UIが「ハング」している場合、単に処理すべき更新が多すぎることがあります。そのためメインスレッドで、1秒あたりに何回UI更新を行っているか、各更新にどれだけ時間がかかっているかを測定してください。UIコールバックが数ミリ秒以上かかるようであれば、更新の抑制(スロットリング)や簡素化が必要です。
進捗UI付きの TParallel.For はいつ本当に有益か?
並列化は目的そのものではありません。特に有効なのは次の場合です:
- イテレーションが十分に大きく(ミリ秒〜秒単位)スレッドプールのオーバーヘッドが相殺される場合、
- ジョブがCPU負荷型(パース、圧縮、ハッシュ)であるか、もしくは並列化しやすいI/Oを伴う場合(複数ファイル、制限付きの複数HTTPリクエストなど)、
- 明確なキャンセルとエラー戦略を持っている場合、
- UI要件が集約された進捗で十分である場合。
各イテレーションが極端に短い(マイクロ操作)場合や、すべてのイテレーションが同じボトルネックに依存する場合(シリアルなDBトランザクション、グローバルロック、単一のファイルなど)は、並列化の効果は小さいです。その場合、より効果的な手段はアルゴリズムの改善、バッチ処理、データアクセスの削減、あるいはボトルネックの明示的な切り離しです。
実践チェックリスト: UI を安定させるために
- メインスレッドをブロックしない: 待機やメッセージポンプのない長いループを避ける。
- ワーカーはコントロールに触れない: UIスレッド外での VCL/FMX へのアクセスを行わない。
- UI更新は抑制する: 各イテレーションごとではなく、カウンタ/タイマーや結合されたキュー更新を使う。
- ロックの中からの Synchronize 呼び出しを行わない。
- キャンセルは協調的で頻繁にチェックし、クリーンに後片付けする。
- 例外は収集され、最後に順序立てて処理される。
結論: キューで切り離し、集約で安定化する
一貫して堅牢な進捗UIはTParallel.Forに「どこかでSynchronizeを入れる」ことで実現するものではなく、明確なアーキテクチャ原則によって達成されます:ワーカーは独立して動作し、メインスレッドは開放されたままで、少数かつ迅速なUI更新のみを処理します。TThread.Queueは、目的を絞って抑制しつつ用いる場合に適切なツールです。「もたつく」あるいは不安定な挙動の原因はほとんどの場合二つに集約されます:メインスレッドがどこかでブロッキングして待機しているか、あるいはあまりに多くのキューに溜まった更新に溺れているか、です。
このパターンを一度きちんと構築すれば(Counter/Coalescing、キャンセルフラグ、完了コールバック)、成長したDelphiアプリケーションの多くの箇所で効果を発揮します:インポート/エクスポート、データ検証、ファイルやAPIのジョブ──いずれも応答性が高まり、各進捗更新ごとに新たなデッドロックを招くリスクを避けられます。
Parallelコードの安定化、UIハングのデバッグ、または成長してきたDelphiアプリケーションのクリーンなモダナイズで支援が必要な場合は、お問い合わせください。
このテーマではDelphi Parallel Programming LibraryとTthread.queue Vs Synchronizeも重要です。本稿はこれらの側面を分かりやすく整理し、実務で何が重要かを示します。
次のステップ
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
私たちは単なる個別の問い合わせへの対応にとどまらず、ソースの断片やレガシー課題、ポータルの構想が堅牢な企業向けプロジェクトへと成長する段階まで支援します。
- 既存環境、目標像、技術的リスクを一体として評価します。
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- 早い段階で、どの選択肢が経済的かつ運用上実行可能であるかを見極められます。