# Free-Threading URL: /bengal/docs/0.5.0/about/free-threading/ Section: about Tags: free-threading, performance, architecture -------------------------------------------------------------------------------- Free-Threading Bengal is designed for Python 3.14t (free-threaded Python). On free-threaded builds, page rendering achieves true parallelism — no GIL contention. The same code runs on standard Python; it just uses sequential rendering until you switch interpreters. Run It uv python install 3.14t uv run --python=3.14t bengal build Bengal auto-detects free-threading at runtime and uses ThreadPoolExecutor when available. Key Patterns Runtime Detection Bengal checks sys._is_gil_enabled() to decide whether threads provide real parallelism: def is_free_threaded() -> bool: if hasattr(sys, "_is_gil_enabled"): try: return not sys._is_gil_enabled() except (AttributeError, TypeError): pass # Fallback: sysconfig for Py_GIL_DISABLED ... When True, Bengal spins up a ThreadPoolExecutor for page rendering. Immutable Snapshots (Lock-Free) After content discovery, Bengal freezes the entire site into immutable dataclasses — PageSnapshot, SectionSnapshot, SiteSnapshot. SiteSnapshot is composed of focused plan types (NavigationPlan, TaxonomyPlan, RenderSchedule) for organizational clarity. During rendering, workers only read from snapshots. No locks in the hot path. Each pipeline stage also produces its own frozen record — SourcePage (discovery), ParsedPage (parsing), RenderedPage (rendering) — so data flows through the pipeline as stable snapshot-style values. These records are frozen at the top level, but nested containers may still be mutable, so this is a shallow immutability boundary rather than a deep thread-safety guarantee by itself. @dataclass(frozen=True, slots=True) class PageSnapshot: title: str href: str source_path: Path parsed_html: str content_hash: str section: SectionSnapshot | None = None next_page: PageSnapshot | None = None prev_page: PageSnapshot | None = None ... @dataclass(frozen=True, slots=True) class SiteSnapshot: pages: tuple[PageSnapshot, ...] sections: tuple[SectionSnapshot, ...] navigation: NavigationPlan # Menus, nav trees taxonomy: TaxonomyPlan # Tag/category mappings schedule: RenderSchedule # Topological render order ... Context Propagation ThreadPoolExecutor.submit() does not inherit ContextVar values. Bengal uses contextvars.copy_context().run so each worker gets the parent's context: ctx = contextvars.copy_context() future_to_page = { executor.submit(ctx.run, process_page_with_pipeline, page): page for page in batch } Thread-Local Pipelines Each worker gets its own rendering pipeline via threading.local(), avoiding shared mutable state: def get_or_create_pipeline(current_gen, create_pipeline_fn): needs_new = ( not hasattr(thread_local, "pipeline") or getattr(thread_local, "pipeline_generation", -1) != current_gen ) if needs_new: thread_local.pipeline = create_pipeline_fn() thread_local.pipeline_generation = current_gen return thread_local.pipeline Performance Workers Time (1K pages) Speedup 1 ~9.0 s 1.0x 4 ~3.0 s 3.0x 8 ~2.5 s 3.6x On free-threaded Python, ~1.5–2x faster than GIL builds at the same worker count. Incremental builds: 35–80 ms for a single-page change on an 800-page site. Thread-Safe Internals (0.2.7+) Several internal components were hardened for free-threading in v0.2.7: EffectTracer Lock Serialization The build tracer records timing, cache hits, and fingerprint data. Without the GIL, concurrent defaultdict access can observe partially-constructed entries. All EffectTracer mutations (record, record_batch, update_fingerprint, flush_pending_fingerprints, clear) and reads now acquire a lock: class EffectTracer: def __init__(self): self._lock = threading.Lock() self._effects: dict[str, list] = defaultdict(list) def record(self, phase, event): with self._lock: self._effects[phase].append(event) Thread-Safe LRU Cache functools.lru_cache relies on the GIL for internal dict coherency. Bengal's get_bengal_dir() — called from multiple threads during rendering — now uses LRUCache from bengal.utils.primitives.lru_cache, which protects access with an RLock: # Before (GIL-dependent): @lru_cache(maxsize=1) def get_bengal_dir(): ... # After (free-threading safe): _cache = LRUCache(maxsize=1) def get_bengal_dir(): return _cache.get_or_compute("bengal_dir", _find_bengal_dir) Plugin Registry Freeze The plugin system uses a builder→immutable pattern. PluginRegistry collects extensions during startup, then freeze() produces a FrozenPluginRegistry (frozen dataclass with tuple fields) — no locks needed during rendering because the data is immutable. Code References Pattern File GIL detection bengal/utils/concurrency/gil.py Parallel rendering, thread-local pipeline bengal/orchestration/render/parallel.py Context propagation to workers bengal/utils/concurrency/context_propagation.py Immutable snapshots bengal/snapshots/types.py Pipeline records (SourcePage, ParsedPage, RenderedPage) bengal/core/records.py Rendering (asset ContextVar) bengal/orchestration/build/rendering.py EffectTracer (lock-serialized) bengal/effects/tracer.py Thread-safe LRUCache bengal/utils/primitives/lru_cache.py Plugin registry (freeze pattern) bengal/plugins/registry.py See Also Large Site Optimization — Free-threading notes and scaling Performance Overview — Incremental and parallel strategies Bengal source -------------------------------------------------------------------------------- Metadata: - Author: lbliii - Word Count: 598 - Reading Time: 3 minutes