Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions compileopts/finalizer_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package compileopts

import (
"go/build/constraint"
"os"
"path/filepath"
"strings"
"testing"
)

// TestFinalizerRunnerSchedulerCoverage checks that the build constraints on the
// gc_finalizer_sched*.go files define spawnFinalizerRunner for exactly one file
// per scheduler. The three constraints must partition the scheduler space: every
// scheduler matches exactly one file, so none can be left with the symbol
// undefined or defined twice. It iterates validSchedulerOptions as the source of
// truth, so a newly added scheduler is covered by this check automatically.
func TestFinalizerRunnerSchedulerCoverage(t *testing.T) {
files := []string{
"gc_finalizer_sched.go",
"gc_finalizer_sched_none.go",
"gc_finalizer_sched_other.go",
}
exprs := make([]constraint.Expr, len(files))
for i, name := range files {
exprs[i] = readBuildConstraint(t, filepath.Join("..", "src", "runtime", name))
}

for _, sched := range validSchedulerOptions {
// The finalizer table exists under the block GCs; gc.conservative
// satisfies the "gc.conservative || gc.precise" half of every constraint.
tags := map[string]bool{
"gc.conservative": true,
"scheduler." + sched: true,
}
var matched []string
for i, expr := range exprs {
if expr.Eval(func(tag string) bool { return tags[tag] }) {
matched = append(matched, files[i])
}
}
if len(matched) != 1 {
t.Errorf("scheduler.%s: spawnFinalizerRunner defined in %d files %v, want exactly 1",
sched, len(matched), matched)
}
}
}

// readBuildConstraint returns the parsed //go:build expression of a Go file.
func readBuildConstraint(t *testing.T, path string) constraint.Expr {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if constraint.IsGoBuild(line) {
expr, err := constraint.Parse(line)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
return expr
}
if line != "" && !strings.HasPrefix(line, "//") {
break // reached code before any //go:build line
}
}
t.Fatalf("%s: no //go:build line found", path)
return nil
}
26 changes: 16 additions & 10 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ func TestBuild(t *testing.T) {
"channel.go",
"embed/",
"finalizer.go",
"finalizerbits.go",
"finalizeridle.go",
"float.go",
"gc.go",
"generics.go",
Expand Down Expand Up @@ -364,16 +366,20 @@ func runPlatTests(options compileopts.Options, tests []string, t *testing.T) {
continue
}
}
if name == "finalizer.go" && options.Target != "wasm" {
// runtime.SetFinalizer is implemented for the block GC, but the
// test asserts deterministic collection of a dropped object, which
// only holds on the GOOS=js wasm target. The host default GC is
// boehm (SetFinalizer is a no-op there); conservative stack scanning
// on the emulated targets can pin the object; and the wasip2
// component entry lays out the stack differently, so collection is
// not deterministic on those. The feature still works on all of
// them, it just can't be golden-tested for firing.
continue
if options.Target != "wasm" {
switch name {
case "finalizer.go", "finalizerbits.go", "finalizeridle.go":
// runtime.SetFinalizer is implemented for the block GC, but the
// finalizer tests assert deterministic collection of a dropped
// object, which only holds on the GOOS=js wasm target. The host
// default GC is boehm (SetFinalizer is a no-op there);
// conservative stack scanning on the emulated targets can pin the
// object; and the wasip2 component entry lays out the stack
// differently, so collection is not deterministic on those. The
// feature still works on all of them, it just can't be
// golden-tested for firing.
continue
}
}

name := name // redefine to avoid race condition
Expand Down
46 changes: 46 additions & 0 deletions src/internal/task/task_asyncify.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ type state struct {
stackState

launched bool

// finishing is set immediately before this goroutine, having run to
// completion, pauses for the last time. Resume observes it and clears the
// goroutine's stack. It lives on the task so each finishing goroutine owns
// its own flag, independent of scheduler timing.
finishing bool
}

// stackState is the saved state of a stack while unwound.
Expand All @@ -42,6 +48,11 @@ type stackState struct {
// overwritten. It can be checked from time to time to see whether a stack
// overflow happened in the past.
canaryPtr *uintptr

// top is the first address past the end of the stack allocation (the
// initial C stack pointer). Kept so the whole stack buffer can be located
// again after the goroutine finishes.
top unsafe.Pointer
}

// start creates and starts a new goroutine with the given function and arguments.
Expand Down Expand Up @@ -78,6 +89,31 @@ func (s *state) initialize(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
// Calculate stack base addresses.
s.asyncifysp = unsafe.Add(stack, unsafe.Sizeof(uintptr(0)))
s.csp = unsafe.Add(stack, stackSize)
s.top = unsafe.Add(stack, stackSize)
}

//go:linkname memzero runtime.memzero
func memzero(ptr unsafe.Pointer, size uintptr)

// MarkFinishing records that the current goroutine has finished and will not be
// resumed, so Resume may reclaim its stack once control returns to the scheduler.
// The flag lives on the task itself, so each finishing goroutine owns its own and
// the handoff to Resume does not depend on scheduler timing.
func MarkFinishing() {
currentTask.state.finishing = true
}

// clearStack zeroes a finished goroutine's entire stack buffer. The buffer is a
// plain heap allocation scanned conservatively by the GC (it can hold arbitrary
// pointers), so any stale pointer left in it by the goroutine's now-returned
// call frames would keep unrelated objects reachable (and, transitively, other
// finished stacks reachable through them) until a later collection happens to
// break the chain. Zeroing the buffer the moment the goroutine finishes drops
// those stale references immediately, so the objects they pointed at (and the
// stack itself) become collectable at the next cycle.
func (t *Task) clearStack() {
base := unsafe.Pointer(t.state.canaryPtr)
memzero(base, uintptr(t.state.top)-uintptr(base))
}

// currentTask is the current running task, or nil if currently in the scheduler.
Expand Down Expand Up @@ -123,6 +159,16 @@ func (t *Task) Resume() {
if uintptr(t.state.asyncifysp) > uintptr(t.state.csp) {
runtimeFatal("stack overflow")
}
if t.state.finishing {
// The goroutine just ran to completion and paused for the last time. It
// will never be resumed, so its stack can be cleared now to drop any
// pointers its returned frames left behind (see clearStack). The args
// bundle is likewise no longer needed, so drop that reference too, else
// any pointers in the arguments would keep their objects reachable.
t.state.finishing = false
t.clearStack()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might need t.state.args = nil?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed. Added a testFinishedGoroutineArgs case in finalizeridle.go to cover it. Thanks!

t.state.args = nil
}
}

//go:linkname saveStackPointer runtime.saveStackPointer
Expand Down
10 changes: 10 additions & 0 deletions src/internal/task/task_finishing_tasks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build scheduler.tasks

package task

// MarkFinishing is a no-op for the stack-based scheduler. Zeroing a finished
// goroutine's stack to drop the stale pointers its returned frames leave behind
// is only implemented for the asyncify scheduler, whose goroutine stacks are
// heap buffers scanned conservatively (see the asyncify MarkFinishing and
// Resume). deadlock and goexit in the cooperative scheduler call this for both.
func MarkFinishing() {}
Loading
Loading