| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
|
Here are some lightly edited generated comments: PR #5545 Review Comments1. finishing global vs per-task fieldThe var finishing bool in task_asyncify.go is a plain package-level global, justified by the cooperative scheduler guarantee that no suspension point exists between deadlock()→Pause()→Resume(). This is correct today, but fragile: if a preemption point is ever introduced in that path, two goroutines could clobber each other's flag silently. Consider moving this to a field on *Task (or a bit in an existing field). That would make the intent explicit ("this specific task is finishing") and eliminate the implicit coupling to cooperative scheduling semantics. The cost is one extra byte or bit per task, which is negligible compared to the stack buffer it already owns. 2. Build tag coverage across scheduler variantsThe PR splits gc_finalizer_sched.go into two files:
The existing scheduler.none path has no spawnFinalizerRunner (it drains inline via wakeFinalizer). Please confirm the union of these three paths is exhaustive for every scheduler variant (none, tasks, asyncify, cores, threads). In particular, verify that no future scheduler variant could fall through all three build constraints and leave spawnFinalizerRunner undefined. 3. Threshold of 32 regarding tunability and documentationfinalizerGCThreshold = 32 is described as "a policy constant, like Go's forcegcperiod." A few points worth discussing:
|
Sorry, something went wrong.
|
Thanks @deadprogram, all fair points. Done: 1. finishing flag. Now a per-goroutine field on the asyncify state struct instead of a package global, so each finishing goroutine owns its own and the handoff to Resume no longer depends on scheduler timing. One byte per task, asyncify only. 2. Build tag coverage. There's a fourth file, gc_finalizer_sched_none.go (no-op for scheduler.none). The three constraints partition the scheduler space, so every variant, present or future, matches exactly one file (cores/threads land in the !none && !tasks && !asyncify catch-all). I documented the partition in a comment and added a test that iterates validSchedulerOptions, so a newly added scheduler is checked automatically. 3. Threshold of 32. Kept it a const. It only fires at an idle point and resets the counter, so a big setup burst costs one collection, not one per 32: Happy to make it tunable via a runtime/debug setter or an env var if you'd rather; I left the API shape to you since it can be added later without breaking anything. |
Sorry, something went wrong.
|
Any further comments from @jakebailey @dgryski or anyone else also wasm-involved? |
Sorry, something went wrong.
| // 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?
Sorry, something went wrong.
There was a problem hiding this comment.
Good catch, fixed. Added a testFinishedGoroutineArgs case in finalizeridle.go to cover it. Thanks!
Sorry, something went wrong.
|
@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. testdata/finalizerbits.go covers clear-then-register, register-twice-replaces, register/clear churn, allocation into reused memory and a mixed batch. They pass with and without the change, by design. Merged current dev, and TestBinarySize comes out unchanged on all three targets, so nothing to adjust there. |
Sorry, something went wrong.
…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! |
Sorry, something went wrong.
|
@jakebailey any more comments on this PR? |
Sorry, something went wrong.
| // 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?
Sorry, something went wrong.
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.
Sorry, something went wrong.
|
If this code works on other platforms/schedulers than just javascript, it would be nice to have some tests demonstrating that. |
Sorry, something went wrong.
|
@dgryski I added some tests for the other platforms. testdata/finalizerinvariants.go runs on every scheduler now: asyncify (wasm), tasks (cortex-m-qemu), cores (riscv-qemu), plus threads and none on the host with -gc=conservative. 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 runtime.GC() never comes back, and gc.go is already skipped on simavr for the same reason. As for whether other platforms actually gain anything, honestly I think it's just WASM for now. syscall/js is the only place in the tree that registers a finalizer at all. That might change though. TinyGo has its own os package, and unlike upstream Go its os.File doesn't close the fd on GC. If that ever lands, WASI would pick this up for free, since it's already on the block GC with a cooperative idle point. |
Sorry, something went wrong.
|
Had to make a small change after the Windows CI broke: the two host scheduler variants I added don't build there. scheduler.threads needs threadID, which internal/task only defines for linux and darwin, and scheduler.none doesn't link either. Both reproduce with any testdata file, so they aren't new. Limited those two runs to linux and darwin, same as TestTimerStopResetRace already does. |
Sorry, something went wrong.
| // 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.
Sorry, something went wrong.
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.
Sorry, something went wrong.
|
All Tests passed. Thanks for the reviews so far @deadprogram @jakebailey @dgryski . Let me know if you have anything else. |
Sorry, something went wrong.
| // 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.
Sorry, something went wrong.
There was a problem hiding this comment.
Made them sync/atomic. Should be solved now.
Sorry, something went wrong.
| // 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.
Sorry, something went wrong.
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.
Sorry, something went wrong.
|
The gc and scheduler, if build with -tags=runtime_asserts, set gcAsserts = true and schedulerAsserts = true, which are used to validate data structures and other things to ensure there are no invalid states with their bookkeeping or usage. Are there any asserts we could add to the new finalizer code? |
Sorry, something went wrong.
|
@dgryski Added some asserts for the finalizer bookkeeping. The main ones check the registration bitmap against the table it stands in for: when the bit says "not registered" and the clear or replace path takes the shortcut, the assert walks the table anyway and fails if the object is actually there. The others cover numFinalizers matching the table, every pending object surviving resurrection, and nothing being dequeued after it's been freed. |
Sorry, something went wrong.
|
Hey all, just a friendly check-in on this one. All review comments are addressed and CI is green. The new cross-scheduler tests cover asyncify, tasks, cores and threads, and the locking fix from the last round is verified on wasm, wasip1, microbit and pico. The runtime_asserts coverage for the finalizer bookkeeping landed as well. This branch has been serving as the wasm backend for a framework I maintain, and the bridge table stays flat under sustained load, which was the whole point of the PR. A fresh pass would be really welcome when you have time. Thanks for all the careful reviews so far. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Runtime: run syscall/js finalizers on wasm without a manual GC
Follow-up to #5521, which implemented runtime.SetFinalizer so syscall/js can
auto-release bridge-table slots. While stress-testing a syscall/js-heavy wasm
project 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=js program creates
lots of small, short-lived js.Values. Each one pins a JS object and a bridge
slot 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, and haveSysmon = GOARCH != "wasm". There is no sysmon on wasm, and
the 4 MB heapMinimum keeps the heap-growth trigger from firing for a small live
set. 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 since
the 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 alloc is the important part.
An alloc-based trigger scales GC frequency with allocation churn: a goroutine
that boxes hundreds of short-lived js.Values would force dozens of collections
mid-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 is
collected. The scheduler now zeroes a finished goroutine's stack.
The idle hook is installed lazily by the first SetFinalizer, next to the
existing runner spawn, so a program that never registers a finalizer links none
of it. A microbit binary with no SetFinalizer is byte-identical before and
after (code 2788), so TestBinarySize is unaffected. Non-block GCs and the
cores/threads schedulers get a nil hook; the tasks scheduler gets a no-op
stack-zero.
Scope
Cooperative schedulers (asyncify, tasks) with the block GC only. The
finalizer semantics, wasm_exec.js, and the public API are untouched. The
threshold is a policy constant, like Go's forcegcperiod.
Testing
New testdata/finalizeridle.go golden test, wasm only (same determinism
rationale and skip as finalizer.go). It registers a batch of finalizers over
the threshold, then only parks the goroutine with time.Sleep and never calls
runtime.GC(), and checks that every finalizer ran. A second case registers
finalizers on goroutine-stack-local objects, lets the goroutines finish, and
checks the objects are still collected.
finalizer.go and gc.go still pass on host and wasm. The change was built
across every scheduler and GC it touches (asyncify, tasks, none, cores,
threads, with block, boehm, and leaking) on wasm, wasip1, wasip2,
microbit, pico, and host. make fmt-check is 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 of
mine 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 manual runtime.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 same
operation with the trigger inside alloc forced roughly ten, almost all of them
wasted on values that were still live.