От темата в списанието към проектната практика
Подходящи страници за услуги и технологии към публикацията
Който в Delphi иска да паралелизира изчислително натоварени или I/O-интензивни задания, бързо стига до Parallel Programming Library (PPL) и конкретно до TParallel.For. Ефектът често е веднага измерим – докато не опитате в същия дъх „бързо“ да обновите интерфейс за прогрес (Progress-UI). Точно тук възникват типичните забивания: на пръв поглед случайни замръзвания на интерфейса, една ProgressBar, която скача назад, или пълен deadlock, щом стъпите през изпълнението в дебъгъра.
В тази статия става дума за thread-сигурен Progress-UI при TParallel.For: устойчив шаблон, който работи във VCL и FMX, който аккуратно групира UI-актуализации чрез TThread.Queue, отчита Cancel/Abort и последователно заобикаля най-честите капани за deadlock. Фокусът е върху реалната оперативна среда: възпроизводимо поведение, ясни отговорности и указания за дебъгване, които помагат дори когато грешката се проявява само „при клиенти“.
Защо Progress-UI при TParallel.For толкова често се проваля
TParallel.For обикновено се изпълнява на работни нишки от Delphi-threadpool-а. Тези нишки не трябва да засягат директно VCL- или FMX-контроли, защото UI-фреймуърковете (Message Loop, Window Handles, Rendering) са обвързани с основната нишка (Main Thread). Дори един на пръв поглед безвреден ProgressBar.Position := … от Worker може да доведе до непредвидимо поведение: спорадични AV-та, замръзнали прозорци или „фликерищи“ обновявания.
Очевидният ремонт често е TThread.Synchronize. Това решава въпроса за thread-safety, но в паралелни цикли бързо води до друг проблем: създава сериен тесен участък. Всеки Worker чака UI-нишката, която от своя страна е заета с рендериране и обработка на Synchronize-извиквания. Под натоварване това изглежда като deadlock – дори когато е „само“ ефект на starvation/lockstep.
И после има истинския клас deadlock: Main Thread чака (напр. чрез WaitFor, Task.Wait или индиректно чрез блокиращи извиквания) края на паралелната операция, докато Worker-нишките се опитват чрез Synchronize или неблагоприятно използване на Queue да изпратят работа към Main Thread. Резултат: Main Thread чака Worker, Worker чакат Main Thread.
TThread.Queue vs. TThread.Synchronize: практическата разлика
И двата механизма служат за безопасно изпълнение на код в Main Thread. Разликата е в семантиката на изчакване:
- TThread.Synchronize: Извикващият Worker чака, докато Main Thread изпълни кода. Това е „синхронно“, увеличава латенцията и е класическа причина за deadlock, когато Main Thread е в блокирано състояние.
- TThread.Queue: Worker-ът поставя кода само в опашката за главния поток и продължава изпълнението. Това е „асинхронно“, разcouple-ва нишките и в паралелни сценарии почти винаги е по-добрият избор по подразбиране – стига да се контролира честотата на актуализациите.
Важно: Queue не е безплатен пропуск. Ако при всяка итерация на цикъл изпращате актуализация чрез Queue, ще наводните опашката на главния поток. В такъв случай UI-то няма да блокира заради Deadlock, но ще страда от просто прекомерно количество съобщения. UI-то ще изглежда „завалено“/„бавно“, а завършването на обработката ще се забави, защото все още ще се изпълняват стотици или хиляди UI-актуализации.
Граничният случай, който наистина боли: изчакване в UI нишката
В корпоративни приложения често се наблюдава следният поток: натиска се бутон „Start“, UI-то се деактивира, отваря се ProgressDialog, след което се извършва синхронно „изчакване“, докато всичко не приключи, и накрая UI-то се активира отново. Този модел е в основата на много Deadlock-и.
Типични варианти (в зависимост от кодовата база):
- Главният поток стартира TParallel.For и след това извиква блокираща логика за изчакване (директно или индиректно).
- ProgressDialog извиква в конструктора или OnShow рутина, която вътрешно изчаква.
- Бутонът „Отказ“ задава флаг, но въпреки това главният поток остава в изчакващ цикъл.
Ако през това време работещите нишки използват Synchronize, Deadlock-ът е практически гарантиран. При използване на Queue също може да настъпи задържане, ако главният поток е блокиран и не обработва съобщенията — тогава и опашката няма да бъде изпразнена.
Практическото следствие е: главният поток не бива да чака блокиращо за паралелния цикъл, ако паралелно са необходими UI-актуализации. Вместо това обработката трябва или да бъде изцяло пренесена във фоновa задача, или да се организира „асинхронен финал“ (callback/queued крайна операция), който накрая освобождава UI-то.
Чист подход: прогрес само агрегирано, UI-актуализации регулирани
Стабилен модел се състои от три ясно разделени отговорности:
- Worker-нишки извършват реалната работа за всеки елемент/индекс. Те докладват само напредъка в нишкобезопасна форма (брояч, Queue, thread-safe Queue).
- Агрегатор (често: главният поток или специализиран таймер в UI) изчислява от напредъка статус за UI (позиция, текст, ETA) и обновява контролите. Така се избягват 1:1 актуализации за всяка итерация.
- Завършване (също в главния поток): реактивиране на UI-то, показване на резултата, обобщаване на грешки, освобождаване на ресурси.
Защо това разделение работи толкова добре: работният обем може да е с висока честота (хиляди елементи), но UI-то се нуждае само от няколко актуализации в секунда. На практика 5–10 актуализации/секунда са достатъчни, при много бързи задачи дори 2–4. Всичко над това обикновено е само оптичен шум и струва CPU време в Message Pump.
Потокобезопасно броене: атомарно вместо Lock
За проста ProgressBar често е достатъчен един атомарен брояч. „Атомарно“ означава: инкрементът и четенето се извършват без race condition, типично чрез TInterlocked. Така избягвате Locks (Critical Sections) в hot path-а на цикъла.
Проверена минимална идея:
- Общото количество е известно предварително (напр. брой записи, файлове, ID-та).
- Всяка итерация атомарно увеличава DoneCounter.
- UI-Timer периодично чете брояча и задава ProgressBar.Position.
Предимство: няма TThread.Queue на елемент, няма препълване на UI-то. Недостатък: нямате подробни съобщения за всеки елемент (напр. име на файл). За това може да се добави второ, ограничено съобщение за статус (виж следващия раздел).
Statusmeldungen ohne Spam: „letzter Status gewinnt“
Ако искате допълнително да покажете кратък текст (текущ елемент, фаза, съобщение за грешка), също е необходим шаблон, който да не залива UI при всяка стъпка на Worker-а. На практика „letzter Status gewinnt“ работи много добре:
- Worker записва статусна информация в потокобезопасна структура (напр. атомарно заменяем низ, или защитено с малък Lock).
- UI-Timer периодично поема последно видяния статус в Label.
Така UI-то остава отзивчиво и все пак виждате, че „нещо се случва“. По-важна тук е не самата стойност на низа, а продължителността на живота: не пренасяйте референции към краткотрайни обекти от Worker нишки в UI нишката. Ако предавате обекти, уточнете Ownership експлицитно.
TParallel.For threadsichere Progress-UI mit TThread.Queue: ein robustes Muster
Има сценарии, в които само UI-Timer не е достатъчен: напр. когато в края искате сигурно да предизвикате точно едно „Fertig“-обновление, или когато UI-обновлението е по-сложна стъпка (напр. запис в лог-прозорец, но ограничен). Тогава е подходящо TThread.Queue – но не за всяка итерация, а целенасочено.
Практичен подход е една опашка само за събития с ниска честота:
- Start-Event (подготовка на UI, блокиране на бутони)
- Периодични Progress-Events (макс. на всеки X милисекунди)
- Fehler-Events (по избор събрани)
- Done-Event (възстановяване на UI, показване на резултата)
Периодичността не се постига от UI нишката, а в контекста на Worker-а: позволявате на Worker-а да queue-не UI-обновление само ако е изминало достатъчно време от последното UI-обновление. За това е подходящ монотонен източник на време (напр. TickCount) плюс атомарна стойност „last update“.
Важно: самото UI-обновление трябва да е „бързо“. Скъпи изчисления, файлов I/O или заявки към база данни не трябва да бъдат в gequeu-вания UI callback. Callback-ът трябва само да чете състояния и да задава контроли.
Cancel-Handling: Прекъсване ohne Hänger
В реални приложения прекъсването не е по желание. Ключово е: Cancel не е „Kill“, а кооперативно приключване. Worker-ите трябва редовно да проверяват дали е зададен сигнал за прекъсване и тогава да излизат чисто. В Delphi има няколко начина за това (в зависимост от PPL-конструкцията): собствен Volatile-флаг, атомарен Boolean, или концепция за Cancellation през Tasks (в зависимост от версията и структурата на Delphi).
За експлоатацията важат две правила:
- Cancel muss schnell sichtbar werden: Проверявайте флага за прекъсване на подходящи места, не само в края на итерация, когато итерацията може да продължи и секунди.
- Cancel muss aufräumen: Отворени хендъли, временни файлове, транзакции или заключвания не бива да остават. Това означава: Във всяка Worker-итерация са задължителни try/finally-блоковете, когато участват ресурси.
От гледна точка на UI Cancel следва само да постави сигнал и да постави 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-callbacks и не може да обработва съобщения. Това изглежда като deadlock, дори когато Worker-ите продължават да работят коректно. Решение: никакви блокиращи изчаквания в UI-Thread. Вместо това извършете финализиращото действие чрез TThread.Queue или чрез управление по събития (например Timer проверява „готово“).
Falle 2: Synchronize innerhalb eines Locks
Класика: Worker държи Critical Section, извиква Synchronize, а в UI-callback-а (директно или индиректно) отново се изисква същата Critical Section. Резултатът е кръг на изчакване. Правилото е просто: Keine UI-Übergabe (Synchronize/Queue) aus einem gehaltenen Lock heraus. Ако е необходим Lock, вземете всички данни първо в локални променливи, напуснете Lock-а и едва след това направете queue.
Falle 3: UI-Callback triggert Reentrancy
Понякога самото обновяване на UI не е „безобидно“: задаване на свойства може да предизвика събития (OnChange, OnResize), които пък стартират логика, достъпваща състояния на Worker-ите. Това не е deadlock в тесния смисъл, но води до трудно обясними замръзвания и състезателни условия. Мерки: поставяйте UI-ъпдейтите в „тихи“ пътища (временно деактивирайте събитията) или използвайте Reentrancy-Guards (напр. атомарен гард за фазата на обновяване).
Falle 4: Zu viele queued Updates
Дори без изчаквания UI може да „замръзне“, ако генерирате десетки хиляди queued callbacks. Симптоми: ProgressBar продължава дълго да реагира, прозорецът е муден, CPU натоварването на Main Thread е високо. Решение: дроселиране (времеви прозорец), агрегиране (брояч) или истинска Producer/Consumer структура, при която може да има само един „pending“ UI-ъпдейт (Coalescing).
Wenn es komplizierter wird: Ergebnisse sammeln, Fehler bündeln, Reihenfolgen garantieren
TParallel.For е идеален, когато итерациите са независими. В бизнес-софтуера обаче итерациите често са само „в голяма степен“ независими: четат файлове, извикват REST-APIs, записват редове в база данни. Тогава трябва да планирате три допълнителни неща внимателно:
- Thread-sichere Ergebnis-Sammlung: Entweder pro Thread ein lokaler Buffer (am Ende zusammenführen) oder eine threadsichere Queue/Collection. Locks im Hot Path vermeiden.
- Обработка на грешки: Изключенията от Worker-нишки трябва да се събират. На практика е доказано: запазете първото изключение и стартирайте Cancel, или събирайте всички изключения и в края ги покажете обобщено.
- Подредба: Ако изходът изисква стабилен ред (напр. логове по индекс), паралелната обработка с последващо сортиране често е по-лесна от „потокобезопасно подредено вмъкване“.
За UI това означава: Не показвайте всяко едно съобщение за грешка незабавно. Това води до ада на модалните диалози. Събирайте грешките (напр. списък от низове) и в края покажете обобщение или лог, който може да се експортира.
Отстраняване на грешки: Как наистина да направите deadlock видим
Deadlock-ите в паралелен код са фрустриращи, защото могат да изглеждат различно в дебъгъра отколкото в релийз. Въпреки това има няколко много практични подхода:
Използвайте прозореца за нишки и стековете на извикванията
Ако UI замръзва, огледайте всички нишки: къде е Main Thread? Изчаква ли? В message-loop ли е? Къде са Worker-нишките? Ако Worker-ите заседнат в Synchronize, причината почти винаги е „Main Thread блокиран“ или „Main Thread се нуждае от lock“.
Маркирайте местата за Queue/Synchronize
Поставяйте целенасочено логване на точките на предаване (преди Queue-то, в Queue-callback-а, в края на итерацията). В продукция това често е по-ценно от Breakpoints, защото времевите характеристики са решаващи. Внимавайте логването само по себе си да е потокобезопасно и неблокиращо (напр. да няма UI-лог изход директно от Worker-нишки).
Измерете времето на последно обновяване
Ако UI-то „замръзва“, може просто да обработва твърде много обновления. Измерете в Main Thread колко пъти в секунда извършвате UI-ъпдейт и колко време отнема. Веднага щом UI-callback-овете изискват повече от няколко милисекунди, е необходимо ограничаване на честотата или опростяване.
Кога TParallel.For с Progress-UI наистина си струва?
Паралелизацията не е цел сама по себе си. Тя е полезна особено когато:
- итерациите са достатъчно големи (милисекунди до секунди), така че overhead-ът на thread pool-а да се прехвърли на заден план,
- задачата е CPU-интензивна (парсинг, компресия, хеширане) или има I/O, който се поддава добре на паралелизация (няколко файла, няколко HTTP-заявки с лимити),
- имате ясна стратегия за Cancel и обработка на грешки,
- изискванията на UI-то могат да се задоволят с агрегирано представяне на прогреса.
Тя е по-малко полезна, когато всяка итерация е изключително кратка (микрооперации) или когато всички итерации се сблъскват със същия тесен ресурс (серийна DB-транзакция, глобален lock, един единствен файл). В този случай по-бързият лост често е: подобряване на алгоритъма, пакетиране, намаляване на достъпа до данни или явното разкачване на тесния ресурс.
Практически контролен списък: Как да задържите UI-то стабилно
- Main Thread не трябва да бъде блокиран: никакви Waits, никакви дълги цикли без message pump.
- Worker-ите никога не засягат Controls: никакви VCL/FMX-достъпи извън UI-нишката.
- UI-ъпдейтите са ограничени: брояч/таймер или коалесцирани Queue-ъпдейти вместо по итерация.
- Никакви повиквания на Synchronize от вътре в locks.
- Cancel е кооперативен, често проверяван и извършва чисто почистване.
- Изключенията се събират и в края се обработват подредено.
Заключение: Разкачайте с Queue, стабилизирайте с агрегация
Робустен Progress-UI при TParallel.For не се постига чрез „просто някъде да се вика Synchronize“, а чрез ясно архитектурно правило: Worker-ите работят независимо, Main Thread остава свободен и обработва само няколко бързи UI-ъпдейта. TThread.Queue е правилният инструмент, когато го използвате целево и дозирано. За „тежко“ или нестабилно поведение почти винаги са отговорни два фактора: Main Thread чака блокиращо някъде – или потъва в твърде много queued Updates.
Когато веднъж настроите шаблона чисто (Counter/Coalescing, Cancel-Flag, Abschluss-Callback), струва си да го прилагате на много места в едно съществуващо Delphi-приложение: Import/Export, Datenprüfungen, Datei- und API-Jobs – всичко става по-отзивчиво, без да си навличате нови Deadlocks при всяко Progress-Update.
Ако имате нужда от подкрепа при стабилизиране на паралелен код, при дебъгване на замръзвания на 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.
- Вие виждате навреме кой път е икономически и оперативно жизнеспособен.