From magazine topic to project implementation
Relevant service and technical pages for this post
Anyone who wants to parallelize compute-intensive or I/O-heavy jobs in Delphi quickly lands on the Parallel Programming Library (PPL) and specifically TParallel.For. The effect is often immediately measurable — until you want to update a progress UI „real quick“ in the same breath. This is precisely where the typical stalls occur: seemingly random UI freezes, a ProgressBar that jumps backward, or a complete deadlock when stepping through the code in the debugger.
This article is about TParallel.For thread-safe progress UI: a robust pattern that works in VCL and FMX, consolidates UI updates via TThread.Queue, respects Cancel/Abort and consistently avoids the most common deadlock pitfalls. The focus is on operational reality: reproducible behavior, clear responsibilities and debugging advice that helps even when the fault only appears „at customers“.
Why progress UI so often goes wrong with TParallel.For
TParallel.For typically runs on worker threads from the Delphi thread pool. These threads must not touch VCL or FMX controls directly because the UI frameworks (message loop, window handles, rendering) are bound to the main thread. Even an apparently harmless ProgressBar.Position := … from a worker can lead to undefined behavior: sporadic AVs, frozen windows or „flickering“ updates.
The obvious fix is often TThread.Synchronize. That does solve thread-safety, but in parallel loops it quickly introduces another problem: it creates a serial bottleneck. Each worker waits for the main thread to execute the code, while the main thread is busy rendering and handling Synchronize calls. Under load this looks like a deadlock — even if it is „only“ a starvation/lockstep effect.
And then there is the class of genuine deadlocks: the main thread waits (e.g. via WaitFor, Task.Wait or indirectly through blocking calls) for the parallel operation to finish, while worker threads try to push work to the main thread via Synchronize or ill-advised use of Queue. Result: main thread waits for workers, workers wait for main thread.
TThread.Queue vs. TThread.Synchronize: the practical difference
Both mechanisms are used to run code safely on the main thread. The difference is the waiting semantics:
- TThread.Synchronize: The calling worker waits until the main thread has executed the code. This is „synchronous“, increases latency and is a classic ingredient for deadlocks if the main thread is currently blocked.
Important: Queue is not a free pass. If you send a Queue update on every iteration of a loop, you will flood the Main Thread queue. The UI then may not hang due to a deadlock, but it will because of the sheer volume of messages. The UI will feel „sluggish“, and the completion of processing will be delayed because hundreds or thousands of UI updates are still being processed.
The edge case that really hurts: waiting in the UI thread
In enterprise applications you frequently see the following sequence: click the „Start“ button, disable the UI, open a ProgressDialog, then synchronously „wait“ until everything is finished and only then re-enable the UI. This pattern is the root cause of many deadlocks.
Typical variants (depending on the codebase):
- The Main Thread starts TParallel.For and then invokes blocking wait logic (directly or indirectly).
- A ProgressDialog calls a routine in its constructor or OnShow that waits internally.
- A cancel button sets a flag, but the Main Thread nevertheless remains stuck in a wait loop.
If worker threads use Synchronize during this time, deadlock is virtually guaranteed. Using Queue can also hang if the Main Thread is blocked and not pumping messages—because then the Queue is not processed either.
The operationally robust consequence is: The Main Thread must not block while waiting for the parallel loop when concurrent UI updates are required. Instead, processing must either be fully offloaded to a background task, or you must organize an „asynchronous completion“ (callback/Queued completion action) that releases the UI at the end.
Clean approach: aggregate progress only, throttle UI updates
A robust pattern consists of three clearly separated responsibilities:
- Worker threads perform the actual work per element/index. They report only progress in a thread-safe form (counter, Queue, thread-safe queue).
- Aggregator (often: the Main Thread or a dedicated timer in the UI) computes a UI status (position, text, ETA) from the progress and updates controls. This avoids one-to-one updates per iteration.
- Completion (also on the Main Thread): reactivate the UI, display the result, summarize errors, release resources.
Why this separation works so well: the workload can be high-frequency (thousands of items), but the UI only needs a few updates per second. In practice 5–10 updates/sec are sufficient, and for very fast jobs even 2–4. Anything above that is mostly visual noise and costs CPU time in the Message Pump.
Thread-safe counting: atomic instead of lock
For a simple ProgressBar an atomic counter is often sufficient. “Atomic” means: increment and read occur without race conditions, typically via TInterlocked. This lets you avoid locks (Critical Sections) in the hot path of the loop.
Proven minimal idea:
- Total amount is known in advance (e.g., number of records, files, IDs).
- Each iteration increases a DoneCounter atomically.
- A UI timer periodically reads the counter and sets ProgressBar.Position.
Advantage: no TThread.Queue per element, no UI flooding. Disadvantage: you have no per-item detail messages (e.g., filename). You can, however, add a second, throttled status message (see next section).
Status messages without spam: “last status wins”
If you also want to show a short text (current item, phase, error message), you need a pattern that does not flood the UI with each worker step. In practice “last status wins” works very well:
- Worker writes a status information into a thread-safe structure (e.g., an atomically replaceable string, or protected by a small lock).
- UI timer periodically transfers the most recently seen status into a label.
This keeps the UI responsive, and you still see that “something is happening”. What matters here is less the string itself than its lifetime: do not carry references to short-lived objects from worker threads into the UI thread. If you pass objects, clarify ownership explicitly.
TParallel.For thread-safe progress UI with TThread.Queue: a robust pattern
There are scenarios where a UI timer alone is not enough: for example when you want to guarantee exactly one “done” update at the end, or when the UI update is a more complex step (e.g., a log entry, but throttled). Then TThread.Queue is appropriate — but not per iteration, rather selectively.
A practical approach is a queue only for low-frequency events:
- Start event (prepare UI, disable buttons)
- Periodic progress events (max. once every X milliseconds)
- Error events (optionally aggregated)
- Done event (reset UI, present result)
You do not achieve the periodicity via the UI thread, but already in the worker context: let workers queue a UI update only if sufficient time has passed since the last UI update. For this a monotonic time source (e.g., TickCount) plus an atomic “last update” value is suitable.
Important: the UI update itself must be “fast”. Expensive calculations, file I/O or database accesses do not belong in the queued UI callback. The callback should only read states and set controls.
Cancel handling: cancel without hangs
In real applications cancel is not optional. Crucial is: cancel is not a “kill”, but a cooperative termination. Workers must regularly check whether a cancellation signal is set and then exit cleanly. In Delphi there are several approaches for this (depending on the PPL construction): a dedicated Volatile flag, an atomic boolean, or a cancellation concept via tasks (depending on Delphi version and structure).
Two rules are important for operation:
- Cancel must be visible quickly: Check the cancel flag at meaningful points, not only at the end of an iteration when the iteration itself can take seconds.
- Cancel must clean up: Open handles, temporary files, transactions or locks must not be left behind. That means: in every worker iteration try/finally blocks are mandatory when resources are involved.
On the UI side, Cancel should only set a signal and put the UI into a “Stopping…” state. The actual termination and reactivation of the UI then occur in the Done event, not immediately on the click.
Avoiding deadlocks: the most common pitfalls in practice
Pitfall 1: WaitFor/Task.Wait on the main thread
If the main thread is blocked, it cannot execute Queue callbacks or process messages. That behaves like a deadlock, even if the workers continue to run correctly. Solution: no blocking waits in the UI thread. Instead perform the completion action via TThread.Queue or event-driven control (e.g. a timer checking “finished”).
Pitfall 2: Synchronize inside a lock
A classic: a worker holds a critical section, then calls Synchronize, and the UI callback (directly or indirectly) requires the same critical section again. Result: circular waiting. The rule is simple: No handover to the UI (Synchronize/Queue) from inside a held lock. If a lock is necessary, copy all data into local variables, exit the lock, then call Queue.
Pitfall 3: UI callback triggers reentrancy
Sometimes the UI update itself is not “harmless”: setting properties can trigger events (OnChange, OnResize), which in turn start logic that accesses worker state. This is not a deadlock in the strict sense, but it leads to hard-to-explain freezes and race conditions. Mitigation: place UI updates on “silent” paths (temporarily disable events) or use reentrancy guards (e.g. an atomic guard for the update phase).
Pitfall 4: Too many queued updates
Even without waits the UI can become unresponsive if you generate tens of thousands of queued callbacks. Symptoms: the ProgressBar keeps running long after, windows respond sluggishly, high CPU on the main thread. Solution: throttle (time windows), aggregate (counters), or use a proper producer/consumer structure where only one UI update can be pending (coalescing).
When it gets more complicated: collecting results, aggregating errors, guaranteeing order
TParallel.For is ideal when iterations are independent. In business software, however, iterations are often only “largely” independent: you read files, call REST APIs, write database rows. Then you must plan three additional points carefully:
- Thread-safe result collection: Either a per-thread local buffer (merge at the end) or a thread-safe queue/collection. Avoid locks in the hot path.
- Error handling: Exceptions from worker threads must be collected. In practice the following works well: remember the first exception and trigger Cancel, or collect all exceptions and display them summarized at the end.
- Ordering: If the output requires a stable order (e.g. logs by index), parallel processing with subsequent sorting is often simpler than „thread-safe ordered insertion“.
For the UI this means: Do not display every single error immediately. That leads to modal-dialog hell. Collect errors (e.g. a list of strings) and show a summary or an exportable log at the end.
Debugging: How to actually make the deadlock visible
Deadlocks in parallel code are frustrating because they can look different in the debugger than in Release. Still, there are a few practical levers:
Use the Threads window and call stacks
If the UI is frozen, inspect all threads: Where is the Main Thread? Is it waiting? Is it in a message loop? Where are the worker threads? If workers are stuck in Synchronize, the cause is almost always „Main Thread blocked“ or „Main Thread needs a lock“.
Mark queue/synchronize points
Add targeted logging at handover points (before the queue, in the queue callback, at the end of the iteration). In production this is often more valuable than breakpoints because timing is critical. Ensure that logging itself is thread-safe and non-blocking (e.g. no UI log output directly from worker threads).
Measure last update time
If the UI „hangs“, it may simply be that it has too many updates to process. Therefore measure in the Main Thread how often you execute a UI update per second and how long it takes. As soon as UI callbacks take more than a few milliseconds, throttling or simplification is required.
When is TParallel.For with a progress UI really worth it?
Parallelization is not an end in itself. It is particularly worthwhile when:
- the iterations are large enough (milliseconds to seconds) so that thread pool overhead is outweighed,
- the job is CPU-bound (parsing, compression, hashing) or has I/O that parallelizes well (multiple files, multiple HTTP requests with limits),
- you have a clear Cancel and error strategy,
- the UI requirements can be satisfied by aggregated progress.
It is less worthwhile when every iteration is extremely short (micro-operations) or when all iterations contend for the same bottleneck (a serial DB transaction, a global lock, a single file). In those cases the quicker lever is often: improve the algorithm, batch operations, reduce data access or explicitly decouple the bottleneck.
Practical checklist: Keeping the UI stable
- Main Thread must not be blocked: no waits, no long loops without a message pump.
- Workers never touch controls: no VCL/FMX access outside the UI thread.
- UI updates are throttled: counters/timers or coalesced queue updates instead of per-iteration updates.
- No Synchronize calls from within locks.
- Cancel is cooperative, frequently checked and performs proper cleanup.
- Exceptions are collected and handled in an ordered fashion at the end.
Conclusion: Decouple with a queue, stabilize with aggregation
A robust progress UI with TParallel.For is not achieved by ‚just throwing Synchronize in somewhere‘, but by a clear architectural principle: workers operate independently, the Main Thread stays free and processes only a few, fast UI updates. TThread.Queue is the right tool when used deliberately and throttled. For ’sluggish‘ or unstable behavior, almost always two causes are responsible: the Main Thread is blocking somewhere – or it is drowning in too many queued updates.
Once youve set the pattern up cleanly (counter/coalescing, cancel flag, completion callback), it pays off across many areas of a mature Delphi application: import/export, data validations, file and API jobs everything becomes more responsive without you introducing new deadlocks with every progress update.
If you need support stabilizing parallel code, debugging UI freezes, or cleanly modernizing mature Delphi applications: contact us.
For this topic, the Delphi Parallel Programming Library and Tthread.queue Vs Synchronize are also important. The article places these aspects into a clear context and shows what matters in everyday practice.
Discuss a project or modernization initiative with Net-Base.
Next step
When the topic becomes an actual project, architecture, existing systems and operations should be considered together from the outset.
We support not only with individual issues, but also when source snippets, legacy topics, or portal ideas are to be turned into a robust enterprise project.
- Current state, target state and technical risks are assessed jointly.
- REST, data access, portals and rollout are not deferred to a later stage as secondary consequences.
- You can see early on which path is economically and operationally viable.