Threads Without Tears: Background Work in Delphi and Lazarus
Bjørn T. Nøstdahl
~6 min read
Every frozen desktop application I have ever been asked to fix had the same bug: somebody did slow work on the main thread. A database query, an HTTP call, a file copy, a serial port read. The window stops repainting, Windows adds "(Not Responding)" to the title bar, and the user force-closes it. Object Pascal gives you everything you need to avoid this, and the rules fit on an index card.
The one rule
Only the main thread touches the user interface. Everything else is detail.
Controls in the VCL and the LCL are not thread-safe. Reading a label's caption from a worker thread may work for months and then crash on a customer site. The pattern is always the same: do the work on a worker thread, then hand the result to the main thread, which updates the controls.
TThread: the workhorse
A worker is a class that inherits from TThread and overrides Execute. The code below polls an API every few seconds and delivers the result to a form, using the FetchReadings function from the REST article.
type
TReadingPoller = class(TThread)
private
FBaseUrl, FToken, FSensor: string;
FOnReadings: TProc<TJSONArray>;
protected
procedure Execute; override;
public
constructor Create(const ABaseUrl, AToken, ASensor: string;
AOnReadings: TProc<TJSONArray>);
end;
constructor TReadingPoller.Create(const ABaseUrl, AToken, ASensor: string;
AOnReadings: TProc<TJSONArray>);
begin
inherited Create(False); // start immediately
FBaseUrl := ABaseUrl;
FToken := AToken;
FSensor := ASensor;
FOnReadings := AOnReadings;
end;
procedure TReadingPoller.Execute;
var
Items: TJSONArray;
I: Integer;
begin
NameThreadForDebugging('ReadingPoller');
while not Terminated do
begin
try
Items := FetchReadings(FBaseUrl, FToken, FSensor);
Queue(
procedure
begin
FOnReadings(Items); // the handler owns Items now
end);
except
on E: Exception do
Queue(procedure begin Log('Poll failed: ' + E.Message); end);
end;
for I := 1 to 50 do // sleep 5 s, but wake quickly on Terminate
if Terminated then Break else Sleep(100);
end;
end;
This is Delphi syntax with anonymous methods. In Free Pascal 3.2 you pass a method of the thread to Queue instead and store the data in fields, as in the Raspberry Pi dashboard article. The shape is the same.
Three details carry most of the weight:
Terminatedis checked in the loop, including inside the sleep.Terminateonly sets a flag; the thread has to notice it. A thread that sleeps for five seconds in one call takes five seconds to stop.- Exceptions are caught inside
Execute. An unhandled exception in a thread kills the thread silently, and your poller simply stops polling with no message anywhere. - The thread is named. Named threads show up in the debugger's thread list, which turns "which of these twelve threads is stuck" into a one-second question.
Synchronize versus Queue
Both run a method on the main thread. Synchronize blocks the worker until the main thread has run it; Queue returns immediately and the main thread runs it when it gets around to it.
Use Synchronize when the worker needs the result, or when it reuses the same fields for the next message and must not overwrite them early. Use Queue when the worker should keep going, and give each queued call its own data. For most progress reporting, Queue is the right choice; for the final result, either works.
One trap: never call Synchronize from the main thread, and never make the main thread wait for a worker that is about to call Synchronize, or the two will wait for each other forever. If the main thread has to wait, keep the worker's communication to Queue.
Stopping cleanly
Every thread needs an owner that stops it, and the form that started it is usually the right owner.
procedure TfrmMain.FormDestroy(Sender: TObject);
begin
if Assigned(FPoller) then
begin
FPoller.Terminate;
FPoller.WaitFor;
FPoller.Free;
end;
end;
FreeOnTerminate := True is tempting because it removes the Free, but then you can no longer safely call WaitFor, because the thread may already have freed itself. I use it only for fire-and-forget work that the form never needs to wait for, and even then I think twice.
Sharing data between threads
The safest shared data is data that is never shared. Pass immutable records or freshly created objects across the boundary, hand over ownership, and never touch them from the old side again. The poller above does exactly that: it creates Items and the main thread owns and frees it.
When something really must be shared, protect it:
TCriticalSection(both compilers) is the basic lock. Keep the locked region small and never call back into the UI from inside it.TMonitor(Delphi) locks any object and supports waiting and signalling.TThreadList(both) is a list with a built-in lock.TThreadedQueue<T>(Delphi) is a bounded queue that blocks on empty or full, which is ideal for producer and consumer pairs.TInterlocked(Delphi) andInterlockedIncrementand friends (both) cover counters and flags without a lock.
Every time you reach for one of these, ask: could I pass a copy instead?
The parallel library in Delphi
Delphi also has the Parallel Programming Library in System.Threading. TTask.Run schedules a piece of work on a thread pool and returns an ITask you can wait on; TParallel.For splits a loop across cores. For a one-off background job it is less code than a TThread descendant:
TTask.Run(
procedure
var
Items: TJSONArray;
begin
Items := FetchReadings(BaseUrl, Token, 'boiler');
TThread.Queue(nil,
procedure
begin
ShowReadings(Items);
end);
end);
The pool handles the thread lifetime, which is convenient, but it also means you have no Terminate flag to check and no WaitFor on form close unless you keep the ITask around and cancel it yourself. For long-lived workers I still prefer TThread. For short jobs, TTask wins.
Free Pascal has no equivalent in the standard library. TThread.ExecuteInThread covers the simple fire-and-forget case, and for pools you either write a small one or use a third-party library. In practice a handful of named long-lived threads has served my Lazarus projects well.
A checklist
Before I merge any threaded code, I go through this:
- Does every control access happen on the main thread?
- Does the thread check
Terminatedoften, including inside waits? - Is every exception inside
Executecaught and reported? - Who owns the data crossing the thread boundary, and is it ever touched from both sides?
- Who stops the thread, and does that happen before the form is destroyed?
- Is the thread named?
Questions for you
- What is the worst threading bug you have shipped in a Pascal application, and how long did it take to find?
- Do you use
TTaskand the parallel library, or do you stick toTThread? Has the pool ever surprised you? - Lazarus users: have you found a thread pool or async library you trust?
Email me at bjorn.nostdahl@nostdahl.com. The last article in this series takes everything so far and puts it under version control, tests and a build pipeline.