Від теми журналу до практики проєкту
Відповідні сторінки послуг і технічні сторінки до публікації
Хто в Delphi хоче розпаралелити обчислювально інтенсивні або I/O-навантажені задачі, швидко опиняється на Parallel Programming Library (PPL) і конкретно на TParallel.For. Ефект часто відразу вимірний — доки не потрібно в тому ж моменті «швидко» оновити Progress-UI. Саме тут виникають типові зависання: на вигляд випадкові UI-freezes, ProgressBar, яка стрибає назад, або повний deadlock, щойно ви крокуєте у відлагоджувачі.
У цій статті йдеться про потокобезпечні Progress-UI для TParallel.For: надійний патерн, що працює у VCL і FMX, акуратно агрегує оновлення UI через TThread.Queue, враховує Cancel/Abort і послідовно обходить найпоширеніші пастки deadlock. Акцент на експлуатаційній реальності: відтворювана поведінка, зрозумілі зони відповідальності та поради для відлагодження, що допомагають навіть коли помилка виникає лише «у клієнтів».
Чому Progress-UI при TParallel.For так часто йде не так
TParallel.For зазвичай виконується на робочих потоках із Delphi-пулу потоків. Ці потоки не повинні безпосередньо торкатися VCL- або FMX-контролів, бо UI-фреймворки (Message Loop, Window Handles, Rendering) прив’язані до головного потоку. Навіть, здавалося б, нешкідливий ProgressBar.Position := … із робочого потоку може спричинити невизначену поведінку: спорадичні AV, завислі вікна або «мерехтливі» оновлення.
Очевидним вирішенням часто стає TThread.Synchronize. Воно хоч і вирішує проблему потокобезпеки, але в паралельних петлях швидко породжує іншу проблему: серійний вузький горлечко. Кожен Worker чекає на UI-потік, який, у свою чергу, зайнятий рендерингом та обробкою викликів Synchronize. Під навантаженням це виглядає як deadlock — навіть якщо це «лише» ефект starvation/lockstep.
Існує також класичний випадок справжнього deadlock: головний потік чекає (наприклад через WaitFor, Task.Wait або опосередковано через блокуючі виклики) завершення паралельної операції, тим часом як робочі потоки намагаються через Synchronize або при невдалому використанні Queue передати роботу в головний потік. Результат: головний потік чекає на Worker, Worker чекають на головний потік.
TThread.Queue vs. TThread.Synchronize: der praktische Unterschied
Обидва механізми служать для безпечного виконання коду в головному потоці. Різниця — у семантиці очікування:
- TThread.Synchronize: викликаючий Worker чекає, поки головний потік виконає код. Це «синхронно», збільшує затримки і є класичною складовою deadlock-ів, якщо головний потік наразі заблокований.
- TThread.Queue: Worker stellt den Code nur in eine Warteschlange für den Main Thread und läuft weiter. Das ist „asynchron“, entkoppelt Threads und ist in Parallel-Szenarien fast immer die bessere Default-Wahl – sofern man die Update-Frequenz kontrolliert.
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 потребує лише кілька оновлень на секунду. На практиці достатньо 5–10 оновлень/секунду, при дуже швидких завданнях навіть 2–4. Усе понад це зазвичай лише візуальний шум і витрачає CPU-час у Message Pump.
Threadsicher zählen: Atomic statt Lock
Для простої ProgressBar часто достатньо атомарного лічильника. «Атомарно» означає: інкремент і читання відбуваються без Race Condition, зазвичай через TInterlocked. Це дозволяє уникнути Locks (Critical Sections) у Hot Path циклу.
Перевірена мінімальна ідея:
- Загальний обсяг відомий заздалегідь (наприклад кількість записів, файлів, IDs).
- Кожна ітерація атомарно збільшує DoneCounter.
- UI-таймер періодично зчитує лічильник і встановлює ProgressBar.Position.
Перевага: жодного TThread.Queue на елемент, немає перевантаження UI. Недолік: немає детальних повідомлень на елемент (наприклад ім’я файлу). Для цього можна додати друге, з пониженою частотою статусне повідомлення (див. наступний розділ).
Statusmeldungen ohne Spam: „letzter Status gewinnt“
Якщо ви додатково хочете показувати короткий текст (поточний елемент, фаза, повідомлення про помилку), потрібна також схема, яка не буде заливати UI при кожному кроці Worker. На практиці підхід «letzter Status gewinnt» працює дуже добре:
- Worker записує інформацію статусу в потокобезпечну структуру (наприклад атомарно замінюваний String, або захищений невеликим Lock).
- UI-таймер періодично переносить останній зафіксований статус у Label.
Це зберігає UI реактивним, і ви все одно бачите, що «щось відбувається». Тут важливіша не сама рядок, а тривалість життя: не переносіть посилання на короткоживучі об’єкти з Worker-threads у UI-thread. Якщо ви передаєте об’єкти, явно з’ясовуйте права власності.
TParallel.For threadsichere Progress-UI mit TThread.Queue: ein robustes Muster
Існують сценарії, коли одного UI-таймера недостатньо: наприклад якщо ви в кінці хочете гарантовано викликати рівно одне оновлення «Готово», або коли UI-оновлення є складнішим кроком (наприклад запис у лог-вікно, але з обмеженням частоти). Тоді підходить TThread.Queue — але не на кожну ітерацію, а прицільно.
Практичний підхід — eine Queue nur für Ereignisse mit geringer Frequenz:
- Start-Event (підготувати UI, блокувати Buttons)
- Періодичні Progress-Events (не частіше ніж кожні X Millisekunden)
- Fehler-Events (опційно збираються)
- Done-Event (скинути UI, показати Ergebnis)
Періодичність ви забезпечуєте не через UI-Thread, а вже в контексті Worker: ви дозволяєте Worker ставити в чергу UI-оновлення лише тоді, коли з останнього UI-оновлення пройшло достатньо часу. Для цього підходить монотонне джерело часу (наприклад TickCount) плюс атомарне значення «last update».
Важливо: саме UI-оновлення має бути «швидким». Дорогі обчислення, файл-I/O або звернення до бази даних не належать у gequeueten UI-Callback. Der Callback повинен лише читати стани та встановлювати Controls.
Cancel-Handling: Abbrechen ohne Hänger
У реальних застосунках відміна не є опцією. Важливо: Cancel — це не «Kill», а кооперативне завершення. Worker мають регулярно перевіряти, чи встановлено сигнал скасування, і тоді коректно виходити. У Delphi для цього є кілька шляхів (залежно від PPL-Konstruktion): власний Volatile-флаг, атомарний Boolean, або концепція Cancellation через Tasks (залежно від версії та структури Delphi).
Для експлуатації важливі два правила:
- Кнопка «Cancel» повинна швидко стати помітною: Перевіряйте прапорець відміни в логічних точках, а не лише в кінці ітерації, якщо ітерація може тривати секунди.
- Скасування має виконувати прибирання: Відкриті дескриптори, тимчасові файли, транзакції або блокування не повинні залишатися. Тобто: у кожній ітерації воркера обов’язкові try/finally-блоки, коли залучені ресурси.
З боку UI «Cancel» має лише встановлювати сигнал і переводити інтерфейс у стан «Stopping…». Саме завершення й повторна активація UI відбуваються в Done-Event, а не відразу при кліку.
Коли виникає ускладнення: уникнення взаємних блокувань (Deadlocks) — найпоширеніші пастки на практиці
Пастка 1: WaitFor/Task.Wait у головному потоці
Якщо головний потік блокується, він не може виконувати Queue-колбеки і обробляти повідомлення. Це виглядає як deadlock, навіть якщо воркери продовжують працювати. Рішення: не використовувати блокуючі Wait у UI-потоці. Натомість завершальну дію виконують через TThread.Queue або керування подіями (наприклад таймер перевіряє «готово»).
Пастка 2: Synchronize всередині захопленого локу
Класика: воркер утримує Critical Section, потім викликає Synchronize, і в UI-колбеку (прямо чи опосередковано) необхідна та сама Critical Section. Результат: циклічне очікування. Правило просте: Не передавати контроль UI (Synchronize/Queue) з-під утримуваного Lock’а. Якщо Lock необхідний, спочатку зчитайте всі дані у локальні змінні, звільніть Lock, а потім помістіть у чергу.
Пастка 3: UI-колбек викликає повторний вхід (Reentrancy)
Іноді саме оновлення UI не є «безпечним»: встановлення властивостей може спричиняти події (OnChange, OnResize), які в свою чергу запускають логіку, що звертається до станів воркерів. Це не deadlock у вузькому сенсі, але призводить до важко пояснених зависань і race conditions. Рішення: помістити UI-оновлення у «тихі» шляхи (тимчасово вимкнути події) або використовувати reentrancy-guards (наприклад атомарний захисник для фази оновлення).
Пастка 4: Забагато queued-оновлень
Навіть без блокуючих Wait інтерфейс може «зависнути», якщо ви генеруєте десятки тисяч queued-колбеків. Симптоми: ProgressBar довго «добігає», вікно відчутно пригальмовує, завантаження CPU у головному потоці високе. Рішення: обмежувати (часові вікна), агрегувати (лічильник), або застосувати справжню Producer/Consumer-структуру, де може бути лише одне очікуване UI-оновлення (Coalescing).
Wenn es komplizierter wird: Ergebnisse sammeln, Fehler bündeln, Reihenfolgen garantieren
TParallel.For ідеальний, коли ітерації незалежні. У бізнес‑ПЗ ітерації часто лише «переважно» незалежні: вони читають файли, викликають REST-APIs, записують рядки в базу. Тоді потрібно чітко спланувати ще три пункти:
- Потокобезпечний збір результатів: або локальний буфер на кожен потік (в кінці злиття), або потокобезпечна Queue/Collection. Уникати локів у Hot Path.
- Обробка помилок: Виключення з робочих потоків потрібно зібрати. На практиці виправдовує себе підхід: запам’ятати перше виключення і ініціювати Cancel, або зібрати всі виключення й наприкінці показати їх разом.
- Порядок: Якщо вивід потребує стабільного порядку (наприклад, логи за індексом), паралельна обробка з подальшим сортуванням часто простіша за «потокобезпечне впорядковане вставляння».
Для UI це означає: не показуйте кожне повідомлення про помилку миттєво. Це призводить до натовпу модальних діалогів. Збирайте помилки (наприклад, список рядків) і показуйте наприкінці зведення або експортований лог.
Налагодження: як зробити взаємне блокування дійсно видимим
Взаємні блокування в паралельному коді викликають розчарування, бо в дебагері вони можуть виглядати інакше, ніж у релізі. Проте є кілька практичних важелів:
Використовуйте вікно потоків і стеки викликів
Якщо UI зависає, подивіться всі потоки: де знаходиться головний потік? Чи очікує він? Чи знаходиться він у циклі обробки повідомлень (Message-Loop)? Де знаходяться робочі потоки? Якщо робочі потоки застрягли в Synchronize, причина майже завжди — «головний потік заблокований» або «головному потоку потрібен lock».
Позначайте Queue-/Synchronize-станції
Цілеспрямовано додавайте логування в точках передачі (перед Queue, у Queue-callback, в кінці ітерації). У роботі це часто цінніше за брейкпойнти, бо важливе саме часування. Слідкуйте, щоб саме логування було потокобезпечним і неблокувальним (наприклад, не виводити лог у UI безпосередньо з робочих потоків).
Вимірюйте час останнього оновлення
Якщо UI «зависає», можливо, він просто обробляє надто багато оновлень. Тому вимірюйте в головному потоці, скільки UI-оновлень за секунду виконується і скільки вони забирають часу. Як тільки UI-колбеки потребують більше кількох мілісекунд, потрібне обмеження або спрощення.
Коли дійсно варто використовувати TParallel.For з індикацією прогресу?
Паралелізація не є самоціллю. Вона виправдана особливо коли:
- ітерації досить великі (мілісекунди до секунд), так що накладні витрати пулу потоків стають неістотними,
- завдання навантажує CPU (парсинг, стиснення, хешування) або має I/O, що добре розпаралелюється (кілька файлів, кілька HTTP-запитів з обмеженнями),
- у вас є чітка стратегія Cancel і обробки помилок,
- вимоги UI дозволяють обмежений/агрегований індикатор прогресу.
Менш доцільно застосовувати, якщо кожна ітерація надзвичайно коротка (мікрооперації) або коли всі ітерації зіштовхуються з тим самим вузьким місцем (послідовна транзакція в БД, глобальний lock, один файл). Тоді швидший важіль — покращити алгоритм, батчити, зменшити доступ до даних або явно розв’язати вузьке місце.
Практичний чекліст: як зберегти стабільність UI
- Головний потік не блокується: жодних очікувань (Waits), жодних довгих циклів без циклу обробки повідомлень.
- Робочі потоки ніколи не торкаються Controls: жодних звернень до VCL/FMX поза UI-потоком.
- Оновлення UI регулюються: лічильник/таймер або об’єднані Queue-оновлення замість оновлення після кожної ітерації.
- Жодних викликів Synchronize зсередини блокувань.
- Cancel — кооперативний, часто перевіряється і коректно очищує ресурси.
- Виключення збираються і наприкінці впорядковано обробляються.
Висновок: відокремлюйте через Queue, стабілізуйте через агрегацію
Надійний інтерфейс прогресу при TParallel.For не з’являється через «кінець-кінець Synchronize кинути», а через чіткий архітектурний принцип: воркери працюють незалежно, головний потік залишається вільним і обробляє лише небагато швидких оновлень UI. TThread.Queue — правильний інструмент, якщо ви застосовуєте його цілеспрямовано та обмежено. За «тягучою» або нестабільною поведінкою майже завжди відповідають дві причини: головний потік десь блокується — або він тоне в занадто великій кількості поставлених у чергу оновлень.
Якщо ви цей патерн налаштуєте правильно (Counter/Coalescing, Cancel-Flag, Abschluss-Callback), він виправдовує себе в багатьох місцях у дорослому Delphi-додатку: імпорт/експорт, перевірки даних, файлові та API-завдання — усе стає більш реактивним, без ризику завести нові дедлоки при кожному оновленні прогресу.
Якщо вам потрібна підтримка у стабілізації паралельного коду, у відлагодженні зависань 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.
Ми підтримуємо не лише в окремих питаннях, а й тоді, коли з уривків вихідного коду, питань, пов’язаних із legacy, або ідей порталу має вирости надійний корпоративний проєкт.
- Поточний стан, цільова архітектура та технічні ризики оцінюються спільно.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Ви заздалегідь бачите, який шлях є економічно та операційно життєздатним.