Pinned Cowns¶
A PinnedCown is a Cown whose value never leaves the
main interpreter. Behaviors whose request set contains any
pinned cown run on the main thread, drained by pump() (called
from your event loop) or implicitly by wait(). Use a pinned
cown when the underlying value cannot survive an XIData round-trip:
pyglet shapes, Tk widgets, open file handles, ctypes pointers, GPU
contexts, asyncio loops.
When to Use a Pinned Cown¶
Reach for PinnedCown when the value:
has a
__reduce__that raises or silently reconstructs a broken object on the other side of the worker boundary,is a handle into a library loaded only by
__main__(pyglet GL state, a Tk root, an open SQLite connection, a CUDA context),must observe identity across acquires (
id(value)stable between behaviors).
A regular Cown stores its value as cross-interpreter data
and the same Python object is never observed twice in a worker. A
PinnedCown holds its value as a plain PyObject
reference in the main interpreter; every acquire sees the same
object.
Pump Contract¶
pump() drains the main-thread queue of pinned-aware
behaviors. Each behavior runs to completion before the next starts.
The pump is non-preemptive: deadline_ms gates starting the
next behavior, not interrupting one already running.
from bocpy import pump
result = pump() # drain to empty
result = pump(deadline_ms=4) # wall-clock budget
result = pump(max_behaviors=8) # hard count
result = pump(raise_on_error=True) # re-raise first body exception
The result is a PumpResult NamedTuple:
executed— pinned behaviors whose lifecycle (acquire attempt → optional body → release) ran to completion.deadline_reached—Trueiff the loop exited becausedeadline_mstripped before the queue drained.raised— pinned behaviors whose body raised anExceptioncaptured to the result cown’s.exception.
Script-mode programs need not call pump() explicitly —
wait() pumps internally when any PinnedCown exists
in the process.
The Coarse-Grained Dispatch Pattern¶
The pinned arm is single-consumer: only the main thread drains the pump queue. If you schedule a pinned behavior per item, those behaviors serialise on the main thread and you lose worker parallelism.
Schedule pinned behaviors coarsely — one per logical frame or batch, not per item:
Wrap per-item state in regular
Cowns.Schedule worker
@whens that compute per-item physics and return a resultCown.Schedule one pinned
@whenper frame that captures all the result cowns together with the main-thread handle, and performs the batched write-back.
The boids example follows this pattern: worker behaviors compute per-cell flock physics in parallel; one pinned behavior per frame writes the results back into the global pyglet-visible position and velocity matrices. Dispatch through the pump queue is ~1 per frame, not N.
Integrating with an Event Loop¶
Pyglet¶
Call pump() at the top of each scheduled tick so the prior
frame’s write-back drains before new work is scheduled:
import pyglet
from bocpy import PinnedCown, pump, start, when
start()
canvas = PinnedCown(MyCanvas()) # holds a pyglet handle
def update(dt):
pump() # drain prior frame's write-back
# ... schedule worker behaviors that return result cowns ...
results = [worker_compute(i) for i in range(num_items)]
@when(*results, canvas)
def _writeback(*args):
*cells, canvas = args
for cell in cells:
canvas.value.draw(cell.value)
pyglet.clock.schedule_interval(update, 1 / 60)
pyglet.app.run()
Tk / asyncio¶
Drive pump() from a periodic callback (root.after(ms,
...) for Tk, loop.call_later(...) or an asyncio-friendly
periodic task for asyncio). The same coarse-grained pattern
applies: keep dispatch rate at one pinned behavior per logical
batch.
Starvation and the Watchdog¶
The watchdog is disabled until you call set_pump_watchdog().
No call means no warnings — the runtime stays silent regardless of
how long the pinned queue has been non-empty.
Once enabled, if pinned work piles up because the host event loop
is wedged or not calling pump() often enough, the watchdog
logs a warning carrying the queue’s age and depth. The threshold
gates on queue-non-empty time: a program that runs only
unpinned work indefinitely never trips it.
from bocpy import set_pump_watchdog
set_pump_watchdog(warn_ms=1000) # enable warn-at-1s (matches the kwarg default)
set_pump_watchdog(warn_ms=None) # disable
No call ⇒ no watchdog. The runtime ships with the warn threshold unset; you opt in by calling
set_pump_watchdog()at least once.warn_ms(default1000when the kwarg is omitted) logs a warning carrying the queue’s non-empty duration (ms) and current depth. PassNoneto turn the warning off.on_starvelets the host replace the defaultloggingsink. Use it to escalate (on_starve=lambda s, m: pytest.fail(m)in tests, a counter / alert hook in production).
The watchdog deliberately never raises on its own: the pinned queue
is bounded by the live PinnedCown count by construction,
so there is no back-pressure threat the library can defend against
without lying about it. Fail-fast policy belongs in the host’s
on_starve callback, where the calling code can record the right
context and pick the right exception class.
Hosts that need to tune wait()’s internal pump cadence call
set_wait_pump_poll(). The default cadence is 50 ms, which
is the upper bound on how long the auto-pump loop will park between
checks when no broadcast wakes it.
Main-thread Direct Reads¶
The pinned-cown contract refuses worker-side reads of the underlying
value (the owner CAS rejects them). The symmetric question — “may
the main thread read the value directly, outside a pinned @when
body?” — has a narrower answer:
Reading the underlying object from the main thread is safe iff no pinned
@whenis currently executing against that cown. Pinned bodies run synchronously insidepump(); oncepump()(orwait()’s auto-pump) returns, no body holds the cown, and an immediate main-thread read sees a consistent value.Reading from the main thread while
pump()is dispatching a body that targets the same cown is undefined. Do not alternate between “I’m pumping” and “I’m reading directly” inside the same callback.The safe pattern is: stash any main-thread alias for read-only rendering / event-loop integration, but treat the pinned
@whenbody as the only writer.
In the boids example, the Simulation object aliases the same
Matrix under self.positions (for pyglet rendering) and
self.positions_cown = PinnedCown(positions) (for the per-frame
write-back). The render path runs on the main thread between
pump() calls; the write-back runs inside pump(). They never
overlap.
Thread Affinity and Free-Threaded Builds¶
PinnedCownmay only be constructed from the main interpreter; a worker that callsPinnedCown(x)raisesRuntimeError.pump()must run on the main interpreter. On classic CPython, any thread within the main interpreter may pump (the per-interpreter GIL serialises).On free-threaded builds (
Py_GIL_DISABLED) only one thread at a time may pump, enforced by a CAS on pump entry that raisesRuntimeErrorif a second thread tries to enter concurrently. The CAS is cleared on every exit path, includingBaseExceptionpropagation from a pinned body.pump()is not reentrant. Callingpump()from inside a pinned-behavior body raisesRuntimeError.
Handle vs. Value¶
A PinnedCown handle (the Python wrapper and its C
capsule) is a normal cross-interpreter shareable. It travels via
the same XIData mechanism as a regular Cown and may be:
shipped as a captured variable to a worker behavior,
embedded in any value graph stored in a regular
Cown(Cown(PinnedCown(x))is supported),placed in a noticeboard entry via
notice_write()ornotice_update().
What never crosses interpreter boundaries is the value. A worker
that ends up holding a pinned-cown handle can do exactly one
useful thing with it: schedule pinned @whens against it,
which the runtime auto-routes to the main pump queue. Any attempt
to acquire the value from a worker is rejected by the C-level
owner CAS.
Mixed Request Sets¶
A behavior may freely combine pinned and unpinned cowns; the 2PL acquisition order is unchanged. As soon as the request set contains any pinned cown, the body runs on the main thread. Unpinned cowns in the set still travel through XIData into the main interpreter for the body’s duration.
Exception Model¶
Body exceptions follow the same rules as worker behaviors:
captured on the result Cown and surfaced through
cown.exception. The default pump() does not re-raise;
pass raise_on_error=True to opt into fail-fast propagation.
BaseException (KeyboardInterrupt, SystemExit,
GeneratorExit) propagates from pump() immediately after
the offending behavior’s per-iteration cleanup completes; any
behaviors still queued remain in the pinned queue and resume on
the next pump() (or wait()-driven auto-pump) call.
Free-Threaded Trajectory¶
On free-threaded CPython (the 3.13t and 3.15t builds),
PinnedCown works identically to classic CPython — the
sub-interpreter boundary still exists for FT workers, and
free-threaded support is “experimental” across all of bocpy.
PinnedCown inherits that label. The single-pumper CAS
prevents silent data races from concurrent pumpers, raising
RuntimeError instead.
Long term, bocpy will fork into a classic-CPython build (using
sub-interpreters — where PinnedCown is meaningful) and a
free-threaded build (running workers as plain main-interpreter
threads — where every cown is effectively pinned and
PinnedCown becomes a no-op). In that future, the
single-pumper CAS is removed. Out of scope for v1.
API Reference¶
See API for the autodoc-generated reference for
PinnedCown, pump(), PumpResult,
set_pump_watchdog(), and set_wait_pump_poll().