Net-Base Magazyn

24.07.2026

Delphi: TParallel.For z wątkowo bezpiecznym interfejsem postępu (TThread.Queue) bez zakleszczeń

Tak łączysz Delphi TParallel.For z wątkowo bezpiecznym interfejsem postępu: aktualizacje przez TThread.Queue, czysta agregacja, obsługa anulowania oraz typowe pułapki deadlocków podczas debugowania i eksploatacji.

24.07.2026

Od tematu magazynowego do praktyki projektowej

Pasujące strony usługowe i techniczne do artykułu

Kto w Delphi chce zrównoleglić obciążające obliczeniowo lub I/O zadania, szybko trafia na Parallel Programming Library (PPL), a konkretnie na TParallel.For. Efekt jest często od razu mierzalny — aż do chwili, gdy jednocześnie chce się „szybko” zaktualizować interfejs postępu (Progress-UI). Właśnie tutaj pojawiają się typowe zawieszenia: pozornie losowe zamrożenia UI, ProgressBar, który cofa się, lub kompletny deadlock, gdy w debuggerze wykonuje się krok po kroku.

W tym wpisie chodzi o wielowątkowo bezpieczne Progress-UI dla TParallel.For: solidny wzorzec działający w VCL i FMX, który starannie grupuje aktualizacje UI przez TThread.Queue, uwzględnia Cancel/Abort i konsekwentnie omija najczęstsze pułapki deadlocków. Skupienie jest na realiach operacyjnych: powtarzalne zachowanie, jasne zakresy odpowiedzialności i wskazówki do debugowania, które pomagają nawet wtedy, gdy błąd występuje tylko „u klienta”.

Dlaczego Progress-UI przy TParallel.For tak często zawodzi

TParallel.For zazwyczaj działa na wątkach roboczych z puli wątków Delphi. Te wątki nie mogą bezpośrednio manipulować kontrolkami VCL ani FMX, ponieważ frameworki UI (pętla komunikatów, uchwyty okien, renderowanie) są powiązane z wątkiem głównym. Nawet pozornie nieszkodliwe ProgressBar.Position := … wykonywane w workerze może prowadzić do niezdefiniowanego zachowania: sporadycznych AVs, zamrożonych okien lub „migających” aktualizacji.

Oczywistą naprawą jest często TThread.Synchronize. Co prawda rozwiązuje to problem bezpieczeństwa wątków, ale w równoległych pętlach szybko prowadzi do innego problemu: tworzy wąskie gardło sekwencyjne. Każdy worker czeka na wątek UI, który z kolei jest zajęty renderowaniem i wykonywaniem wywołań Synchronize. Pod obciążeniem wygląda to jak deadlock — nawet jeśli to „tylko” efekt starvation/lockstep.

A potem jest prawdziwa klasa deadlocków: wątek główny czeka (np. za pomocą WaitFor, Task.Wait lub pośrednio przez wywołania blokujące) na zakończenie operacji równoległej, podczas gdy wątki robocze próbują wysłać pracę do wątku głównego przez Synchronize lub niefortunne użycie Queue. Efekt: wątek główny czeka na workerów, workerzy czekają na wątek główny.

TThread.Queue vs. TThread.Synchronize: praktyczna różnica

Unschärfer Debugger-Blick mit Notizen zu Threads und Queue als Kontext für Queue vs Synchronize
Podczas debugowania łatwo zauważyć, czy wątki robocze czekają na wątek główny, czy tylko kolejkują aktualizacje.

Oba mechanizmy służą do bezpiecznego uruchamiania kodu w wątku głównym. Różnica leży w semantyce oczekiwania:

  • TThread.Synchronize: Wywołujący worker czeka, aż wątek główny wykona kod. To jest „synchroniczne”, zwiększa opóźnienia i jest klasycznym składnikiem deadlocków, gdy wątek główny jest akurat zablokowany.
  • TThread.Queue: Worker umieszcza kod tylko w kolejce dla wątku głównego i kontynuuje działanie. To „asynchroniczne”, odłącza wątki i w scenariuszach równoległych jest niemal zawsze lepszym wyborem domyślnym – pod warunkiem, że kontroluje się częstotliwość aktualizacji.

Ważne: Queue nie jest przepustką do bezkarności. Jeśli w każdej iteracji pętli wysyłają Państwo aktualizację do kolejki, zapełnią Państwo kolejkę wątku głównego. Wówczas UI co prawda nie zablokuje się wskutek deadlocku, ale z powodu samej liczby wiadomości. Interfejs będzie działał wolno, a zakończenie przetwarzania opóźni się, ponieważ wciąż będą wykonywane setki lub tysiące aktualizacji UI.

Przypadek brzegowy, który naprawdę boli: oczekiwanie w wątku UI

W aplikacjach korporacyjnych często spotyka się następujący przebieg: kliknięcie przycisku „Start”, dezaktywacja UI, otwarcie ProgressDialog, a następnie synchroniczne „oczekiwanie”, aż wszystko się zakończy, po czym ponowna aktywacja. Ten wzorzec jest źródłem wielu zakleszczeń.

Typowe warianty (w zależności od bazy kodu):

  • Wątek główny uruchamia TParallel.For i następnie wywołuje blokującą logikę oczekiwania (bezpośrednio lub pośrednio).
  • ProgressDialog wywołuje w konstruktorze lub w OnShow rutynę, która wewnętrznie oczekuje.
  • Przycisk Anuluj co prawda ustawia flagę, ale wątek główny mimo to zostaje uwięziony w pętli oczekiwania.

Jeśli w tym czasie wątki robocze używają Synchronize, deadlock jest praktycznie gwarantowany. Przy użyciu Queue też może dojść do zablokowania, jeśli wątek główny jest zablokowany i nie przetwarza komunikatów – bo wtedy kolejka również nie zostanie opracowana.

Konsekwencja operacyjna jest następująca: Wątek główny nie może blokująco oczekiwać na pętlę równoległą, jeśli równocześnie wymagane są aktualizacje UI. Zamiast tego przetwarzanie musi być albo w całości przeniesione do zadania w tle, albo należy zorganizować „asynchroniczne zakończenie” (Callback/kolejkowana akcja zakończeniowa), które na końcu ponownie odblokuje UI.

Czyste podejście: postęp tylko agregowany, aktualizacje UI ograniczone

Motyw stanowiska pracy z abstrakcyjnym wskaźnikiem postępu i pomiarem czasu jako symbol ograniczonych aktualizacji UI
Agregacja i ograniczanie oparte na czasie zapobiegają zalewaniu UI zbyt wieloma aktualizacjami.

Solidny wzorzec składa się z trzech jasno rozdzielonych odpowiedzialności:

  • Wątki robocze wykonują właściwą pracę dla każdego elementu/indeksu. Zgłaszają jedynie postęp w sposób bezpieczny dla wątków (licznik, Queue, kolejka bezpieczna dla wątków).
  • Agregator (często: wątek główny lub dedykowany timer w UI) oblicza na podstawie postępu status UI (pozycja, tekst, ETA) i aktualizuje kontrolki. Dzięki temu unikają Państwo aktualizacji 1:1 na iterację.
  • Zakończenie (również w wątku głównym): reaktywacja UI, wyświetlenie wyniku, podsumowanie błędów, zwolnienie zasobów.

Dlaczego to rozdzielenie działa tak dobrze: obciążenie może mieć wysoką częstotliwość (tysiące elementów), podczas gdy UI potrzebuje tylko kilku aktualizacji na sekundę. W praktyce wystarcza 5–10 aktualizacji/sekundę, przy bardzo szybkich zadaniach nawet 2–4. Wszystko powyżej tego to zazwyczaj tylko szum wizualny i kosztuje cykle CPU w pętli obsługi komunikatów (Message Pump).

Threadsicher zählen: Atomic statt Lock

Dla prostej ProgressBar często wystarcza licznik atomowy. „Atomar“ oznacza: inkrementacja i odczyt zachodzą bez Race Condition, typowo przy użyciu TInterlocked. Dzięki temu unikasz blokad (Critical Sections) w hot path pętli.

Sprawdzone, minimalne podejście:

  • Całkowita wielkość jest znana z góry (np. liczba rekordów, plików, ID).
  • Każda iteracja zwiększa atomowo DoneCounter.
  • Timer UI okresowo odczytuje licznik i ustawia ProgressBar.Position.

Zaleta: brak TThread.Queue na element, brak przepływu aktualizacji do UI. Wada: nie masz szczegółowych komunikatów dla każdego elementu (np. nazwy pliku). Można w tym celu dodać drugą, ograniczaną informację statusową (patrz następny rozdział).

Statusmeldungen ohne Spam: „letzter Status gewinnt“

Jeżeli chcesz dodatkowo wyświetlać krótki tekst (bieżący element, faza, komunikat o błędzie), potrzebujesz wzorca, który nie będzie zalewał UI przy każdym kroku workera. W praktyce dobrze działa podejście „ostatni status wygrywa“:

  • Worker zapisuje informację statusową do struktury bezpiecznej dla wątków (np. atomowo wymienialny string albo chroniony małym Lockiem).
  • Timer UI okresowo przejmuje ostatnio widziany status do etykiety.

Dzięki temu UI pozostaje responsywne, a mimo to widać, że „coś się dzieje”. Istotniejsza niż sam string jest tu jego żywotność: nie przenoś referencji do krótkotrwałych obiektów z wątków worker do wątku UI. Jeśli przekazujesz obiekty, określ jasno ich ownership.

TParallel.For threadsichere Progress-UI mit TThread.Queue: ein robustes Muster

Są scenariusze, w których sam timer UI nie wystarczy: np. gdy chcesz na końcu mieć pewność, że zostanie wyemitowana dokładnie jedna aktualizacja „Fertig”, albo gdy aktualizacja UI jest bardziej złożonym krokiem (np. wpis do okna logu, ale ograniczany). Wtedy TThread.Queue jest odpowiednie — lecz nie per iteracja, tylko selektywnie.

Praktyczne podejście to kolejka tylko dla zdarzeń o niskiej częstotliwości:

  • Start-Event (przygotowanie UI, zablokowanie przycisków)
  • Okresowe zdarzenia postępu (maks. co X milisekund)
  • Zdarzenia błędów (opcjonalnie zbierane)
  • Done-Event (przywrócenie UI, wyświetlenie wyniku)

Okresowości nie osiągasz przez wątek UI, lecz już w kontekście workera: pozwalasz workerom umieszczać UI-aktualizację w kolejce tylko wtedy, gdy od ostatniego UI-update minęło wystarczająco dużo czasu. Do tego nadaje się monotoniczne źródło czasu (np. TickCount) plus atomowa wartość „last update”.

Ważne: sama aktualizacja UI musi być „szybka”. Kosztowne obliczenia, operacje plikowe lub dostęp do bazy danych nie należą do umieszczanego w kolejce callbacku UI. Callback powinien jedynie odczytać stany i ustawić kontrolki.

Cancel-Handling: Abbrechen ohne Hänger

W prawdziwych aplikacjach anulowanie nie jest opcjonalne. Kluczowe: Cancel to nie „Kill”, lecz kooperacyjne zakończenie. Workery muszą regularnie sprawdzać, czy sygnał przerwania został ustawiony, i wtedy poprawnie zakończyć działanie. W Delphi istnieje kilka sposobów na to (w zależności od konstrukcji PPL): własna flaga Volatile, atomowy boolean, albo koncepcja anulowania oparta na Tasks (w zależności od wersji i struktury Delphi).

Dla eksploatacji ważne są dwie zasady:

  • Przycisk Anuluj musi być szybko widoczny: Sprawdzaj flagę przerwania w sensownych miejscach, nie tylko na końcu iteracji, jeśli iteracja może trwać nawet kilka sekund.
  • Przycisk Anuluj musi posprzątać: Otwarte handle, pliki tymczasowe, transakcje lub locki nie mogą pozostać. To znaczy: w każdej Worker-Iterationen są try/finally-bloki obowiązkowe, gdy zasoby są angażowane.

Po stronie UI przycisk Anuluj powinien jedynie ustawić sygnał i przełączyć UI w stan „Zatrzymywanie…”. Rzeczywiste zakończenie i ponowne aktywowanie UI następuje w Done-Event, a nie natychmiast po kliknięciu.

Deadlocks vermeiden: die häufigsten Fallen in der Praxis

Diagramm eines zyklischen Wartens zwischen Threads als Visualisierung eines Deadlocks
Deadlocks entstehen oft durch zyklisches Warten: UI-Thread blockiert, Worker warten auf UI-Zugriff.

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.
  • Obsługa błędów: Wyjątki z wątków roboczych muszą być zbierane. W praktyce sprawdza się: zapamiętać pierwszy wyjątek i zainicjować anulowanie, albo zebrać wszystkie wyjątki i na końcu wyświetlić je zbiorczo.
  • Kolejność: Jeśli wyjście wymaga stabilnej kolejności (np. logi według indeksu), równoległe przetwarzanie z późniejszym sortowaniem jest często prostsze niż „wątkowo-bezpieczne uporządkowane wstawianie”.

Dla UI oznacza to: nie pokazuj każdej pojedynczej informacji o błędzie natychmiast. To prowadzi do piekła modalnych dialogów. Zbieraj błędy (np. lista ciągów znaków) i pokaż na końcu podsumowanie lub log możliwy do eksportu.

Debugging: Jak naprawdę uwidocznić zakleszczenie

Zakleszczenia w kodzie równoległym są frustrujące, ponieważ mogą wyglądać inaczej w debugerze niż w wersji Release. Mimo to istnieje kilka praktycznych dźwigni:

Korzystanie z okna wątków i stosów wywołań

Gdy UI się zawiesza, sprawdź wszystkie wątki: gdzie stoi wątek główny? Czy czeka? Czy jest w pętli komunikatów? Gdzie są wątki robocze? Jeśli workerzy wiszą w Synchronize, przyczyna niemal zawsze jest jedna: „wątek główny zablokowany” lub „wątek główny potrzebuje locka”.

Oznaczanie miejsc Queue/Synchronize

Wstawiaj ukierunkowane logowanie w punktach przekazania (przed Queue, w callbacku kolejki, na końcu iteracji). W działaniu produkcyjnym jest to często cenniejsze niż punkty przerwania, ponieważ timing jest decydujący. Zadbaj o to, aby samo logowanie było bezpieczne dla wątków i nieblokujące (np. brak bezpośredniego wypisywania logu do UI z wątków roboczych).

Mierzenie czasu ostatniej aktualizacji

Jeśli UI „zawiesza się”, może to po prostu oznaczać, że musi obsłużyć zbyt wiele aktualizacji. Mierz więc w wątku głównym, jak często na sekundę wykonujesz aktualizację UI i ile ona trwa. Gdy callbacki UI zajmują więcej niż kilka milisekund, konieczne jest ograniczenie częstotliwości lub uproszczenie aktualizacji.

Kiedy naprawdę warto TParallel.For z Progress-UI?

Paralelizacja nie jest celem samym w sobie. Ma sens szczególnie wtedy, gdy:

  • iteracje są wystarczająco duże (milisekundy do sekund), tak że narzut puli wątków jest pomijalny,
  • zadanie obciąża CPU (parsowanie, kompresja, haszowanie) lub ma dobrze równolegle wykonalne operacje I/O (wiele plików, wiele żądań HTTP z limitami),
  • masz jasną strategię anulowania i obsługi błędów,
  • wymagania UI dają się spełnić przy zagregowanym postępie.

Ma ona mniejszy sens, gdy każda iteracja jest ekstremalnie krótka (mikrooperacje) lub gdy wszystkie iteracje trafiają na ten sam wąski gardło (seryjna transakcja DB, globalny lock, pojedynczy plik). Wtedy szybszym dźwignią jest często: poprawa algorytmu, przetwarzanie wsadowe, ograniczenie dostępu do danych lub jawne odseparowanie wąskiego gardła.

Praktyczna lista kontrolna: Jak utrzymać stabilność UI

  • wątek główny nie jest blokowany: brak oczekiwań blokujących, brak długich pętli bez obsługi komunikatów,
  • wątki robocze nigdy nie manipulują kontrolkami: brak dostępu do VCL/FMX poza wątkiem UI,
  • aktualizacje UI są ograniczone: liczniki/timery lub scalone aktualizacje przez kolejkę zamiast na każdą iterację,
  • brak wywołań Synchronize z wnętrza locków,
  • anulowanie jest kooperacyjne, często sprawdzane i starannie zwalnia zasoby,
  • wyjątki są zbierane i na końcu traktowane w uporządkowany sposób.

Wniosek: Odseparować przez kolejkę, ustabilizować przez agregację

Solidny interfejs postępu przy TParallel.For nie powstaje przez „wstawienie Synchronize gdzie popadnie“, lecz dzięki jasnej zasadzie architektonicznej: wątki robocze pracują niezależnie, wątek główny pozostaje wolny i przetwarza jedynie niewiele, szybkich aktualizacji UI. TThread.Queue jest w tym kontekście właściwym narzędziem, jeśli stosują je Państwo celowo i z ograniczeniem częstotliwości. Za „opornym” lub niestabilnym zachowaniem odpowiadają niemal zawsze dwie przyczyny: wątek główny gdzieś czeka blokująco – albo tonie w zbyt wielu umieszczonych w kolejce aktualizacjach.

Jeśli wzorzec zostanie raz poprawnie skonfigurowany (Counter/Coalescing, Cancel-Flag, Abschluss-Callback), opłaca się go stosować w wielu miejscach w rozwijanej Delphi-aplikacji: Import/Export, walidacje danych, zadania plikowe i API – wszystko stanie się bardziej responsywne, bez ryzyka wprowadzania nowych deadlocków przy każdym aktualizowaniu postępu.

Jeśli potrzebują Państwo wsparcia przy stabilizowaniu kodu równoległego, debugowaniu zawieszeń UI lub przy uporządkowanym modernizowaniu rozrośniętych aplikacji Delphi: prosimy o kontakt.

Dla tego tematu istotne są także Delphi Parallel Programming Library oraz Tthread.queue Vs Synchronize. Artykuł porządkuje te aspekty w zrozumiały sposób i pokazuje, na czym to polega w praktyce.

Omówić projekt lub przedsięwzięcie modernizacyjne z Net-Base.

Następny krok

Jeżeli temat stanie się rzeczywistym projektem, architektura, stan istniejący i eksploatacja powinny być rozpatrywane razem na wczesnym etapie.

Wspieramy nie tylko w pojedynczych zagadnieniach, lecz także wtedy, gdy z fragmentów kodu źródłowego, kwestii związanych z systemami legacy lub koncepcji portalu ma powstać solidny projekt dla przedsiębiorstwa.

  • Stan istniejący, obraz docelowy i ryzyka techniczne są oceniane łącznie.
  • REST, dostęp do danych, portale i Rollout nie będą przesuwane na później.
  • Wcześnie widzą Państwo, która droga jest ekonomicznie i operacyjnie wykonalna.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e-mail są natychmiast dostępne. Dla Instagrama przygotowujemy bezpośrednio link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.