Exploring the Parallel Programming Library
On top of this core foundation, Delphi offers the more abstract Parallel Programming Library (PPL). This library allows you to express your application logic in a way that is less tied to the actual hardware and the capabilities of the CPU the program is running into. To clarify, the core element of the PPL is the TTask
class; this is conceptually similar to a thread but abstracted from the hardware. For example, if you create 20 threads, you are making requests to the operating system and the CPU. If you create 20 tasks, the PPL will create some threads in a pool, depending on the CPU multicore capabilities, and assign tasks to those threads, without overloading the CPU. Also, reusing threads saves time, as thread creation is a heavy operation at the operating system level.
The very first thing to do in order to use the PPL is to add the System.Threading
unit to the uses
clause of your program. As mentioned, on behalf of the app, the library maintains a self-tuning pool of threads that are used to execute tasks.
In the following sections, we’ll look at the high-level features that PPL offers to Delphi developers.
Parallel loops
The easiest concept to grasp in the PPL is the parallel for
loop. This is useful when calculations for different values of a control variable are independent and it is not important in which order they are executed. A good example of this use case comes from ray tracing algorithms used in computer graphics. To generate an image, we need to calculate the color of each pixel that makes up the resulting image. This is done by calculating the path of each ray of light in space. Calculating the color of a given pixel is completely independent of other pixels and can be performed simultaneously to generate the resulting bitmap faster.
Instead of implementing a ray tracer here, let’s create a truly simple demo that will allow us to observe how much faster a parallel loop would execute compared to a traditional loop. Instead of calculating pixel colors in each iteration of the loop, we will just call the Sleep
procedure, which will make the current thread sleep for a given number of milliseconds and then it will continue to run.
Let’s create a complete example using a parallel loop from scratch. First, create a new multi-device project in Delphi. Next, drop two buttons on the form and write the following code; in the caption of each button, this will display how much time it took to execute the loop. To calculate the elapsed time, we are using a TStopWatch
record type from the System.Diagnostics
unit. This is the code used to perform a slow operation:
function TForm1.DoTimeConsumingOperation(Length: integer): Double; begin var Tot := 1.0; for var I := 1 to 10_000 * Length do // some random slow math operation Tot := Log2(Tot) + Sqrt(I); Result := Tot; end;
Now, let’s write a regular for
loop using this slow operation and check how much time it takes:
procedure TForm1.btnForLoopRegularClick(Sender: TObject); var SW: TStopwatch; begin SW := TStopwatch.StartNew; for var I := 0 to 99 do DoTimeConsumingOperation(10); SW.Stop; (Sender as TButton).Text := SW.ElapsedMilliseconds.ToString + 'ms'; end;
By comparison, this is the same code using a parallel for
loop:
procedure TForm1.btnForLoopParallelClick(Sender: TObject); var SW: TStopwatch; I: Integer; begin SW := TStopwatch.StartNew; TParallel.For(0, 99, procedure(I: integer) begin DoTimeConsumingOperation(10); end); SW.Stop; (Sender as TButton).Text := SW.ElapsedMilliseconds.ToString + 'ms'; end;
The TParallel.For
class method we are using in the preceding code has many overloaded versions. In this example, it takes the starting index, ending index, and an anonymous procedure that takes an integer
parameter for the index.
On my Windows PC, for a 64-bit application, the regular for
loop takes about half a second (425 milliseconds, on average) while the parallel version takes only 35 milliseconds, about one-tenth of the time. While the absolute speed depends on your CPU power, the difference between the two timings depends on the number of cores available to your CPU.
By comparison on my Android phone, the same app compiled for 64-bit takes 2,814 milliseconds for the regular loop and only 670 milliseconds for the parallel version. Your time will vary depending on the device, but the difference between the two versions should remain significant (unless you run on virtualized hardware with only a few available cores).
Using tasks
One of the key use cases for multithreading code is to keep the user interface responsive while performing some long-running operation, such as downloading data from the internet or performing calculations. The main thread of the application (the one where the user interface runs) should not be busy with those time-consuming operations. They should be executed in the background thread to keep the user interface responsive.
Now, you can add two more buttons to the form and enter the following code in their OnClick
events:
procedure TForm1.btnNonResponsiveClick(Sender: TObject); begin DoTimeConsumingOperation(3_000); (Sender as TButton).Text := 'Done'; end; procedure TForm1.btnResponsive1Click(Sender: TObject); var ATask: ITask; begin ATask := TTask.Create(procedure begin DoTimeConsumingOperation(3_000); end); ATask.Start; (Sender as TButton).Text := 'Done'; end;
When you click the first button, the form freezes for a few seconds. When you click on the second button, the form stays responsive because the time-consuming operation is executed in a different thread and the main app thread can process user events normally. In fact, the Done
message is displayed almost immediately because the main thread continues to execute just after it starts the task. If you would like to display the Done
text on the button after the task is complete, you would need to do it from inside the background thread. You can only manipulate the user interface from the main app thread, which is why the call to change the button’s Text
property needs to be synchronized:
procedure TForm1.btnResponsive2Click(Sender: TObject); var ATask: ITask; begin ATask := TTask.Create(procedure begin DoTimeConsumingOperation(3000); TThread.Synchronize(nil, procedure begin (Sender as TButton).Text := 'Done'; end); end); ATask.Start; end;
Now, the label doesn’t change immediately, but after a few seconds, when the operation completes. A task is a bit like an anonymous procedure. You can declare it, but it only starts to execute when it is told to. You need to call the Start
method, and only then will the task start.
The beauty of futures
The PPL also provides the notion of a “future.” This is a specialization of a task generally tied to calculations or mathematical operations. A future is a task that returns a value. In the case of a task, you call its Start
method to execute. In the case of a future, you can just declare it and assign it to a variable. The PPL will execute the code when it has a thread available. When you try to retrieve the value of this variable, if the future has already been executed, the value is returned; if the future is still pending, it is executed as soon as possible and the program will wait for its completion. In any case, when you access the value of the future, you’ll read the value.
There are some interesting use cases when futures are used. For example, you may want to calculate something based on values of two or more parameters that need to be calculated first. Instead of calculating these parameters sequentially, we could perform these calculations in parallel.
For this demo, let’s reuse the calculations done earlier and read the resulting value. Let’s assume we have to calculate the value for three different parameters and add them at the end.
Let’s first have a look at how we could implement this functionality without futures. Here is an example using standard code (I’ve omitted the lines for timing and for displaying results):
var First, Second, Third: Double; begin First := DoTimeConsumingOperation(200); Second := DoTimeConsumingOperation(300); Third := DoTimeConsumingOperation(400); var GrandTotal := First + Second + Third;
GrandTotal
has to be computed by performing all the previous calculations first; on my PC, this code takes about 389 milliseconds.
You can implement the same functionality using futures. In this case, the code will look like this:
var First, Second, Third: IFuture<Double>; begin First := TTask.Future<Double>(function: Double begin Result := DoTimeConsumingOperation(200); end); Second := TTask.Future<Double>(function: Double begin Result := DoTimeConsumingOperation(300); end); Third := TTask.Future<Double>(function: Double begin Result := DoTimeConsumingOperation(400); end); var GrantTotal: Double := First.Value + Second.Value + Third.Value;
This code wraps each of the three operations in a future by providing the corresponding code in an anonymous method. At the end, you need to read the result of that calculation using the Value
property of each IFuture<Double>
variable.
Executing this code on the same PC requires about 176 milliseconds. This is about half and it’s possibly the time it takes for the slowest of the three calculations: the one with 400
as a parameter.
So, what’s the difference between a task and a future? A task is an operation or a procedure that you want to be executed in a thread. A future is a calculation or a function that you want to be executed in a thread.