Mar
In a previous post we saw how easy it is to use multi-threading in Lucee. But as Uncle Ben said, with great power comes great responsibility. When multiple threads access shared mutable objects at the same time, it is important to synchronize the access or else you will run into problems commonly known as race conditions.
The most common race conditions are "Check-then-Act" and "Read-Modify-Write". Both of these operations are simple, and are used all the time without an issue, as long as the objects can not be modified (immutable), or the operations are guaranteed to be done by a single thread only.
Let's take a look at Check-then-Act first. Say you have an object which has an initialize() method that should be called once, and only once. The following Check-then-Act idiom works fine in a single-threaded environment:
if (!isInitialized){ // check isInitialized = true; // act initialize(); }
But in a multi-threaded environment, a race condition can take place. One thread might check
isInitialized and find it to be false, and therefore go into the act
block. Before it manages to sets isInitialized to true, another thread check
s isInitialized, and finds it to still be false, and so it also goes into the act
block. Oops, we called initialize() more than once and broke the invariant.
Read-Modify-Write has a similar problem. The construct i = i + 1
, and even its shorthand operator i++
looks very innocouous. In this case, the value is first Read, then Modified, and then Written to the variable. In a single-threaded environment, everything is fine. You read the value, you add one, and you write it back. But in a multi-threaded environment, weird things can happen. Let's try it out.
This code below is similar to the one in the previous post, Easy Parallelism in Lucee. The only difference is that we have added a shared object (a struct named "completed") that is accessed inside the process() function. We also removed the output from inside the function, and shortened the sleep time to about 10ms:
completed = { count: 0 }; function process(element) { sleep(randRange(8, 12)); // simulate a slow process completed.count++; // unsynchronized access }
We use the same collection as before:
elements = []; for (i=1; i<=20; i++){ elements.append(i); }
And our "client code" remains the same as well:
tc1 = getTickCount(); each(elements, process); tc2 = getTickCount(); echo("<p>Set completed in #numberFormat(tc2 - tc1,',')#ms; completed: #replace(serializeJSON(completed), ',', ', ', 'all')#");
Calling each() without parallel runs it in a single-thread, and produces the following expected output (notice that count is 20):
Set completed in 200ms; completed: {"count":20}
Calling each() with 10 threads, i.e.:
each(elements, process, true, 10);
Produces the following output:
Set completed in 23ms; completed: {"count":19}
Wait! What?? We called process() 20 times, so how come count
shows only 19?! As you probably already realized, when two or more threads try to increment completed.count
at the same time, they run into a Read-Modify-Write race condition. For example, thread-A reads the value 1 and adds 1 to it, but before it writes the value back to the variable, thread-B reads the value 1 as well. Now thread-A might write 2 back to the variable, but the damage is already done -- thread-B adds 1 to the value that it has read earlier, and writes 2 back to the variable. At the end of that operation we have a value of 2 instead of the expected value of 3.
In order for this problem to appear, very specific conditions need to take place at the same time, so this problem might not necessarily show in every execution. I had to run the code a few times in order to get the bug to show. But this only makes things worse: you write your code, you test it and it seems to be working fine, and then some time later someone realizes that something is wrong (and probably has been wrong for a while). Now you have to go figure what and where the problem is.
Running more threads at the same time makes things worse. When I called each() with 20 threads I got a count value of 16:
Set completed in 15ms; completed: {"count":16}
So how do we avoid this problem? We need to synchronize the access to the shared object. By adding a lock
around the operations that use the completed variable, we synchronize the access to it and ensure that that part of the code is only accessed by one thread at a time:
function process(element) { sleep(randRange(8, 12)); // simulate a slow process lock name="completed" timeout="10" { completed.count++; // synchronized access via lock } }
Now I consistently get a count of 20 every time we run that code, even with 20 threads:
Set completed in 15ms; completed: {"count":20}
So when it comes to multithreading, keep an eye out for potential race conditions, and be sure to sychronize access to shared mutable objects.
Social Media
FOLLOW US