Thursday, February 12, 2015

PPL - TTask an example in how not to use.

Delphi XE7 contains a new Parallel Programming Library, which is really powerful and easy to start using.   But it can be something that can be done wrong, and not realize it until it's much later.

Lets take the following fictional example.

There is form with a single button and a list box.   When the button is pressed a long process occurs, when the process is complete it needs to add an item to the list box.

This could be done without Multi-threading.
 
procedure TForm5.Button1Click(Sender: TObject);
begin
  Button1.Enabled := False;
  SlowProc;
  Button1.Enabled := True;
end;

procedure TForm5.SlowProc;
begin
  Sleep(10000); // simulate long process
  Listbox1.Items.Add('10 Seconds');
end;
During testing it is determined that the user needs to be able to re-size the application while the long process is running.

But never fail XE7 has been released with TPL and TTask to the rescue.

The first iteration SlowProc is changed to use a task.
 
procedure TForm5.SlowProc;
var
 Task : ITask;
begin
 Task := TTask.Create( procedure
                begin
                  Sleep(10000); // simulate long process
                  Listbox1.Items.Add('10 Seconds');
                end);
 Task.Start;
end;
Run the application and it appears to work.   Then further testing reveals a couple of problems.
The first being that button can now be pressed multiple times.   The second is that if the form is closed form right after pressing the button an few seconds later and access violation occurs.   
  • The reason the button can be pressed multiple times is that the enabled is set back to true after the task is started and not 
  • The cause of the access violation is that the code is still executing after the form has been freed.
The second iteration the code is now changed:
 
procedure TForm5.Button1Click(Sender: TObject);
begin
  Button1.Enabled := False;
  SlowProc;
end;

procedure TForm5.SlowProc;
var
 Task : ITask;
begin
 Task := TTask.Create( procedure
                begin
                  Sleep(10000);
                  if Assigned(ListBox1) then
                  begin
                    Listbox1.Items.Add('10 Seconds');
                    Button1.Enabled := True;
                  end;
                end);
 Task.Start;
end;
This now appears to work.  But, now there can be an up to 10 second delay before the application stops running after the form closes.  Now this is fictional example that has just a single sleep() call.   This can occur with real world items as well, but often there are several steps in the method, so I am going to simulate multiple steps, with a loop 0..9 with a call to sleep(1000);
 
procedure TForm5.SlowProc;
var
 Task : ITask;
begin
 Task := TTask.Create( procedure
                var
                   I : Integer;
                begin
                  for I := 0 to 9 do
                     Sleep(1000);
                  if Assigned(ListBox1) then
                  begin
                    Listbox1.Items.Add('10 Seconds');
                    Button1.Enabled := True;
                  end;
                end);
 Task.Start;
end;
Now the fictional example show multiple steps.    But it does not solve the problem with the application running for up to 10 seconds after the main form is closed.     When the form is begin closed the Task needs to be notified so it can stop running. This can be be done with the ITask.Cancel method.
To resolve this a third iteration is produced.

Task : ITask;  has been moved from SlowProc, and is now a member of the form.
 
procedure TForm5.Button1Click(Sender: TObject);
begin
  Button1.Enabled := False;
  SlowProc;
end;

procedure TForm5.FormDestroy(Sender: TObject);
begin
  Task.Cancel;
end;

procedure TForm5.SlowProc;
begin
 Task := TTask.Create( procedure
                var
                   I : Integer;
                begin
                  for I := 0 to 9 do
                  begin
                     if TTask.CurrentTask.Status = TTaskStatus.Canceled then
                        exit;
                     Sleep(1000);
                  end;
                  if Assigned(ListBox1) then
                  begin
                    Listbox1.Items.Add('10 Seconds');
                    Button1.Enabled := True;
                  end;
                end);
 Task.Start;
end;
This all appears to work and is released.   Sometime later in real world strange behaviors and errors are reported on this screen.    After research it is learned that the GUI is not thread safe, so we use a TThread.Queue, to the GUI code to run in the main thread.
Now onto the forth iterations of the code
 
procedure TForm5.SlowProc;
begin
 Task := TTask.Create( procedure
                var
                   I : Integer;
                begin
                  for I := 0 to 9 do
                  begin
                     if TTask.CurrentTask.Status = TTaskStatus.Canceled then
                        exit;
                     Sleep(1000);
                  end;
                  if TTask.CurrentTask.Status <> TTaskStatus.Canceled then
                  begin
                    TThread.Queue(TThread.CurrentThread,
                    procedure
                    begin
                      if Assigned(ListBox1) then
                      begin
                        Listbox1.Items.Add('10 Seconds');
                        Button1.Enabled := True;
                      end;
                    end);
                 end;
              end);
 Task.Start;
end;
Now we have an finally application that should work without error.     Granted this a fictional example, but it shows just some of the pitfalls that can come with multi-threading.  Each is relatively easy to deal with.   

11 comments:

  1. Great example! Why not just call Synchronize(...) instead of TThread.Queue(...) ?

    ReplyDelete
    Replies
    1. I have just seen it done that way and never thought to ask that question.

      After doing a code review, it appears there is a slightly different behavior in both. Ultimately both call TThread.Synchronize(ASyncRec: PSynchronizeRecord; QueueEvent: Boolean = False)

      QueueEvent = true when using Queue, otherwise the default is false which is what the other typical synchronize method use.

      I am not 100% sure but it appears the Synchronize will pause the current thread waiting a INFINITE amount of time for the code to execute. Queue will place it in the queue to be run and not then return.

      Delete
    2. Synchronize tries to post it to the main thread immediately and runs greater risk of deadlock. Queue waits for the main thread to be idle and has less risk of deadlock.

      Delete
    3. Agreed, there is no need to wait at the end of the task to update form controls. Queue seems the right thing for the task here.

      Delete
  2. Now your button will stay disabled if your task is being canceled somehow.

    ReplyDelete
    Replies
    1. Very true, although currently the only way to cancel task is to free the form.

      Delete
  3. Dang! This is the third article I've seen about Task Parallel Library, it looks very nice. Almost single handedly, it makes me consider upgrading my codebase from XE2.

    ReplyDelete
  4. IMHO
    TThread.Queue(TThread.CurrentThread,
    should be
    TThread.Queue(nil, //synchronize to the main thread

    ReplyDelete
    Replies
    1. The documentation http://docwiki.embarcadero.com/Libraries/XE7/en/System.Classes.TThread.Queue
      Mentions that: "You can use nil/NULL as the AThread parameter if you do not need to know the information of the caller thread in the main thread."

      Given this example, where I don't need to reference the original thread, it's not required.

      Delete
  5. The new PPL makes Parallel programming easier, but you still need understand how it works and handle threads appropriately. Good post.

    ReplyDelete
  6. Use messages to communicate between threads and components (good old PostMessage and message procedures). Object reference are problematic, either because the objects can be freed, or in a GC/ARC because they will retain references.
    A message will just be discarded automatically if its target is gone.
    As a further step, you also need tho provision against exceptions, and give an ability to cancel a task before it has been completed.

    In the end, good tasks are either complex hand-made, case-specific tailored affairs, or ... scripts! A good script engine will allow to cancel any task at any time, will wrap exceptions and will naturally keep script-side references distinct from those Delphi-side through sandboxing.

    ReplyDelete