Skip to content

nostdahl.com

A technology journal
Article archive
← All articles
Programming

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.

pascal
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:

  • Terminated is checked in the loop, including inside the sleep. Terminate only 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.

pascal
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) and InterlockedIncrement and 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:

pascal
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:

  1. Does every control access happen on the main thread?
  2. Does the thread check Terminated often, including inside waits?
  3. Is every exception inside Execute caught and reported?
  4. Who owns the data crossing the thread boundary, and is it ever touched from both sides?
  5. Who stops the thread, and does that happen before the form is destroyed?
  6. 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 TTask and the parallel library, or do you stick to TThread? 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.

Continue exploring
← All articlesMore in Programming →