Thread Safety

How Chirp avoids data races by design on Python's free-threaded build

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Chirp targets Python 3.14's free-threaded build, where many threads run Python code at the same time with no GIL (the global lock that older builds used to let only one thread interpret bytecode at a time). Free-threading removes the safety net that most Python web code quietly relies on — so Chirp avoids data races by design rather than by hoping a lock happens to be in the right place.

The strategy is three tiers:

  • Shared state is immutable. Config, requests, and the route table are frozen dataclasses. Many threads read them at once with zero synchronization.
  • Per-request state is isolated. Anything that changes during a request lives in a ContextVar(a variable whose value is private to the current task/thread), so two concurrent requests never see each other's data.
  • Genuinely-shared mutable state is locked. The handful of caches, registries, and event buses that multiple requests do write to are each guarded by an explicit lock.

An evaluator skimming the first screen should already have the model: read-only data is free, request data is isolated, and the rest is locked.

The Bengal stack (Chirp, Kida, Pounce, and in-tree Pelt) shares one free-threading stack ledger that classifies shared warm vs isolated ownership and names honest boundaries across the stack.

At a glance

Concern Pattern
Configuration Frozen dataclass — no locks
Request data Frozen dataclass — no locks
Route table Compiled at freeze, immutable after
Per-request state ContextVar (g, get_request())
Response building Immutable.with_*()chains
Shared mutable state Explicitthreading.Lock()
Module-level state None — no global mutables

Immutable data structures

Data that does not change after creation is frozen. Multiple threads read these structures concurrently without any synchronization.

Abstraction Pattern Why it's safe to share
AppConfig @dataclass(frozen=True, slots=True) Config does not change at runtime
Request @dataclass(frozen=True, slots=True) Received data does not change
Route @dataclass(frozen=True, slots=True) Routes do not change after compile
Headers / QueryParams Immutable mappings Request inputs do not change
Router Compiled trie The route table is built once, at freeze

Responses follow the same rule. Each.with_*()returns a new object; the original is never mutated, so middleware can transform a response without stepping on another thread's copy:

response = Response("OK")
response = response.with_header("X-Custom", "value")
response = response.with_status(201)

ContextVar for request scope

Per-request state usesContextVar, which gives each concurrent request its own isolated value automatically:

from contextvars import ContextVar

# Each request reads and writes its own value — never another request's.
request_var: ContextVar[Request] = ContextVar("chirp_request")

When you access g.user or call get_request(), you get the value for the current request, no matter how many other requests are in flight. This is the same isolation pattern kida uses for render context and patitas uses for parse config — no shared mutable globals.

For per-request scratch state, useg. Each request gets its own namespace:

from chirp import g

g.user = current_user
g.start_time = time.monotonic()

The freeze: setup → runtime

AnApptransitions from mutable (setup) to immutable (runtime) exactly once, and that transition is guarded so concurrent first-requests are safe:

# Setup phase — single-threaded, mutable
app = App()
app.add_middleware(cors)

@app.route("/")
def index():
    return "Hello"

# Freeze — compiles routes, creates the kida env, makes shared state immutable
app.run()

# Runtime phase — multi-threaded, immutable. No synchronization needed.

After freeze, every structure in the At a glance table is read-only, so the hot request path takes no locks at all.

No module-level mutable state

Chirp has no module-level mutable state — no global caches, module-level dicts, or singletons that requests write to. Unguarded module-level state is the genuine free-threading footgun, because the GIL used to hide the race for you:

When you need mutable state

Some state genuinely is shared across requests: caches, rate limiters, event buses, registries. Chirp guards each one with an explicitthreading.Lock(). The shape is always the same — take the lock, touch the shared state, release:

class ReactiveBus:
    def __init__(self, *, maxsize: int = 256) -> None:
        self._subscribers: dict[str, set[asyncio.Queue]] = {}
        self._lock = threading.Lock()
        self._emitted_count = 0

    def emit_sync(self, event: ChangeEvent) -> None:
        with self._lock:                         # critical section guarded
            queues = set(self._subscribers.get(event.scope, set()))
            self._emitted_count += 1
        for queue in queues:                     # work happens outside the lock
            queue.put_nowait(event)

Every shared-mutable-state primitive in Chirp is lock-guarded this way. Representative examples include the in-memory cache backend, the in-memory rate-limit and lockout backends, the OOB registry, the signal registry, the shape registry, and the security audit sink — each with dedicated concurrency stress tests.

For state bound to a specific worker thread or event loop, use @app.on_worker_startup / @app.on_worker_shutdownand run production with worker_mode="async". Pounce 0.7 sync workers do not emit worker-lifecycle scopes, so Chirp fails production startup when worker hooks are registered and the effective worker mode resolves to sync.

Stress-tested under contention

Every lock-protected module has concurrency stress tests in tests/test_concurrency/.

Module Test What it proves
ReactiveBus 100 subscribers, 50 emitter threads No deadlock, no lost subscriptions
ReactiveBus Queue saturation at capacity Silent drop count is accurate
MemoryCacheBackend 100 threads doing get/set/delete NoKeyError, no corrupt values
Rate limiter 200 burst login attempts Rate counts accurate (no under/over-counting)
Lockout backend Concurrent lockout checks Threshold triggers at the correct count
OOB registry Concurrent contract builds Single build, cache hit on subsequent access
ContextVar 50 concurrent async tasks Each task sees only its owng, request, and session
Database pool 50 concurrent queries + parallel-reader timing Readers run in parallel (WAL pool); writes serialize behind one writer; no pool exhaustion

PEP 703 declaration

Chirp declares_Py_mod_gil = 0, which tells Python 3.14t that the framework is free-threading safe and does not need the GIL re-enabled on its account.

Code references

Pattern File
PEP 703 declaration src/chirp/__init__.py
Request / ContextVar (g, get_request) src/chirp/context.py
App freeze, double-check locking src/chirp/app/__init__.py
ReactiveBus (lock + observability) src/chirp/pages/reactive/bus.py
Concurrency stress tests tests/test_concurrency/