Breaking Down Concurrency Problems
The goal of today’s article is to consider when you want to run your tasks simultaneously and how to do that. I am not going to give any rules for this because what works one time may not work the next. Instead, I will focus on sharing some guidelines that I have learned from personal experience.
Remember Your Promises
Whenever you use concurrency, you want to hold on to the related
Promise
objects. They are almost
always the best way to rejoin your tasks, to cause the main thread to await
completion of your concurrent tasks, etc.
A common pattern I see in my code looks like this:
my $gui-task = start { ... }
my $console = start { ... }
my $jobs = start { ... }
await Promise.allof($gui-task, $console, $jobs);
Just like that, I have three tasks running in three different threads and the main thread is holding until the three tasks complete.
That await
is also where you want to add your CATCH
blocks as that’s the
point at which exceptions from the other threads will rejoin the calling thread.
The Main Thread is Special
When you write your concurrent program, be aware that the main thread is
special. It will not be scheduled to run a task and your program will continue
to run as long as it is doing something or await
ing on something. As soon as
the main thread exits, your other tasks will immediately be reaped and quit.
Prefer a Single Thread for Input or Output
Avoid sharing file handles or sockets between threads. Only a single thread can read or write to a single handle at a time. The easiest way to make sure you do that safely is to keep that activity in a single thread. On a multi-threaded program where any thread may output to standard output or standard error, I often invoke a pattern like the following:
my Supplier $out .= new;
my Supplier $err .= new;
start {
react {
whenever $out { .say }
whenever $err { .note }
}
}
start {
for ^10_000 { $out.emit: $_ }
}
start {
for ^10_000 { $out.emit: $_ }
}
If you don’t employ a pattern like that, your program will probably still work, but you may end up with some strange oddities with your output.
Raku Data Structures are Not Inherently Safe
Similar to what was said in the previous section for input and output, please note that most Raku data structures are not thread safe. If you want to use a data structure across threads, you must use some strategy for making that access thread safe. Some strategies that will work are:
-
Use a thread that manages access to that data structure, as was done above for standard output and standard error.
-
Use a monitor pattern to secure the data structure.
-
Make use of
cas
,Lock
,Lock::Async
,Semaphore
,Promise
, or one of the other locking mechanisms available to guard access to the object as appropriate. -
Manage the modifications to the object using a
Channel
orSupply
.
Whatever you do, do not assume access to an object is thread safe unless thread safety is explicitly part of the design of the object.
Use a Task per GUI Event Loop or Window
If your application has a GUI, you almost certainly want a separate thread for managing input and output with the GUI. Most GUI libraries have a built in event loop already and you want to run that as a task in a separate loop. You may want a single task for your whole GUI or you may want a separate task per Window.
Batch Small Tasks
You do not always want to have a single task for every action. Some actions are simply too trivial and the execution is too short to manage this. What is a reasonable size of task is really up to you and your execution environment. Just be aware that running your tasks in batches is often a better strategy than running them in tiny bits when the processing involved is trivial.
If you use the hyper
or race
keywords
or methods to parallelize
work, batching is built-in and automatic. You may want to experiment with the
parameters to see if tuning the batch sizes of your task results in speed
increases.
Break Larger Tasks into Smaller Ones
Some concurrent tasks you just want to run continuously as CPU time is available or trigger whenever an event comes available. However, single run tasks that run long can sometimes benefit from being broken down into smaller ones. There are only a finite number of tasks that can run simultaneously and breaking them down can help make sure that the CPU stays busy.
One easy way to break down your tasks is into insert await
statements in
natural places. As of Raku v6.d, you can effectively turn your tasks into
coroutines by pausing with an await
until a socket is ready, more data comes
in, a signal arrives from a Promise
or Channel
, etc. Remember that any time
Raku encounters an await
, it is an opportunity for Raku to schedule work for
another task on the thread the current task is using.
Beware of Thread Limitations
There are a limited number of threads available. If you have a task with the potential to run a large number of tasks, take some time to consider how the tasks are broken down. Limiting the number of dependencies between tasks will allow your program to scale efficiently without exhausting resources.
Any time your tasks must pause for input or for whatever reason, making sure to
do that with an await
will ensure that the maximum number of threads are ready
for work.
Avoid Sleep
I consider sleep
to be harmful.
Instead, prefer await Promise.in(...)
as that gives
Raku the ability to reuse the current thread for another task. Only use sleep
when you deliberately want to lock up a thread during the pause. I make use of
sleep
in this Advent calendar mostly because it is more familiar. In practice,
I generally only use it on the main thread.
Conclusion
Much of this advice overlaps the advice on breaking up async problems. I hope this provides some useful guidelines when writing concurrent programs.
Cheers.