22. Thread Safety¶
This chapter describes various issues when C extensions interact with Python threads [1].
22.1. When You Need a Lock¶
If your Extension is likely to be exposed to a multi-threaded environment then you need to think about thread safety. I had this problem in a separate project which was a C++ SkipList which could contain an ordered list of arbitrary Python objects.
The problem in a multi-threaded environment that was sharing the same structure was that the following sequence of events could happen:
Thread A tries to insert a Python object into the SkipList. The C++ code searches for a place to insert it preserving the existing order. To do so it must call back into Python code for the user defined comparison function (using
functools.total_orderingfor example).At this point the Python interpreter is free to make a context switch allowing thread B to, say, remove an element from the same SkipList. This removal may well invalidate C++ pointers held by thread A.
When the interpreter switches back to thread A it accesses an invalid pointer and a segfault happens.
The solution is to use a lock to prevent a Python context switch until A has completed its insertion, but how?
I found the existing Python documentation misleading and I couldn’t get it to work reliably, if at all. It was only when I stumbled upon the source code for the bz module that I realised there was a whole other, low level way of doing this, largely undocumented.
Here is a version that concentrates on those essentials.
As an example, here is a subclass of a list that has a max() method that returns the maximum value in the list.
To do the comparison it must call
PyObject_RichCompareBool
to decide which of two objects is the maximum.
So during that call to max() the Python interpreter is free too switch to another thread that might alter the
list we are inspecting.
What we need to do is to block that thread with a lock so that can’t happen.
Then once the result of max() is known we can relase that lock.
This class deliberately has sleep() calls to allow a thread switch to take place.
The code (C and C++) is in src/cpy/Threads and the tests are in tests/unit/test_c_threads.py.
Lets walk through it.
22.2. Coding up the Lock¶
First we need to include pythread.h as well as the usual includes:
#include <Python.h>
#include "structmember.h"
#ifdef WITH_THREAD
#include "pythread.h"
#endif
Note
Your Python may have been compiled without thread support in which case we don’t have to concern ourselves with
thread locking.
We can discover this from the presence of the macro WITH_THREAD so all our thread support code is conditional
on the definition of this macro.
22.2.1. Adding a PyThread_type_lock to our object¶
Then we add a PyThread_type_lock (an opaque pointer) to the Python structure we are intending to protect.
Here is the object declaration:
typedef struct {
PyListObject list;
#ifdef WITH_THREAD
PyThread_type_lock lock;
#endif
} SubListObject;
22.2.2. Initialising and Deallocating the Lock¶
If you have a __new__ method then set the lock pointer to NULL.
The lock needs to be initialised only in the __init__ method.
In the __init__ method we allocate the lock by calling PyThread_allocate_lock() [2]:
1static int
2SubList_init(SubListObject *self, PyObject *args, PyObject *kwds) {
3 if (PyList_Type.tp_init((PyObject *) self, args, kwds) < 0) {
4 return -1;
5 }
6#ifdef WITH_THREAD
7 self->lock = PyThread_allocate_lock();
8 if (self->lock == NULL) {
9 PyErr_SetString(PyExc_MemoryError, "Unable to allocate thread lock.");
10 return -2;
11 }
12#endif
13 return 0;
14}
When deallocating the object we should free the lock pointer with PyThread_free_lock [3]:
1static void
2SubList_dealloc(SubListObject *self) {
3 /* Deallocate other fields here. */
4 #ifdef WITH_THREAD
5 if (self->lock) {
6 PyThread_free_lock(self->lock);
7 self->lock = NULL:
8 }
9 #endif
10 Py_TYPE(self)->tp_free((PyObject *)self);
11}
22.3. Using the Lock¶
So now our object has a lock but we need to acquire it and release it.
22.3.1. From C Code¶
It is useful to declare a couple of macros. These are from the bz module:
#define ACQUIRE_LOCK(obj) do { \
if (!PyThread_acquire_lock((obj)->lock, 0)) { \
Py_BEGIN_ALLOW_THREADS \
PyThread_acquire_lock((obj)->lock, 1); \
Py_END_ALLOW_THREADS \
} } while (0)
#define RELEASE_LOCK(obj) PyThread_release_lock((obj)->lock)
The code that acquires the lock is slightly clearer if the Py_BEGIN_ALLOW_THREADS and Py_END_ALLOW_THREADS macros are fully expanded:
if (! PyThread_acquire_lock(_pSL->lock, NOWAIT_LOCK)) {
{
PyThreadState *_save;
_save = PyEval_SaveThread();
PyThread_acquire_lock(_pSL->lock, WAIT_LOCK);
PyEval_RestoreThread(_save);
}
}
Before any critical section we need to use ACQUIRE_LOCK(self); (which blocks) then RELEASE_LOCK(self);
when done.
Failure to call RELEASE_LOCK(self); in any code path will lead to deadlocking.
22.3.1.1. Example¶
Here is an example of out sublist append().
In the body of the function it makes a super() call and then introduces a sleep() which allows the
Python interpreter to switch threads (it should not because of the lock).
1static PyObject *
2SubList_append(SubListObject *self, PyObject *args) {
3 ACQUIRE_LOCK(self);
4 PyObject *result = call_super_name(
5 (PyObject *) self, "append", args, NULL
6 );
7 // 0.25s delay to demonstrate holding on to the thread.
8 sleep_milliseconds(250L);
9 RELEASE_LOCK(self);
10 return result;
11}
22.3.2. From C++ Code¶
We can make this a little smoother in C++ by creating a class that will lock and unlock.
22.3.2.1. Creating a class to Acquire and Release the Lock¶
We can acquire and release the lock in a RAII fashion in C++ where the constructor blocks until the lock is acquired and the destructor releases the lock. This is a template class for generality.
The code is in src/cpy/Threads/cThreadLock.h
1#include <Python.h>
2#include "structmember.h"
3
4#ifdef WITH_THREAD
5#include "pythread.h"
6#endif
7
8#ifdef WITH_THREAD
9 /* A RAII wrapper around the PyThread_type_lock. */
10 template<typename T>
11 class AcquireLock {
12 public:
13 AcquireLock(T *pObject) : m_pObject(pObject) {
14 assert(m_pObject);
15 assert(m_pObject->lock);
16 Py_INCREF(m_pObject);
17 if (!PyThread_acquire_lock(m_pObject->lock, NOWAIT_LOCK)) {
18 Py_BEGIN_ALLOW_THREADS
19 PyThread_acquire_lock(m_pObject->lock, WAIT_LOCK);
20 Py_END_ALLOW_THREADS
21 }
22 }
23 ~AcquireLock() {
24 assert(m_pObject);
25 assert(m_pObject->lock);
26 PyThread_release_lock(m_pObject->lock);
27 Py_DECREF(m_pObject);
28 }
29 private:
30 T *m_pObject;
31 };
32
33#else
34 /* Make the class a NOP which should get optimised out. */
35 template<typename T>
36 class AcquireLock {
37 public:
38 AcquireLock(T *) {}
39 };
40#endif
22.3.2.2. Using the AcquireLock class¶
Before any critical section we create an AcquireLock object which blocks until we have the lock.
Once the lock is obtained we can make any calls, including calls into the Python interpreter without preemption.
The lock is automatically freed when we exit the code block:
1/** append with a thread lock. */
2static PyObject *
3SubList_append(SubListObject *self, PyObject *args) {
4 AcquireLock<SubListObject> local_lock((SubListObject *)self);
5 PyObject *result = call_super_name(
6 (PyObject *) self, "append", args, NULL
7 );
8 // 0.25s delay to demonstrate holding on to the thread.
9 sleep_milliseconds(250L);
10 return result;
11}
22.4. Example Code and Tests¶
The code (C and C++) is in src/cpy/Threads and the tests are in tests/unit/test_c_threads.py.
22.4.1. Example Code¶
The example code is here:
C:src/cpy/Threads/csublist.cC++:src/cpy/Threads/cThreadLock.handsrc/cpy/Threads/cppsublist.cpp.
setup.py creates two extensions; cPyExtPatt.Threads.csublist (in C) and cPyExtPatt.Threads.cppsublist
(in C++):
1Extension(name=f"{PACKAGE_NAME}.Threads.csublist",
2 include_dirs=[
3 '/usr/local/include',
4 'src/cpy/Util',
5 "src/cpy/Threads",
6 ],
7 sources=[
8 "src/cpy/Threads/csublist.c",
9 'src/cpy/Util/py_call_super.c',
10 ],
11 extra_compile_args=extra_compile_args_c,
12 language='c',
13 ),
14Extension(name=f"{PACKAGE_NAME}.Threads.cppsublist",
15 include_dirs=[
16 '/usr/local/include',
17 'src/cpy/Util',
18 "src/cpy/Threads",
19 ],
20 sources=[
21 "src/cpy/Threads/cppsublist.cpp",
22 'src/cpy/Util/py_call_super.c',
23 ],
24 language='c++11',
25 ),
The individual C and C++ modules can be accessed with:
from cPyExtPatt.Threads import cppsublist
from cPyExtPatt.Threads import csublist
22.4.2. Example Tests¶
The tests are in tests/unit/test_c_threads.py.
Here are some examples:
22.4.2.1. Tests in C¶
First create two function to call max() and append().
These functions print out their progress and which thread they are running in:
1def csublist_max(obj, count):
2 print(
3 f'sublist_max(): Thread name {threading.current_thread().name}',
4 flush=True
5 )
6 for _i in range(count):
7 print(
8 f'sublist_max(): Thread name {threading.current_thread().name}'
9 f' Result: {obj.max()}',
10 flush=True
11 )
12 time.sleep(0.25)
13 print(
14 f'sublist_max(): Thread name {threading.current_thread().name} DONE',
15 flush=True
16 )
17
18
19def csublist_append(obj, count):
20 print(
21 f'sublist_append(): Thread name {threading.current_thread().name}',
22 flush=True
23 )
24 for _i in range(count):
25 print(
26 f'sublist_append(): Thread name {threading.current_thread().name}',
27 flush=True
28 )
29 obj.append(len(obj))
30 time.sleep(0.25)
31 print(
32 f'sublist_append(): Thread name {threading.current_thread().name} DONE',
33 flush=True
34 )
Now a test that creates a single shared sub-list and four threads for each of the max() and append()
functions:
1def test_threaded_c():
2 print()
3 print('test_threaded_c() START', flush=True)
4 obj = csublist.cSubList(range(128))
5 threads = []
6 for i in range(4):
7 threads.append(
8 threading.Thread(
9 name=f'sublist_max[{i:2d}]',
10 target=csublist_max,
11 args=(obj, 2),
12 )
13 )
14 threads.append(
15 threading.Thread(
16 name=f'sublist_append[{i:2d}]',
17 target=csublist_append,
18 args=(obj, 2),
19 )
20 )
21 for thread in threads:
22 thread.start()
23 print('Waiting for worker threads', flush=True)
24 main_thread = threading.current_thread()
25 for t in threading.enumerate():
26 if t is not main_thread:
27 t.join()
28 print('Worker threads DONE', flush=True)
Running this test gives this output, typically:
1test_threaded_c() START
2sublist_max(): Thread name sublist_max[ 0]
3sublist_append(): Thread name sublist_append[ 0]
4sublist_append(): Thread name sublist_append[ 0]
5sublist_max(): Thread name sublist_max[ 1]
6sublist_append(): Thread name sublist_append[ 1]
7sublist_max(): Thread name sublist_max[ 2]
8sublist_max(): Thread name sublist_max[ 1] Result: 127
9sublist_append(): Thread name sublist_append[ 2]
10sublist_max(): Thread name sublist_max[ 3]
11sublist_append(): Thread name sublist_append[ 3]
12Waiting for worker threads
13sublist_append(): Thread name sublist_append[ 1]
14sublist_max(): Thread name sublist_max[ 0] Result: 128
15sublist_max(): Thread name sublist_max[ 3] Result: 128
16sublist_append(): Thread name sublist_append[ 2]
17sublist_append(): Thread name sublist_append[ 3]
18sublist_append(): Thread name sublist_append[ 0]
19sublist_max(): Thread name sublist_max[ 2] Result: 128
20sublist_append(): Thread name sublist_append[ 2]
21sublist_append(): Thread name sublist_append[ 1]
22sublist_max(): Thread name sublist_max[ 1] Result: 131
23sublist_max(): Thread name sublist_max[ 0] Result: 132
24sublist_append(): Thread name sublist_append[ 0] DONE
25sublist_max(): Thread name sublist_max[ 1] DONE
26sublist_max(): Thread name sublist_max[ 3] Result: 134
27sublist_append(): Thread name sublist_append[ 3]
28sublist_append(): Thread name sublist_append[ 1] DONE
29sublist_max(): Thread name sublist_max[ 2] Result: 134
30sublist_max(): Thread name sublist_max[ 0] DONE
31sublist_append(): Thread name sublist_append[ 2] DONE
32sublist_max(): Thread name sublist_max[ 3] DONE
33sublist_append(): Thread name sublist_append[ 3] DONE
34sublist_max(): Thread name sublist_max[ 2] DONE
35Worker threads DONE
22.4.2.2. Tests in C++¶
A very similar example in C++ is in tests/unit/test_c_threads.py.
Footnotes