Moving Blocking Work Off the Delphi UI Thread with TTask
Freeze-prone VCL form? Use TTask.Run from System.Threading to move blocking work off the UI thread, TThread.Queue to update controls safely, and learn the capture and exception pitfalls.
14 Sept 2025, 15:34 UTC

Your VCL form freezes for four seconds every time the user clicks "Load Report". The fix is not a faster query — it is running that query somewhere other than the UI thread. Delphi ships a thread-pool API in the System.Threading unit (available since roughly the XE7 era) that makes this straightforward without hand-managing TThread descendants. The catch: the old rule still applies. UI controls may only be touched from the main thread, so the background task must hand results back safely.
The pattern: TTask.Run plus TThread.Queue
TTask.Run schedules an anonymous method on the default thread pool. When the work finishes, TThread.Queue posts a second anonymous method back to the main thread, where it is safe to update controls. Queue is asynchronous — the task does not wait for the UI to process it — which avoids the classic deadlock where the main thread waits on a task that is itself blocked in Synchronize.
procedure TForm1.btnLoadClick(Sender: TObject);
var
QueryText: string;
begin
QueryText := edtFilter.Text; // capture UI input NOW, on the main thread
btnLoad.Enabled := False;
TTask.Run(
procedure
var
Rows: Integer;
begin
Rows := RunReportQuery(QueryText); // blocking DB call, off the UI thread
TThread.Queue(nil,
procedure
begin
lblStatus.Caption := Format('%d rows loaded', [Rows]);
btnLoad.Enabled := True;
end);
end);
end;Two details matter here. First, QueryText is read from the edit box before the task starts — reading edtFilter.Text inside the task would be a cross-thread UI access. Second, the queued update re-enables the button, so the user cannot stack up duplicate queries.
Capture rules that bite in loops
Delphi anonymous methods capture variables by reference, not by value. If you launch tasks in a for loop and reference the loop variable, every task sees the same variable — usually its final value. Copy it to a local inside the loop first:
for i := 0 to 9 do
begin
Idx := i; // local copy declared inside the loop body
TTask.Run(procedure begin ProcessChunk(Idx); end);
end;The same by-reference capture is why holding a component reference (say, Self or a TMemo) inside a long-running task is risky: the form may be freed while the task still runs. Pass plain data in, and let the queued completion update the UI.
Exceptions and cancellation
Exceptions raised inside a task are captured and re-raised when you call Wait on the returned ITask. If you never wait and never handle them, they can be silently swallowed — your query fails and the button just stays disabled. A pragmatic pattern is a try/except inside the task that queues the error message to the UI, so failure is always observed.
Cancellation is cooperative. There is no safe way to kill a running task; instead pass in a flag (or a cancellation token, depending on your Delphi version) and check it inside the task's loop, then exit early. The queued UI update should also check whether the form is still interested in the result.
Trade-offs and limits
The default pool sizes itself around CPU core count. That is ideal for CPU-bound work (and TParallel.For will happily saturate all cores), but a burst of I/O-bound tasks — fifty concurrent HTTP calls — can stall waiting for pool threads. For that scenario, a custom TThreadPool with more workers is the usual answer. Also, TFuture<T> looks tempting for returning values, but reading .Value blocks until completion; calling it immediately from the UI thread recreates the freeze you were removing. Exact overloads and members vary across Delphi releases, so check the System.Threading source in your installed version.
Verify it actually works
Build a minimal test: a button that runs TTask.Run with a Sleep(3000) loop, then queues a label update. While it runs, drag the form and click other controls — the UI should stay responsive. Then deliberately raise an exception inside the task and confirm your queued error handler fires. Finally, run the ten-task loop with and without the local index copy and watch the captured-variable bug appear and disappear. Fifteen minutes of testing here saves a very confusing bug report later.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.