Multi-threaded programming

A multi-threaded program allows several "threads of execution" to run simultaneously.  This means that the program is executing in several places at the same time!  If you haven't encountered threads before, then you are probably worried that this sounds like a recipe for disaster.  In fact it usually is.

There are three common reasons for doing this:

  1. Logically there are several things happening concurrently, and it's the easiest way to express the logic of the program.
  2. For performance reasons, running things simultaneously is faster on multiple CPU cores.
  3. To avoid blocking operations such as IO or time-consuming computations, which would interfere with the normal operation of the program, such as keeping a GUI responsive.

There are many problems with multi-threaded programming.  When different threads try to modify the same data at exactly the same time, the program could corrupt the data, or enter a race condition where the precise sequence of operations changes the outcome of the program.  You could also have a situation where the threads are all waiting on the same thing so the program becomes "starved".  And you could get a situation where all threads are waiting on each other - which is called a deadlock.

C++Script is designed to make it easy and safe to write multi-threaded programs.  C++Script uses an "apartment-threaded" model, which basically creates a different "apartment" or heap per thread.  A garbage collector runs independently in each thread.  A thread runs in just one apartment at a time.  When a thread yields (for example accessing a different apartment, waiting on a mutex, condition, or sleeping etc), then it releases its current apartment so other threads can access the data in the apartment. This guarantees that var is threadsafe, whilst still allows threads to access each others' data in a controlled manner.  You can still get race conditions but your program won't crash.

Creating threads

A thread is created by the thread() function which runs the given functor in a new thread.  The following example (thread1.cpp) creates functors using bind(), which when executed writes out the numbers in the range.  However the thread() function creates a new thread and executes this in a different thread, and the script_main() function waits for one second (using sleep()) whilst the threads do their work.

#include <cppscript>
 
void thread_fn(var r)
{
    foreach(i,r) writeln(i);
}
 
var script_main(var)
{
    thread( bind( thread_fn, range(0,100) ) );
    thread( bind( thread_fn, range(100,200) ) );
    sleep(1);
    return 0;
}

Synchronising threads

A thread is an object which contains a "join" method.  Calling this method waits for the thread to finish, and returns the exit value of the thread.  The exit value can be any var type and it isn't limited to integers.  In this respect, "join" is much more like a future.  e.g. (join.cpp)

#include <cppscript>
 
var sum_members(var data)
{
    var total = 0;
    foreach( i, data ) total += i;
    return total;
}
 
var script_main(var)
{
    var thread1 = thread(bind(sum_members, range(1,10000)));
    var thread2 = thread(bind(sum_members, range(10001,20000)));
    var total = thread1["join"]() + thread2["join"]();
    writeln( "The total is " + total );
    return 0;
}

If a thread throws an exception, then the exception is re-thrown by the "join" method.

There are also functions for mutexes and events.  You should avoid these functions and prefer message queues.

Message queues

A good way for threads to communicate is using message queues.  A message queue is a sequence of values, and a number of producer threads push values onto the queue, and a number of consumer threads pop values from the queue.

A message queue is created using the queue() function, which has the following methods:

 

queue() implements a FIFO stack.

Thread pools

A thread pool is a message queue with a number of consumer threads.  Each consumer is a functor (executed in a separate thread), which receives and processes messages.  A thread pool is created by the message_queue() function, which accepts a functor, and optionally a number of threads (default=1).

A message_queue is a type of queue, with the following methods:

It is good practise to "close"() the queue in order to tidy up an running threads (which may otherwise not stop when the program exits).

The following example (queue.cpp) creates a message queue with a single consumer thread, which receives a sequence of numbers.  The consumer (the function counter_add), adds each number to the total (counter["total"]).  The += operator is actually atomic so multiple consumer threads could work safely.  As an added refinement, the "post" method of the queue is assigned to the "queued_add" member of the counter, implementing an active method.

#include <cppscript>
 
void counter_add(var counter, var value)
{
    counter["total"] += value;
}
 
var counter()
{
    return object("counter").extend
         ("total", 0)
         ("add", counter_add);
}
 
var script_main(var)
{
    // Set up
    var counter1 = counter();
    var queue1 = message_queue(counter1["add"]);
    finally(queue1["close"]);   // Join the thread
    counter1["queued_add"] = queue1["post"];
 
    // Queue the data
    foreach(i, range(1,1000)) counter1["queued_add"](i);
 
    // Wait for the queue to process all items
    queue1["wait"]();
 
    // Finish
    writeln( "The total is " + counter1["total"] );
    return 0;
}