Od tématu magazínu k projektové praxi
Vhodné stránky služeb a technické stránky k příspěvku
Kdo v Delphi chce paralelizovat výpočetně nebo I/O náročné úlohy, rychle narazí na Parallel Programming Library (PPL) a konkrétně na TParallel.For. Efekt je často okamžitě měřitelný – až do chvíle, kdy současně chcete „jen tak“ aktualizovat Progress-UI. Právě zde se objevují typické záseky: zdánlivě náhodná zmrazení UI, ProgressBar, která poskakuje zpět, nebo úplný deadlock, jakmile v debuggeru krokujete po krocích.
V tomto článku jde o threadsafé Progress-UI pro TParallel.For: robustní vzor, který funguje ve VCL i FMX, soustředěně zpracovává aktualizace UI přes TThread.Queue, bere v úvahu Cancel/Abort a důsledně obchází nejčastější pasti deadlocku. Důraz je na provozní realitu: reprodukovatelné chování, srozumitelné odpovědnosti a tipy pro debugging, které pomohou i tehdy, když se chyba projeví „jen u zákazníka“.
Warum Progress-UI bei TParallel.For so oft schiefgeht
TParallel.For obvykle běží na worker vláknech z Delphi-threadpoolu. Tato vlákna nesmí přímo manipulovat s VCL- nebo FMX-kontrolkami, protože UI frameworky (Message Loop, Window Handles, Rendering) jsou vázány na hlavní vlákno. I zdánlivě neškodné ProgressBar.Position := … z workeru může vést k nedefinovanému chování: sporadické AVs, zamrzlá okna nebo „blikající“ aktualizace.
Logickým řešením je často TThread.Synchronize. To sice zajistí threadsafety, ale v paralelních smyčkách rychle vytváří jiný problém: seriální úzké hrdlo. Každý worker čeká na hlavní vlákno, které mezitím renderuje a zpracovává volání Synchronize. Pod zátěží to vypadá jako deadlock – i když jde „pouze“ o starvation/lockstep efekt.
A pak tu máme skutečnou třídu deadlocků: hlavní vlákno čeká (např. přes WaitFor, Task.Wait nebo nepřímo blokující volání) na dokončení paralelní operace, zatímco worker vlákna se snaží pomocí Synchronize nebo nevhodného použití Queue odeslat práci do hlavního vlákna. Výsledek: hlavní vlákno čeká na workery, workery čekají na hlavní vlákno.
TThread.Queue vs. TThread.Synchronize: der praktische Unterschied
Oba mechanismy slouží k bezpečnému spuštění kódu v hlavním vlákně. Rozdíl je v semantice čekání:
- TThread.Synchronize: Volající worker čeká, dokud hlavní vlákno kód neprovede. To je „synchronní“, zvyšuje latenci a je klasickou složkou pro deadlocky, pokud je hlavní vlákno právě zablokované.
- TThread.Queue: Der 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 vykonávají vlastní práci pro prvek/index. Hlásí pouze průběh v threadsicherer Form (počítadlo, Queue, thread-safe Queue).
- Aggregator (oft: hlavní vlákno oder ein dedizierter Timer im UI) vypočítá z průběhu stav UI (pozice, text, ETA) a aktualizuje ovládací prvky. So vermeiden Sie 1:1-Updates pro Iteration.
- Abschluss (ebenfalls im Main Thread): UI reaktivieren, Ergebnis anzeigen, Fehler zusammenfassen, Ressourcen freigeben.
Proč toto rozdělení tak dobře funguje: zátěž může být vysoce frekventovaná (tisíce položek), ale UI potřebuje jen několik aktualizací za sekundu. V praxi stačí 5–10 aktualizací/s, u velmi rychlých úloh dokonce 2–4. Všechno nad tím je většinou jen vizuální šum a stojí CPU čas v Message Pump.
Vlákny bezpečné počítání: Atomic statt Lock
Pro jednoduchý ProgressBar často stačí atomární čítač. „Atomární“ znamená: inkrement a čtení proběhnou bez race condition, typicky pomocí TInterlocked. Tím se vyhnete lockům (Critical Sections) v hot pathu smyčky.
Prověřená minimální myšlenka:
- Celkové množství je předem známo (např. počet záznamů, souborů, ID).
- Každá iterace atomicky zvýší DoneCounter.
- UI-Timer periodicky přečte čítač a nastaví ProgressBar.Position.
Výhoda: žádné TThread.Queue pro prvek, žádné zahlcení UI. Nevýhoda: nemáte detailní hlášení pro každý prvek (např. název souboru). To lze vyřešit přidáním druhé, omezené statusové zprávy (viz další oddíl).
Statusmeldungen ohne Spam: „letzter Status gewinnt“
Pokud chcete navíc zobrazit krátký text (aktuální položka, fáze, chybová hláška), potřebujete také vzor, který nebude při každém kroku workeru zaplavovat UI. V praxi velmi dobře funguje přístup „poslední stav vyhrává“:
- Worker zapíše statusovou informaci do threadsichere struktury (např. atomicky vyměnitelný String, nebo chráněný malým Lock).
- UI-Timer periodicky přenese naposledy viděný stav do Labelu.
Tím zůstane UI responsivní a přesto uvidíte, že „něco se děje“. Důležitější než samotný String je životnost: netahejte do UI-threadu reference na krátce žijící objekty z worker-threadů. Pokud předáváte objekty, určete explicitně jejich Ownership.
TParallel.For vlákny bezpečná Progress-UI s TThread.Queue: ein robustes Muster
Existují scénáře, kdy UI-Timer sám nestačí: např. když na konci chcete jistě vyvolat přesně jedno ‚Hotovo‘-update, nebo když UI-aktualizace je složitější krok (např. zápis do log okna, ale omezený). Pak je TThread.Queue vhodný – ale ne pro každou iteraci, nýbrž cíleně.
Praktický přístup je fronta pouze pro události s nízkou frekvencí:
- Start-Event (připravit UI, zamknout tlačítka)
- Periodické Progress-Events (max. každých X milisekund)
- Chybové Eventy (volitelně shromažďované)
- Done-Event (resetovat UI, zobrazit výsledek)
Periodiku neřešíte v UI-threadu, ale již v kontextu workeru: necháte worker queuenout UI-aktualizaci pouze tehdy, když od poslední UI-aktualizace uplynula dostatečná doba. K tomu se hodí monotónní zdroj času (např. TickCount) plus atomární hodnota „last update“.
Důležité: samotná UI-aktualizace musí být „rychlá“. Nákladné výpočty, operace se soubory nebo přístupy do databáze nepatří do gequeuovaného UI-callbacku. Callback by měl pouze přečíst stavy a nastavit ovládací prvky.
Cancel-Handling: Zrušení ohne Hänger
V reálných aplikacích není zrušení volitelné. Rozhodující je: Cancel není „Kill“, ale kooperativní ukončení. Worker musí pravidelně kontrolovat, zda je nastaven signál zrušení, a pak čistě ukončit. V Delphi existuje pro to několik cest (v závislosti na PPL-konstrukci): vlastní Volatile-flag, atomární Boolean, nebo koncept Cancellation přes Tasks (v závislosti na verzi a struktuře Delphi).
Pro provoz jsou důležitá dvě pravidla:
- Cancel muss schnell sichtbar werden: Kontrolujte příznak zrušení na rozumných místech, ne jen na konci iterace, pokud iterace může trvat i několik sekund.
- Cancel muss aufräumen: Otevřené handles, dočasné soubory, transakce nebo zámky nesmějí zůstat. To znamená: v každé Worker-iteraci jsou try/finally-bloky povinné, pokud jsou zapojeny zdroje.
Na úrovni UI by měl Cancel pouze nastavit signál a přepnout UI do stavu „Stopping…“. Skutečné ukončení a opětovné aktivování UI pak proběhne v Done-Eventu, nikoli okamžitě po kliknutí.
Vyhnout se deadlockům: nejčastější pasti v praxi
Past 1: WaitFor/Task.Wait v hlavním vlákně
Pokud je hlavní vlákno zablokované, nemůže spouštět Queue-callbacky ani zpracovávat zprávy. Chová se to jako deadlock, i když Worker běží dál správně. Řešení: žádná blokující čekání v UI-threadu. Místo toho dokončovací akci provádějte přes TThread.Queue nebo řízení událostí (např. timer kontroluje „hotovo“).
Past 2: Synchronize uvnitř zámku
Klasika: Worker drží kritickou sekci, pak volá Synchronize, a v UI-callbacku se (přímo nebo nepřímo) znovu požaduje ta samá kritická sekce. Výsledek: kruhové čekání. Pravidlo je jednoduché: Žádné předání UI (Synchronize/Queue) z drženého locku. Pokud je lock nutný, načtěte všechna data nejprve do lokálních proměnných, opusťte lock a teprve potom zařaďte update do queue.
Past 3: UI-Callback spouští reentrancy
Někdy samotná aktualizace UI není „neškodná“: nastavení vlastností může vyvolat události (OnChange, OnResize), které zase spustí logiku přistupující ke stavům Workerů. To není deadlock v užším smyslu, ale vede k těžko vysvětlitelným zaseknutím a race conditions. Řešení: umístit UI-aktualizace do „tichých“ cest (události dočasně deaktivovat) nebo použít reentrancy-guardy (např. atomický guard pro fázi aktualizace).
Past 4: Příliš mnoho queued aktualizací
I bez čekání se UI může „zaseknout“, pokud generujete desetitisíce queued callbacků. Symptomy: ProgressBar dlouho dojíždí, okno reaguje pomalu, CPU hlavního vlákna vysoké. Řešení: omezit (časová okna), agregovat (čítač) nebo použít skutečnou strukturu Producer/Consumer, kde může být maximálně jedno UI-aktualizace „pending“ (coalescing).
Když se to zkomplikuje: shromažďování výsledků, agregace chyb, zaručení pořadí
TParallel.For je ideální, pokud jsou iterace nezávislé. V business softwaru jsou iterace však často jen „do značné míry“ nezávislé: čtete soubory, voláte REST-API, zapisujete řádky do databáze. V tom případě musíte pečlivě naplánovat další tři body:
- Threadově bezpečné sbírání výsledků: Buď pro každý Thread lokální buffer (na konci sloučit), nebo threadsafe Queue/Kolekce. Vyhněte se lockům v hot pathu.
- Ošetření chyb: Výjimky z pracovních vláken musí být zachyceny. V praxi se osvědčilo: zapamatovat si první výjimku a vyvolat zrušení, nebo shromažďovat všechny výjimky a na konci je zobrazit souhrnně.
- Pořadí: Pokud výstup vyžaduje stabilní pořadí (např. logy podle indexu), je paralelní zpracování s následným seřazením často jednodušší než „vláknově bezpečné řazené vkládání“.
Pro UI to znamená: Nezobrazujte každé chybové hlášení okamžitě. To vede k peklu modálních dialogů. Shromažďujte chyby (např. seznam řetězců) a na konci ukažte souhrn nebo exportovatelný log.
Debugging: Jak skutečně odhalit deadlock
Deadlocky v paralelním kódu jsou frustrující, protože v debuggeru mohou vypadat jinak než v release sestavení. Přesto existuje několik praktických postupů:
Využijte okno vláken a zásobníky volání
Pokud UI zamrzne, podívejte se na všechna vlákna: kde stojí hlavní vlákno (Main Thread)? Čeká? Je v message-loopu? Kde jsou pracovní vlákna? Pokud pracovní vlákna visí ve Synchronize, je příčina téměř vždy blokované hlavní vlákno nebo hlavní vlákno potřebuje zámek.
Označte místa Queue/Synchronize
Cíleně umístěte logování na místa předávání (před queue, v callbacku queue, na konci iterace). V provozu je to často cennější než breakpointy, protože na pořadí událostí záleží. Dávejte pozor, aby samotné logování bylo vláknově bezpečné a neblokovalo (např. žádné přímé výstupy do UI logu z pracovních vláken).
Měřte čas poslední aktualizace
Pokud UI „zamrzá“, může to být jednoduše tím, že musí zpracovat příliš mnoho aktualizací. Měřte tedy v hlavním vlákně, kolikrát za sekundu provádíte UI-aktualizaci a jak dlouho trvá. Jakmile UI-callbacky potřebují více než několik milisekund, je nutné omezit frekvenci nebo zjednodušit aktualizace.
Kdy se skutečně vyplatí TParallel.For s Progress-UI?
Paralelizace není účel sama o sobě. Vyplatí se zejména, když:
- iterace jsou dostatečně velké (milisekundy až sekundy), takže režie threadpoolu je zanedbatelná,
- úloha je náročná na CPU (parsování, komprese, hashování) nebo má dobře paralelizovatelné I/O (více souborů, více HTTP požadavků s limity),
- máte jasnou strategii pro zrušení a ošetření chyb,
- požadavky UI se spokojí s agregovaným průběhem (progress).
Méně se vyplatí, pokud je každá iterace extrémně krátká (mikro-operace) nebo pokud všechny iterace narážejí na stejnou úzkou hrdlo (seriální DB transakce, globální zámek, jednotlivý soubor). Rychlejším zásahem je často: zlepšit algoritmus, dávkovat práci, snížit přístup k datům nebo úzké hrdlo explicitně oddělit.
Praktický kontrolní seznam: Jak zachovat stabilitu UI
- Hlavní vlákno není zablokované: žádná čekání (waits), žádné dlouhé smyčky bez message pumpy.
- Pracovní vlákna nikdy nezasahují do ovládacích prvků: žádné VCL/FMX přístupy mimo UI-Thread.
- UI-aktualizace jsou omezené: čítač/timer nebo sloučené (coalesced) queue-aktualizace místo aktualizace po každé iteraci.
- Žádné volání Synchronize z uzamčení (Locks) ven.
- Zrušení (Cancel) je kooperativní, často kontrolované a provádí čisté úklidy.
- Výjimky (Exceptions) jsou shromažďovány a na konci se zpracují uspořádaně.
Závěr: Oddělit pomocí fronty (Queue), stabilizovat agregací
Robustní Progress-UI u TParallel.For nevznikne tím, že se „někde strčí Synchronize“, ale jasným architektonickým principem: workery pracují nezávisle, hlavní vlákno zůstává volné a zpracovává jen několik rychlých UI aktualizací. TThread.Queue je přitom správný nástroj, pokud jej použijete cíleně a omezeně. Za „tupé“ nebo nestabilní chování jsou téměř vždy zodpovědné dvě příčiny: hlavní vlákno někde blokuje – nebo se topí v příliš mnoha zařazených aktualizacích.
Jakmile máte tento vzor dobře nastavený (Counter/Coalescing, Cancel-Flag, Abschluss-Callback), vyplatí se jej použít v mnoha částech rostoucí Delphi-aplikace: Import/Export, kontroly dat, souborové a API úlohy – vše bude reaktivnější, aniž byste si při každé aktualizaci průběhu přivodili nové deadlocky.
Pokud potřebujete podporu při stabilizaci paralelního kódu, při debugování zaseknutí UI nebo při čisté modernizaci existujících Delphi-aplikací: kontaktujte nás.
Pro toto téma jsou také důležité Delphi Parallel Programming Library a Tthread.queue Vs Synchronize. Příspěvek tyto aspekty srozumitelně zařazuje a ukazuje, na čem to v každodenní praxi záleží.
další krok
Když se z tématu stane reálný projekt, měly by být architektura, stávající systém a provoz posuzovány společně již v rané fázi.
Podporujeme nejen při jednotlivých otázkách, ale i v případě, že se z útržků zdrojového kódu, legacy témat nebo nápadů na portál má vyvinout robustní podnikový projekt.
- Současný stav, cílový stav a technická rizika jsou hodnoceny společně.
- REST, přístup k datům, portály a rollout nebudou přesunuty do pozdějších fází.
- Včas zjistíte, která varianta je ekonomicky i provozně životaschopná.