Sterling has too many projects

Blogging about Raku programming, microcontrollers & electronics, 3D printing, and whatever else...

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 awaiting 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:

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.