A magazintémától a projektgyakorlatig
A bejegyzéshez tartozó szolgáltatási és technikai oldalak
Akinek a Delphi-ben számításigényes vagy I/O-terhelt feladatokat kell párhuzamosítania, gyorsan a Parallel Programming Library (PPL) és konkrétan a TParallel.For mellett találja magát. Az eredmény gyakran azonnal mérhető – egészen addig, amíg ugyanabban a lélegzetben egy Progress-UI-t „csak gyorsan” frissíteni szeretnénk. Pont itt keletkeznek a tipikus akadozások: látszólag véletlenszerű UI-fagyások, egy ProgressBar, amely visszalép, vagy komplett deadlock, amint a debuggerben lépésenként haladunk.
Ez a bejegyzés a TParallel.For threadsichere Progress-UI-ról szól: egy robusztus minta, amely VCL és FMX alatt működik, a UI-frissítéseket TThread.Queue segítségével tisztán egyesíti, figyelembe veszi a Cancel/Abort eseteket, és következetesen elkerüli a leggyakoribb deadlock-csapdákat. A fókusz az üzemi valóságon van: reprodukálható viselkedés, érthető felelősségek és hibakeresési tanácsok, amelyek akkor is segítenek, ha a hiba csak „ügyfélnél” jelentkezik.
Miért siklik el olyan gyakran a Progress-UI TParallel.For esetén
TParallel.For tipikusan a Delphi-threadpoolból származó worker-threadeken fut. Ezek a threadek nem nyúlhatnak közvetlenül VCL- vagy FMX-vezérlőkhöz, mert a UI-keretrendszerek (Message Loop, Window Handles, Rendering) a Main Thread-hez kötöttek. Már egy látszólag ártalmatlan ProgressBar.Position := … egy workerből nem meghatározott viselkedéshez vezethet: sporadikus AVs, lefagyott ablakok vagy „villódzó” frissítések.
A kézenfekvő javítás gyakran a TThread.Synchronize. Ez bár megoldja a thread-biztonságot, párhuzamos ciklusokban gyorsan egy másik problémát idéz elő: soros szűk keresztmetszetet hoz létre. Minden worker a UI-threadre vár, amely viszont a renderelésre és a Synchronize-hívások feldolgozására van lekötve. Terhelés alatt ez deadlocknak tűnhet – még ha valójában csak egy starvation/lockstep effektus is.
Van aztán a valódi deadlock-osztály: a Main Thread (például WaitFor, Task.Wait vagy indirekt, blokkoló hívások révén) a párhuzamos művelet befejezésére vár, miközben a worker-threadek Synchronize vagy nem szerencsés Queue-használat révén próbálnak munkát bedobni a Main Thread-be. Eredmény: a Main Thread a workerre vár, a worker pedig a Main Thread-re.
TThread.Queue vs. TThread.Synchronize: a gyakorlati különbség
Mindkét mechanizmus arra szolgál, hogy a kód biztonságosan a Main Thread-en fusson. A különbség a várakozási szemantikában rejlik:
- TThread.Synchronize: a hívó worker vár, amíg a Main Thread végrehajtja a kódot. Ez „szinkron”, növeli a késleltetést, és klasszikus összetevője a deadlockoknak, ha a Main Thread épp blokkolva van.
- TThread.Queue: A Worker a kódot csak a Main Thread számára egy sorba helyezi, majd folytatja a futást. Ez „asynchron”, leválasztja a szálakat, és párhuzamos forgatókönyvekben szinte mindig jobb alapértelmezett választás – feltéve, hogy kontrolláljuk a frissítések gyakoriságát.
Fontos: Queue nem ad felmentést. Ha egy ciklus minden iterációjában küld egy Queue-frissítést, túlterheli a Main-Thread-Queue-t. Ilyenkor a UI nem deadlock miatt akad meg, hanem a parancsok puszta mennyisége miatt. A felület „záslatosnak”/„lassúnak” tűnik, és a feldolgozás vége késik, mert még százak vagy ezrek UI-frissítések futnak le késleltetve.
A fájdalmas szélső eset: várakozás az UI-szálon
Vállalati alkalmazásokban gyakran a következő folyamatot látjuk: rákattintanak a „Start” gombra, az UI deaktiválódik, megjelenik egy ProgressDialog, majd szinkron módon „várakoznak”, amíg minden kész nem lesz, és csak ezután aktiválják újra. Ez a minta sok deadlock magja.
Típusos variánsok (kódbázistól függően):
- A Main Thread elindít egy TParallel.For-t, majd egy blokkoló várakozó logikát hív meg utána (közvetlenül vagy közvetve).
- Egy ProgressDialog a Constructor-ban vagy az OnShow-ban meghív egy rutint, amely belső várakozást végez.
- Egy Mégse-gomb ugyan beállít egy flag-et, de a Main Thread mégis egy várakozó ciklusban ragad.
Ha a Worker-Threads ezalatt Synchronize-t használnak, a deadlock gyakorlatilag garantált. A Queue is okozhat akadozást, ha a Main Thread blokkolt és nem pumpál üzeneteket – ilyenkor a Queue sem kerül feldolgozásra.
A gyakorlati következtetés: A Main Thread nem várakozhat blokkoló módon a párhuzamos ciklusra, ha párhuzamos UI-frissítésekre van szükség. Ehelyett a feldolgozást vagy teljesen át kell helyezni egy háttérfeladatra, vagy szervezni kell egy „aszinkron befejezést” (callback/Queued befejező művelet), amely a végén visszaadja az UI irányítását.
Tisztább megközelítés: a Progress csak aggregált, az UI-frissítések korlátozottak
Egy robusztus minta három, világosan elkülönített felelősségre épül:
- Worker-Threads végzik az egyes elemen/indexen a tényleges munkát. Csak threadsafe módon jelentik a haladást (számláló, Queue, thread-safe Queue).
- Aggregator (gyakran: a Main Thread vagy egy dedikált timer az UI-ban) kiszámítja a haladásból az UI-státuszt (pozíció, szöveg, ETA) és frissíti a vezérlőket. Így elkerülhető az 1:1 frissítés iterációnként.
- Befejezés (szintén a Main Thread-en): az UI újraaktiválása, az eredmény megjelenítése, a hibák összefoglalása és az erőforrások felszabadítása.
Miért működik ez a szétválasztás jól: a munkaterhelés magas frekvenciájú lehet (több ezer elem), de a felhasználói felületnek csak néhány frissítésre van szüksége másodpercenként. A gyakorlatban 5–10 frissítés/mp elegendő, nagyon gyors feladatoknál akár 2–4 is. Minden e fölött általában csak vizuális zaj, és CPU-időt emészt fel a Message Pump-ban.
Szálbiztos számlálás: atomikus műveletek a zárolás helyett
Egy egyszerű ProgressBar-hoz gyakran elegendő egy atomikus számláló. „Atomikus” azt jelenti: az inkrement és az olvasás Race Condition nélkül történik, tipikusan TInterlocked használatával. Így elkerülheti a lockokat (Critical Sections) a ciklus hot path-jén.
Bevált minimálmegoldás:
- A teljes mennyiség előre ismert (pl. rekordok száma, fájlok, azonosítók).
- Minden iteráció atomikusan növeli a DoneCounter-t.
- Egy UI-timer periódikusan olvassa a számlálót és beállítja a ProgressBar.Position-t.
Előny: nincs TThread.Queue elemenként, nincs UI-túlterhelés. Hátrány: nincs részletes értesítés elemenként (pl. fájlnév). Ezt pótolhatja egy második, ritkított státuszüzenettel (lásd következő szakasz).
Státuszüzenetek spam nélkül: „az utolsó státusz érvényesül”
Ha emellett rövid szöveget is meg akar jeleníteni (aktuális elem, fázis, hibaüzenet), szintén szükség van egy mintára, amely nem árasztja el a UI-t minden worker-lépésnél. A gyakorlatban a „az utolsó státusz érvényesül” megközelítés jól működik:
- A worker egy státuszinformációt ír egy szálbiztos struktúrába (pl. atomikusan cserélhető string, vagy kis zárral védett).
- Egy UI-timer periódikusan átveszi az utoljára látott státuszt egy Label-be.
Így a UI reagálóképessége megmarad, és mégis látható, hogy „valami történik”. Itt kevésbé maga a string a fontos, mint az élettartam: ne vigyen át hivatkozásokat rövid életű objektumokra a worker-szálakról a UI-szálra. Ha objektumokat ad át, tisztázza a tulajdonjogot egyértelműen.
TParallel.For szálbiztos Progress-UI TThread.Queue-val: egy robusztus minta
Vannak olyan forgatókönyvek, amikor egy UI-timer önmagában nem elegendő: például ha a végén biztosan pontosan egy „Fertig”-frissítést szeretne kiváltani, vagy ha az UI-frissítés összetettebb lépés (pl. bejegyzés egy log-ablakba, de ritkítva). Ilyenkor a TThread.Queue megfelelő – de nem iterációnként, hanem célzottan.
Egy gyakorlatias megközelítés egy csak alacsony frekvenciájú eseményekre szánt Queue:
- Start-Event (UI előkészítése, gombok letiltása)
- Periodikus Progress-Events (max. minden X milliszekundumban)
- Fehler-Events (opcionálisan gyűjtve)
- Done-Event (UI visszaállítása, eredmény megjelenítése)
A periodikusságot nem a UI-szálon, hanem már a worker-környezetben éri el: úgy engedi a workert, hogy csak akkor queue-olja az UI-frissítést, ha az utolsó UI-frissítés óta elegendő idő eltelt. Ehhez alkalmas egy monotón időforrás (pl. TickCount) plusz egy atomikus „last update”-érték.
Fontos: maga az UI-frissítés gyorsnak kell lennie. Drága számítások, fájl-I/O vagy adatbázis-hozzáférések nem tartoznak a queu-olt UI-visszahívásba. A visszahívásnak csak állapotokat kell kiolvasnia és vezérlőket beállítania.
Cancel-Handling: megszakítás lefagyás nélkül
Valódi alkalmazásokban a megszakítás nem opcionális. Fontos: a Cancel nem „Kill”, hanem egy kooperatív leállítás. A workereknek rendszeresen ellenőrizniük kell, hogy be van-e állítva egy megszakítási jel, és akkor tisztán ki kell lépniük. A Delphi-ben több mód is van erre (a PPL-konstrukciótól függően): egy saját Volatile-flag, egy atomikus Boolean, vagy egy Cancellation-koncepció Tasks-on keresztül (a Delphi verziójától és struktúrájától függően).
A működtetés szempontjából két szabály fontos:
- A Mégse gombnak gyorsan láthatónak kell lennie: Ellenőrizze a megszakítási jelzőt ésszerű pontokon, ne csak az iteráció végén, ha maga az iteráció akár másodpercekig is tarthat.
- A Mégse-nek takarítania kell: Nyitott handle-ek, ideiglenes fájlok, tranzakciók vagy zárolások nem maradhatnak hátra. Tehát: minden Worker-iterációban kötelező a try/finally-blokk, ha erőforrások érintettek.
UI-oldalról a Mégse csak jelet adjon, és a felhasználói felületet egy „Leállítás…“ állapotba tegye. A tényleges befejezés és a UI újraaktiválása a Done-Event-ben történjen, ne rögtön a kattintáskor.
Holtpontok elkerülése: a gyakorlat leggyakoribb csapdái
Csapda 1: WaitFor/Task.Wait a fő szálon
Ha a fő szál blokkolódik, nem tud Queue-callbackokat végrehajtani és nem tud üzeneteket feldolgozni. Ez holtpontnak tűnik, még akkor is, ha a Worker-ek rendben futnak tovább. Megoldás: Ne használjon blokkoló Wait-eket a UI-szálon. Ehelyett a befejező műveletet végezze TThread.Queue-vel vagy eseményvezérléssel (pl. egy timer ellenőrzi, hogy „kész“).
Csapda 2: Synchronize egy zárolás (Lock) belsejében
Egy klasszikus eset: a Worker tart egy kritikus szakaszt, majd meghívja a Synchronize-ot, és az UI-callbackban (közvetlenül vagy közvetve) ismét ugyanarra a kritikus szakaszra van szükség. Eredmény: körkörös várakozás. A szabály egyszerű: Ne adjon át UI-műveletet (Synchronize/Queue) egy megtartott lockból. Ha zárolás szükséges, olvassa ki az összes adatot lokális változókba, lépjen ki a lockból, majd küldje a queue-ba.
Csapda 3: Az UI-callback újrabelépést (Reentrancy) vált ki
Az UI-frissítés maga sem mindig „ártalmatlan“: Property-k beállítása eseményeket válthat ki (OnChange, OnResize), amelyek viszont logikát indítanak, ami a Worker-állapotokra támaszkodik. Ez nem holtpont szigorúan véve, de nehezen magyarázható fagyásokhoz és versenyhelyzetekhez vezet. Megoldás: helyezze az UI-frissítéseket „csendes“ utakra (események ideiglenes letiltása), vagy használjon újrabelépés elleni őröket (pl. atomikus őr az update-fázishoz).
Csapda 4: Túl sok sorba állított (queued) frissítés
Még Wait-ek nélkül is megállhat a UI, ha tízezrével hoz létre queued callbackeket. Tünetek: a ProgressBar hosszasan utólag fut, az ablak lassan reagál, a fő szál CPU-terhelése magas. Megoldás: csillapítás (időablak), aggregálás (számláló), vagy valódi termelő/fogyasztó-struktúra, ahol csak egy UI-frissítés lehet „pending“ (összevonás).
Ha bonyolultabbá válik: eredmények gyűjtése, hibák összegzése, sorrendek garantálása
TParallel.For ideális, ha az iterációk függetlenek. Üzleti szoftverben az iterációk azonban gyakran csak „nagyjából“ függetlenek: fájlokat olvasnak, hívnak REST-APIs-t, adatbázis-sorokat írnak. Ilyenkor három további pontot kell alaposan megtervezni:
- Szálbiztos eredménygyűjtés: Vagy thread-onként egy lokális puffer (a végén egyesítve), vagy egy szálbiztos Queue/Collection. Kerülje a zárolásokat a hot pathon.
- Hibakezelés: A worker-szálakból származó kivételeket össze kell gyűjteni. A gyakorlatban beválik: az első kivétel megjegyzése és a Cancel kiváltása, vagy minden kivételt összegyűjteni és a végén egyben megjeleníteni.
- Sorrend: Ha a kimenet stabil sorrendet igényel (pl. logok index szerinti sorrendje), a párhuzamos feldolgozás utólagos rendezéssel gyakran egyszerűbb, mint a „szálbiztos rendezett beszúrás”.
A UI számára ez azt jelenti: Ne jelenítsen meg minden egyes hibaüzenetet azonnal. Ez modal párbeszédablak-pokolhoz vezet. Gyűjtse össze a hibákat (pl. stringek listája), és a végén mutasson összefoglalót vagy egy exportálható naplót.
Debugging: Hogyan tegye a holtpontot valóban láthatóvá
A párhuzamos kódban előforduló holtpontok frusztrálóak, mert a hibakeresőben máshogy nézhetnek ki, mint a release buildben. Mégis vannak néhány nagyon praktikus eszköz:
Szálak-ablak és hívásveremek használata
Ha a UI lefagy, nézze meg az összes szálat: Hol áll a főszál? Várakozik? Üzenetciklusban van? Hol tartózkodnak a worker-szálak? Ha a worker-ek a Synchronize-ben akadoznak, az oka szinte mindig az, hogy a főszál blokkolt vagy a főszálnak zárolásra van szüksége.
Queue-/Synchronize-helyek megjelölése
Helyezzen célzott naplózást az átadási pontokra (a Queue előtt, a Queue-callbackben, az iteráció végén). Üzem közben ez gyakran többet ér, mint töréspontok, mert az időzítés döntő. Ügyeljen arra, hogy a naplózás maga is szálbiztos és nem blokkoló legyen (pl. ne jelenítsen meg UI-naplózást közvetlenül worker-szálakból).
Utolsó frissítés idejének mérése
Ha a UI „lefagy”, egyszerűen előfordulhat, hogy túl sok frissítést kell feldolgoznia. Mérje ezért a főszálon, hogy másodpercenként hányszor hajt végre UI-frissítést és mennyi ideig tart. Amint az UI-callbackek több mint néhány milliszekundumot igényelnek, szükséges a visszafogás vagy egyszerűsítés.
Mikor éri meg valóban a TParallel.For Progress-UI-val?
A párhuzamosítás nem öncélú. Különösen megéri, ha:
- az iterációk elég nagyok (milliszekundumoktól másodpercekig), így a threadpool-overhead elenyészik,
- a feladat CPU-intenzív (parszolás, tömörítés, hashing) vagy jól párhuzamosítható I/O-val rendelkezik (több fájl, több HTTP-kérés limitálással),
- van egy egyértelmű Cancel- és hibastratégia,
- a UI-követelmények beérik aggregált előrehaladással.
Kevésbé éri meg, ha minden iteráció rendkívül rövid (mikroműveletek), vagy ha az összes iteráció ugyanarra a szűk keresztmetszetre fut (soros DB-tranzakció, globális zárolás, egyetlen fájl). Ilyenkor a gyorsabb megoldás gyakran: algoritmus javítása, batchelés, adathozzáférés csökkentése vagy a szűk keresztmetszet explicit elkülönítése.
Gyakorlati ellenőrzőlista: így marad stabil a UI
- A főszál ne legyen blokkolva: ne legyenek várakozások, ne legyenek hosszú ciklusok üzenetpumpa nélkül.
- A worker-ek soha ne érintsenek Controls-okat: ne legyen VCL/FMX-hozzáférés a UI-szálon kívül.
- A UI-frissítések korlátozottak: számláló/timer vagy összevont Queue-frissítések per iteráció helyett.
- Ne legyenek Synchronize-hívások lockok belsejéből.
- A Cancel kooperatív, gyakran ellenőrzött és gondosan takarít utána.
- A kivételeket összegyűjtik és a végén rendezett módon kezelik.
Fazit: Queue-val elkülöníteni, aggregációval stabilizálni
Egy robusztus Progress-UI a TParallel.For esetén nem attól jön létre, hogy „valahova Synchronize-t beleteszünk”, hanem egy világos architekturális elv: a worker-szálak függetlenül dolgoznak, a főszál szabad marad és csak néhány, gyors UI-frissítést dolgoz fel. TThread.Queue ebben az esetben a megfelelő eszköz, ha célzottan és szabályozottan használja. „Lassú” vagy instabil viselkedésnél szinte mindig két ok felelős: a főszál valahol blokkolva vár – vagy elsüllyed túl sok sorba állított frissítésben.
Ha ezt a mintát egyszer rendesen felépítette (Counter/Coalescing, Cancel-Flag, Abschluss-Callback), megéri sok helyen egy meglévő Delphi-alkalmazásban alkalmazni: Import/Export, adatellenőrzések, fájl- és API-feladatok – minden reaktívabbá válik anélkül, hogy minden egyes Progress-Update-nél új deadlockokat szerezne.
Ha támogatásra van szüksége a párhuzamos kód stabilizálásához, a UI-akadások hibakereséséhez vagy a meglévő Delphi-alkalmazások tiszta modernizálásához: vegye fel a kapcsolatot.
Ehhez a témához kapcsolódóan fontosak a Delphi Parallel Programming Library és a Tthread.queue Vs Synchronize. A bejegyzés érthetően rendezi ezeket a szempontokat és bemutatja, mire kell figyelni a gyakorlatban.
Projekt vagy modernizációs terv megvitatása Net-Base kapcsán.
Következő lépés
Ha a téma valós projektté válik, az architektúrát, a meglévő rendszert és az üzemeltetést már korán együtt kell értékelni.
Nemcsak egyedi kérdésekben támogatunk, hanem akkor is, amikor forráskódrészletekből, örökölt rendszerekkel kapcsolatos témákból vagy portálötletekből robusztus vállalati projektet kell kialakítani.
- A jelenlegi állapotot, a célállapotot és a műszaki kockázatokat együttesen értékeljük.
- REST, az adathozzáférés, a portálok és a Rollout nem kerülnek utólagos teendőkként elhalasztásra.
- Már korán láthatja, melyik út gazdaságilag és üzemeltetési szempontból életképes.