January 10, 2023·7 min read·engineeringpythonconcurrencycpython

PEP 703 proposes changing how CPython shares objects

The optional GIL proposal moves synchronization into reference counting and containers, with consequences for native extensions and package distribution.

Python libraries can spend most of their execution time inside native numerical routines and still encounter a bottleneck in Python. A program has to prepare inputs, schedule operations and handle results. When those stages execute Python code, the global interpreter lock serializes them within an interpreter. Making a matrix operation parallel does not make the surrounding coordination parallel. This is the problem Sam Gross puts at the centre of the original PEP 703 proposal.

The public discussion opened on January 10, with Łukasz Langa sponsoring the proposal. PEP 703 carries a January 9 creation date. Its January 10 revision proposes an optional CPython build without the GIL. It remains a draft. The design has to explain how ordinary Python objects survive concurrent access, and how the native extensions surrounding them can participate.

I tend to choose Python because its libraries have already solved the difficult part of a numerical problem. That makes this proposal interesting even without much affection for the language itself. The extension ecosystem is both the reason to attempt this change and a constraint on almost every implementation decision.

Parallel computation still needs Python coordination

The original proposal distinguishes parallel work inside one operation from parallel execution of different operations. A native library can divide a large calculation among its own worker threads. Python can also release the GIL while a native function runs. Neither arrangement allows two threads to execute arbitrary Python code simultaneously in the same interpreter.

This distinction matters when computation consists of many small operations. Each return to Python requires the interpreter lock, even if the next native operation could run independently. It also matters for request concurrency. Several requests may each perform native work, but their Python orchestration still competes for the same lock. The proposal describes these constraints in scientific computing and model inference, where fast native kernels can make Python coordination a greater proportion of total execution time.

Moving work into processes changes the communication model. Each process has its own interpreter and GIL, so it can execute Python independently. Objects crossing a process boundary generally require serialization or explicit shared memory. Startup and communication become part of the library design. The PEP describes the additional machinery maintained around scientific Python to accommodate those costs.

The usability argument deserves attention. An application author may encounter worker configuration, data copying or restrictions on GPU access without ever writing a thread. A library has already selected a process-based architecture to get parallel execution. The interpreter limitation therefore affects its public API, including users who only wanted to call a function over some data.

Reference counting becomes a concurrent protocol

CPython reference counting changes object lifetimes as references appear and disappear. Under the GIL, those updates do not need to compete with another thread executing ordinary Python code. Removing that protection requires a new account of who may modify each count and when an object can be freed.

The January design proposes biased reference counting. An object records an owning thread, normally the thread that created it. That thread updates a local reference count without atomic read-modify-write operations. Other threads use an atomic shared count. Separating these counts preserves a cheaper path for objects that remain within one thread while supporting objects that cross thread boundaries.

This is a bet about object access patterns. A multithreaded application does not necessarily share every temporary object. A worker may create and discard many objects privately. Charging those operations the full cost of shared atomic reference counting would impose synchronization where no other thread needs it. The bias lets the implementation treat that common case differently.

Ownership also creates bookkeeping. Local and shared counts eventually have to be reconciled, including when an owning thread exits. An object cannot be reclaimed just because one component of its count reaches zero. The combined state must establish that no remaining reference keeps it alive. The PEP describes transitions and merging rules for that purpose.

Some objects receive another treatment. Interned strings, small integers and certain other long-lived objects can be marked immortal. Incrementing and decrementing their reference counts then becomes unnecessary. This avoids several threads repeatedly modifying the same count for an object that is expected to remain alive anyway.

Deferred reference counting addresses another source of contention. For selected objects, the interpreter can omit some reference count updates associated with its execution stack. The garbage collector must later account for those stack references. Avoiding synchronization during execution therefore transfers work to collection and changes when some objects can be destroyed.

Collection and containers need coordinated changes

The original PEP proposes stop-the-world collection for its no-GIL build. The collector needs a stable view of references while it determines which objects are unreachable. It cannot establish that view while other threads freely change the same object relationships. Pausing threads supplies the consistency that the GIL previously helped provide.

Reference counting still handles much ordinary reclamation. The collector does not become the sole mechanism for disposing of every object. This distinction also motivates the proposal to use a non-generational cycle collector. Objects that die young often disappear through reference counting before a cycle collection occurs, so the remaining collection workload differs from that of a runtime relying entirely on tracing.

Containers introduce a related problem. A list must remain internally consistent while one thread changes its storage and another accesses an element. The proposal uses fine-grained locking for container operations and describes optimistic access paths for lists and dictionaries. Those access paths avoid taking a mutex in common cases but still depend on rules that prevent unsafe reclamation.

The memory allocator consequently participates in correctness. The PEP proposes using mimalloc and controlling memory reuse so an optimistic read cannot mistake reclaimed storage for a valid object. Changing the allocator is connected to how containers return references. It is not an independent tuning choice.

These mechanisms protect interpreter data structures. Application operations that combine several container accesses still need their own synchronization when they depend on a shared invariant. A safe individual lookup does not make an arbitrary sequence of lookups and writes indivisible. The proposal aims to permit concurrent Python execution while retaining coherent objects, which leaves application-level coordination necessary.

Native extensions need more than a rebuild

The January proposal names its build option --without-gil. It proposes a Py_NOGIL macro and an ABI tag containing the letter n. Those are details of this draft, and the draft keeps ordinary CPython builds and python.org downloads using the GIL by default.

The changed object header prevents ABI compatibility with existing CPython extension binaries, including the stable ABI. Native extensions would need builds specifically for the proposed configuration. Source compatibility also needs examination because the GIL often protects state in extension code. A C global variable does not gain protection when CPython adds locks to its own containers.

Borrowed references expose the lifetime problem particularly clearly. Extension code can obtain a pointer without acquiring a reference that keeps the object alive. If another thread removes the object from its container, the pointer may become invalid before the extension uses it. Acquiring ownership after an unsafe gap does not repair the earlier race.

The PEP therefore proposes APIs that return owned references for affected operations. The important boundary is the interval between finding an object and securing its lifetime. An API can close that interval within the implementation, where synchronization and reference acquisition can occur together. Extension code needs to use those APIs where concurrent mutation makes borrowing unsafe.

I find the distribution implications harder to dismiss than the build flag suggests. Python packaging already asks users to manage relationships between interpreters and native dependencies. Another incompatible extension configuration makes package availability part of whether the proposed interpreter is usable.

Gross addresses that problem explicitly in the draft. He proposes working with Anaconda to distribute a compatible interpreter and extension packages through conda channels. That concentrates some build work in one distribution, while acknowledging that broader adoption depends on extension authors supplying suitable packages.

The draft leaves a performance cost to resolve

Thread safety adds work even when only one application thread executes. The January revision reports an approximately ten percent geometric-mean regression on pyperformance from biased reference counting and immortalization when applied to Python 3.11. It also describes a smaller regression in the proof-of-concept interpreter, with mitigating changes outside this PEP. Those figures describe the reported configurations, not a promised result for every program.

The draft also leaves thread-safe quickening and specialization as an open issue. Python 3.11 uses specialization to replace general operations with faster variants. Some of those optimizations depend on assumptions that require reconsideration when multiple threads can execute concurrently. Preserving single-thread execution speed and enabling shared-memory parallelism therefore involve overlapping interpreter work.

My interest is in whether Python can express parallel coordination without forcing libraries to redesign their APIs around processes. The proposed implementation takes that requirement seriously by connecting reference lifetimes, container access and extension compatibility. Its unresolved build-mode question is equally concrete. Combining configurations eventually would require a path through incompatible object layouts and different synchronization assumptions, and the January draft does not yet provide that path.