Inconsistency in GC behavior depending on runtime.Goexit

274 views
Skip to first unread message

Yaroslav Brustinov

unread,
Apr 3, 2025, 8:46:10 PMApr 3
to golang-nuts
Hello, experts.

Given following code as example:

package main

import (
"fmt"
"io"
"runtime"
"sync/atomic"
"time"
)

type S struct {
foo int
}

var released1 atomic.Bool
var released2 atomic.Bool

func releaseCb(releaseFlag *atomic.Bool) {
fmt.Println("release CB")
releaseFlag.Store(true)
}

func deferredCheckRelease(goexit bool, releaseFlag *atomic.Bool) {
for range 20 {
runtime.GC()
if releaseFlag.Load() {
fmt.Println("released, cond:", goexit)
return
}
time.Sleep(10 * time.Millisecond)
}
fmt.Println("not released, cond:", goexit)
}

func f(goexit bool, releaseFlag *atomic.Bool) {
defer deferredCheckRelease(goexit, releaseFlag)
s := &S{1}
runtime.AddCleanup(s, releaseCb, releaseFlag)
if goexit {
// releaseFlag will not be set
runtime.Goexit()
}
// releaseFlag will be set
fmt.Fprint(io.Discard, s)
}

func main() {
go f(true, &released1)
go f(false, &released2)
time.Sleep(time.Second)
}

As comment inside mentions, release flag is not set if runtime.Goexit() executed.

Maybe it's because runtime.AddCleanup is not "guaranteed" to run?
In such case would be great to clarify (in docs?) in which cases it might not run.
People might rely on the callback...



robert engels

unread,
Apr 3, 2025, 8:50:24 PMApr 3
to Yaroslav Brustinov, golang-nuts
It states this in the API docs:

"The cleanup(arg) call is not always guaranteed to run; in particular it is not guaranteed to run before program exit."

--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to [email protected].
To view this discussion visit https://groups.google.com/d/msgid/golang-nuts/a0df1e83-2e25-4de2-89b5-25de4e892670n%40googlegroups.com.

Yaroslav Brustinov

unread,
Apr 4, 2025, 1:28:41 AMApr 4
to golang-nuts
I was sure the doc means the case when a program exits too quickly, before cleanup has a chance to run, or am I wrong?
Otherwise, why need to specify "not guaranteed to run before program exit."? It obviously can't run after program exits :)

In the code example above, I can wait indefinitely for cleanup to run, it will not trigger:

for range 20 { // <-- can increase this arbitrary

Sean Liao

unread,
Apr 4, 2025, 3:57:00 AMApr 4
to golang-nuts
Your value of `s` may be on the stack rather than the heap. Cleanup will run when it goes out of scope, which is when the function returns. Your deferred function where you're trying to check is preventing that. 

You can force heap allocation, or check after the functions return. 
Your two cases behave differently because of inlining making the fmt.Fprint invocation unreachable (usually that forces it's arguments onto the heap).

- sean

Robert Engels

unread,
Apr 4, 2025, 7:16:34 AMApr 4
to Yaroslav Brustinov, golang-nuts
I think you are misreading it. It is a semicolon which means both clauses are of equal rank. So the sentence is referring to when they run, and they may not run at all, and there is no guarantee that they run before program exit - some systems have exit actions that are always attempted to run before program termination. 

On Apr 3, 2025, at 5:28 PM, Yaroslav Brustinov <[email protected]> wrote:



Robert Engels

unread,
Apr 4, 2025, 7:18:32 AMApr 4
to Yaroslav Brustinov, golang-nuts
And as Sean pointed out, the reason it is never run is that the object being cleaned up is still referenced (I didn’t verify this)

On Apr 3, 2025, at 5:28 PM, Yaroslav Brustinov <[email protected]> wrote:



Yaroslav Brustinov

unread,
Apr 9, 2025, 3:55:41 PMApr 9
to golang-nuts
Thanks for the explanation!
I ended up ignoring cleanup checks if test failed (=> runtime.Goexit() is called)
IMHO it's somewhat counter-intuitive that after calling runtime.Goexit() something is still held...

Michael Knyszek

unread,
Apr 14, 2025, 7:07:14 PMApr 14
to golang-nuts
I dug into your example and I think I see two problems.

The first problem is that `S` gets allocated in the tiny allocator, and so its lifetime may get batched together with something long-lived. This creates some inconsistent behaviors in the non-Goexit case. (I have an experimental debug mode in the works that will help flag such issues in the future.) Making `S` bigger or contain a pointer fixes this.

The second problem is that the variable `s` stays live until after the condition, since it's used by Fprint. The compiler doesn't understand that `Goexit` exits the function, because it's just a normal function implemented in the runtime. Therefore, when the GC is executed from Goexit, `s` is actually live on the stack still, so the runtime.GC calls inside the defer fail to reclaim it. To prove this point: put a `return` after `runtime.Goexit`. For me, this makes the program consistently call both AddCleanup callbacks.

Reply all
Reply to author
Forward
0 new messages