runtime: run syscall/js finalizers on wasm without a manual GC#5545
runtime: run syscall/js finalizers on wasm without a manual GC#5545felipegenef wants to merge 3 commits into
Conversation
|
Here are some lightly edited generated comments: PR #5545 Review Comments1.
|
|
Thanks @deadprogram, all fair points. Done: 1. 2. Build tag coverage. There's a fourth file, 3. Threshold of 32. Kept it a Happy to make it tunable via a |
|
Any further comments from @jakebailey @dgryski or anyone else also wasm-involved? |
| // will never be resumed, so its stack can be cleared now to drop any | ||
| // pointers its returned frames left behind (see clearStack). | ||
| t.state.finishing = false | ||
| t.clearStack() |
There was a problem hiding this comment.
I think this might need t.state.args = nil?
There was a problem hiding this comment.
Good catch, fixed. Added a testFinishedGoroutineArgs case in finalizeridle.go to cover it. Thanks!

Runtime: run syscall/js finalizers on wasm without a manual GC
Follow-up to #5521, which implemented
runtime.SetFinalizersosyscall/jscanauto-release bridge-table slots. While stress-testing a
syscall/js-heavy wasmproject of my own on top of that change, I found the finalizers almost never run
on their own, so the bridge tables keep growing under load. Reclaiming a slot
still needs an explicit
runtime.GC().Why the slots still leak
The finalizer frees the JS slot, so reclamation depends on the GC running, and
the block GC only runs when the Go heap is exhausted. A
GOOS=jsprogram createslots of small, short-lived
js.Values. Each one pins a JS object and a bridgeslot but costs only a few bytes of Go heap, so the heap barely grows and the GC
never fires. The finalizers are correct, they just never get a turn.
Standard Go covers this case with a 2-minute forced GC, but that runs from
sysmon, andhaveSysmon = GOARCH != "wasm". There is nosysmonon wasm, andthe 4 MB
heapMinimumkeeps the heap-growth trigger from firing for a small liveset. So on wasm there is no trigger at all for an idle, allocation-light workload.
The fix
Two small changes.
1. Collect on finalizer-registration pressure, from the scheduler's idle
point. Once
finalizerGCThreshold(32) finalizers have been registered sincethe last GC, the cooperative scheduler collects when its run queue drains (top
level only, no goroutine mid-run). A registered finalizer is a good proxy for the
external pressure the heap size can't see.
Running this from the idle point rather than from
allocis the important part.An
alloc-based trigger scales GC frequency with allocation churn: a goroutinethat boxes hundreds of short-lived
js.Values would force dozens of collectionsmid-run, almost all of them wasted on values that are still live. The idle point
collects a finished run's now-dead values in one pass instead.
2. Zero a finished goroutine's stack (asyncify scheduler). Asyncify goroutine
stacks are heap buffers scanned conservatively, so a returned event handler's
stale frame pointers keep its
js.Values reachable until the buffer itself iscollected. The scheduler now zeroes a finished goroutine's stack.
The idle hook is installed lazily by the first
SetFinalizer, next to theexisting runner spawn, so a program that never registers a finalizer links none
of it. A
microbitbinary with noSetFinalizeris byte-identical before andafter (
code2788), soTestBinarySizeis unaffected. Non-block GCs and thecores/threadsschedulers get a nil hook; thetasksscheduler gets a no-opstack-zero.
Scope
Cooperative schedulers (
asyncify,tasks) with the block GC only. Thefinalizer semantics,
wasm_exec.js, and the public API are untouched. Thethreshold is a policy constant, like Go's
forcegcperiod.Testing
New
testdata/finalizeridle.gogolden test, wasm only (same determinismrationale and skip as
finalizer.go). It registers a batch of finalizers overthe threshold, then only parks the goroutine with
time.Sleepand never callsruntime.GC(), and checks that every finalizer ran. A second case registersfinalizers on goroutine-stack-local objects, lets the goroutines finish, and
checks the objects are still collected.
finalizer.goandgc.gostill pass on host and wasm. The change was builtacross every scheduler and GC it touches (
asyncify,tasks,none,cores,threads, with block,boehm, andleaking) onwasm,wasip1,wasip2,microbit,pico, and host.make fmt-checkis clean.Beyond the golden test, this fixes real breakage. On top of #5521's finalizers
but with nothing to trigger a collection, a
syscall/js-heavy wasm project ofmine grew the bridge table without bound under load and failed its leak checks;
with this change the same runs hold net growth near zero and pass. Bridge-table
(
_values) growth measured over a run, no manualruntime.GC():The other half of the win is cadence. An operation that boxes a few hundred
short-lived
js.Values triggers about one collection at the idle point; the sameoperation with the trigger inside
allocforced roughly ten, almost all of themwasted on values that were still live.