Skip to content

python: release the GIL around the operations that block (ibx#271) - #391

Open
userFRM wants to merge 1 commit into
deepentropy:mainfrom
userFRM:fix/python-gil-holds
Open

python: release the GIL around the operations that block (ibx#271)#391
userFRM wants to merge 1 commit into
deepentropy:mainfrom
userFRM:fix/python-gil-holds

Conversation

@userFRM

@userFRM userFRM commented Jul 31, 2026

Copy link
Copy Markdown

Problem

The Python layer held the GIL across operations that block. A thread join on disconnect() and on Drop, the registration waits that can sit for up to five seconds, and every control-channel send: each one blocked with the interpreter locked, so one stalled call froze every other Python thread in the process.

What this changes

The GIL is released around the wait and reacquired after — the blocking operation is what moves inside the detach, and nothing Python-backed crosses that boundary. Commands are built while attached, so only an owned ControlCommand and a channel reference travel with the closure.

The sends stay blocking. The channel is bounded, so a burst applies backpressure and then succeeds, which is the behaviour callers already have; what was wrong was holding the GIL while waiting, not the waiting itself.

Drop mints its token with Python::try_attach rather than Python::attach. Dealloc can run during interpreter shutdown, and the common App(EWrapper, EClient) shape means the cyclic collector rather than refcounting may be what calls it. Where the token cannot be had, the join still happens — the handle is never dropped unjoined.

ClientCore is untouched. It is shared with the plain Rust API, which has no GIL to release, so the fix stays on the Python side that wraps it.

Verification

cargo check --offline --lib --features python is clean, and the full gate passes.

Not run: cargo test --lib --features python and the pytest suite, neither of which links in this tree (#381). The unblocking behaviour and the try_attach shutdown path are established by reading pyo3 0.29's Ungil bounds and detach/try_attach contracts against this repo's existing usage in connect() and run(), rather than by exercising an interpreter.

Closes #271.

The Python EClient held the GIL across every operation that can block for an unbounded time: joining the engine thread in disconnect() and Drop, sending on the bounded(64) control channel, and waiting on the registration reply channel used by find_or_register_instrument, register_mkt_data, and register_tbt. connect() and run() already released the GIL around their own blocking calls, the gateway login and wait_for_data; the rest of the surface did not, so a stalled engine thread, the wedge deepentropy#254 describes one way to get there, froze the interpreter instead of just blocking its own caller, taking every unrelated Python thread down with it.

disconnect() and Drop now detach around the thread join. Drop has no Python<'_> token to work with, so it mints one through Python::try_attach rather than Python::attach: dealloc can also run during interpreter shutdown, and since EClient.wrapper commonly points back at the object embedding it (the App(EWrapper, EClient) pattern passes self as the wrapper), the cyclic GC, not just refcounting, can be what calls drop, and attaching is unsound in the shutdown case. When try_attach declines, drop falls back to the plain join it used before this change rather than lose the handle.

find_or_register_instrument (used by place_order), register_mkt_data (req_mkt_data), and register_tbt (req_tick_by_tick_data) each wait on a bounded(1) reply channel for up to REGISTRATION_TIMEOUT to hear back from the hot loop. Those call sites now copy the contract fields they need into owned values before detaching for the round trip, so a slow reply stalls the caller rather than the interpreter. client_core.rs itself is untouched: it is shared with the plain Rust API, which has no GIL to release, so the fix lives entirely on the Python call sites that wrap it.

Every control_tx.send across orders.rs, market_data.rs, reference.rs, and account.rs now goes through EClient::send_control. The channel is bounded(64) on purpose, applying backpressure to a caller that outruns the hot loop, so the send itself stays a normal blocking send rather than becoming a probe that fails as soon as the queue is momentarily full. What changes is that the wait no longer holds the GIL: send_control builds the command while still attached, then moves it into a detached closure around the send, so a caller stalled behind a full queue blocks only itself instead of every Python thread. A SendError (the channel gone, not merely full) still surfaces as the same "Engine stopped" PyRuntimeError callers saw before this change.

cargo check --offline --lib --features python is clean. cargo test --lib --features python does not link in this environment (ibx#381) and could not be run; the pytest suite under tests/python needs the same built extension module and could not be exercised either. The full gate suite passes: check --lib, check --lib --features python, check --bins, check --examples, every tests/*.rs check including the ib_paper_compat baseline diff, and cargo test --lib, with the two config::expiry_tests tzdata failures that are expected in this environment.

Closes deepentropy#271.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

python: the GIL is held across thread joins, control-channel sends and registration waits, so one stalled call freezes the whole interpreter

1 participant