Assigning result to a function from asynchronous code
One of the more common problems that comes up in multi-threading, especially when refactoring existing code, is assigning a result obtained from asynchronous code. In other words, how do you write a function that will return some value, and calculate that value in a background thread?
Basically, converting the following code:
function GetData(...): string;
var
Data: string;
begin
Data := LongTask(...);
// assign result of a long-running task to a function
Result := Data;
end;
To this one:
function GetData(...): string;
var
Data: string;
begin
TThread.CreateAnonymousThread(
procedure
begin
Data := LongTask(...);
end).Start;
// assign result of a long-running task to a function
Result := Data;
end;
Now, the above code will compile, but it will not achieve the desired functionality. Even if
we ignore its thread safety issues (the background thread writes to the Data
variable and
that can interfere with assigning it to the function result), the function
will return long before the anonymous thread and the LongTask
have finished the work
and have had a chance to assign something to Data
.
The actual effect of running such a GetData
function will simply be to return an empty string.
If we try to move the result assignment into a thread, we will not be able to compile such code, and we will get an error:
E2555 Cannot capture symbol 'Result'
One way of retrieving the result from a thread is waiting for the thread to finish its work, and then using whatever data it has provided.
function GetData: string;
var
Data: string;
Thread: TThread;
begin
Thread := TThread.CreateAnonymousThread(
procedure
begin
Data := LongTask;
end);
// if we want to wait for a thread
// we need to manually manage its memory
Thread.FreeOnTerminate := False;
Thread.Start;
Thread.WaitFor;
Thread.Free;
Result := Data;
end;
Now we have successfully retrieved the data from the thread. But what we have accomplished by waiting for the thread is just a more complicated variant of the original single-threaded function.
Because the main reason for using background threads for running long tasks is keeping the application responsive, our goal is not achieved. Yes, we will run the long task in a background thread, but then waiting for the background thread will still block the main thread and make our application unresponsive.
Of course, you could make a more elaborate waiting mechanism which will also
include pumping the message queue with Application.ProcessMessages
, but this kind of
code just opens another can of worms.
The proper solution is to refactor a function into a procedure, and use a callback that will receive the data once it is retrieved. You need to move from running the code synchronously, where calling a function will return a result, into event-driven code that will run some code when some event happens—in this case, when the background thread retrieves the data we need.
This is similar to the existing Delphi application architecture: when the user clicks a
button and if the button has an associated OnClick
event handler, the code in that
event handler will run.
The same principle applies on threads: When a thread finishes, it can call an associated method (callback or completion handler) and pass data to that callback procedure, or it can use some kind of event (message) dispatching system to send a event with the data attached.
The TThread
class already has a build-in callback mechanism in the form of OnTerminate
notification event, but this is a plain TNotifyEvent
method, where the sender will be
the associated thread, and to pass data to that event, you either need to use a custom
thread class that will store data in a property, or you need to involve other
variables in a broader scope.
There is another way to achieve similar functionality that can work on any kind of thread, including anonymous ones: Using a different callback procedure that defines parameters for all the data we need to return from a thread:
procedure GetData(...; OnDataRetrieved: TProc<string>);
begin
TThread.CreateAnonymousThread(
procedure
var
Data: string;
begin
Data := LongTask(...);
TThread.Synchronize(nil,
procedure
begin
OnDataRetrieved(Data);
end);
end).Start;
end;
And the usage of GetData
changes from:
procedure TForm1.ButtonClick(Sender: TObject);
var
Data: string;
begin
Data := GetData(...);
Memo1.Lines.Add(Data);
// do anything else with data here
end;
to the following code, using an anonymous method as a callback:
procedure TForm1.ButtonClick(Sender: TObject);
begin
GetData(...,
procedure(Data: string)
begin
Memo1.Lines.Add(Data);
// do anything else with data here
end);
GetData(ProcessData);
end;
or the following code, using an additional regular method as a callback:
procedure TForm1.ButtonClick(Sender: TObject);
begin
GetData(..., ProcessData);
end;
procedure TForm1.ProcessData(Data: string);
begin
Memo1.Lines.Add(Data);
// do anything else with data here
end;
Note: Synchronizing callback OnDataRetrieved
with the main thread is needed because we are accessing UI in the callback and we can only do that safely from the context of the main thread. Synchronizing callback with the main thread also matches the behavior of OnTerminate
event handler which also runs synchronized with the main thread.
You should have pointed out that using TThread.Synchronize is very important here because the callback procedure is called in the context of the thread but accesses the VCL, so it must switch to the main thread in order to do that safely.
ReplyDeleteThanks! I added a small note for those that might wonder why is synchronize used there.
DeleteThis approch called continuation passing style and lead to callback hell (see JavaScript before async/await came)
DeleteTask/feature-based style is way more readable, IMHO
consider we have 3 long operations and wanna combine them
Sync style
function LongOp1(Input: Interger): string;
function LongOp2(Input: string): TDateTime;
function LongOp3(Input: TDateTime): TObject;
var Op1Result := LongOp1(0);
var Op2Result := LongOp2(Op1Result);
var Op3Result := LongOp3(Op2Result);
// do something with Op3Result
Continuation passing style:
procedure LongOp1(Input: Interger; const AContinuation: TProc);
procedure LongOp2(Input: string; const AContinuation: TProc);
procedure LongOp3(Input: TDateTime; const AContinuation: TProc);
LongOp1(0,
procedure (Op1Result: string)
begin
LongOp2(Op1Result,
procedure(Op2Result: TDateTime)
begin
LongOp3(Op2Result,
procedure (Op3Result: TObject)
begin
// You see, every LongOpX call increases nesting of your code
// do something with Op3Result
end);
end);
end)
Task/feature style
TFuture = record
type TFutureFunc = reference to function (AResult: TInput): TTask;
function ContinueWith(AContinuation: TFutureFunc): TFuture; overload;
procedure ContinueWith(AContinuation: TProc); overload;
end;
function LongOp1(Input: Interger): TFuture;
function LongOp2(Input: string): TFuture;
function LongOp3(Input: TDateTime): TFuture;
var Op1ResultFuture := LongOp1(0);
var Op2ResultFuture := Op1ResultFuture.ContinueWith(LongOp2);
var Op3ResultFuture := Op1ResultFuture.ContinueWith(LongOp3);
Op3ResultFuture.ContinueWith(
procedure (Op3Result: TObject)
begin
// do something with Op3Result
end);
Callback hell can be an issue if there are several levels of callbacks. Using some kind of Future/Continuation framework can solve that part.
DeleteHowever, the simplest and the most effective solution would be running those multiple long tasks that need to be done one after another in a single background thread/task which has single callback if necessary.
That's a nice way to deal with the problem, yes.
ReplyDeleteHowever, if you have a desktop application and a VCL Form is involved in this process, I still prefer to use messages. Pass the handle of the window to the threaded GetData() method and it can call SendMessage/PostMessage when done. It's easier to implement a more complex logic that deals with different (unexpected) conditions like errors. The biggest bonus is that you get rid of Synchronize() completely.
This was just example of how to use callbacks feature.
DeleteMessaging (windows default messaging or using some other messaging framework) is another way of handling completion, but it can be more complicated to setup in some situations instead of having a simple callback method.
Additional problem with Windows messaging approach and using VCL controls is that its handle can become invalid in the meantime as handles that belong to windowed controls can be recreated during control lifetime. You could allocate separate handle just for messaging purposes, though.