Skip to content

Commit 85ad1e5

Browse files
committed
Add CFFI thread safety docs
1 parent 24e42cb commit 85ad1e5

1 file changed

Lines changed: 152 additions & 0 deletions

File tree

‎doc/source/overview.rst‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,158 @@ with C code to initialize global variables.
595595
The actual ``lib.*()`` function calls should be obvious: it's like C.
596596

597597

598+
.. _thread-safety:
599+
600+
Thread Safety
601+
-------------
602+
603+
Multithreading can be a powerful but tricky way to exploit the many cores on
604+
modern CPUs. Combining CFFI with the Python `threading` module is a convenient
605+
way to use multithreaded parallelism with a C library.
606+
607+
On the GIL-enabled build, CFFI will release the GIL before calling into a C
608+
library. That means that it is possible to get multithreaded speedups using CFFI
609+
on both the free-threaded and GIL-enabled builds of Python. However, that also
610+
means that the GIL does not protect multithreaded shared use of C data
611+
structures exposed via FFI.
612+
613+
If the C library you are wrapping is not thread-safe, then it is not thread-safe
614+
to use the library via Python without adding some kind of locking. If the
615+
library *is* thread-safe, then no additional locking is necessary to ensure the
616+
thread safety of CFFI itself. As of version 2.0, CFFI generates thread-safe
617+
bindings.
618+
619+
Let's make that concrete by wrapping some code that is not thread-safe due to
620+
use of a C global variable:
621+
622+
.. code-block:: python
623+
624+
from cffi import FFI
625+
ffibuilder = FFI()
626+
627+
ffibuilder.set_source("_thread_safety_example",
628+
r"""
629+
#include <stdint.h>
630+
631+
static int64_t value = 0;
632+
static int64_t increment(void) {
633+
value++;
634+
return value;
635+
}
636+
""",
637+
libraries=[]
638+
)
639+
640+
ffibuilder.cdef(r"""
641+
int64_t increment(void);
642+
"""
643+
)
644+
645+
if __name__ == "__main__":
646+
ffibuilder.compile(verbose=True)
647+
648+
The way that the ``increment`` uses the ``value`` global variable is not
649+
thread-safe. `Data races
650+
<https://en.wikipedia.org/wiki/Race_condition#Data_race>`_ are possible if two
651+
threads simultaneously call ``increment``. We can engineer that situation with a
652+
Python script that calls into the wrapper like so:
653+
654+
.. code-block:: python
655+
656+
import sys
657+
658+
from concurrent.futures import ThreadPoolExecutor, wait
659+
import threading
660+
661+
from _thread_safety_example import ffi, lib
662+
663+
# Make races more likely by switching threads more often
664+
# on the GIL-enabled build. This has no effect on the
665+
# free-threaded build.
666+
sys.setswitchinterval(.0000001)
667+
668+
N_WORKERS = 4
669+
670+
l = threading.Lock()
671+
672+
def work():
673+
lib.increment()
674+
675+
def run_thread_pool():
676+
with ThreadPoolExecutor(max_workers=N_WORKERS) as tpe:
677+
try:
678+
futures = [tpe.submit(work) for _ in range(100000)]
679+
# block until all work finishes
680+
wait(futures)
681+
finally:
682+
# check for exceptions in worker threads
683+
[f.result() for f in futures]
684+
685+
686+
run_thread_pool()
687+
688+
print(lib.increment())
689+
690+
On the system used to run this example by the author, this script prints random
691+
results, with possible result values ranging from 99960 to 99980, indicating
692+
that, on average, races happen a few dozen times over the hundred thousand loop
693+
iterations. The results you get will depend on your hardware, system
694+
configuration, and Python interpreter version.
695+
696+
Note that races are relatively rare. The CFFI bindings and Python interpreter
697+
add enough overhead that it is not very likely for two threads to simultaneously
698+
increment the static integer. This can make code *appear* to be sequentially
699+
consistent for small sample sizes, when it is in fact not consistent. See `this
700+
tutorial
701+
<https://github.com/facebookincubator/ft_utils/blob/main/docs/fine_grained_synchronization.md#understanding-the-gil>`_
702+
for more examples of how the GIL and Python overhead can mask thread safety
703+
issues that only manifest under production load.
704+
705+
We can make the above example script thread-safe by using a lock:
706+
707+
.. code-block:: python
708+
709+
l = threading.Lock()
710+
711+
def work():
712+
l.acquire()
713+
lib.increment()
714+
l.release()
715+
716+
The `threading.Lock` ensures only one thread can call into the wrapped C library
717+
at a time. Any thread that calls ``l.acquire()`` while another thread has
718+
already acquired the lock will block until the lock is released.
719+
720+
Using a global lock like this is necessary it is not safe for more than one
721+
thread to simultaneously call into any part of the library. This is the case if
722+
the library relies on global state its implementation that does not have any
723+
explicit synchronization. Libraries like this are not re-entrant.
724+
725+
For re-entrant libraries, where two threads can simultaneously use the library
726+
so long as the threads do not share references to an object, generally you will
727+
want to use a per-object lock instead of a global lock. Keep in mind in this
728+
case that any program with more than one lock can lead to a deadlock and care
729+
must be taken to avoid situations where two threads can deadlock.
730+
731+
If you do not expect to use the bindings for a thread-unsafe library in a
732+
multithreaded program, locking is not necessary. Similarly if you know that you
733+
are using the library in a thread-safe manner by construction, it is not
734+
necessary to add locking. Also, if you know that the C library you are wrapping
735+
is thread-safe, no additional locking is necessary to make the CFFI bindings
736+
thread-safe. As of version 2.0, CFFI generates thread-safe bindings to C
737+
libraries.
738+
739+
If you publish CFFI bindings for a library, you should document the thread
740+
safety guarantees of your bindings. It may make sense to add locking into the
741+
bindings but it might also make sense to clearly document the bindings are not
742+
thread-safe and it is up to users to ensure appropriate synchronization or
743+
exclusive access if users do want to use the bindings in a thread pool.
744+
745+
See the Python free-threading guide page on `improving the thread safety of
746+
Python code
747+
<https://py-free-threading.github.io/porting/#thread-safety-of-pure-python-code>`_
748+
for more information about updating a Python library with thread safety in mind.
749+
598750
.. _abi-versus-api:
599751

600752
ABI versus API

0 commit comments

Comments
 (0)