Table of Contents

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:

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 TCancellation​Token​Source, give its ICancellation​Token​Source.​Token to the work, and call ICancellation​Token​Source.​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

ICancellation​Token.​IsCancellation​Requested 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 ICancellation​Token.​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. ICancellation​Token.​Throw​IfCancellation​Requested 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. ICancellation​Token.​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, before Cancel returns, 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 Register returns.
  • Register returns 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 Cancel then raises the first exception again.

Canceling After a Timeout

ICancellation​Token​Source.​Cancel​After 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. TCancellation​Token​Source.​Create​Linked 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 TCancellation​Token.​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.