mirror of
https://github.com/tinygo-org/tinygo.git
synced 2026-09-01 02:09:06 +00:00
runtime: run syscall/js finalizers on wasm without a manual GC (#5545)
* runtime: run syscall/js finalizers on wasm without a manual GC * runtime: address review feedback on finalizer idle GC * runtime: clear a finished task's args pointer so its arguments are collectable * runtime: skip the finalizer scan with a per-block registration bit * runtime: guard the finalizer registration bitmap with gcLock * testdata: cover finalizer invariants on every scheduler * main_test: limit the finalizer scheduler variants to linux and darwin * testdata: wait for the finalizer queue to drain before asserting * testdata: make the finalizer counters atomic and wait for a known drain count * runtime: add finalizer bookkeeping asserts under runtime_asserts * runtime: address finalizer GC review feedback * testdata: strengthen blocked stack finalizer test * runtime: fix finalizer cleanup edge cases * runtime: decouple wasm export scheduling from finalizers * runtime: avoid redundant wakeups for re-entrant wasm exports * runtime: simplify finalizer comments
This commit is contained in:
Vendored
+177
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
// Test finalizer registration, replacement, removal, and reused heap addresses.
|
||||
// The wasm target provides deterministic finalization for these tests.
|
||||
|
||||
import "runtime"
|
||||
|
||||
type box struct{ x int }
|
||||
|
||||
const batch = 32
|
||||
|
||||
var (
|
||||
reregisteredRan int
|
||||
replacedOldRan int
|
||||
replacedNewRan int
|
||||
churnRan int
|
||||
reuseFirstRan int
|
||||
reuseSecondRan int
|
||||
keptRan int
|
||||
droppedRan int
|
||||
sink int
|
||||
)
|
||||
|
||||
// scrubStack removes stale pointers from the helper frame so collection is deterministic.
|
||||
// Call it at the same call depth as the allocation helper.
|
||||
//
|
||||
//go:noinline
|
||||
func scrubStack(depth int) int {
|
||||
if depth <= 0 {
|
||||
return sink
|
||||
}
|
||||
var buf [64]int
|
||||
for i := range buf {
|
||||
buf[i] = depth + i
|
||||
}
|
||||
sink += buf[depth&63]
|
||||
return scrubStack(depth-1) + buf[0]
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func allocClearThenRegister() {
|
||||
p := &box{x: 1}
|
||||
runtime.SetFinalizer(p, func(*box) { panic("cleared finalizer ran") })
|
||||
runtime.SetFinalizer(p, nil)
|
||||
runtime.SetFinalizer(p, func(*box) { reregisteredRan++ })
|
||||
}
|
||||
|
||||
func testClearThenRegister() {
|
||||
allocClearThenRegister()
|
||||
for i := 0; i < 200 && reregisteredRan == 0; i++ {
|
||||
sink += scrubStack(40)
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if reregisteredRan != 1 {
|
||||
panic("finalizerbits: re-registered finalizer did not run exactly once")
|
||||
}
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func allocRegisterTwice() {
|
||||
for i := 0; i < batch; i++ {
|
||||
p := &box{x: i}
|
||||
runtime.SetFinalizer(p, func(*box) { replacedOldRan++ })
|
||||
runtime.SetFinalizer(p, func(*box) { replacedNewRan++ })
|
||||
}
|
||||
}
|
||||
|
||||
func testRegisterTwiceLeavesOne() {
|
||||
allocRegisterTwice()
|
||||
for i := 0; i < 200 && replacedNewRan < batch; i++ {
|
||||
sink += scrubStack(40)
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if replacedOldRan != 0 {
|
||||
panic("finalizerbits: replaced finalizer still ran")
|
||||
}
|
||||
if replacedNewRan != batch {
|
||||
panic("finalizerbits: replacement did not run exactly once per object")
|
||||
}
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func allocChurn() {
|
||||
p := &box{x: 3}
|
||||
for i := 0; i < 64; i++ {
|
||||
runtime.SetFinalizer(p, func(*box) { churnRan++ })
|
||||
runtime.SetFinalizer(p, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func testChurnLeavesNothing() {
|
||||
allocChurn()
|
||||
for i := 0; i < 200; i++ {
|
||||
sink += scrubStack(40)
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if churnRan != 0 {
|
||||
panic("finalizerbits: churned register/clear left a live registration")
|
||||
}
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func allocFirstRound() {
|
||||
for i := 0; i < batch; i++ {
|
||||
p := &box{x: i}
|
||||
runtime.SetFinalizer(p, func(*box) { reuseFirstRan++ })
|
||||
}
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func allocSecondRound() {
|
||||
for i := 0; i < batch; i++ {
|
||||
p := &box{x: i}
|
||||
runtime.SetFinalizer(p, func(*box) { reuseSecondRan++ })
|
||||
}
|
||||
}
|
||||
|
||||
func testAddressReuse() {
|
||||
allocFirstRound()
|
||||
for i := 0; i < 200 && reuseFirstRan < batch; i++ {
|
||||
sink += scrubStack(40)
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if reuseFirstRan != batch {
|
||||
panic("finalizerbits: first round did not run every finalizer")
|
||||
}
|
||||
allocSecondRound()
|
||||
for i := 0; i < 200 && reuseSecondRan < batch; i++ {
|
||||
sink += scrubStack(40)
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if reuseSecondRan != batch {
|
||||
panic("finalizerbits: second round into reused memory lost finalizers")
|
||||
}
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func allocMixedBatch() {
|
||||
for i := 0; i < batch; i++ {
|
||||
p := &box{x: i}
|
||||
if i%2 == 0 {
|
||||
runtime.SetFinalizer(p, func(*box) { droppedRan++ })
|
||||
runtime.SetFinalizer(p, nil)
|
||||
} else {
|
||||
runtime.SetFinalizer(p, func(*box) { keptRan++ })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testMixedBatch() {
|
||||
allocMixedBatch()
|
||||
for i := 0; i < 200 && keptRan < batch/2; i++ {
|
||||
sink += scrubStack(40)
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if droppedRan != 0 {
|
||||
panic("finalizerbits: a cleared finalizer inside the batch ran")
|
||||
}
|
||||
if keptRan != batch/2 {
|
||||
panic("finalizerbits: kept finalizers did not all run exactly once")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
testClearThenRegister()
|
||||
testRegisterTwiceLeavesOne()
|
||||
testChurnLeavesNothing()
|
||||
testAddressReuse()
|
||||
testMixedBatch()
|
||||
println("ok")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
Vendored
+182
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
// Test idle finalizer collection and the lifetime of blocked and completed asyncify stacks.
|
||||
// The wasm target provides deterministic finalization for these tests.
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batch must exceed the finalizer registration threshold to trigger idle collection.
|
||||
const batch = 64
|
||||
|
||||
var (
|
||||
ranDropped int
|
||||
ranOnStack int
|
||||
ranInArgs int
|
||||
sink int
|
||||
|
||||
blockedRan [3]atomic.Int32
|
||||
controlRan atomic.Int32
|
||||
)
|
||||
|
||||
type blockedObject struct{ x int }
|
||||
|
||||
//go:noinline
|
||||
func blockOperation(kind int, ready chan<- struct{}, ch chan struct{}) {
|
||||
ready <- struct{}{}
|
||||
switch kind {
|
||||
case 0:
|
||||
select {}
|
||||
case 1:
|
||||
ch <- struct{}{}
|
||||
case 2:
|
||||
<-ch
|
||||
}
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func holdWhileBlocked(kind int, ready chan<- struct{}, ch chan struct{}) {
|
||||
p := &blockedObject{x: kind}
|
||||
runtime.SetFinalizer(p, func(*blockedObject) { blockedRan[kind].Add(1) })
|
||||
blockOperation(kind, ready, ch)
|
||||
// blockOperation can return, so p remains live on this suspended stack.
|
||||
runtime.KeepAlive(p)
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func dropProgressControl() {
|
||||
p := &blockedObject{x: 8}
|
||||
runtime.SetFinalizer(p, func(*blockedObject) { controlRan.Add(1) })
|
||||
}
|
||||
|
||||
// testPermanentlyBlockedStacks checks that blocked task stacks remain GC roots.
|
||||
// A control finalizer confirms that GC and finalizer processing made progress.
|
||||
func testPermanentlyBlockedStacks() {
|
||||
ready := make(chan struct{}, 3)
|
||||
go holdWhileBlocked(0, ready, nil) // select{}
|
||||
go holdWhileBlocked(1, ready, nil) // nil channel send
|
||||
go holdWhileBlocked(2, ready, nil) // nil channel receive
|
||||
<-ready
|
||||
<-ready
|
||||
<-ready
|
||||
|
||||
dropProgressControl()
|
||||
for i := 0; i < 100 && controlRan.Load() == 0; i++ {
|
||||
sink += scrubStack(40)
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if controlRan.Load() != 1 {
|
||||
panic("control finalizer did not prove GC progress")
|
||||
}
|
||||
// Collect once more so temporary scheduler roots cannot hide an unrooted
|
||||
// blocked task during the control collection.
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
for i, name := range [...]string{"select{}", "nil-channel send", "nil-channel receive"} {
|
||||
if blockedRan[i].Load() != 0 {
|
||||
panic(name + " stack-held object was finalized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scrubStack removes stale pointers from the helper frame so collection is deterministic.
|
||||
// Call it at the same call depth as the allocation helper.
|
||||
//
|
||||
//go:noinline
|
||||
func scrubStack(depth int) int {
|
||||
if depth <= 0 {
|
||||
return sink
|
||||
}
|
||||
var buf [64]int
|
||||
for i := range buf {
|
||||
buf[i] = depth + i
|
||||
}
|
||||
sink += buf[depth&63]
|
||||
return scrubStack(depth-1) + buf[0]
|
||||
}
|
||||
|
||||
// registerAndDrop creates unreachable objects with finalizers that do not capture them.
|
||||
// This allows the idle GC to collect the objects.
|
||||
//
|
||||
//go:noinline
|
||||
func registerAndDrop() {
|
||||
for i := 0; i < batch; i++ {
|
||||
p := new([2]int)
|
||||
runtime.SetFinalizer(p, func(*[2]int) { ranDropped++ })
|
||||
}
|
||||
}
|
||||
|
||||
func testIdleCollect() {
|
||||
registerAndDrop()
|
||||
for i := 0; i < 500 && ranDropped < batch; i++ {
|
||||
sink += scrubStack(40)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if ranDropped != batch {
|
||||
panic("idle collection did not run every finalizer")
|
||||
}
|
||||
}
|
||||
|
||||
func testFinishedGoroutineStacks() {
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < batch; i++ {
|
||||
go func() {
|
||||
p := new([2]int)
|
||||
runtime.SetFinalizer(p, func(*[2]int) { ranOnStack++ })
|
||||
// p stays on this goroutine's stack until it returns just below.
|
||||
done <- struct{}{}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < batch; i++ {
|
||||
<-done
|
||||
}
|
||||
for i := 0; i < 500 && ranOnStack < batch; i++ {
|
||||
sink += scrubStack(40)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if ranOnStack != batch {
|
||||
panic("finished goroutine stack still pinned finalized objects")
|
||||
}
|
||||
}
|
||||
|
||||
// launchArgGoroutine passes an object through the task argument bundle.
|
||||
// The caller returns so scrubStack can remove its transient pointer.
|
||||
//
|
||||
//go:noinline
|
||||
func launchArgGoroutine(done chan struct{}) {
|
||||
p := new([2]int)
|
||||
runtime.SetFinalizer(p, func(*[2]int) { ranInArgs++ })
|
||||
go func(q *[2]int) {
|
||||
sink += q[0]
|
||||
done <- struct{}{}
|
||||
}(p)
|
||||
}
|
||||
|
||||
func testFinishedGoroutineArgs() {
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < batch; i++ {
|
||||
launchArgGoroutine(done)
|
||||
}
|
||||
for i := 0; i < batch; i++ {
|
||||
<-done
|
||||
}
|
||||
for i := 0; i < 500 && ranInArgs < batch; i++ {
|
||||
sink += scrubStack(40)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if ranInArgs != batch {
|
||||
panic("finished goroutine args still pinned finalized objects")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
testPermanentlyBlockedStacks()
|
||||
testIdleCollect()
|
||||
testFinishedGoroutineStacks()
|
||||
testFinishedGoroutineArgs()
|
||||
println("ok")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
Vendored
+87
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
// Test finalizer invariants that do not require unreachable objects to be collected.
|
||||
// runtime_asserts checks the finalizer table, count, and bitmap.
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type obj struct{ x int }
|
||||
|
||||
const batch = 8
|
||||
|
||||
// Finalizers may run concurrently with main under the threads and cores
|
||||
// schedulers, so all observations shared with a finalizer are atomic.
|
||||
var (
|
||||
clearedRan atomic.Int32
|
||||
replacedRan atomic.Int32
|
||||
reachedRan atomic.Int32
|
||||
ranTwice atomic.Int32
|
||||
seen [batch]atomic.Int32
|
||||
reachable []*obj
|
||||
)
|
||||
|
||||
//go:noinline
|
||||
func dropCleared() {
|
||||
p := &obj{}
|
||||
runtime.SetFinalizer(p, func(*obj) { clearedRan.Add(1) })
|
||||
runtime.SetFinalizer(p, nil)
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func dropReplaced(id int) {
|
||||
p := &obj{}
|
||||
runtime.SetFinalizer(p, func(*obj) { replacedRan.Add(1) })
|
||||
runtime.SetFinalizer(p, func(*obj) {
|
||||
if seen[id].Add(1) > 1 {
|
||||
ranTwice.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func keepReachable(id int) {
|
||||
p := &obj{x: id}
|
||||
runtime.SetFinalizer(p, func(*obj) { reachedRan.Add(1) })
|
||||
reachable = append(reachable, p)
|
||||
}
|
||||
|
||||
func main() {
|
||||
for i := 0; i < batch; i++ {
|
||||
dropCleared()
|
||||
dropReplaced(i)
|
||||
keepReachable(i)
|
||||
}
|
||||
|
||||
// Run GC to check bookkeeping and give finalizers bounded opportunities to run.
|
||||
// The checks do not require finalization of an unreachable object.
|
||||
for i := 0; i < 8; i++ {
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
// Keep the reachable objects live across every collection above.
|
||||
total := 0
|
||||
for _, p := range reachable {
|
||||
total += p.x
|
||||
}
|
||||
if total != batch*(batch-1)/2 {
|
||||
println("FAIL: reachable set corrupted:", total)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case clearedRan.Load() != 0:
|
||||
println("FAIL: cleared finalizer ran:", clearedRan.Load())
|
||||
case replacedRan.Load() != 0:
|
||||
println("FAIL: replaced finalizer ran:", replacedRan.Load())
|
||||
case reachedRan.Load() != 0:
|
||||
println("FAIL: reachable object was finalized:", reachedRan.Load())
|
||||
case ranTwice.Load() != 0:
|
||||
println("FAIL: finalizer ran more than once:", ranTwice.Load())
|
||||
default:
|
||||
println("ok")
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import "runtime"
|
||||
|
||||
type largeFinalizerObject struct {
|
||||
data [128]byte
|
||||
}
|
||||
|
||||
var largeFinalizerRan bool
|
||||
|
||||
//go:noinline
|
||||
func registerLargeFinalizer() {
|
||||
p := new(largeFinalizerObject)
|
||||
runtime.SetFinalizer(p, func(*largeFinalizerObject) {
|
||||
largeFinalizerRan = true
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
registerLargeFinalizer()
|
||||
for i := 0; i < 100 && !largeFinalizerRan; i++ {
|
||||
runtime.GC()
|
||||
runtime.Gosched()
|
||||
}
|
||||
if !largeFinalizerRan {
|
||||
panic("large object finalizer did not run")
|
||||
}
|
||||
println("ok")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
//go:wasmimport tester finalizerRan
|
||||
func finalizerRan()
|
||||
|
||||
//go:wasmimport tester backgroundRan
|
||||
func backgroundRan()
|
||||
|
||||
//go:wasmimport tester callNestedExport
|
||||
func callNestedExport()
|
||||
|
||||
var nestedCallback js.Func
|
||||
|
||||
//go:wasmexport launchBackground
|
||||
func launchBackground() {
|
||||
go func() {
|
||||
backgroundRan()
|
||||
}()
|
||||
}
|
||||
|
||||
//go:wasmexport installNestedCallback
|
||||
func installNestedCallback() {
|
||||
nestedCallback = js.FuncOf(func(js.Value, []js.Value) any {
|
||||
callNestedExport()
|
||||
return nil
|
||||
})
|
||||
js.Global().Set("nestedExportCallback", nestedCallback)
|
||||
}
|
||||
|
||||
//go:noinline
|
||||
func registerFinalizersImpl() {
|
||||
for i := 0; i < 32; i++ {
|
||||
p := new([2]int)
|
||||
runtime.SetFinalizer(p, func(*[2]int) { finalizerRan() })
|
||||
}
|
||||
}
|
||||
|
||||
//go:wasmexport registerFinalizers
|
||||
func registerFinalizers() {
|
||||
registerFinalizersImpl()
|
||||
go func() {
|
||||
backgroundRan()
|
||||
}()
|
||||
}
|
||||
|
||||
func main() {}
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
const fs = require('fs');
|
||||
|
||||
require('../targets/wasm_exec.js');
|
||||
|
||||
let finalized = 0;
|
||||
let backgroundRuns = 0;
|
||||
let instance;
|
||||
const go = new Go();
|
||||
const sleepTicks = go.importObject.gojs['runtime.sleepTicks'];
|
||||
let zeroRearms = 0;
|
||||
go.importObject.gojs['runtime.sleepTicks'] = timeout => {
|
||||
if (Number(timeout) === 0) {
|
||||
zeroRearms++;
|
||||
}
|
||||
return sleepTicks(timeout);
|
||||
};
|
||||
go.importObject.tester = {
|
||||
finalizerRan: () => {
|
||||
finalized++;
|
||||
},
|
||||
backgroundRan: () => {
|
||||
backgroundRuns++;
|
||||
},
|
||||
callNestedExport: () => {
|
||||
instance.exports.launchBackground();
|
||||
},
|
||||
};
|
||||
|
||||
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async result => {
|
||||
instance = result.instance;
|
||||
await go.run(instance);
|
||||
|
||||
const topLevelRearms = zeroRearms;
|
||||
instance.exports.launchBackground();
|
||||
if (backgroundRuns !== 0) {
|
||||
throw new Error('wasm export ran a background goroutine before returning');
|
||||
}
|
||||
if (zeroRearms !== topLevelRearms + 1) {
|
||||
throw new Error('top-level wasm export did not schedule exactly one wakeup');
|
||||
}
|
||||
for (let i = 0; i < 500 && backgroundRuns === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
if (backgroundRuns !== 1) {
|
||||
throw new Error('wasm scheduler did not resume after an export without finalizers');
|
||||
}
|
||||
|
||||
instance.exports.installNestedCallback();
|
||||
const nestedRearms = zeroRearms;
|
||||
global.nestedExportCallback();
|
||||
if (zeroRearms !== nestedRearms) {
|
||||
throw new Error('re-entrant wasm export scheduled a redundant wakeup');
|
||||
}
|
||||
if (backgroundRuns !== 2) {
|
||||
throw new Error('outer scheduler did not drain work from a re-entrant wasm export');
|
||||
}
|
||||
|
||||
instance.exports.registerFinalizers();
|
||||
if (backgroundRuns !== 2) {
|
||||
throw new Error('wasm export ran an unrelated goroutine before returning');
|
||||
}
|
||||
for (let i = 0; i < 500 && (finalized === 0 || backgroundRuns < 3); i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
if (finalized === 0) {
|
||||
throw new Error('no wasm-export finalizer ran after returning to JavaScript');
|
||||
}
|
||||
if (backgroundRuns !== 3) {
|
||||
throw new Error('wasm scheduler did not resume after a finalizer export');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user