The best is the enemy of the good.

—Voltaire

Thread Safe Attributes#

If you use a miros statechart, your program is multi-threaded.

Sometimes, you will want to access an attribute of your statechart from another thread, like the main part of your program. When you do this, you are trying to access memory that could be changed in one thread while it is being read in by another thread.

To see why this is an issue imagine using a calculator to solve a simple math problem: calculate the value of b, starting with a == 0.35, given the following equation:

b = a*cos(0.45) + 3*a^1.2

Seems simple enough. Suppose you pick a straight-forward strategy:

  • mark down the value of a on a piece of paper so you can reference it as you work

  • break the calculation into a * cos(0.45) and 3*a*1.2

  • then add these results together to find b

But while calculated the a*cos(0.45) part of the problem, someone grabs your paper, changes your temporary value of a to 0.3, then puts it back on your desk. You don’t notice it. When you get to the 3*a^1.2 part of the calculation, you use the wrong a value, so you get the wrong answer for b.

This is called a race condition. Here our a variable was shared between two threads, you and the other person. When you program with multiple concurrent processes/threads and you share memory, you are exposed to this kind of problem.

A simple way to avoid such a situation is to not share the temporary paper in the first place. Do not use shared attributes.

Another way to deal with it is to have one thread change a shared attribute and have the other thread read the shared attribute. But, this will require that maintenance developers understand there are hidden rules in your codebase; they could innocently change something an introduce extremely subtle bugs.

Typically, shared variables are protected using thread locks. A lock is a flag which works across multiple threads. You can lock the object for reading and writing while you use it. In our example, we would lock a in its 0.35 state while calculating both sub-parts of our problem then unlock it when we are done. The other process would wait until the thread-lock cleared, then they would change the value of a to 0.3 and do their own work. So, there is a cost, you block one thread while waiting for the other to work, and you have to share lock variables across all of your threads. It is easy to screw this up, and it is tough to test for race conditions.

But why is it hard to test for race conditions? As of Python 3, a thread will run for 15 milliseconds before Python passes control to another thread. Most of the time, the common memory that is used by both threads will work as you expect it will. Infrequently a thread switch will occur midway through a non-atomic operation, where some shared value is to be changed by the other thread. After this unlikely event, your thread will re-gain control and finish its calculation producing the wrong answer.

These kinds of bugs are more probabilistic in nature, than deterministic; Python’s access to the system clock is jittery. The timing between two Python threads will never be the same for every two runs of the program (it’s like playing a slot machine) so, it will be hard for you to reproduce your issue.

The miros library accepts that people will want to access a statechart’s internal attributes from the outside. Significant efforts have been made to make this kind of activity easy for you to do in a “thread-safe” manner. The ThreadSafeAttributes class was constructed to eat the complexity of making thread-safe attributes by wrapping “getting” (use of the .) and “setting” operations (use of the =) within thread-safe locks. In addition to this, the non-atomic +=, -=//= statements using thread-safe attributes were also wrapped within locks. For more complex situations, the thread-safety features provided by the ThreadSafeAttributes class can be used to get the thread lock explicitly.

I will introduce these ideas gradually through a set of examples. Let’s begin by looking at four interacting threads (possible race conditions are highlighted):

 1import time
 2from threading import Thread
 3from threading import Event as ThreadEvent
 4
 5from miros import ThreadSafeAttributes
 6
 7class GetLock1(ThreadSafeAttributes):
 8  _attributes = ['thread_safe_attr_1']
 9
10  def __init__(self, evt):
11    '''running in main thread'''
12    self.evt = evt
13    self.thread_safe_attr_1 = 0
14
15  def thread_method_1(self):
16    '''running in th1 thread'''
17    while(self.evt.is_set()):
18      self.thread_safe_attr_1 += 1
19      print("th1: ", self.thread_safe_attr_1)
20      time.sleep(0.020)
21
22class GetLock2():
23  def __init__(self, evt, gl1):
24    '''running in main thread'''
25    self.evt = evt
26    self.gl1 = gl1
27
28  def thread_method_2(self):
29    '''running in th1 thread'''
30    while(self.evt.is_set()):
31      self.gl1.thread_safe_attr_1 -= 1
32      print("th2: ", self.gl1.thread_safe_attr_1)
33      time.sleep(0.020)
34
35class ThreadKiller():
36  def __init__(self, evt, count_down):
37    '''running in main thread'''
38    self.evt = evt
39    self.kill_time = count_down
40
41  def thread_stopper(self):
42    '''running in killer thread'''
43    time.sleep(self.kill_time)
44    self.evt.clear()
45
46# main thread:
47evt = ThreadEvent()
48evt.set()
49
50gl1 = GetLock1(evt)
51gl2 = GetLock2(evt, gl1=gl1)
52killer = ThreadKiller(evt, count_down=0.1)
53
54threads = []
55threads.append(Thread(target=gl1.thread_method_1, name='th1', args=()))
56threads.append(Thread(target=gl2.thread_method_2, name='th2', args=()))
57
58for thread in threads:
59  thread.start()
60
61thread_stopper = Thread(target=killer.thread_stopper, name='killer', args=())
62thread_stopper.start()
63thread_stopper.join()

The GetLock1 class inherits from the ThreadSafeAttributes class, which uses a metaclass to give it access to the following syntax (seen on line 8 of the above example):

_attributes = ['thread_safe_attr_1']

The ThreadSafeAttributes class tries to protect you. When we write the _attributes = ['thread_safe_attr_1'] syntax, ThreadSafeAttributes creates a set of hidden attributes, which are wrapped inside of a descriptor protocol (think @property). One of the hidden attributes, _lock is a threading.RLock. It is used to lock and unlock itself around accesses to the other hidden attribute _value. Essentially this means that this code:

gl1.thread_safe_attr_1
gl1.thread_safe_attr_1 = 1

… would turn into something like this before it is run:

with gl1._lock:
 gl1.thread_safe_attr_1

with gl1._lock:
 gl1.thread_safe_attr_1 = 1

Note

A lot of Python libraries provide features to change simple syntax into more complex and specific syntax prior to having it run. If this library was written in c, this kind of work would be done inside of a macro, and the preprocessor would create custom c-code before it was compiled into an executable.

The ThreadSafeAttributes class also tries to protect your code from race conditions introduced by non-atomic += statements acting on shared attributes:

gl1.thread_safe_attr_1 += 1

When using the ThreadSafeAttributes class the above code turns into something like this:

with gl1._lock:
  temp = gl1.thread_safe_attr_1
  temp = temp + 1
  gl1.thread_safe_attr_1 = temp

So the ThreadSafeAttributes class protects calls to the seemingly-innocuous-looking, yet dangerous, +=, -=, … //= family of Python calls. They are dangerous because they are not-atomic and can cause race conditions if they are applied to attributes shared across threads.

So our example, written without the ThreadSafeAttributes class, but with the same protections would look like this (shared attributes protections highlighted):

 1place code here
 2import time
 3from threading import RLock
 4from threading import Thread
 5from threading import Event as ThreadEvent
 6
 7class GetLock1():
 8
 9  def __init__(self, evt):
10    '''running within main thread'''
11    self._rlock = RLock()
12    self.evt = evt
13    self.thread_safe_attr_1 = 0
14
15  def thread_method_1(self):
16    '''running within th1 thread'''
17    while(self.evt.is_set()):
18      with self._rlock:
19        self.thread_safe_attr_1 += 1
20      with self._rlock:
21        print("th1: ", self.thread_safe_attr_1)
22      time.sleep(0.020)
23
24class GetLock2():
25  def __init__(self, evt, gl1):
26    '''running within main thread'''
27    self.evt = evt
28    self.gl1 = gl1
29
30  def thread_method_2(self):
31    '''running within th2 thread'''
32    while(self.evt.is_set()):
33      with self.gl1._rlock:
34        self.gl1.thread_safe_attr_1 -= 1
35      with self.gl1._rlock:
36        print("th2: ", self.gl1.thread_safe_attr_1)
37      time.sleep(0.020)
38
39class ThreadKiller():
40  def __init__(self, evt, count_down):
41    '''running within main thread'''
42    self.evt = evt
43    self.kill_time = count_down
44
45  def thread_stopper(self):
46    '''running within killer thread'''
47    time.sleep(self.kill_time)
48    self.evt.clear()
49
50evt = ThreadEvent()
51evt.set()
52
53gl1 = GetLock1(evt)
54gl2 = GetLock2(evt, gl1=gl1)
55killer = ThreadKiller(evt, count_down=0.1)
56
57threads = []
58threads.append(Thread(target=gl1.thread_method_1, name='th1', args=()))
59threads.append(Thread(target=gl2.thread_method_2, name='th2', args=()))
60
61for thread in threads:
62  thread.start()
63
64thread_stopper = Thread(target=killer.thread_stopper, name='stopper', args=())
65thread_stopper.start()
66thread_stopper.join()

We haven’t looked at any code results yet. Let’s run it and see what it does:

$python thread_safe_attributes_2.py
th1:  1
th2:  0
th1:  1
th2:  0
th1:  1
th2:  0
th2:  -1
th1:  0
th1:  1
th2:  0

We see that the number oscillates about 0. If we remove the time delays at the bottom of the thread functions, you will see wild oscillation in this number, since one thread by chance will get many more opportunities to run. So you can see that it might be hard to reproduce precisely two identical traces of the program output.

Ok, now for something scary, let’s look at our code without thread-locks (the race conditions are highlighted):

 1import time
 2from threading import Thread
 3from threading import Event as ThreadEvent
 4
 5class GetLock1():
 6
 7  def __init__(self, evt):
 8    '''running within main thread'''
 9    self.evt = evt
10    self.thread_race_attr_1 = 0
11
12  def thread_method_1(self):
13    '''running within th1 thread'''
14    while(self.evt.is_set()):
15      self.thread_race_attr_1 += 1
16      print("th1: ", self.thread_race_attr_1)
17      time.sleep(0.020)
18
19class GetLock2():
20  def __init__(self, evt, gl1):
21    '''running within main thread'''
22    self.evt = evt
23    self.gl1 = gl1
24
25  def thread_method_2(self):
26    '''running within th2 thread'''
27    while(self.evt.is_set()):
28      self.gl1.thread_race_attr_1 -= 1
29      print("th2: ", self.gl1.thread_race_attr_1)
30      time.sleep(0.020)
31
32class ThreadKiller():
33  def __init__(self, evt, count_down):
34    '''running within main thread'''
35    self.evt = evt
36    self.kill_time = count_down
37
38  def thread_stopper(self):
39    '''running within killer thread'''
40    time.sleep(self.kill_time)
41    self.evt.clear()
42
43evt = ThreadEvent()
44evt.set()
45
46gl1 = GetLock1(evt)
47gl2 = GetLock2(evt, gl1=gl1)
48killer = ThreadKiller(evt, count_down=0.1)
49
50threads = []
51threads.append(Thread(target=gl1.thread_method_1, name='th1', args=()))
52threads.append(Thread(target=gl2.thread_method_2, name='th2', args=()))
53
54for thread in threads:
55  thread.start()
56
57thread_stopper = Thread(target=killer.thread_stopper, name='stopper', args=())
58thread_stopper.start()
59thread_stopper.join()

I changed the thread_safe_attr_1 name to thread_race_attr_1 to make a point. The highlighted code shows where race conditions can occur. If we run the code we see:

python thread_safe_attributes_3_unsafe.py
th1:  1
th2:  0
th1:  1
th2:  0
th2:  -1
th1:  0
th1:  1
th2:  0
th1:  1
th2:  0

Which looks almost exactly the same as the last run. Race conditions are very hard to find.

Let’s move back to our original example, suppose we absolutely needed to run calculations on the thread_safe_attr_1 in more than one thread (which I can’t see the need for). I’ll change the name of thread_safe_attr_1 to a. The ThreadSafeAttributes class can not implicitly protect you in such situations, but what it can do is give you the lock and you can use it to protect your own code (highlighting how to get the lock):

 1import math
 2import time
 3from threading import Thread
 4from threading import Event as ThreadEvent
 5
 6from miros import ThreadSafeAttributes
 7
 8class GetLock1(ThreadSafeAttributes):
 9  _attributes = ['a']
10
11  def __init__(self, evt):
12    '''running within main thread'''
13    self.evt = evt
14    self.a = 0
15
16  def thread_method_1(self):
17    '''running within th1 thread'''
18    _, _lock = self.a
19    while(self.evt.is_set()):
20      with _lock:
21        self.a = 0.35
22        b = self.a * math.cos(0.45) + 3 * self.a ** 1.2
23        print("th1: ", b)
24      time.sleep(0.020)
25
26class GetLock2():
27  def __init__(self, evt, gl1):
28    '''running within main thread'''
29    self.evt = evt
30    self.gl1 = gl1
31
32  def thread_method_2(self):
33    '''running within th2 thread'''
34    _, _lock = self.gl1.a
35    while(self.evt.is_set()):
36      with _lock:
37        self.gl1.a = 0.30
38        b = self.gl1.a * math.cos(0.45) + 3 * self.gl1.a ** 1.2
39        print("th2: ", b)
40      time.sleep(0.020)
41
42class ThreadKiller():
43  def __init__(self, evt, count_down):
44    '''running within main thread'''
45    self.evt = evt
46    self.kill_time = count_down
47
48  def thread_stopper(self):
49    '''running within killer thread'''
50    time.sleep(self.kill_time)
51    self.evt.clear()
52
53# main thread:
54evt = ThreadEvent()
55evt.set()
56
57gl1 = GetLock1(evt)
58gl2 = GetLock2(evt, gl1=gl1)
59killer = ThreadKiller(evt, count_down=0.1)
60
61threads = []
62threads.append(Thread(target=gl1.thread_method_1, name='th1', args=()))
63threads.append(Thread(target=gl2.thread_method_2, name='th2', args=()))
64
65for thread in threads:
66  thread.start()
67
68thread_stopper = Thread(target=killer.thread_stopper, name='stopper', args=())
69thread_stopper.start()
70thread_stopper.join()

The lock can be obtained by calling _, _lock = <thread_safe_attribute>.

This nasty little piece of metaprogramming could baffle a beginner or anyone who looks at the thread safe attribute: Most of the time your thread-safe attribute acts as an attribute, but other times it acts as an iterable, what is going on? It only acts as an iterable when proceeded by _, _lock. If you use this technique in one of your threads, you must also explicitly get the lock in all other threads that share the attribute.

This lock-access feature was added for difficult situations, where the client code absolutely needs the lock, maybe for advanced database calls or that kind of thing.

I recommend against explicitely getting a lock and performing calculations directly on your shared attributes.

Instead, copy their contents into a local variable (automatically locked) , perform a calculation using local variables, then assign the results back into the shared attribute (automatically locked).

In our example, we don’t need to use shared attribute at all, so we shouldn’t. The example was arbitrary, a better way to perform the calculation can be seen in the following code listing. If we needed to place the 0.3 back into the shared-attribute, we can do that, but we keep the shared-attribute out of our equation. The equation will use non-shared, thread-safe, local variables which are placed on the stack during a thread’s context switch.

# code which doesn't require an explicit lock
temp = 0.30
b = temp * math.cos(0.45) + 3 * temp ** 1.2
print("thr2: ", b)
# this code will be implicitly locked by ThreadSafeAttributes
self.gl1.a = temp

Note

The ThreadSafeAttributes feature actually reads the last line of code you have written, the behaves differently depending on what you have written. It is because of this feature it can release its lock in what looks like a syntactically inconsistent way.