Author: Ken Jin
Due to PEP 703 (Making the Global Interpreter Lock Optional in CPython), tagged pointers will be introduced to CPython. Tagged pointers have in past attempts, been very hard to debug. From the author's own experience, there are the following non-exhaustive sources of bugs:
- Casting to-and-from a tagged pointer directly.
- Untagging a tagged pointer, then operating on it directly.
- Forgetting to convert deferred references to new references when required, this leads to hard-to-track segfaults.
- Using the wrong conversion function to convert a tagged pointer
to a
PyObject *.
We can solve 1. using the C compiler by making tagged pointers a struct, called
_PyStackRef. 2. is quite easily detectable because it immediately leads to
crashes during GC. However, 3. and 4. lead to hard-to-track reference leaks
in the CPython interpreter.
Mark Shannon suggested since we are revamping the entire interpreter loop,
we might as well make sure we do it right and in a principled way.
Taking inspiration from HPy and Mark, I introduce _PyStackRef, basically
a watered-down HPy for CPython that solves problems 3. and 4. automatically.
(Copied from pycore_stackrefs.h)
This file introduces a new API for handling references on the stack, called
_PyStackRef. This API is inspired by HPy.
There are 3 main operations, that convert _PyStackRef to PyObject* and
vice versa:
- Borrow (discouraged)
- Steal
- New
Borrow means that the reference is converted without any change in ownership. This is discouraged because it makes verification much harder. It also makes unboxed integers harder in the future.
Steal means that ownership is transferred to something else. The total number of references to the object stays the same.
New creates a new reference from the old reference. The old reference is still valid.
With these 3 API, a strict stack discipline must be maintained. All _PyStackRef must be operated on by the new reference operations:
- DUP
- CLOSE
DUP is roughly equivalent to Py_NewRef. It creates a new reference from an old
reference. The old reference remains unchanged
CLOSE is roughly equivalent to Py_DECREF. It destroys a reference.
An implementation PR is at python/cpython#118450. It implements the foundations, without any of the debugging guarantees yet.
We define initial mapping:
The program state is defined as:
All operations are defined as operations on this state. These are the operational semantics:
Additionally, these operations are used to manipulate references:
-
$O_{Ref}$ reads as "the reference of the object O". - The
$mapping - A$ operation must deduct$1$ from the corresponding$A$ in$mapping$ . - The
$mapping + A$ operation should add$1$ to the corresponding$A$ in$mapping$ .
-
$\text{PyStackRef}$ s are unique. -
$\text{PyObject}$ s are not necessarily unique (this is for compatibility with CPython). - At normal function call frame exit, the program state should be
$len(\text{live}) == 1$ . -
$steal(Ref)[\text{PyStackRef} \hookrightarrow \text{PyObject}]$ should at the point of stealing, have exactly one stack ref available to steal. -
$steal(Ref)[\text{PyObject} \hookrightarrow \text{PyStackRef}]$ does not need to have exactly onePyObject *in the mapping, because of 2. -
$borrow(Ref)[\text{PyStackRef} \hookrightarrow \text{PyObject}]$ requires the stackref to map to 1. Ie. the stackref entry cannot be 0. - The mapping for
$\text{PyStackRef}$ can only map to 0 or 1. - The mapping for
$live_{\text{PyObject}}$ can never map to a negative number.
x = PyStackRef_DUP(stackref)
// Error on frame pop, as live(PyStackRef) > 1PyStackRef_CLOSE(stackref)
// Error!
foo(PyStackRef_AsPyObjectBorrow(stackref))
// borrow needs mapping to > 0.
foo(PyStackRef_AsPyObjectSteal(stackref))
// Error!
bar(PyStackRef_AsPyObjectSteal(stackref))
// steal needs mapping to 1.
# This should be new, or there should be no decref_inputs after.
steal_pyobject(PyStackRef_AsPyObjectSteal(stackref))
// Error!
DECREF_INPUTS()
// close needs mapping to 1.
Note: we can't fix most of the problems if borrow is used!
We will only enforce invariants in debug builds. There will be no performance loss in release builds.
- For each thread state, we create an array that contains the mappings
of references (handles) to actual
$\text{PyObject}$ . - On each frame push, we note the current (most recent) reference ID.
- On frame pop, we note the current (most recent) reference ID. We then scan all the reference IDs from obtained in step 2 to 3 to ensure that there is only one live refrence ID at the point of exit.
- Each reference ID is just the index into the handle array.