Allow releasing GVL when calling exported Wasm functions#603
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds an opt-in path to release Ruby’s GVL during Wasm function calls (via to_func(gvl: false)), while ensuring Ruby host callbacks still execute with the GVL held.
Changes:
- Extend
Extern#to_functo acceptgvl:and return aFuncconfigured to release the GVL duringcall. - Add
nogvl/with_gvlhelpers and wire them into Wasm calls and host-callback closures. - Add unit specs covering correctness, threading behavior, host callbacks, and exception propagation under GC stress.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/unit/func_spec.rb | Adds specs validating GVL release behavior and callback/exception correctness under concurrency/GC stress. |
| ext/src/ruby_api/instance.rs | Updates Func::invoke callsite to pass the new GVL flag parameter. |
| ext/src/ruby_api/func.rs | Adds per-Func GVL configuration, releases GVL around Wasm calls, and re-acquires GVL for Ruby host callbacks. |
| ext/src/ruby_api/externals.rs | Adds gvl: kwarg parsing to Extern#to_func and updates YARD docs accordingly. |
| ext/src/helpers/nogvl.rs | Implements panic-safe trampolines plus with_gvl to re-enter the GVL from nogvl contexts. |
| ext/src/helpers/mod.rs | Re-exports with_gvl alongside nogvl. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let data = unsafe { &mut *(arg as *mut (Option<F>, MaybeUninit<thread::Result<R>>)) }; | ||
| let func = data.0.take().expect("closure called more than once"); | ||
| data.1.write(panic::catch_unwind(AssertUnwindSafe(func))); |
| counter = 0 | ||
| running = true | ||
| sibling = Thread.new { counter += 1 while running } | ||
|
|
||
| marks = [] | ||
| store = Store.new(engine) | ||
| mark = Func.new(store, [], []) { marks << counter } | ||
| linker = Linker.new(engine) | ||
| linker.define(store, "env", "mark", mark) | ||
| instance = linker.instantiate(store, mod) | ||
|
|
||
| instance.export("spin").to_func(gvl: false).call(500_000_000) | ||
|
|
||
| running = false | ||
| sibling.join |
| linker.define(store, "env", "mark", mark) | ||
| instance = linker.instantiate(store, mod) | ||
|
|
||
| instance.export("spin").to_func(gvl: false).call(500_000_000) |
| // Borrow `ty` so the `move` closure captures a reference, not the owned value. | ||
| let ty = &ty; | ||
| with_gvl(move || { |
saulecabrera
left a comment
There was a problem hiding this comment.
Currently we have ~4 nogvl uses, all them involve CPU bound tasks, in which there are no callbacks to Ruby. I understand the motivation behind this change, but I think this is inherently unsafe and as such, I expect the documentation around it to reflect that clearly e.g., one case that I think that is not covered in the current state is the interaction between Wasmtime and Ruby through Wasmtime's API:
- A store declared with
store = Store.new(engine, limits: {memory_size: 1_000_000}) - Will end-up calling Wasmtime's resource limiter
- Which in turn will call
rb_gc_adjust_memory_usage
According to the docs for rb_gc_adjust_memory_usage:
/**
* Informs that there are external memory usages. Our GC runs when we are
* running out of memory. The amount of memory, however, can increase/decrease
* behind-the-scene. For instance DLLs can allocate memories using `mmap(2)`
* etc, which are opaque to us. Registering such external allocations using
* this function enables proper detection of how much memories an object used
* as a whole. That will trigger GCs more often than it would otherwise. You
* can also pass negative numbers here, to indicate that such external
* allocations are gone.
It seems to me that the GVL is needed in that case since GC could mutate the VM state?
This PR adds a
gvl:boolean kwarg toExtern#to_func. With.to_func(gvl: false)the GVL is released during the Wasm call, enabling true parallelism in Ruby.