CHAPTER 19
Concurrent programming is a major feature of Julian and built into its core from the beginning. Julian supports traditional threading mechanism mimicking that of Java and C#, but also provides an asynchronous model that is reminiscent of JavaScript's promise framework. This chapter will cover the traditional concurrency, the next asynchronous scripting.
So far we have been writing and running code on a single thread, which is created when the engine starts. You can, however, create more threads and let your code run on them. All threads will be running in parallel, although the real concurrency level is dependent on the number of CPU cores.
Thread's constructor takes a Function
, which will be invoked on the new thread.
import System.Concurrency;
import System;
int value = 0;
void f(){
for(int i = 0; i < 100 ; i++){
value++;
}
}
Thread t1 = new Thread(f, "t1", ThreadPriority.NORMAL);
t2.start();
// Or use factory method. The thread is kicked off by create().
Thread t2 = Thread.create(f);
Since the parameter is a function, you may pass along a global function, a method, or a lambda. In fact, it's pretty common to pass along a nullary lambda when starting threads in Julian.
void f(int i){
...
}
Thread t1 = Thread.create(()=>f(5));
Passing a lambda that takes parameter is allowed, but there is no way to pass along the arguments. So you normally don't want do it.
Threads won't be waited by the engine. Once you start a thread it's basically forgotten. The engine will terminate as soon as the main threads finishes. To wait on the spawned threads, call join()
.
t.start();
...
t.join(); // This will block the current thread, until t is done.
From the engine's perspective, there is no reliable way to terminate threads. The runtime management of threads are almost impossible to unify across platforms, such that many years ago Java deprecated proactive termination of threads by another thread. The philosophy behind this is that a thread must be designed to be self-regulating, otherwise it's almost certainly a bug or at least detrimental to the system in general.
To paraphrase this, the implementor for thread logic must be heeding to certain signals sent by the system and acting reasonably. Only this way, multiple threads can co-exist in the same engine runtime. One of such signals is called INTERRUPT. While a thread cannot be force-terminated, it can be hinted at termination by this signal.
import System.Concurrency;
import System;
bool pleaseStop = false;
void f(){
for(int i = 0; i < 1000000000; i++){
// Do some calculation
...
if (Thread.getCurrent().checkInterruption()) { // checkInterruption() will flip the signal if it is true.
// This means the second call to it will get false (if no one call t.interrupt() again)
// We are interrupted! Why?
if (pleaseStop) {
// Got it. Exit now.
break;
}
}
}
}
Thread t1 = Thread.create(f);
pleaseStop = true;
t.interrupt();
t.join(); // We should be joining this very soon.
When the engine tears down, it will send a KILL signal to all the threads. The engine will handle this signal in a little different way, ensuring that the script interpretation halts as soon as it can. So even if you don't honor INTERRUPT, you don't need to worry about your threads hanging the engine.
One way of coordinating between threads is using a monitor, which provides two main functions. First, it enables exclusive access to a critical region (CR). Julian provides Lock for this purpose and implements language-level support for its use.
import System.Concurrency;
import System;
class SynchedValue {
int value = 100;
Lock lock = new Lock();
void add(int delta){
lock.lock(); // Start of critical region. If a thread runs to this line while another is already in the next, the first thread will be blocking here.
value += delta; // Exclusive access by current thread. No fear that this is also accessed by some other threads.
lock.unlock(); // End of critical region
}
}
void f(SynchedValue sv, int delta){
for(int i = 0; i < 100; i++){
sv.add(delta);
}
}
SynchedValue sv = new SynchedValue();
Thread t1 = Thread.create(()=>f(sv, 1));
Thread t2 = Thread.create(()=>f(sv, -1));
t1.start();
t2.start();
t1.join();
t2.join();
int value = sv.value; // No change. Still 100
In the example above, two threads are accessing the same method on a single object (SynchedValue
). The concurrent access, however, is guaranteed to be safe since the access to shared data is guarded by the lock. The guarding can be further simplified by lock
keyword.
void add(int delta){
sync(lock){
value += delta;
}
}
Guarding CR is not the only thing a Lock
can do. Another function it provides is to allow communication between threads waiting on the lock. This is achieved through a pair of methods: wait() and notify().
A thread can wait on a lock only if it is already holding it. Calling wait()
would immediately put the thread into waiting state and yield the control over the lock. This means one of those threads which are waiting to enter into the guarded region will now be granted entrance. However, threads which are waiting due to having called wait()
are not awakened at this point. The opposite call of wait()
is notify()
, which must be called by the locker's owner as well. By calling notify()
, all the threads which are in waiting state induced by wait()
will now be awaken, then one of them, chosen arbitrarily, is going to have the access to the thread. However, the newly awakened thread will not proceed until the notifying thread exits the region.
The usage of these two methods can be best illustrated by the classic producer/consumer model.
class BlockedQueue {
private List list;
private Lock lock;
private int capacity;
private int cap;
public BlockedQueue(int capacity){
this.capacity = capacity;
this.lock = new Lock();
list = new List();
cap = capacity;
}
public void produce(var value){
sync(lock){
while(list.size()==cap){
// Supply overwhelms consumption. Stop producing until we get a signal.
lock.wait();
}
// Produce one item.
list.add(value);
// Notify the consumer that new item is available.
lock.notify();
}
}
public var consume(){
sync(lock){
while(list.size()==0){
// Consumption outpaces supply. Stop consuming until we get a signal.
lock.wait();
}
// Consume one item.
var res = list.remove(0);
// Notify the producer that one item has been consumed.
lock.notify();
return res;
}
}
}
In the example above, both producer and consumer may proactively yield the lock if they find that they cannot operate normally due to the shortage of resources (items and storage room). If they don't yield but just keep waiting, the end result would be dead-locking. The symmetrical notify()
call is also needed to prevent starvation. Without it the waiting thread would wait forever.
One more point about wait()
. A thread in waiting state can be interrupted by INTERRUPT signal, upon which the method will return true. Depending on the actual scenario, the programmer may choose to either abort or continue the control flow. Enclosing the wait()
call inside loop check is usual practice.
while(interrupted = wait()){
... // Do some checks to decide if we want to break out.
}
Another important method exposed by Thread is wait(). This is a static method, implying that it's only going to affect the current thread. Indeed, you can't hypnotize other threads.
sleep()
takes millisecond as unit. Sleeping can be interrupted by INTERRUPT signal, upon which the method will return true. Therefore if you want to keep sleeping you should check for the return value and, if true, recall the method.
There are three types of threads managed by Julian engine. Main Thread is the default thread that the starting code runs from. This thread is always created by the engine in preparation for user-code execution, and there will be one and only one such thread through the entire life cycle of an engine instance.
All the threads created by calling Thread.create() or the constructor itself are Worker Thread which are running as background thread on the platform. These threads are allocated on a thread pool. The main difference between Main Thread and Worker Threads manifests upon their completion. See the next section on exactly how.
The third type of thread, called IO-continuation Thread, is behaviorally similar to Worker Thread but managed separately. We will talk more about these threads in Asychronous Programming.
Each thread has its own stack. When an exception is thrown, it will unwind the stack until it gets caught or hits the bottom. Hitting bottom of a thread created by Thread API will cause the thread to complete in a faulted state. Notably, it will not affect the state of script engine, whose life cycle is only tied to the main thread that is created by the engine itself.
When the main thread completes, the engine will shut down, sending a KILL signal to each user-created thread. The threads in waiting or blocking state will be awaken to this signal and abort; the other running threads have a periodic check against this signal and will also quit. To allow graceful completion for these threads, the engine will wait for a very short period before it proceeds to final tear-down. But even after that some threads may still be ongoing for a short while since the termination check can come later than expected.