Let’s be honest: most goroutines are launched with all the careful forethought of a “Hello World” tutorial written at 2AM. We spin them up like we’re farming concurrency achievements, then conveniently forget they need to be cleaned up like any other real-world resource.
Enter runtime.AddCleanup
: your new favorite tool for making goroutines die with dignity.
What is runtime.AddCleanup
?
It’s a lifecycle hook for goroutines. You register a cleanup function, and it gets executed when the goroutine exits, no matter how messy the exit is. Whether it's a normal return, an error path, or a full-blown panic that ends in a stack trace longer than your backlog, the cleanup still runs.
It’s like defer
, except it doesn’t get lost when your logic splits into fifteen callbacks and three nested closures. One call. One cleanup. Done.
Why You Should Care
Before Go 1.24, your options for cleanup looked like this:
-
Option A: Sprinkle
defer
statements throughout your codebase like you’re adding salt to code that already tastes bad. - Option B: Hope the garbage collector is feeling generous today. Spoiler: It’s not.
With runtime.AddCleanup
, you now get:
- Proper resource cleanup without a tangled mess of deferred lambdas.
- Reliable termination logic even when the goroutine exits early or crashes.
- Fewer memory leaks, orphaned resources, or mysterious “why is this connection still open?” bugs at 3AM.
You can finally write code that knows how to leave the room.
How It Works
Just register a function and go:
runtime.AddCleanup(func() {
fmt.Println("Goroutine is shutting down. Running cleanup.")
// Close stuff. Free stuff. Undo the damage.
})
That’s it. No magic. No ceremony. Just graceful exits, like the kind your CLI tool fails to make when you hit Ctrl+C mid-request.
Real-World Example: Worker Pool That Doesn’t Leak Like a Faucet
Let’s say you’ve got a pool of goroutines processing background jobs from Kafka, a task queue, or whatever latest message broker the devops team convinced you to adopt.
Each worker:
- Opens a DB transaction.
- Buffers data in memory.
- Writes logs to a temporary file.
- May get interrupted by a shutdown signal, a timeout, or a rogue exception.
You want to clean all that up, every time.
Here’s how:
func processJob(ctx context.Context, job Job) {
tx := db.Begin()
buf := bytes.NewBuffer(nil)
tmpFile, _ := os.CreateTemp("", "job-log-*.txt")
committed := false
runtime.AddCleanup(func() {
if !committed {
tx.Rollback()
}
tmpFile.Close()
os.Remove(tmpFile.Name())
log.Println("Cleaned up job resources.")
})
if err := doWork(ctx, job, tx, buf); err != nil {
log.Printf("Job failed: %v", err)
return
}
if err := tx.Commit(); err != nil {
log.Printf("Commit failed: %v", err)
return
}
committed = true
}
Note:
Rollback()
is safe to call even afterCommit()
, but it may return a harmless error like "transaction already committed or rolled back".
If you want to avoid even that, guard it with acommitted
flag like we do above.
With this setup, your cleanup runs once, safely, and intentionally, regardless of how the job ends.
In Conclusion
runtime.AddCleanup
is the goroutine’s answer to "I got this." It’s a tiny feature with big implications for production systems, CI pipelines, and anyone tired of debugging resource leaks that only appear under load at 2AM in staging, but never locally, of course.
This is the kind of tool that makes your goroutines feel more like disciplined microservices and less like wild threads you forgot to feed.
Go 1.24 gave us cleanup. Use it now, Thank me later.
Top comments (0)