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_ordering for 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.c

  • C++: src/cpy/Threads/cThreadLock.h and src/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