Argentum threads act as ultra-lightweight processes. They don't use mutexes, conditional variables or atomics to share state. They share immutable objects naturally and isolate hierarchies of mutable objects inside single thread. There is a mechanism of asynchronous message passing that allows:
- to move mutable objects between threads
- to execute code on the thread of interest with full access to needed objects
- to pass weak and shared-pointers across threads.
Since all objects of all threads reside in the same address space, all these operations are lightweight. So Argentum provides data isolation and well-formed IPC at the cost comparable to the usual multithreaded processing.
Argentum rules on multithreading
- Each thread has its own hierarchy of mutable objects.
- Mutable objects of one thread are not directly accessible by other threads (like pointer in one OS process has no meaning in other processes).
- Threads do not communicate. Instead objects inside threats communicate with one another by posting asynchronous tasks (like IPC).
- Task contains:
- a delegate (that combines a weak pointer to the receiver object and a function to call)
- a tuple of actual invocation parameters.
- Object in one thread can hold delegates connected to objects in other threads, but this delegates are opaque:
- If called synchronously, they behave as not connected to any object.
- But if posted asynchronously, they:
- automatically discover the right queue to be put into,
- activate on the rights thread,
- lock its target and allow full access to this object and all its in-thread hierarchy.
- Object in one thread can also have weak pointers to objects from another threads. And these pointers are also opaque:
- If dereferenced, they read as null.
- They can be used to create delegates, that if posted asynchronously, allow to execute code on the targets' threads.
- They can also be posted as a parameters of another tasks that:
- If delivered to the same thread as target, can be used to access that object,
- Or if delivered to another thread allows to share knowledge about target objects between threads.
- Frozen shared objects can be shared between threads without limitations.
Thread Creation and Maintenance
Main application thread is special. It pre-exists. The application entry point executed on this thread as well as constants' initializers. Runtime library has a special method sys_setMainObject
that installs any object as the root object of the main thread.
Application works as long as this object is not null. This object will represent all application's mutable state.
Somewhere in this application state there may be created and retained multiple sys_Thread
objects. They represent additional threads. Any additional thread lives as long as its sys_Thread
object is alive. Thread object has start
method that accepts an object to become the root in this thread hierarchy. Upon start, this object is transferred to that thread. The started thread is ready to accept asynchronous requests to its internal objects. There is a way to acquire a weak pointer to the thread's root object.
Thread interop
Argentum has delegates - special type of callable objects that combine a weak pointer to the instance and a method of this object (or an inline code that can access this object using 'this
'-pointer).
Delegates are created with two kinds of syntax:
- pinPointerToObject.methodName
- weakOrPinPointerToObject.&someModuleGlobalName(parmeters){code}
Delegates can be called immediately (the same way as functions), stored in fields/variables/passed as parameters and results but also they can be posted asynchronously using syntax:
- delegate ~ (parameters)
This action
- evaluates parameter values,
- finds the thread where delegate target located and posts all data to this thread queue
- later in context of the target thread this data is extracted from this queue
- target object by this weak pointer is locked
- and this code or method is executed with all passed parameters.
If delegate parameters are objects, they are detached from their previous thread and attached to a new one.
using sys { Object, Array, String, Thread, log, setMainObject }
// Main thread state
class App{
// Main thread holds additional worker thread
worker = Thread(Object); // worker thread hosts a generic Object with no internal state
}
// Set state for main thread
app = App;
setMainObject(app);
// Start the worker thread
app.worker.start(Object); // Set an `Object` instance as the root in the worker thread.
// Create a delegate bound to the root object of the worker thread
greetingsDelegate = app.worker.root().&workerCode(){
log("Hello from the worker thread");
};
// Post it
greetingsDelegate~();
// Create another delegate bound to the main app object, that stops the application
endAppDelegate = app.&endEverything(){
log("Shutdown from the main thread");
setMainObject(?sys_Object); // destroys `app`, `worker` and inner object of the thread
};
// Create and post a delegate to the worker thread that calls our main thread delegate
app.worker.root().&workerWithCallback(callback &()){
log("Hello again from the worker thread");
callback~();
}~(endAppDelegate);
This code will print
Hello from the worker thread
Hello again from the worker thread
Shutdown from the main thread
Post-task Syntax
There is alternative syntax that combines creation and posting of delegate:receiverWeak ~~ taskName(params) { code }
With this syntax the above code can be rewritten as follows:
app.worker.root() ~~ workerCode { log("Hello from the worker thread") };
app.worker.root() ~~ workerWithCallback(&app) {
log("Hello again from the worker thread");
app ~~ endEverything {
log("Shutdown from the main thread");
setMainObject(?sys_Object)
}
}
In parenthesis there might be three types of expressions:
name = expression
// it creates task parameter withname
and initializes it withexpression
,name
// sugar forname = name
&name
// sugar forname = &name
This lets dramatically simplify inter-thread and asynchronous message syntax. See the playground threads
example.
Task termination
When task is posted from one thread to another, there is no way to terminate it from outside the thread on which it works. However there is a way to signal this task that it should stop. Usually task receives a weak pointer to an object that should be notified when task makes some progress or stops. In normal case the worker thread cannot pin this weak pointer, check or dereference it, because its target resides in a separate thread and as such always reads as null. But the runtime library has a tricky function sys_weakExists
that for any given weak pointer could say if its target was alive at the time of checking.
In order to make inter-thread task cancellable we should:
- Create an object representing the long lasting process that outlives multiple inter-thread tasks.
- Pass to the task a weak pointer to this process object. In most cases this weak pointer already exists as a completion handler.
- The long lasting thread task should periodically check this weak pointer with
sys_weakExists
function (which is a very cheap operation) and gracefully terminate task if it returns false. - When in need to terminate a long lasting process and all its tasks, we just delete the process object. Et voila, everything shuts down gracefully and swiftly!
Conclusion
Argentum has lightweight multithreaded environment.
Runtime requires very little points of synchronization. Mutexes are locked only on thread creation/disposal, posting message and every 8000th operation on shared objects stored in fields.
Existing code can work on any thread without modifications.
Argentum threading model:
- isolates object mutations in threads in transactional manner
- eliminates data races
- makes it not necessary to use any synchronization objects like mutexes, c-vars, atomics.
- own-weak-frozen pointers keep working in multithreaded environment with the same semantics, at the full speed
- these pointers help to structure inter-thread communications and automate operations that otherwise require heavy-weight machinery (for example weak pointers to objects in a different threads, are thread-safe COM-moniker or resource locator service, but it is represented just by a single machine-word pointer to the existing weak-block structure).
What's next with multithreading:
- a number of corner cases, optimizations and checks left not implemented yet
- thread-pools.
1. There\’s an error in ThreadExample.ag, line 28:2
2. I\’ve checked threads example in the playground, and for me the results are quite \”consecutive\”. I mean there could be 30 iterations for threads 1-5 only, then 20 for threads 11 and 12 only. For example, at the end execution I had 80 iterations in-a-row for thread 13 and then 52 in-a-row for thread 10. Is there a way to make them more \”random\”, i.e. 1,19,2,3,18,4,4,15,…,N then again 5,2,2,1,17,12,8…,N ?
As I understand, each thread in that example has the same \”priority\”. So when you have a lot of iterations for thread 1, then a lot for thread 2 and then a lot for thread 3, it looks like a single-threaded behaviour.
1. Thanks for good catch, fixed.
2. What do you see here is a typical behavior of Linux on multicore ARM. It schedules a number of threads on the number of CPU cores, and they are executed in perfect parallel. Then it reschedules. Time slices are usually 1/5…1/10 seconds. We see here interleaved output from a batch of threads, and then another batch arrives etc etc. Your compiled code works along with all other system processes, this might happen that sometimes it would be executed only on one core. And the single core execution looks like sequential with time-to-time switching between threads. This behavior you usually can observe in the end of the example code from the playground. All in all, thread scheduling is completely undeterministic.