Case Study 2: Building a Responsive GUI
Overview
In this case study, we solve the "frozen GUI" problem — the most common motivation for multithreading in desktop applications. We take a Lazarus application that freezes during a long-running operation and refactor it to use a background thread, keeping the UI responsive with a progress bar and a cancel button.
The Problem: The Frozen Form
Rosa opens PennyWise and clicks "Generate Annual Report." The report requires reading 12 months of data, computing statistics, and formatting the output. It takes 8 seconds. During those 8 seconds:
- The window title shows "(Not Responding)"
- The progress bar is frozen
- The Cancel button does not respond to clicks
- Moving or resizing the window produces visual artifacts
- The operating system might offer to kill the application
This happens because the report generation runs in the main thread — the same thread that processes GUI events (mouse clicks, window redraws, keyboard input). While the main thread is busy computing, it cannot process events.
The Solution Architecture
Main Thread (GUI) Worker Thread
| |
| User clicks "Generate" |
| |
| Creates TReportThread |
| Starts it |
| Returns to event loop |
| <-- continues processing GUI events -->
| |
| Queue(UpdateProgress) <--- Worker computes
| Progress bar updates |
| |
| Queue(UpdateProgress) <--- Worker computes
| Progress bar updates |
| |
| User clicks Cancel |
| Sets Thread.Terminate |
| | Checks Terminated
| | Exits gracefully
| |
| Queue(ReportComplete) <--- Worker finishes
| Displays results |
The key insight: the main thread never does heavy computation. It starts the worker, returns to the event loop, and processes GUI updates from the worker via Queue.
The Worker Thread
type
TReportThread = class(TThread)
private
FProgressPercent: Integer;
FStatusText: string;
FReportHTML: string;
FSuccess: Boolean;
FOnProgress: TNotifyEvent;
FOnComplete: TNotifyEvent;
procedure DoProgress;
procedure DoComplete;
protected
procedure Execute; override;
public
property ProgressPercent: Integer read FProgressPercent;
property StatusText: string read FStatusText;
property ReportHTML: string read FReportHTML;
property Success: Boolean read FSuccess;
property OnProgress: TNotifyEvent write FOnProgress;
property OnComplete: TNotifyEvent write FOnComplete;
end;
procedure TReportThread.DoProgress;
begin
if Assigned(FOnProgress) then
FOnProgress(Self);
end;
procedure TReportThread.DoComplete;
begin
if Assigned(FOnComplete) then
FOnComplete(Self);
end;
procedure TReportThread.Execute;
var
Month: Integer;
begin
FSuccess := False;
try
for Month := 1 to 12 do
begin
if Terminated then
begin
FStatusText := 'Cancelled by user.';
Queue(@DoComplete);
Exit;
end;
FProgressPercent := (Month * 100) div 12;
FStatusText := Format('Processing %s...', [
FormatDateTime('mmmm', EncodeDate(2026, Month, 1))
]);
Queue(@DoProgress);
{ Simulate month processing (replace with real computation) }
Sleep(600);
end;
{ Generate final report }
FStatusText := 'Formatting report...';
FProgressPercent := 100;
Queue(@DoProgress);
FReportHTML := '<h1>Annual Report</h1><p>12 months processed.</p>';
FSuccess := True;
FStatusText := 'Report complete.';
Queue(@DoComplete);
except
on E: Exception do
begin
FStatusText := 'Error: ' + E.Message;
FSuccess := False;
Queue(@DoComplete);
end;
end;
end;
The Form Code
type
TMainForm = class(TForm)
BtnGenerate: TButton;
BtnCancel: TButton;
ProgressBar: TProgressBar;
StatusLabel: TLabel;
ReportMemo: TMemo;
procedure BtnGenerateClick(Sender: TObject);
procedure BtnCancelClick(Sender: TObject);
private
FReportThread: TReportThread;
procedure OnReportProgress(Sender: TObject);
procedure OnReportComplete(Sender: TObject);
end;
procedure TMainForm.BtnGenerateClick(Sender: TObject);
begin
{ Prevent starting multiple threads }
if FReportThread <> nil then Exit;
{ Update UI state }
BtnGenerate.Enabled := False;
BtnCancel.Enabled := True;
ProgressBar.Position := 0;
StatusLabel.Caption := 'Starting report generation...';
ReportMemo.Clear;
{ Create and start the worker thread }
FReportThread := TReportThread.Create(True);
FReportThread.FreeOnTerminate := False;
FReportThread.OnProgress := @OnReportProgress;
FReportThread.OnComplete := @OnReportComplete;
FReportThread.Start;
end;
procedure TMainForm.BtnCancelClick(Sender: TObject);
begin
if FReportThread <> nil then
begin
FReportThread.Terminate;
StatusLabel.Caption := 'Cancelling...';
BtnCancel.Enabled := False;
end;
end;
procedure TMainForm.OnReportProgress(Sender: TObject);
begin
{ This runs in the main thread — safe to update GUI }
ProgressBar.Position := FReportThread.ProgressPercent;
StatusLabel.Caption := FReportThread.StatusText;
end;
procedure TMainForm.OnReportComplete(Sender: TObject);
begin
{ This runs in the main thread }
ProgressBar.Position := 100;
StatusLabel.Caption := FReportThread.StatusText;
if FReportThread.Success then
ReportMemo.Text := FReportThread.ReportHTML
else
ReportMemo.Text := 'Report generation failed or was cancelled.';
{ Clean up }
FReportThread.WaitFor;
FreeAndNil(FReportThread);
{ Reset UI state }
BtnGenerate.Enabled := True;
BtnCancel.Enabled := False;
end;
Critical Design Decisions
1. FreeOnTerminate = False
We set FreeOnTerminate := False because we need to read the thread's properties (ReportHTML, Success) after it finishes. If FreeOnTerminate were True, the object would be freed before we could read the results.
2. Queue Instead of Synchronize
We use Queue for progress updates because:
- The worker does not need to wait for the GUI update to complete
- Synchronize would block the worker on every progress update, slowing it down
- Queue is fire-and-forget — the worker continues immediately
3. Cancel via Terminate
The Cancel button calls FReportThread.Terminate, which sets Terminated := True. The worker checks Terminated at the beginning of each month's processing. The cancellation is cooperative — the worker finishes its current unit of work, then exits cleanly.
4. Single Thread Guard
The if FReportThread <> nil then Exit check prevents creating multiple worker threads. Clicking "Generate" while a report is already running does nothing.
Before and After
| Behavior | Before (main thread) | After (worker thread) |
|---|---|---|
| GUI during processing | Frozen | Responsive |
| Progress bar | Static | Updates every month |
| Cancel button | Non-functional | Works immediately |
| Window resize | Breaks | Smooth |
| "(Not Responding)" | Yes | No |
| Error handling | Crash (no cleanup) | Graceful message |
Lessons Learned
-
Never do heavy work in the main thread. If an operation takes more than ~200ms, put it in a worker thread. The user notices delays over 100ms.
-
Queue is almost always better than Synchronize for progress updates. The worker should not wait for the GUI to repaint.
-
Guard against multiple threads. Always check if a thread is already running before starting a new one.
-
Clean up in the completion handler. WaitFor and Free the thread in the OnComplete callback, after reading all results.
-
Handle exceptions in the worker. An unhandled exception in a thread silently terminates it. Wrap Execute in try-except and report errors through the completion mechanism.