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 10 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!
|
@deadprogram I kept thinking about your question on the 32 threshold, so I went and read how upstream Go paces this. It ties collection to proportional growth (GOGC) and keeps a per-span bitmap so the collector can skip spans with nothing registered, rather than relying on a fixed count. Borrowing both on top of what's already here:
Both mechanisms are battle-tested in Go's own collector, which counts for something this central. I benchmarked the branch before and after on a WASM workload that registers finalizers heavily: the new one is consistently faster and, more importantly, stops degrading as the table grows. Reclamation behaviour is unchanged. I also tried Go's per-span partitioning (64-way buckets) and measured it as redundant here: with the bit there's no scan left to shorten, and the partitioning mostly buys per-span locking, which doesn't apply under a global lock. Left it out.
Merged current dev, and |
…izeref-pressure-gc
|
Hey, small ping on this one I've been using this branch as the WASM backend engine for a Go web framework I maintain, and it's been holding up really nicely so far. Even on the rougher stress tests (10k clicks on a page over 5 minutes) the slot count stays flat, which was exactly the thing I was hoping to fix. No rush at all, just didn't want it to slip off the radar. Thanks for the reviews so far! |
|
@jakebailey any more comments on this PR? |
| // Clear: remove every registration for this object. | ||
| // Clear: remove every registration for this object. The bit proves in | ||
| // one test that there is nothing to remove. | ||
| if tracked && !finalizerBitGet(addr) { |
There was a problem hiding this comment.
The clear fast path reads finalizerBits without holding gcLock, while registration can concurrently resize the slice or update its bytes. This code is also built for the parallel cores/threads schedulers, so SetFinalizer(obj, nil) could incorrectly observe an unset bit and leave the finalizer registered.
Could the bitmap check be moved under gcLock, keeping the check, bit clear, and table removal in the same critical section?
There was a problem hiding this comment.
Fixed now.
Moved the check, the bit clear and the table removal into the same critical section. isOnHeap went in with them.
I found two more while I was there: the slice header swap in adoptFinalizerBits, and the sizing read in growFinalizerBits (that one's now finalizerBitsShortfall and runs under the lock).
This shouldn't cost anything. The bit was there to skip the O(numFinalizers) walk, not the lock, and the clear path was taking gcLock two lines down anyway.
Rebuilt on wasm, wasip1, microbit, pico, pico+cores and threads. wasm finalizer tests still pass.
|
If this code works on other platforms/schedulers than just javascript, it would be nice to have some tests demonstrating that. |
|
@dgryski I added some tests for the other platforms.
I kept it to assertions that hold under conservative stack scanning: a cleared finalizer never runs, a replaced one never runs the old func, a reachable object is never finalized, nothing runs twice. Those are the ones the registration bitmap can break. What it doesn't assert is that a finalizer actually fired. That needs the dropped object to really get collected, which only holds on wasm, so the three existing tests stay wasm-only. AVR I had to skip. Registering and clearing works fine there, but a single As for whether other platforms actually gain anything, honestly I think it's just WASM for now. |
|
Had to make a small change after the Windows CI broke: the two host scheduler variants I added don't build there. Limited those two runs to linux and darwin, same as |
| // drain anything that was queued. | ||
| for i := 0; i < 4; i++ { | ||
| runtime.GC() | ||
| runtime.Gosched() |
There was a problem hiding this comment.
runtime.Gosched() is a no-op under scheduler.threads, so these loops do not ensure the finalizer runner has drained before checking the counters.
There was a problem hiding this comment.
Got it.
drainFinalizers now polls until no new finalizer runs for three rounds in a row, instead of yielding a fixed number of times. Every target drains in 4 rounds and then 3.
I also made the test fail if no finalizer ran at all, so it can't pass on an empty state. That caught the plain host runs on boehm, which are skipped now.
|
All Tests passed. Thanks for the reviews so far @deadprogram @jakebailey @dgryski . Let me know if you have anything else. |
| // across every counter. Individual counters are asserted on at the end; this | ||
| // sum exists only to tell "the runner is still working" from "the queue is | ||
| // empty". | ||
| func finalizerRuns() int { |
There was a problem hiding this comment.
These counters are still read concurrently with finalizer-thread writes.
There was a problem hiding this comment.
Made them sync/atomic. Should be solved now.
| // that has not started, so keep polling. | ||
| default: | ||
| quiet++ | ||
| if quiet == quietRounds { |
There was a problem hiding this comment.
I don’t think polling like this necessarily fixes the problem; the runner could simply be delayed during the quiet rounds.
There was a problem hiding this comment.
Fair. It counts now instead of waiting for things to go quiet, so the loop keeps collecting until all 8 replacement finalizers have run, and fails if it never gets there.

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.