Skip to main content

1639. gopy eval gil

What we are porting

Python/ceval_gil.c (~1500 lines) plus the eval-breaker bit declarations in Include/internal/pycore_ceval.h. Two intertwined concerns:

  • GIL: the global interpreter lock. CPython serializes Python bytecode execution behind one mutex per interpreter. The eval loop drops it on long-running C calls and re-acquires it before returning to Python.
  • Eval breaker: a bitfield the eval loop polls each RESUME and each backward jump. When any bit is set, the loop stops dispatching and runs the matching handler: GC, signals, async exceptions, GIL drop request, monitoring tool install, profiler attach.

How gopy maps the GIL

Go has goroutines, not OS threads, and Go's scheduler is cooperative-with-preemption. We do not need a mutex to serialize bytecode execution because each interpreter runs in one goroutine at a time. But we do need the GIL surface for two reasons:

  1. C extension shape parity: Go-native modules that mirror CPython C modules (the tamnd/gopy-stdlib companion) call into the same Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS shape.
  2. Sub-interpreters: v0.13 introduces sub-interpreters, each with its own GIL. The lock is a real lock at that point; in v0.6 it is a single-owner mutex with no contention.

So the v0.6 GIL is:

// GIL is the per-interpreter execution lock. Mirrors _PyEval_GilState
// from Python/ceval_gil.c.
//
// In v0.6 each Interpreter runs in one goroutine and the lock has
// at most one waiter. The mutex still goes through Acquire / Release
// so the API matches CPython and so v0.13 sub-interpreters can keep
// the same call sites.
type GIL struct {
mu sync.Mutex // protects the lock itself
locked bool // is anyone holding it?
holder *state.Thread // who holds it
cond sync.Cond // signalled on release
requestDrop atomic.Bool // another thread asked us to drop
interval time.Duration // sys.setswitchinterval
}

// Acquire blocks until ts holds the GIL. Mirrors take_gil from
// ceval_gil.c.
func (g *GIL) Acquire(ts *state.Thread)

// Release drops the GIL. Mirrors drop_gil.
func (g *GIL) Release(ts *state.Thread)

// RequestDrop asks the current holder to drop the GIL at the next
// poll point. Mirrors COMPUTE_EVAL_BREAKER (the GIL_DROP_REQUEST
// bit).
func (g *GIL) RequestDrop()

Eval breaker

The breaker is a per-thread atomic uint32. Bits:

BitSource signal
BreakerGILDropRequestanother thread wants the GIL
BreakerSignalsPendinga Unix signal arrived
BreakerCallsPendingPy_AddPendingCall queued work
BreakerGCScheduledthe cycle collector wants to run
BreakerAsyncExceptionPyThreadState_SetAsyncExc
BreakerProfileOrTracea profiler / tracer was installed
BreakerMonitoringVersionPEP 669 monitoring tool changed
// Breaker holds the eval-loop poll bits. Mirrors the eval_breaker
// field on PyThreadState plus the helpers in ceval_gil.c.
type Breaker struct {
bits atomic.Uint32
}

// Set sets one bit. Mirrors _PyEval_AddPendingFlag.
func (b *Breaker) Set(bit uint32)

// Clear clears one bit. Mirrors _PyEval_ClearPendingFlag.
func (b *Breaker) Clear(bit uint32)

// Load reads the bitfield. Hot-path read; the eval loop checks
// this each RESUME / each backward JUMP.
func (b *Breaker) Load() uint32

The eval loop polls Breaker.Load() once per outer dispatch iteration (1636) and dispatches to a handler when non-zero.

Pending calls

Py_AddPendingCall queues a function to run on the main thread under the GIL. Common use case: signal handlers cannot run Python code directly, so they queue a pending call and set the BreakerCallsPending bit.

// Pending is the per-interpreter pending-call queue. Mirrors
// _Py_AddPendingCall. Bounded ring buffer; overflow returns an
// error.
type Pending struct {
mu sync.Mutex
queue [32]func() error
head int
tail int
}

// Add enqueues a callback to run at the next eval-breaker poll.
func (p *Pending) Add(fn func() error) error

// Drain runs all queued callbacks. Called by the eval loop when
// BreakerCallsPending is set.
func (p *Pending) Drain() error

File mapping

C sourceGo target
Python/ceval_gil.c (lock)vm/gil/gil.go
Python/ceval_gil.c (breaker)vm/gil/breaker.go
Python/ceval_gil.c (pending)vm/gil/pending.go
Include/internal/pycore_ceval.h (bits)vm/gil/bits.go
Python/ceval_gil.c (signal handler bridge)vm/gil/signals.go

Checklist

Status legend: [x] shipped, [ ] pending, [~] partial / scaffold, [n] deferred / not in scope this phase.

Files

  • gil/gil.go: GIL struct, Acquire, Release, RequestDrop, DropRequested, SetSwitchInterval. sync.Mutex plus sync.Cond for the wait. (Flat layout per project convention.)
  • gil/breaker.go: Breaker struct, Set, Clear, Load, IsSet. CAS loop over atomic.Uint32.
  • gil/bits.go: Breaker* bit constants matching pycore_ceval.h numerically.
  • gil/pending.go: Pending struct, Add, Drain, ring-buffer overflow error (ErrPendingFull).
  • gil/signals.go: bridge from os/signal.Notify to Breaker.Set(BreakerSignalsPending) + queued callback. SIGINT KeyboardInterrupt wiring lives in the signal module (1651).
  • gil/gil_test.go + gil/signals_unix_test.go: acquire/release round-trip, request-drop flag, breaker bits set/clear and CAS concurrent stress, pending-call FIFO + overflow + drain-stops-on-error, signal-bridge smoke test (SIGUSR1, gated to non-Windows).

Surface guarantees

  • GIL.Acquire followed by GIL.Release from the same goroutine is contention-free in the steady state. Pinned by gil/gil_test.go (TestGILAcquireRelease, TestGILWaitsForRelease).
  • [~] GIL.RequestDrop sets a flag that Acquire clears on next hand-off. Pinned by TestGILRequestDrop. Bit-on-Breaker.Load wiring at the holder still pending; lands when v0.13 sub-interpreter contention shows up.
  • Pending.Add then Pending.Drain runs callbacks in FIFO order. Overflow returns ErrPendingFull and does not lose existing entries; drain stops on first error and leaves the remainder. Pinned by TestPendingFIFO, TestPendingOverflow, TestPendingDrainStopsOnError.
  • [n] SIGINT delivered while the eval loop runs raises KeyboardInterrupt at the next poll point. Defers to the signal module port (1651); the bridge wiring is in place (signals_unix_test.go covers SIGUSR1) but the KeyboardInterrupt exception path needs the exception module (1686).
  • Eval-breaker bit values match Include/internal/pycore_ceval.h byte-for-byte. Pinned by TestBreakerBitValues (every bit + BreakerEventsMask mask).
  • Eval loop polls the breaker at three places: top of dispatch, JUMP_BACKWARD, and RESUME (oparg < 2). A queued Pending.Add callback paired with Breaker.Set(BreakerCallsPending) runs at the next poll, and the bit clears after a successful drain. JUMP_BACKWARD_NO_INTERRUPT skips the per-arm poll. Pinned by vm/eval_breaker_test.go (TestEvalBreakerTopOfLoopPoll, TestEvalBreakerJumpBackwardPoll, TestEvalBreakerResumePoll, TestEvalBreakerJumpBackwardNoInterruptSkipsPoll, TestEvalBreakerNoBitNoDrain).
  • [~] sys.setswitchinterval(s) updates GIL.interval. Pinned by TestGILSwitchInterval. The cross-block sys binding test (partest/setswitchinterval_test.go) still needs the sys module port (1651).

Out of scope for v0.6

  • Real GIL contention between sub-interpreters. Lives in v0.13.
  • PEP 703 free-threaded build (no GIL at all). Lives in v0.14.
  • Multi-thread Py_AddPendingCall from worker goroutines. The v0.6 surface accepts the call but assumes one drainer.

Cross-references

  • Eval loop poll point: 1636.
  • state.Thread struct that owns the breaker: 1615 (state spec, reserved).
  • sys.setswitchinterval binding: 1651 (modules spec, reserved).
  • Signal module bridge: 1651.