Thread Safety

How Chirp makes data races structurally impossible

3 min read 587 words

By Architecture, Not by Testing

Chirp doesn't "pass tests on 3.14t." It makes data races structurally impossible through design choices baked into every abstraction.

Immutable Data Structures

Data that doesn't change after creation is frozen:

Abstraction Pattern Why
AppConfig @dataclass(frozen=True, slots=True) Config doesn't change at runtime
Request @dataclass(frozen=True, slots=True) Received data doesn't change
Route @dataclass(frozen=True, slots=True) Routes don't change after compile
Headers Immutable mapping Request headers don't change
QueryParams Immutable mapping Query parameters don't change
Router Compiled trie Route table doesn't change after freeze

No locks needed. Multiple threads can read these structures concurrently without synchronization.

ContextVar for Request Scope

Per-request state usesContextVar, which provides automatic isolation between concurrent requests:

from contextvars import ContextVar

# Each request gets its own copy
_request_var: ContextVar[Request] = ContextVar("request")
_g_var: ContextVar[Namespace] = ContextVar("g")

When you access g.user or get_request(), you get the value for the current request, regardless of how many other requests are being handled concurrently.

This is the same pattern used by patitas forParseConfigand by kida for rendering context.

Response Chains

Responses are built through immutable transformations:

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

Each .with_*()returns a new object. The original is never mutated. Multiple middleware can transform responses without interference.

App Freeze

TheApptransitions from mutable (setup) to immutable (runtime) exactly once:

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

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

# Freeze -- double-check locking
app.run()  # Compiles routes, creates kida env, sets _frozen = True

# Runtime phase -- multi-threaded, immutable
# All shared state is frozen. No synchronization needed.

The freeze uses double-check locking to be safe if multiple threads trigger it simultaneously:

if not self._frozen:
    with self._freeze_lock:
        if not self._frozen:
            self._compile()
            self._frozen = True

Module-Level State

Chirp has no module-level mutable state. No global caches, no module-level dicts, no singletons.

Compare with typical Python patterns:

# ❌ Not thread-safe (common in other frameworks)
_cache = {}

def get_cached(key):
    if key not in _cache:
        _cache[key] = compute(key)  # Race condition
    return _cache[key]

# ✅ Chirp pattern: ContextVar or locked cache
from contextvars import ContextVar
_request_cache: ContextVar[dict] = ContextVar("cache")

_Py_mod_gil

Chirp declares_Py_mod_gil = 0(PEP 703), telling Python 3.14t that the module is free-threading safe. The GIL is not needed for Chirp's code.

When You Need Mutable State

For shared mutable state (caches, rate limiters, metrics), use explicit locks:

import threading

class MetricsCollector:
    def __init__(self) -> None:
        self._counts: dict[str, int] = {}
        self._lock = threading.Lock()

    def increment(self, metric: str) -> None:
        with self._lock:
            self._counts[metric] = self._counts.get(metric, 0) + 1

For per-request mutable state, use g:

from chirp import g

# Safe: each request gets its own g namespace
g.user = current_user
g.start_time = time.monotonic()

Summary

Concern Pattern
Configuration Frozen dataclass
Request data Frozen dataclass
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)

Next Steps