Async Tasks in VCL Projects

  

Sometimes actions inside an application need their time. From retrieving data from REST service or a database to scanning your hard disk for all files containing images, there are a plethora of things that can be time consuming. In case these tasks are executed in the main thread the application will probably become unresponsive and feels like frozen – to the user as well as to the operating system.
There is a good chance that one can solve this by moving the time consuming task into a thread. That is usually where the problems start creeping in.
Let’s pick one of the above scenarios for a concrete example. Searching for files is something we all do often enough to be of practical relevance and it is something everyone can do without any other requirements. It can even be done without an internet connection, although one would have difficulties to actually read this article in that case.
I have created a small VCL project containing an edit to specify the root folder for the search and a search box for the file mask. The found file names will be displayed in a list view. As the list can be pretty long the list view is used in virtual mode.

The relevant methods doing the search are pretty straight forward:procedure TSearchForm.StartSearch;
begin
StatusBar.SimpleText := ”;
dspFiles.Clear;
Files.Clear;
BeginSearch;
SearchFolder(edtRootFolder.Text, edtSearchPattern.Text);
EndSearch;
end;

procedure TSearchForm.SearchFolder(const APath, ASearchPattern: string);
var
arr: TArray<string>;
dir: string;
begin
arr := TDirectory.GetFiles(APath, ASearchPattern);
AddFiles(arr);
{ release memory as early as possible }
arr := nil;
for dir in TDirectory.GetDirectories(APath) do begin
if not TDirectory.Exists(dir) then Continue;
SearchFolder(dir, ASearchPattern);
end;
end;

procedure TSearchForm.AddFiles(const AFiles: TArray<string>);
begin
Files.AddStrings(AFiles);
dspFiles.Items.Count := Files.Count;
StatusBar.SimpleText := Format(‘%d files found’, [Files.Count]);
end;When we start this program and execute a non-trivial search, the app becomes unresponsive until the search is finished. Let’s move the search to a thread now.
Wait!
The first rule when it comes to multi-threaded programming is: Never start with a thread!
Always work in the main thread and refactor the architecture and classes until you feel comfortable to move the code to a thread. Identify the code parts that need synchronization to the main thread. Make sure all data is used by one thread at a time. Think of parallel, but don’t do it. If you think you are ready to add System.Threading (or your preferred library) to the uses clause – continue refactoring for another couple of rounds to also cover the corner cases you missed up to now.
Often it is a good thing to extract the future threading code into its own class and abstract the dependencies to the user interface (in this case the VCL controls). The relevant code parts here are three methods: BeginSearch, EndSearch and AddFiles. We create an interface ISearchTarget for these methods.type
ISearchTarget = interface
procedure AddFiles(const AFiles: TArray<string>);
procedure BeginSearch;
procedure EndSearch;
end;Luckily TComponent happens to implement IInterface and as TForm is derived from TComponent it is sufficient to add ISearchTarget to TSearchForm to make it support the interface.type
TSearchForm = class(TForm, ISearchTarget)Remember, we are still working for a single-threaded implementation and no async-whatever is in sight here, so we simply name the new class TSearch.type
TSearch = class
private
FPath: string;
FSearchPattern: string;
FTarget: ISearchTarget;
procedure SearchFolder(const APath, ASearchPattern: string);
strict protected
procedure AddFiles(const AFiles: TArray<string>); virtual;
procedure BeginSearch; virtual;
procedure EndSearch; virtual;
public
constructor Create(ATarget: ISearchTarget; const APath, ASearchPattern: string);
procedure Execute;
end;

procedure TSearch.Execute;
begin
BeginSearch;
SearchFolder(FPath, FSearchPattern);
EndSearch;
end;The SearchFolder implementation is the same as in TSearchForm, while BeginSearch, EndSearch and AddFiles just forward the call to FTarget .
In TSearchForm we can remove the SearchFolder method completely and change the implementation of StartSearch to make use of our new class.property Search: TSearch read FSearch write SetSearch;

procedure TSearchForm.SetSearch(const Value: TSearch);
begin
FSearch.Free;
FSearch := Value;
end;

procedure TSearchForm.StartSearch;
begin
{ cancel any running search }
Search := nil;
StatusBar.SimpleText := ”;
dspFiles.Clear;
Files.Clear;
Search := TSearch.Create(Self, edtRootFolder.Text, edtSearchPattern.Text);
try
Search.Execute;
finally
Search := nil;
end;
end;The reason for using a property Search in TSearchForm backed up by a field instead of a local variable will become clear later.
Running some tests show a similar experience as before, but that was expected. Make sure that it works as expected, too.
Can we stop this, please?
When it comes to multi-threading we need to think about a way to cancel the search when the form is closed or another search is initiated. Well, strictly speaking we don’t need to actually stop it, but rather make it skip calling the ISearchTarget methods any longer, but that may keep the search using resources without need. For this we add a method Cancel to our class to set an internal flag, which we expose with property Cancelled.procedure Cancel;
property Cancelled: Boolean read FCancelled;

procedure TSearch.Cancel;
begin
FCancelled := True;
end;Then we change BeginSearch, EndSearch and AddFiles to check for Cancelled before forwarding the call (i.e. skip calling the ISearchTarget methods).procedure TSearch.AddFiles(const AFiles: TArray<string>);
begin
if Cancelled then Exit;
{ nothing to to? }
if Length(AFiles) = 0 then Exit;
FTarget.AddFiles(AFiles);
end;

procedure TSearch.BeginSearch;
begin
if Cancelled then Exit;
FTarget.BeginSearch;
end;

procedure TSearch.EndSearch;
begin
if Cancelled then Exit;
FTarget.EndSearch;
end;We also add a break condition in SearchFolder so we don’t waste resources.procedure TSearch.SearchFolder(const APath, ASearchPattern: string);
var
arr: TArray<string>;
dir: string;
begin
arr := TDirectory.GetFiles(APath, ASearchPattern);
AddFiles(arr);
{ release memory as early as possible }
arr := nil;
for dir in TDirectory.GetDirectories(APath) do begin
if Cancelled then Exit;
if not TDirectory.Exists(dir) then Continue;
SearchFolder(dir, ASearchPattern);
end;
end;How can we test the new Cancel mechanism? Remember that we introduced the property Search for the TSearch instance instead of a local variable, which actually would have done as well? The property allows us to call Search.Cancel inside AddFiles, which provides a way to test cancelling even in a single threaded scenario.procedure TSearchForm.AddFiles(const AFiles: TArray<string>);
begin
Files.AddStrings(AFiles);
dspFiles.Items.Count := Files.Count;
StatusBar.SimpleText := Format(‘%d files found’, [Files.Count]);
if Files.Count >= 10000 then begin
if Search <> nil then
Search.Cancel;
EndSearch; // When Cancelled the final EndSearch is not called from the search
end;
end;
Now we go parallel!
Well, not yet!
The first thing to note when switching to a parallel execution is that we cannot keep the code in TSearchForm.StartSearch. Creating an instance of TSearch, calling Execute and free it is not a valid approach for asynchronous processing. So, what are we actually doing with Search after its creation? We only call Execute and Cancel.
Perhaps we don’t need the full fledged TSearch instance and just a way to call Cancel would do? What about an interface ICancel providing such a method that we can hold in TSearchForm instead of the TSearch instance?type
ICancel = interface
procedure Cancel;
function IsCancelled: Boolean;
end;To make TSearch give us such an interface we declare a class method Execute returning the interface as an out parameter. The protected overloaded Execute is for future extension.TSearch = class

strict protected
procedure Execute(ACancel: ICancel); overload; virtual;
public
class procedure Execute(ATarget: ISearchTarget; const APath, ASearchPattern: string; out ACancel: ICancel); overload;

procedure TSearch.Execute(ACancel: ICancel);
begin
Execute;
end;We declare a nested class TCancel implementing ICancel, merely just forwarding the calls to the TSearch instance given to its constructor.TSearch = class
type
TCancel = class(TInterfacedObject, ICancel)
private
FSearch: TSearch;
strict protected
procedure Cancel;
function IsCancelled: Boolean;
public
constructor Create(ASearch: TSearch);
destructor Destroy; override;
end;
private

constructor TSearch.TCancel.Create(ASearch: TSearch);
begin
inherited Create;
FSearch := ASearch;
end;

destructor TSearch.TCancel.Destroy;
begin
FSearch.Free;
inherited Destroy;
end;

procedure TSearch.TCancel.Cancel;
begin
FSearch.Cancel;
end;

function TSearch.TCancel.IsCancelled: Boolean;
begin
Result := FSearch.Cancelled;
end;Note that TCancel is also responsible for destroying that instance when its reference count goes to zero. This makes the implementation of Execute pretty straight forward.class procedure TSearch.Execute(ATarget: ISearchTarget; const APath, ASearchPattern: string; out ACancel: ICancel);
var
instance: TSearch;
begin
instance := Self.Create(ATarget, APath, ASearchPattern);
{ TCancel is responsible for destroing instance }
ACancel := TCancel.Create(instance);
instance.Execute(ACancel);
end;The declaration and implementation of TSearchForm now look like this.property Search: ICancel read FSearch write SetSearch;

destructor TSearchForm.Destroy;
begin
Search := nil;
FFiles.Free;
inherited;
end;

procedure TSearchForm.SetSearch(const Value: ICancel);
begin
if FSearch <> nil then
FSearch.Cancel;
FSearch := Value;
end;

procedure TSearchForm.StartSearch;
begin
Search := nil;
StatusBar.SimpleText := ”;
dspFiles.Clear;
Files.Clear;
TSearch.Execute(Self, edtRootFolder.Text, edtSearchPattern.Text, FSearch);
end;Setting Search to nil in TSearchForm.StartSearch and TSearchForm.Destroy makes sure that a still running background search will be cancelled and stop calling methods of the soon vanishing form instance or interfering with the new search results.
Why an out parameter instead of a function result?
If TSearch.Execute were a function returning ICancel, the Search property in TSearchForm would be assigned when the function returns. As we still are non-threaded that would be when the search already has finished. Thus we would not have the ICancel interface in Search when we try to call it inside AddFiles. We have already taken care to assign the out parameter before we call instance.Execute.
We can still test all this with our single thread approach.
Now we really go parallel!
We derive TAsyncSearch from TSearch overriding some methods.TAsyncSearch = class(TSearch)
strict protected
procedure AddFiles(const AFiles: TArray<string>); override;
procedure BeginSearch; override;
procedure EndSearch; override;
procedure Execute(ACancel: ICancel); overload; override;
public
end;

procedure TAsyncSearch.Execute(ACancel: ICancel);
begin
TTask.Run(
procedure
begin
{ capture ACancel to keep the current during the lifetime of the async method }
if not ACancel.IsCancelled then
Execute;
end);
end;To keep ACancel alive during the anonymous method (and thus the current TAsyncSearch instance), we call ACancel.IsCancelled instead of simply Cancelled.
We have to add some synchronization for the codes that access FTarget when we are not in the main thread.procedure TASyncSearch.AddFiles(const AFiles: TArray<string>);
begin
TThread.Synchronize(nil, procedure begin inherited; end);
end;

procedure TASyncSearch.BeginSearch;
begin
TThread.Synchronize(nil, procedure begin inherited; end);
end;

procedure TASyncSearch.EndSearch;
begin
TThread.Synchronize(nil, procedure begin inherited; end);
end;There we are. Searching in the background.
Note the number of lines in this article actually doing parallel execution compared to the number of lines describing the preparation and refactoring.
As a side effect we got a simple class TSearch that can even be used in a separate thread providing a suitable ISearchTarget. Note that TAsyncSearch may not be fit for that, because it starts its own thread and synchronizes with the main thread.
The sources used in this article can be downloaded here: AsyncTasksInVclProjectsSource. (need Delphi 10.4.2 or up)
During my research for this article I found that the incredible Dalija Prasnikar has a similar example in chapter 35.2.2 of her book Delphi Event-based and Asynchronous Programming. You will also find an explanation why calling Cancel and Cancelled from different threads using a Boolean field is safe and what would be not. I can highly recommend that book for everyone thinking of starting multi threaded programming – and especially for those already doing it.
 

Comments are closed.