Locks
This page is for people working on pikepdf itself. It describes the locks inside pikepdf, what each one protects, and how they behave on GIL-enabled and free-threaded Python. For what users of pikepdf need to know, see Callbacks and the thread safety section of Architecture.
Why pikepdf needs its own locks
qpdf is not thread-safe. A QPDF object, and every object handle that
belongs to it, may only be used by one thread at a time. Even reading is
unsafe, because qpdf loads objects from the file lazily: the first read of an
indirect object moves the file position, parses, and fills the object cache.
On a free-threaded build nothing stops two threads from calling into the
same QPDF at once, so a lock is obviously needed. On a GIL-enabled build
it is needed too, which is less obvious. The GIL serializes Python
bytecode, not qpdf operations, and it changes hands part way through many of
them:
when qpdf reads from a Python stream (
readinto,seekandtellrun Python code);when it writes to a Python stream, or calls a
progressfunction;when it runs a
pikepdf.TokenFilterorpikepdf.StreamParsercallback;when it logs a warning through the
loggingmodule;when an allocation triggers a garbage collection, which can run any finalizer, including the one that destroys another
Pdf;when pikepdf releases the GIL outright, as it does while it opens a file and while it writes one.
Each of these gives another thread a chance to enter the same QPDF while
the first is still in the middle of an operation.
Overview
Lock |
Protects |
Kind |
|---|---|---|
Per- |
one |
|
Unowned lock ( |
every |
|
Teardown lock ( |
objects of a |
|
Registry lock ( |
the map from |
|
Wait table lock ( |
the table used to detect deadlocks |
|
Adopted objects lock ( |
the list of direct objects a |
|
qpdf globals lock ( |
qpdf’s global limits, through |
|
Traversal locks ( |
the state of one graph traversal |
|
Pillow limit lock ( |
|
|
Module-level settings that are a single value, such as the decimal precision
and the default conversion mode, are constinit std::atomic variables, not
locks. Per-thread state, such as the stack of conversion mode overrides, is
thread_local.
On GIL-enabled and free-threaded Python
Lock |
GIL-enabled build |
Free-threaded build |
|---|---|---|
Per- |
Real. An uncontended lock is a few plain loads and stores, which the GIL makes safe. A thread that finds it held releases the GIL and sleeps on a condition variable. |
Real. An uncontended lock is a compare-and-swap. A thread that finds it held detaches from the interpreter and sleeps on a condition variable. |
Unowned lock |
As the per- |
As the per- |
Teardown lock |
As the per- |
As the per- |
Deadlock check |
Runs only when a lock is contended |
Runs only when a lock is contended |
Registry, wait table, adopted objects, qpdf globals, traversal |
|
|
|
Atomic, though the GIL alone would suffice |
Atomic; required |
Pillow limit lock |
|
|
None of these locks compiles to nothing on a GIL-enabled build. Earlier
versions of pikepdf relied on the GIL and made the per-Pdf lock a no-op
there, which was wrong for the reasons listed above.
The per-Pdf lock
Every QPDF that pikepdf creates is registered in QpdfRegistry
(src/core/qpdf_lock.h), which maps the QPDF * to a QpdfEntry. The entry
holds the lock, the document’s conversion mode, and the list of adopted
objects. Entries are reference counted: a thread that is locking a QPDF,
or waiting to, holds a reference to the entry, so the lock outlives the
QPDF if another thread destroys the document meanwhile.
The lock is re-entrant. One call often locks the same Pdf more than once:
the binding locks it, then the type caster for the return value locks it
again to resolve indirect objects; a Python method in _methods.py calls
several bindings in turn; a callback calls back into pikepdf on the same
Pdf.
How bindings take it
Bindings do not usually lock anything themselves. A class bound with
locked_class_ (src/core/locked_bindings.h) wraps each method so that it
locks the Pdf that owns each of its arguments before the body runs. Which
Pdf owns a value of a given C++ type is declared once, by specializing
LockOwner; a class whose type has no such declaration cannot be bound with
locked_class_, so a forgotten lock is a compile error. A prek hook
(cpp-locked-class in prek.toml) rejects plain py::class_ in the files
that have been converted. A binding that must not be wrapped is registered
as unlocked(f), so the exception is visible where it is made. A binding
registered as shallow(f) is locked, but does not walk an argument that
belongs to no Pdf; see below.
Code that locks by hand uses one of three RAII guards:
QpdfLockGuardlocks oneQPDF;DualQpdfLockGuardlocks two, for example when copying objects between documents;MultiQpdfLockGuardlocks any number;ReachGuardlocks what a list of objects needs, including thePdfs reachable from those that belong to noPdf, as the bindings do.
The guards that take several locks always take them in the same order (by
the address of the registry entry), so two calls that each lock the same
pair of Pdfs cannot deadlock. If a later lock throws, they release the ones
already taken.
Pdf.lock() is the same lock, exposed to Python through _acquire_lock and
_release_lock, so that users can make a sequence of calls atomic.
Python code in pikepdf
A method implemented in Python is a sequence of binding calls, each of which
holds the lock only while it runs. A method that changes a Pdf in several
dependent steps holds the lock across them: with pdf.lock():, or a
decorator that does the same, as in sanitize.py and form.py. One that
involves more than one Pdf, such as Pdf.add_pages_from or
Page.add_overlay, uses with _OwnerLock((a, b)): instead. _OwnerLock
(src/core/qpdf.cpp) locks the owner of each item it is given (a Pdf, an
object, or an object helper such as a Page) through MultiQpdfLockGuard,
so the locks are taken in the same order as the bindings take them. Nested
pdf.lock() blocks would take them in the order the code names them, and two
threads copying between the same pair of Pdfs in opposite directions would
then deadlock; pikepdf would detect that and raise in one of them.
Methods that only read do not need this, and neither do the with blocks of
Pdf.open_metadata() and Pdf.open_outline(): they hold the lock while they
read the document and while they write it back, but not in between, since
the block runs user code. tests/test_threading_python_layer.py checks each
method that holds the lock, by interrupting it part way through and probing
the lock from another thread.
How it works
The lock is owner_ (the identifier of the thread holding it, or 0) and
depth_. The same algorithm is used on both kinds of build; only the way a
free lock is taken differs:
On a GIL-enabled build, only a thread that holds the GIL reads or writes
owner_, so taking a free lock or re-entering a held one is a few plain loads and stores, with no atomic read-modify-write and no system call.On a free-threaded build, a free lock is taken with a compare-and-swap of
owner_from 0 to the thread’s identifier.
A thread that finds the lock held by another:
increments
waiters_;detaches from the interpreter, which releases the GIL if there is one;
waits on the condition variable
released_undersleep_mutex_;releases
sleep_mutex_before attaching again, so a thread that is attached (holding the GIL, or holding up a stop-the-world garbage collection) never waits forsleep_mutex_;tries to take the lock again, since another thread may have taken it in between, and goes back to step 2 if that fails.
unlock() notifies the condition variable only when waiters_ is non-zero,
so an uncontended unlock costs nothing extra. It briefly takes
sleep_mutex_ before notifying, which orders the release after a waiter’s
check of owner_ and prevents a lost wake-up. On a free-threaded build,
unlock() stores 0 to owner_ and then reads waiters_, while a waiter
increments waiters_ and then tries to take the lock, all sequentially
consistent: either the waiter finds the lock free or unlock() finds the
waiter.
A free-threaded interpreter often runs with the GIL enabled, because importing a module that does not declare free-threading support (lxml, for example) turns it back on. The free-threaded implementation is used either way: which one is compiled depends on the build, not on whether the GIL happens to be enabled.
Why not PyMutex
The lock is deliberately not fair. A released lock goes to whichever thread
asks for it next, usually the thread that has just released it and is still
running. Earlier versions used nanobind’s ft_mutex, a PyMutex, on
free-threaded builds. Once a waiter has waited for a millisecond, PyMutex
hands the lock directly to it on release, and the lock then stays held until
that thread is scheduled. When several threads take the same lock often,
each handover waits for a sleeping thread, and on a machine whose CPUs are
all busy that wait can be far longer than the work done under the lock.
Threads reading one Pdf through a Python stream ran 4 to 50 times slower
than with the current lock when the CPUs were oversubscribed.
Deadlock detection
A thread can hold one Pdf’s lock and then ask for another’s: from a
callback that runs while a Pdf is locked, or from nested with pdf.lock()
blocks. Two threads doing that in opposite orders would wait for each other
for ever.
ReentrantFtMutex::Waiting keeps a global table of which thread is waiting
for which lock, under its own std::mutex. A thread about to wait follows
the chain from the lock it wants to that lock’s holder, to the lock that
holder is waiting for, and so on. If the chain comes back to the thread
itself, it throws DeadlockError, which reaches Python as RuntimeError.
Unwinding releases the locks the thread holds, and the other threads in the
cycle carry on. Only contended locks pay for this check.
Code that cannot throw waits without the check: lock_unchecked(), and
QpdfLockGuard(q, std::nothrow). The QPDF deleter, the teardown lock and
the return value type caster use these.
Rules
Every
lock()must be made while holding the GIL, or while attached to the interpreter on a free-threaded build. Nothing checks this.unlock()must be called by the thread that calledlock(). The owner is a thread identifier, so a lock that is not released before its thread exits stays held, and a later thread that is given the same identifier would be let in as if it were the owner.Never call Python code while holding one of the
std::mutexlocks listed above. Python code can release the GIL, run a garbage collection or wait for a per-Pdflock, any of which can deadlock against a thread that needs thestd::mutex.A lazy iterator over a qpdf container, returned from a binding, keeps reading the
Pdfafter the binding has released the lock. Return a snapshot instead.
Objects that belong to no Pdf
A direct Dictionary or Array that has not been added to a Pdf has no
owner. All such containers share one lock, unowned_mutex() in
src/core/qpdf_lock.h. lock_target(h) says what to lock for an object: its
Pdf, the stand-in unowned_owner() for a container that belongs to no
Pdf, or nothing for a scalar that belongs to no Pdf, since scalars cannot
be changed. LockOwner<QPDFObjectHandle> and the object helpers answer with
lock_target(), and the guards take unowned_mutex() when given
unowned_owner(). _ObjectList and _ObjectMapping also belong to no Pdf,
but each locks itself (see src/core/object_list.cpp).
unowned_mutex() is always taken after any Pdf lock, never before, so
that the order of every lock is fixed. Code that holds it must not wait for a
Pdf lock. Two places need a Pdf lock while holding it: reading a value out
of an unowned container that belongs to a Pdf (ChildLockGuard), and
converting such a value to Python. Both lock as QpdfLockGuard::AfterUnowned:
if the Pdf lock is not free at once, unowned_mutex() is set aside while
the thread waits for it and taken back afterwards. That is only safe because
the binding has finished with the container by then. _OwnerLock never takes
unowned_mutex(), because it holds its locks while Python code runs.
An object’s owner changes when it is adopted into a Pdf, made indirect, or
detached. Each of these holds both the Pdf’s lock and unowned_mutex()
while the owner changes (object_adoption.cpp), so a thread that holds the
lock of an object’s owner knows the owner cannot change until it lets go.
A thread can still read an object’s owner, then wait for that lock while the
owner changes. So the bindings (ArgsGuard in locked_bindings.h) and the
guards that take an object read the owner again once they hold the lock, and
start over if it changed. There is nothing to re-read when no lock was taken:
a scalar that belongs to no Pdf never comes to belong to one, because
adoption copies it.
The exception is the teardown of a QPDF, which clears the owner of each
object it adopted without taking unowned_mutex(). A thread that finds an
object with no owner waits for any teardown in progress before it uses it:
see the teardown lock below.
An unowned container may hold objects of Pdfs: indirect objects, and
containers adopted by a Pdf. A binding that reads through it to them, such
as repr(), ==, index(), a name path lookup, is_rectangle or any Page
method on an unowned page, needs their locks too, since reading an indirect
object can make qpdf parse it from the file. So by default, when an argument
of a locked binding is an unowned container, ArgsGuard walks its direct
objects (add_reachable_owners() in qpdf_lock.h), stopping at anything
that belongs to a Pdf, and collects those Pdfs. It walks with
unowned_mutex() held, then releases everything and takes all the locks again
through one MultiQpdfLockGuard, in the usual order, and walks again to check
that nothing changed. The walk follows only what was built in Python, never a
Pdf’s object graph, and an argument that belongs to a Pdf costs nothing
extra. ReachGuard does the same for code outside the bindings: repr()
itself, and the comparisons of _ObjectList.
A binding that only ever reads at most one child, under a ChildLockGuard,
is registered as shallow(f) so that it skips the walk: indexing, item and
attribute assignment and deletion, get(), keys(), len(), append(),
the typed getters, and properties such as is_indirect. Without that,
building a large unowned array with append() would take quadratic time. A
binding that is not marked is safe, at worst slow on a large unowned
container, so mark one only when it reads no more than one child.
Adoption keeps every object in a Pdf’s graph owned by that Pdf, which is
what lets one lock cover them. Before adopting, refuse_mixed_owners walks
the direct objects of the value being inserted, the same ones adoption
visits, and raises ForeignObjectError if it finds an indirect object or a
direct container that belongs to another Pdf. It runs before anything is
changed, so a refused insertion leaves the target as it was. Every insertion
path goes through adopt_into or make_direct_indirect; the few that adopt
after qpdf has inserted the value (page insertion, the stream_dict setter,
the Stream constructor) and those that insert several values (extend,
slice assignment) call refuse_mixed_owners themselves first. Adoption on
read does not check, so that a document already in a bad state can still be
read.
The teardown lock
~QPDF clears the owner of each of its objects and then destroys their
values. A thread that read one of those objects in between would find no
owner, take no lock, and read a value that is being destroyed.
So the QPDF deleter (register_qpdf in src/core/qpdf.cpp) takes the
document’s own lock, then calls QpdfRegistry::begin_teardown(), which
takes the global teardown lock and increments a counter. It disconnects the
adopted objects, deletes the QPDF, unregisters it, and calls
end_teardown(). A thread about to use an object with no owner calls
wait_for_teardown(), which does nothing unless the counter is non-zero, and
otherwise takes and releases the teardown lock.
The teardown lock is re-entrant, so code run by the destroying thread
itself, including the destruction of a second Pdf, passes straight
through.
The std::mutex locks
These protect small data structures and are never held while Python code runs.
Registry lock. Held while a
QPDFis registered, unregistered or looked up. Lookups return ashared_ptrto the entry, so the caller can release the registry lock before taking the per-Pdflock.Wait table lock. Held while a thread records that it is about to wait for a per-
Pdflock, checks for a cycle, and removes its record.Adopted objects lock. qpdf records an object’s owner as a raw pointer, and
~QPDFonly clears that pointer on objects still in its cache. A direct object that pikepdf adopted and that was later detached would be left with a dangling pointer. EachQpdfEntrykeeps weak references to the objects it adopted, and the deleter disconnects those still alive. This lock protects that list.qpdf globals lock. qpdf keeps its global limits in plain variables. This serializes the reads and writes made through
pikepdf.settings. It cannot cover qpdf’s own reads while it parses, so limits should be set before threads are started.Traversal locks. An
ObjectSetorContentScopes(src/core/traversal.h) can hold objects of several documents, or of none, so it cannot use a per-Pdflock. Each has a mutex of its own.
Testing
tests/test_threading.py and tests/test_threading_object.py run the same
operations from several threads. tests/test_threading_unowned.py covers
objects that belong to no Pdf: it probes the shared lock from another
thread, checks that operations which read through such an object take the
locks of the Pdfs it holds and that the shallow ones do not, and changes
such objects while threads add them to a Pdf and take them out again. The frequent_thread_switches fixture
lowers the switch interval so that a GIL-enabled interpreter changes threads
as often as it can, which makes interleavings in the middle of a qpdf
operation likely. run_threads reuses its threads from one call to the
next: on a free-threaded build, starting a thread can take a tenth of a
second when every CPU is busy, as it is on a CI runner shared by several
test workers.
The ThreadSanitizer job (.github/workflows/tsan.yml, and
Debugging) runs these tests on a free-threaded build with the GIL
disabled. Free-threaded wheel jobs mostly run with the GIL enabled, because
lxml turns it back on; set PYTHON_GIL=0 to keep it off.