Cancellation
Long-running work - a loop that processes records, a background thread that polls for files, a request that calls a remote service - often has to stop before it is done: the application is shutting down, the user clicked "Cancel", or the operation took too long. Unit Bcl.Cancellation provides a standard way to ask for that stop and to observe the request, known as cooperative cancellation.
Two roles are kept apart:
- The code that requests the stop holds a source, an ICancellationTokenSource, and calls ICancellationTokenSource.Cancel.
- The code that does the work receives only the source's token, an ICancellationToken. It can observe the request but never make one.
Nothing is interrupted by force: the work checks the token at points where stopping is safe, and winds down cleanly.
Creating a Source
Create a TCancellationTokenSource, give its ICancellationTokenSource.Token to the work, and call ICancellationTokenSource.Cancel when the work must stop:
procedure StartAndCancel;
var
Source: ICancellationTokenSource;
Worker: TThread;
begin
Source := TCancellationTokenSource.Create;
Worker := TThread.CreateAnonymousThread(
procedure
begin
ExportInvoices(Source.Token);
end);
Worker.FreeOnTerminate := False;
Worker.Start;
// ... later, for example when the user clicks "Cancel":
Source.Cancel;
Worker.WaitFor;
Worker.Free;
end;
Sources and tokens are reference counted: hold them in interface variables. Cancel is safe to call from any thread and more than once; only the first call has an effect, and a canceled token stays canceled.
Observing the Request
There are three ways for the work to observe a cancellation request. All token members are thread safe.
Checking the token
ICancellationToken.IsCancellationRequested tells whether a stop was requested. Check it between units of work:
procedure ExportInvoices(const AToken: ICancellationToken);
var
Invoice: TInvoice;
begin
for Invoice in LoadPendingInvoices do
begin
if AToken.IsCancellationRequested then
Exit;
ExportInvoice(Invoice);
end;
end;
Waiting on the token
A loop that waits between iterations should wait on the token with ICancellationToken.WaitFor instead of calling Sleep. It returns True as soon as cancellation is requested and False when the time runs out, so the request ends the wait immediately instead of after the full interval:
procedure PollForFiles(const AToken: ICancellationToken);
begin
// Looks for new files every 10 seconds. A cancellation ends the wait at once.
repeat
ImportNewFiles;
until AToken.WaitFor(10000);
end;
Raising an exception
Deep inside a call chain, returning early from every level is tedious. ICancellationToken.ThrowIfCancellationRequested raises EOperationCancelled when cancellation was requested, and does nothing otherwise:
procedure ImportFile(const AFileName: string; const AToken: ICancellationToken);
var
Lines: TStringList;
I: Integer;
begin
Lines := TStringList.Create;
try
Lines.LoadFromFile(AFileName);
for I := 0 to Lines.Count - 1 do
begin
AToken.ThrowIfCancellationRequested;
ImportLine(Lines[I]);
end;
finally
Lines.Free;
end;
end;
The caller that started the work catches the exception where giving up is expected:
try
ImportFile(AFileName, AToken);
except
on EOperationCancelled do
; // canceled on request: nothing was imported past this point
end;
Note
EOperationCancelled is the same exception class that the Delphi RTL raises for canceled tasks (unit System.Threading) in the Delphi versions that provide it, so a single handler catches both.
Waking Up a Blocked Wait
Checking and waiting on the token only help when the work is in control of its own wait. Code that blocks on something else - a queue, a socket, an event of its own - needs to be woken up from outside. ICancellationToken.Register registers a procedure that runs when cancellation is requested:
procedure ConsumeMessages(Queue: TThreadedQueue<string>; const AToken: ICancellationToken);
var
Registration: IInterface;
Msg: string;
begin
// PopItem blocks and knows nothing about the token. Shutting the queue down
// when cancellation is requested wakes it up.
Registration := AToken.Register(
procedure
begin
Queue.DoShutDown;
end);
while Queue.PopItem(Msg) = wrSignaled do
ProcessMessage(Msg);
end;
Keep the rules of callbacks in mind:
- The callback runs on the thread that calls
Cancel, beforeCancelreturns, so it must be short and thread safe. Typically it only wakes a wait: sets an event, shuts down a queue, closes a socket. - If cancellation was already requested, the callback runs immediately, on the calling thread, before
Registerreturns. Registerreturns a registration. Keep the reference for as long as the callback is wanted: releasing it unregisters the callback. Releasing it while the callback is running on another thread waits for the callback to finish.- Callbacks run most recently registered first. If some of them raise an exception, all of them still run, and
Cancelthen raises the first exception again.
Canceling After a Timeout
ICancellationTokenSource.CancelAfter requests cancellation once a number of milliseconds has passed. The same can be done at creation, with the constructor that receives a timeout:
procedure ImportWithTimeout(const AFileName: string);
var
Source: ICancellationTokenSource;
begin
// Canceled automatically after two minutes
Source := TCancellationTokenSource.Create(2 * 60 * 1000);
ImportFile(AFileName, Source.Token);
end;
Calling CancelAfter again restarts the countdown with the new value, INFINITE disarms it, and zero cancels immediately. The countdown runs on a shared background thread, where callbacks registered on the token run when it expires.
Combining Tokens
A common need is to stop when an outer request arrives or when a local limit is reached: a job that must stop when the application stops, or after two minutes, whichever comes first. TCancellationTokenSource.CreateLinked creates a source that is canceled as soon as any of the given tokens is canceled, in addition to its own Cancel and CancelAfter:
procedure ImportWithLinkedTimeout(const AFileName: string; const AToken: ICancellationToken);
var
Source: ICancellationTokenSource;
begin
// Canceled when AToken is canceled, or after two minutes, whichever comes first
Source := TCancellationTokenSource.CreateLinked([AToken]);
Source.CancelAfter(2 * 60 * 1000);
ImportFile(AFileName, Source.Token);
end;
Cancellation flows from the given tokens to the linked source only: canceling the linked source does not cancel the tokens it was created from. When the work needs to know why it was canceled, check the outer token: if it is not canceled, the local limit was reached.
When There Is Nothing to Cancel
An API that takes a token may be called by code that has no reason to cancel it. Pass TCancellationToken.None, a token that is never canceled:
ImportFile('orders.csv', TCancellationToken.None);
Cancellation in TMS Sparkle
The stop token that TMS Sparkle gives to background jobs and workers is a cancellation token: everything in this chapter applies to it, and it can be passed to any code that accepts an ICancellationToken.