Repository navigation
unique: Handle memory leaks; cleanup goroutine can't keep up #71772
Description
Go version
go version go1.24.0 darwin/arm64
go version go1.24.0 linux/amd64
go version go1.23.6 darwin/arm64
go version go1.23.6 linux/amd64
What did you do?
We have a server that processes a stream of user connect & disconnect events from our edge servers. Users are identified by a [32]byte public key, wrapped up like:
type NodePublic struct {
k [32]byte
}This server maintains ~4 maps keyed by NodePublic. Out of curiosity and as an experiment, we decided to kick the tires with unique.Handle to make those maps instead be keyed by unique.Handle[NodePublic] to make them 8 bytes instead of 32. It saves about 96 bytes per connection. Not a ton, but ... an experiment.
The experiment didn't go as expected.
What did you see happen?
What we saw happen was unbounded memory growth and kernel OOMs. These are the two instances of this server we run. (it's an internal debug tool only, so we only run two for HA reasons)
It previously took about ~1GB of memory at steady state. With unique.Handle, it just keeps growing until the kernel OOM kills it.
The flat parts in the graph are where we changed the map key types from unique.Handle[NodePublic] back to just NodePublic, changing nothing else.
To debug, I hacked up the unique package (patch: https://gist.github.com/bradfitz/06f57b1515d58fb4d8b9b4eb5a84262e) to try out two things:
- see if the weak pointers were getting tinyalloc'ed and preventing GC. To counter this, I tried making the
uniquepackage use 24 byte value types in its uniqueMap. No change. - to log stats when the cleaners ran to see how much it tried to delete
- Oh, and I also tried both Go 1.23.6 and Go 1.24.0
The result is that never frees anything. The weak pointer is never nil. It takes ever longer to iterate over the map (up to 2.7 seconds now), never finding anything to delete:
Feb 16 00:30:44 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2 ; deleted= 0 ; kept= 2 ; took 16µs
Feb 16 00:30:46 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2829766 ; deleted= 0 ; kept= 2829766 ; took 2.585244s
Feb 16 00:32:51 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2 ; deleted= 0 ; kept= 2 ; took 14µs
Feb 16 00:32:53 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2850211 ; deleted= 0 ; kept= 2850211 ; took 2.692595s
Feb 16 00:34:58 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2 ; deleted= 0 ; kept= 2 ; took 19µs
Feb 16 00:35:01 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2870410 ; deleted= 0 ; kept= 2870410 ; took 2.728681s
Feb 16 00:37:06 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2 ; deleted= 0 ; kept= 2 ; took 19µs
Feb 16 00:37:08 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2890817 ; deleted= 0 ; kept= 2890817 ; took 2.635071s
Feb 16 00:39:13 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2 ; deleted= 0 ; kept= 2 ; took 18µs
Feb 16 00:39:16 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2911098 ; deleted= 0 ; kept= 2911098 ; took 2.600478s
Feb 16 00:41:20 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2 ; deleted= 0 ; kept= 2 ; took 19µs
Feb 16 00:41:22 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2931399 ; deleted= 0 ; kept= 2931399 ; took 2.606853s
Feb 16 00:43:27 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2 ; deleted= 0 ; kept= 2 ; took 20µs
Feb 16 00:43:29 derptrackd2 derptrackd[771925]: XXX did unique cleanup loop: items= 2951574 ; deleted= 0 ; kept= 2951574 ; took 2.701304s
Looking at historical logs, it never deletes anything on Linux.
I tried to write a repro (on my Mac) and I failed, despite a bunch of efforts.
I ended up doing too much debugging on a remote AWS VM, with a hacked up Go toolchain.
Eventually I ran the same code on my Mac (same server + same hacked up Go toolchain), pulling from the same data sources, and surprisingly: it worked! unique finds weak things to delete in its cleaner and it doesn't grow.
In summary:
uniqueleaks onlinux/amd64(prod cloud VM)- same code on same data doesn't leak on
darwin/arm64(my dev laptop)
I'll now try revisiting my standalone repro but running it on Linux instead of macOS.
I had hoped to deliver @mknyszek a bug on a silver platter before filing, but I'm going a little crazy at this pointed and figured it was time to start sharing.
What did you expect to see?
No memory leaks on Linux also.
Activity
gabyhelp commentedon Feb 16, 2025
Related Issues
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
bradfitz commentedon Feb 16, 2025
Actually, I'm going to close this for now until there's something actionable. I'll reopen if/when I have a repro.
mknyszek commentedon Feb 16, 2025
Tangentially related, but we should probably remove the loop and switch to
runtime.AddCleanup. I don't think that'll fix this issue though.bradfitz commentedon Feb 16, 2025
I played with hacking away at the server, trying to minimize the real code down to minimal repro instead of building up a minimal repro from scratch, with the goal to eventually plug in a in-memory generated random data source rather than reading from the network.
That seemed to have worked.... at a certain point, Linux started reclaiming a small amount of memory on a test Linux box I was running. And over night it did one huge unique.Handle reclaim during one of its GCs, but zero for all the others throughout the night.
Now I need to binary search my deletions and see what the key deletion was.
In any case, it still reclaims super slowly on Linux even after my minimization.
Will report back when I have more.
bradfitz commentedon Feb 16, 2025
Okay, I think I've narrowed it down and figured out the macOS-vs-Linux difference: this is about the number goroutines producing new unique.Handles overwhelming the sole cleanup goroutine. My Mac laptop (M4 Max) is just significantly beefier than my dinky Linux VMs (both prod & dev).
Now I understand why my ground-up repro didn't work before: it was using a single goroutine.
I have a standalone repro now from the full server that doesn't depend on the network (using 100 goroutines adding+removing keys) but now that I think I know what's happening, I think I'll switch back to my bottom-up repro, slapping on some goroutines.
So @mknyszek, maybe
runtime.AddCleanupis indeed the answer here.[-]unique: Handle memory leaks on Linux (but not macOS)[/-][+]unique: Handle memory leaks[/+]bradfitz commentedon Feb 16, 2025
Here ya go: https://go.dev/play/p/rrNRBh6g-WF
Ignore that that's a playground link and also that it's written as a test function. Just run it locally (on either macOS or Linux) and watch it leak.
Running
WithoutUnique, it doesn't grow:But using
unique, it leaks (with my hacked up unique package doing extra logging, as linked above)Note that there are multiples GCs happening while the
uniquecleanup func is still running.[-]unique: Handle memory leaks[/-][+]unique: Handle memory leaks; cleanup goroutine can't keep up[/+]bradfitz commentedon Feb 17, 2025
Yeah,
AddCleanupseems to do the trick. But it's not enough by itself. It also needs a bit of back pressure. Here's a trashy fix:https://gist.github.com/bradfitz/a136006f112fee47ce1d87232e779d24
(I've signed the CLA; consider that trashy patch safe to look at 😅)
bradfitz commentedon Feb 17, 2025
A potential fix for Go 1.23 (without using Go 1.24's AddCleanup) is https://gist.github.com/bradfitz/901f14f9c6dfd5c9344673a2c9478932 , making new handles wait for any ongoing cleanup to finish, but it's a bit too aggressive: it makes all goroutines wait.
But it works. 🤷♂
mknyszek commentedon Feb 17, 2025
Hm... We should make the
uniquecleanup more efficient by usingruntime.AddCleanup(we can use finalizers in Go 1.23, maybe), but (just musing here) the fact that that's not enough and it still needs some kind of back-pressure mechanism makes me wonder if maybe any kind of canonicalization map just isn't worth it for this workload?In other words, if there are so many new entries being created that deletion can't keep up, that suggests there isn't really all that much deduplication happening; that there are more inserts than lookups.
... But also maybe I'm wrong about that. Maybe there's enough of a benefit even if you only get, for example, 1 lookup on average after an insert, before a deletion. I'd love to understand this better if you can collect any metrics from production in an ad-hoc way, @bradfitz. And perhaps we need diagnostics (
runtime/metrics, probably) to help people understand whetheruniqueis actually working for them.Off the top of my head, I'm not sure what the right back-pressure policy would be here. An alternative would be is that we could scale up deletions instead. Deletions on independent parts of the tree (especially for a big tree) should scale well in my testing. The
runtime.AddCleanupcleanup function could put work on a queue instead of doing the deletion, and a pool of goroutines could consume this queue instead of just a single goroutine. We can scale this pool according to the rate of insertions (or even just always have enough goroutines on-hand to handle the load). There's a sort of natural back-pressure built-in as you start spending CPU time on these background cleaners, and the scheduler handles distribution of resources.mknyszek commentedon Feb 17, 2025
Thank you for the thorough investigation @bradfitz!!
17 remaining items
mknyszek commentedon Apr 23, 2025
I have a hunch why there was still an apparent memory leak. In my haste, my patches removed the deletion of interior nodes in the canonicalization map when deleting entries. It's possible the memory growth is actually just the result of the map getting bigger over time. It occurs to me that lots of unique hashes will keep growing the map in new ways, so interior node deletion is vital.
mknyszek commentedon Apr 23, 2025
I've updated https://go.dev/cl/650256 to avoid the potential problem that I mentioned, so https://go.dev/cl/650697 at PS 16 (please do not use PS 17+, too many other things also change) will contain the fix. If you're still up for trying it out, that would be super helpful. (Sorry for the long delay; this only occurred to me today...)
unique: use a bespoke canonicalization map and runtime.AddCleanup
runtime: schedule cleanups across multiple goroutines
mvdan commentedon Aug 13, 2025
Does this remain open to wait for @bradfitz to reply, or is there anything else that we know should be done?
hughlunnon commentedon Feb 2, 2026
Hello - I think we've run into the same issue in go 1.25 - was this meant to be fixed already or should we be waiting to 1.26?
mknyszek commentedon Feb 2, 2026
I think you're going to need to provide more details.
I did try to fix it, but as of #71772 (comment), the fix was never validated, and I don't have another (useful, real-world) reproducer at the moment. (It's still possible to out-run the cleanup goroutines if you really try, but it should be much harder to do it than it used to be. The situation with
uniquespecifically should be orders of magnitude better.) I think I'm going to need more data to keep iterating on this.(For example, it's possible that the reason this isn't fixed is because in my fix I was conservative with how many cleanup goroutines we're willing to spin up, so that might be something to try. Change
gomaxprocs/4togomaxprocshere if you want to try something: https://cs.opensource.google/go/go/+/master:src/runtime/mcleanup.go;l=709;drc=eb63ef9d6676dc0edb112cd297820306a327017a)hughlunnon commentedon Feb 11, 2026
Hi
our usecase is an app that processes a lot of spans and traces, the IDs of which are relatively short-lived (maybe 30 seconds) but appear many times during that window. I figured I could limit memory usage by using unique.
I've created what I think is a demo of this below - you might need to tweak it to become aware of exactly how well the unique map is performing (and to be sure that GC is triggering as you expect) - you can use the
useUniqueflag at the top to observe memory usage with/without unique turned on. I think this demos a leak but I'm fairly new to golang, haven't yet delved into the joys of garbage collection so I'm not certain - our main apps definitely OOM.On my machine, with unique turned on the final memory stats look something like this
and with unique turned off look like this (note the overall execution is ~2.5x quicker, I guess some of that's to be expected) - the GC does seem to be executing after the map is empty based on the numGC stat
hughlunnon commentedon Feb 11, 2026
ok I found something interesting - on a hunch I changed
generateRandomSpanIdto thisand ran it again, this time the performance looks like this
unique off :
unique on:
compared to using an array of bytes for the span ID :
so maybe this issue isn't related to the above issue at all, but is instead due to something around using unique with a byte array instead of a string?