Skip to content

PERF: release the GVL while the V8 thread boots the isolate#424

Open
ursm wants to merge 1 commit into
rubyjs:mainfrom
ursm:perf/release-gvl-during-context-boot
Open

PERF: release the GVL while the V8 thread boots the isolate#424
ursm wants to merge 1 commit into
rubyjs:mainfrom
ursm:perf/release-gvl-during-context-boot

Conversation

@ursm
Copy link
Copy Markdown
Contributor

@ursm ursm commented Jun 1, 2026

Summary

In the default multithreaded mode, Context.new spawns the V8 thread and then blocks on the late_init barrier while that thread runs v8::Isolate::New (snapshot deserialization) and the safe-context setup. barrier_wait is a plain pthread_cond_wait, so the booting Ruby thread holds the GVL for the entire boot, freezing every other Ruby thread for the full duration.

The V8 thread touches no Ruby objects during boot — the snapshot is a plain C buffer already copied into c->snapshot — so this change releases the GVL around the barrier waits via rb_thread_call_without_gvl (the same idiom already used by Context#dispose). A background thread booting contexts from a snapshot (e.g. a warm-context pool) now genuinely overlaps with the main thread instead of stalling it.

Why it's safe

  • The boot path (early_initv8::Isolate::New / context / safe-context setup → late_init) makes no Ruby calls and allocates no VALUEs before late_init, so the initializing thread never needed to hold the GVL there.
  • self/c stay alive across the GVL-released wait via MRI's conservative machine-stack marking — identical to the existing rb_thread_call_without_gvl(context_dispose_do, c, NULL, NULL) in Context#dispose.
  • context_mark only reads c->procs / c->exception, both initialized before the V8 thread exists and untouched during boot, so a concurrent GC mark is race-free.
  • Only the multithreaded branch is affected; :single_threaded mode (which runs the boot inline on the Ruby thread) is unchanged.

Measurement

Synthetic microbenchmark (background thread booting contexts from a heavy snapshot while the main thread runs a tight Ruby loop): on main, every background boot stalls the main thread for the full ~8 ms deserialization; with this change those per-boot stalls disappear.

Real-world A/B in a downstream app that maintains a warm Context pool with background async refill (real snapshot + pool + refill, median of 8 runs, identical workload): ~1.52s → ~1.40s wall, ≈ 8.5% faster.

All existing tests pass (rake test, 113 runs, 0 failures).

🤖 Generated with Claude Code

Context.new (multithreaded mode) spawns the V8 thread and then blocks on
the late_init barrier while that thread runs v8::Isolate::New (snapshot
deserialization) and safe-context setup. barrier_wait is a plain
pthread_cond_wait, so the booting Ruby thread holds the GVL the entire
time, stalling every other Ruby thread for the full boot.

The V8 thread touches no Ruby objects during boot (the snapshot is a C
buffer already copied into c->snapshot), so release the GVL around the
barrier waits via rb_thread_call_without_gvl. A background thread booting
contexts from a snapshot (e.g. a warm-context pool) now overlaps with the
main thread instead of freezing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant