Od teme v reviji do projektne prakse
Ustrezne strani storitev in tehnični opisi k prispevku
Kdor želi v Delphi vzporedno izvesti računsko intenzivne ali I/O obremenjene opravke, hitro pride do Parallel Programming Library (PPL) in konkretno do TParallel.For. Učinek je pogosto takoj opazen – dokler ob istem dihu ne poskušate »na hitro« posodobiti Progress-UI. Tu se pojavijo tipični zastoje: navidez naključna zmrzovanja vmesnika, ProgressBar, ki preskoči nazaj, ali popoln deadlock, ko v razhroščevalniku izvajate korak-po-korak.
V tem prispevku gre za TParallel.For nitno varna Progress-UI: robusten vzorec, ki deluje v VCL in FMX, kjer se posodobitve UI preko TThread.Queue čisto združijo, upoštevajo Cancel/Abort in dosledno obidejo najpogostejše pasti deadlockov. Fokus je na operativni realnosti: reproducirno vedenje, jasne odgovornosti in napotki za razhroščevanje, ki pomagajo tudi, ko se napaka pojavi »samo pri naročnikih«.
Zakaj Progress-UI pri TParallel.For tako pogosto odpove
TParallel.For tipično teče na delovnih nitih iz Delphi-threadpoola. Te niti ne smejo neposredno posegati v VCL- ali FMX-kontrolnike, ker sta UI ogrodji (Message Loop, Window Handles, Rendering) vezana na glavno nit. Tudi navidez nedolžen ProgressBar.Position := … iz delovne niti lahko vodi do nedoločenega vedenja: občasne AV-je, zamrznjena okna ali »utripajoče« posodobitve.
Očitna popravka je pogosto TThread.Synchronize. To sicer odpravi težave s thread-safety, vendar v vzporednih zankah hitro povzroči drugo težavo: serijski ozki grlo. Vsak worker čaka na glavno nit, ki je hkrati zasedena z renderiranjem in obdelavo klicev Synchronize. Pod obremenitvijo to izgleda kot deadlock – tudi če gre zgolj za starvation/lockstep-učinek.
In potem je tu še resnična kategorija deadlockov: glavna nit čaka (npr. z WaitFor, Task.Wait ali posredno prek blokirajočih klicev) na konec vzporedne operacije, medtem ko delovne niti poskušajo z Synchronize ali neugodnim uporabo Queue poslati delo v glavno nit. Rezultat: glavna nit čaka na delovne niti, delovne niti pa čakajo na glavno nit.
TThread.Queue vs. TThread.Synchronize: der praktische Unterschied
Oba mehanizma služita izvajanju kode varno v glavni niti. Razlika je v semantiki čakanja:
- TThread.Synchronize: Kličeči worker čaka, dokler glavna nit ne izvede kode. To je »sinhrono«, poveča latence in je klasična sestavina deadlockov, če je glavna nit trenutno blokirana.
Pomembno: Queue ni dovoljenje brez omejitev. Če v vsaki iteraciji zanke pošljete posodobitev v Queue, boste preplavili Main-Thread-Queue. UI se v tem primeru sicer ne zatakne zaradi deadlocka, temveč zaradi preproste količine sporočil. UI deluje „počasi“, in končanje obdelave se zamakne, ker še sledijo stotine ali tisoče UI-posodobitev.
Mejni primer, ki res boli: čakanje v UI-Thread
V poslovnih aplikacijah pogosto vidimo naslednji potek: klik na gumb „Start“, UI se onemogoči, ProgressDialog se odpre, nato se sinhrono „čaka“, dokler ni vse končano, in šele nato se UI ponovno vključi. Ta vzorec je jedro številnih deadlockov.
Tipične variante (odvisno od kode):
- Main Thread zažene TParallel.For in nato pokliče blokirno čakalno logiko (direktno ali posredno).
- ProgressDialog v konstruktorju ali OnShow pokliče rutino, ki znotraj čaka.
- Gumb za preklic (Abbrechen-Button) sicer nastavi zastavico, a Main Thread vseeno ostane ujet v čakalni zanki.
Če Worker-Threads v tem času uporabljajo Synchronize, je deadlock praktično zagotovljen. Tudi z Queue se lahko zatakne, če je Main Thread blokiran in ne obdeluje sporočil – saj se potem tudi Queue ne izprazni.
Operativni zaključek je: Main Thread ne sme blokirno čakati na paralelno zanko, če so hkrati potrebne UI-posodobitve. Namesto tega mora biti obdelava bodisi v celoti preseljena v ozadni Task, bodisi se organizira „asinkron konec“ (Callback/Queued zaključna akcija), ki na koncu ponovno sprosti UI.
Čistejši pristop: napredek le agregiran, UI-posodobitve omejene
Robusten vzorec sestavljajo tri jasno ločene odgovornosti:
- Worker-Threads opravijo dejansko delo za vsak element/indeks. Poročajo le napredek v niti-varni obliki (število, Queue, thread-safe Queue).
- Aggregator (pogosto: Main Thread ali namenski časovnik v UI) iz napredka izračuna UI-stanje (pozicija, besedilo, ETA) in posodobi kontrolnike. Tako se izognete 1:1-posodobitvam za vsako iteracijo.
- Zaključek (prav tako v Main Thread): ponovno aktivirati UI, prikazati rezultat, povzeti napake, sprostiti vire.
Zakaj ta ločitev tako dobro deluje: delovna obremenitev je lahko visoke frekvence (tisoči elementov), vendar UI potrebuje le nekaj posodobitev na sekundo. V praksi zadostuje 5–10 posodobitev/s, pri zelo hitrih opravilih celo 2–4. Vse nad tem je večinoma le optični šum in porablja CPU-čas v Message Pump.
Nitno varno štetje: atomsko namesto zaklepa
Za enostaven ProgressBar pogosto zadostuje atomski števec. »Atomsko« pomeni: inkrement in branje potekata brez race condition, tipično preko TInterlocked. Tako se izognete zaklepom (Critical Sections) na hot path zanke.
Preizkušena minimalna ideja:
- Skupna količina je znana vnaprej (npr. število zapisov, datotek, ID-jev).
- Vsaka iteracija atomarno poveča DoneCounter.
- UI-Timer periodično prebere števec in nastavi ProgressBar.Position.
Prednost: Ni TThread.Queue na element, ni preplavitve UI. Slabost: nimate podrobnih sporočil za posamezni element (npr. ime datoteke). Zato lahko dodate drugo, omejeno statusno sporočilo (glej naslednji razdelek).
Statusna sporočila brez spama: »zadnje stanje zmaga«
Če želite dodatno prikazati kratek tekst (trenutni element, faza, sporočilo o napaki), potrebujete vzorec, ki ne poplavlja UI ob vsakem koraku workerja. V praksi zelo dobro deluje pristop »zadnje stanje zmaga«:
- Worker zapiše statusno informacijo v nitno varno strukturo (npr. atomsko zamenljiv string ali zaščiten z majhnim lock-om).
- UI-Timer periodično prenese nazadnje zabeleženo stanje v label.
S tem ostane UI odzivna in kljub temu vidite, da »nekaj se dogaja«. Pomembnejša je tu manj vsebina niza kot življenjska doba: ne prenašajte referenc na kratkotrajne objekte iz worker-nitk v UI-nit. Če prenašate objekte, izrecno uredite lastništvo.
TParallel.For nitno varna Progress-UI z TThread.Queue: robusten vzorec
Obstajajo scenariji, kjer UI-Timer sam po sebi ni dovolj: npr. ko želite na koncu zagotoviti natanko en »Fertig«-update, ali ko je UI-posodobitev bolj kompleksen korak (npr. zapis v log-okno, vendar omejen). V takih primerih je TThread.Queue primeren – vendar ne za vsako iteracijo, temveč selektivno.
Praktičen pristop je vrsta (Queue) samo za dogodke z nizko frekvenco:
- Start-Event (priprava UI, onemogočitev gumbov)
- Periodični Progress-Events (max. enkrat na X millisekunden)
- Fehler-Events (opcijsko zbrani)
- Done-Event (ponastavitev UI, prikaz rezultata)
Periodičnost ne upravljate iz UI-nitke, temveč že v kontekstu workerja: dovolite workerjem, da le takrat queuenajo UI-posodobitev, ko je od zadnje UI-posodobitve preteklo dovolj časa. Za to je primeren monotoni vir časa (npr. TickCount) plus atomska vrednost »last update«.
Pomembno: sama UI-posodobitev mora biti »hitro«. Dragi izračuni, datotečni I/O ali dostopi do baze podatkov ne sodijo v gequeuean UI-callback. Callback naj le prebere stanja in nastavi kontrole.
Cancel-Handling: Preklic brez zastojev
V resničnih aplikacijah preklic ni opcija. Ključno je: Cancel ni »Kill«, temveč eno kooperativno zaključevanje. Workerji morajo redno preverjati, ali je nastavljeno signal za prekinitev, in se nato čisto izstopiti. V Delphi za to obstaja več načinov (odvisno od PPL-konstrukcije): lasten Volatile-Flag, atomski Boolean ali koncept Cancellation preko Tasks (odvisno od Delphi-verzije in strukture).
Za obratovanje sta pomembni dve pravili:
- Cancel muss schnell sichtbar werden: Preverjajte zastavico preklica na smiselnih mestih, ne samo na koncu iteracije, če iteracija lahko traja tudi več sekund.
- Cancel muss aufräumen: Odprti handles, začasne datoteke, transakcije ali zaklepi ne smejo ostati. To pomeni: v vsaki Worker-iteraciji so try/finally-bloki obvezni, kadar so vključeni viri.
UI-seitig sollte Cancel nur ein Signal setzen und die UI in einen „Stopping…“-Zustand bringen. Die eigentliche Beendigung und das Reaktivieren der UI passieren dann im Done-Event, nicht sofort beim Klick.
Deadlocks vermeiden: die häufigsten Fallen in der Praxis
Falle 1: WaitFor/Task.Wait im Main Thread
Wenn der Main Thread blockiert, kann er keine Queue-Callbacks ausführen und keine Messages verarbeiten. Das wirkt wie ein Deadlock, auch wenn die Worker korrekt weiterlaufen. Lösung: Keine blockierenden Waits im UI-Thread. Stattdessen Abschlussaktion über TThread.Queue oder eine Ereignissteuerung (z. B. Timer prüft „fertig“).
Falle 2: Synchronize innerhalb eines Locks
Ein Klassiker: Worker hält eine Critical Section, ruft dann Synchronize, und im UI-Callback wird (direkt oder indirekt) wieder dieselbe Critical Section benötigt. Ergebnis: Kreis warten. Die Regel ist simpel: Keine UI-Übergabe (Synchronize/Queue) aus einem gehaltenen Lock heraus. Wenn ein Lock nötig ist, holen Sie alle Daten erst in lokale Variablen, verlassen den Lock, dann queue’n Sie.
Falle 3: UI-Callback triggert Reentrancy
Manchmal ist das UI-Update selbst nicht „harmlos“: Setzen von Properties kann Events auslösen (OnChange, OnResize), die wiederum Logik anwerfen, die auf Worker-Zustände zugreift. Das ist kein Deadlock im engeren Sinne, aber führt zu schwer erklärbaren Hängern und Race Conditions. Abhilfe: UI-Updates in „stille“ Pfade legen (Events temporär deaktivieren) oder Reentrancy-Guards nutzen (z. B. atomarer Guard für Update-Phase).
Falle 4: Zu viele queued Updates
Auch ohne Waits kann die UI „stehen“, wenn Sie zehntausende queued Callbacks erzeugen. Symptome: ProgressBar rennt lange nach, Fenster reagiert träge, CPU im Main Thread hoch. Lösung: Drosseln (Zeitfenster), aggregieren (Counter), oder eine echte Producer/Consumer-Struktur, bei der nur ein UI-Update „pending“ sein kann (Coalescing).
Wenn es komplizierter wird: Ergebnisse sammeln, Fehler bündeln, Reihenfolgen garantieren
TParallel.For ist ideal, wenn Iterationen unabhängig sind. In Business-Software sind Iterationen aber oft nur „weitgehend“ unabhängig: Sie lesen Dateien, rufen REST-APIs, schreiben Datenbankzeilen. Dann müssen Sie drei zusätzliche Punkte sauber planen:
- Thread-sichere Ergebnis-Sammlung: Entweder pro Thread ein lokaler Buffer (am Ende zusammenführen) oder eine threadsichere Queue/Collection. Locks im Hot Path vermeiden.
- Ravnanje z napakami: Exceptions iz Worker-Threads je treba zbrati. V praksi se izkaže: zapomnite si prvo Exception in sprožite Cancel, ali pa zberite vse Exceptions in jih na koncu združeno prikažite.
- Vrstni red: Če izhod zahteva stabilen vrstni red (npr. logi po indeksu), je paralelna obdelava z naknadnim sortiranjem pogosto enostavnejša kot »nitim varno urejeno vstavljanje v vrstnem redu«.
Za die UI to pomeni: Ne prikazujte vsake posamezne napake takoj. To vodi v pekel modalnih dialogov. Zberite napake (npr. seznam nizov) in na koncu prikažite povzetek ali izvozljiv log.
Debugging: Wie Sie den Deadlock wirklich sichtbar machen
Deadlocki v paralelnem kodu so frustrirajoči, ker se v debuggerju lahko pojavijo drugače kot v release. Kljub temu obstaja nekaj zelo praktičnih vzvodov:
Uporaba okna niti in Call Stacks
Če se UI zatakne, si oglejte vse niti: kje stoji Main Thread? Čaka? Je v Message-Loop? Kje so Worker-Threads? Če se Worker zataknejo v Synchronize, je vzrok skoraj vedno, da je Main Thread blokiran ali da Main Thread potrebuje zaklep.
Označevanje mest za Queue/Synchronize
Namestite ciljno beleženje na točke predaje (pred Queue, v Queue-callbacku, na koncu iteracije). V obratovanju je to pogosto bolj koristno kot breakpoints, ker je časovno usklajevanje odločilno. Pazite, da je samo beleženje varno za niti in ne blokira (npr. ne izvajajte izpisa logov v UI neposredno iz Worker-Threads).
Merjenje časa zadnje posodobitve
Če se UI »zatakne«, je lahko preprosto zato, ker mora obdelati preveč posodobitev. Zato v Main Thread izmerite, kolikokrat na sekundo izvajate UI-posodobitev in koliko časa traja. Ko UI-callbacki potrebujejo več kot nekaj milisekund, je potrebna omejitev ali poenostavitev.
Kdaj se TParallel.For z Progress-UI res splača?
Paralelizacija ni cilj sama po sebi. Splača se zlasti, če:
- iteracije so dovolj velike (milisekunde do sekund), tako da se overhead Threadpoola zanemari,
- naloga je CPU-intenzivna (Parsing, Kompression, Hashing) ali ima I/O, ki se dobro paralelizira (več datotek, več HTTP-Requests z omejitvami),
- imate jasno strategijo za Cancel in napake,
- zahteve UI prenesejo agregiran napredek.
Manj se splača, če je vsaka iteracija ekstremno kratka (mikro-operacije) ali če vse iteracije tečejo preko istega ozkega grla (serialna DB-transakcija, globalni zaklep, ena sama datoteka). V takih primerih je hitrejši ukrep pogosto: izboljšati algoritem, izvajati batchanje, zmanjšati dostop do podatkov ali ozko grlo eksplicitno odklopiti.
Praktični kontrolni seznam: tako ostane die UI stabil
- Main Thread ni blokiran: brez Waits, brez dolgih zank brez Message Pump.
- Worker se nikoli ne dotikajo kontrol: brez VCL/FMX-dostopov izven UI-Threada.
- UI-posodobitve so omejene: Counter/Timer ali združene (coalesced) Queue-posodobitve namesto na iteracijo.
- Ni klicev Synchronize iz znotraj zaklepov.
- Cancel je kooperativen, se pogosto preverja in izvede pravilno čiščenje.
- Exceptions se zbirajo in se ob koncu urejeno obravnavajo.
Fazit: Mit Queue entkoppeln, mit Aggregation stabilisieren
Robustna Progress-UI pri TParallel.For ne nastane z „nekje vstavljeno Synchronize“, temveč z jasnim arhitekturnim načelom: delovne niti delujejo neodvisno, glavna nit ostane prosta in obdeluje le nekaj hitrih posodobitev uporabniškega vmesnika. TThread.Queue je pri tem pravo orodje, če ga uporabljate ciljno in omejeno. Za „počasen“ ali nestabilen odziv sta skoraj vedno odgovorna dve vzroka: glavna nit nekje blokirajoče čaka – ali pa se utopi v prevelikem številu v čakalni vrsti nameščenih posodobitev.
Ko imate vzorec enkrat pravilno vzpostavljen (Counter/Coalescing, Cancel-Flag, Abschluss-Callback), se izplača uporabiti ga na mnogih mestih v obstoječi Delphi-aplikaciji: uvoz/izvoz, preverjanja podatkov, datotečna in API opravila – vse postane bolj odzivno, brez da bi si pri vsaki posodobitvi napredka pridelali nove deadlocke.
Če potrebujete podporo pri stabilizaciji paralelne kode, pri odpravljanju zastojev v UI ali pri urejeni modernizaciji obstoječih Delphi-aplikacij: Kontaktirajte nas.
Za to temo so pomembne tudi Delphi Parallel Programming Library in Tthread.queue Vs Synchronize. Prispevek te vidike jasno umešča in pokaže, na kaj je v praksi treba paziti.
Razpravljajte o projektu ali modernizacijskem načrtu z Net-Base.
naslednji korak
Ko iz teme nastane resničen projekt, je treba arhitekturo, obstoječe sisteme in obratovanje zgodaj obravnavati skupaj.
Ne podpiramo le pri posameznih vprašanjih, ampak tudi takrat, ko iz izrezkov izvorne kode, legacy-tem ali idej za portale nastane zanesljiv podjetniški projekt.
- Obstoječe stanje, ciljno stanje in tehnična tveganja se ocenjujejo skupaj.
- REST, dostop do podatkov, portali in Rollout ne bodo prestavljeni v kasnejše faze.
- Že zgodaj vidite, katera pot je ekonomsko in operativno vzdržna.