What to expect from Go 1.24 - Part 2

From Finalisers to Weak Pointers: What's New in Go 1.24's Standard Library?

Hi Folks,

Liam here with another edition of Golang Nugget, where I dish out bite-sized insights about Go and share my top reads of the week in a weekly newsletter.

This week, I’m diving back into Go 1.24 because, let’s face it, it’s packed with enough goodies to deserve its own series.

Previously, I explored two exciting new features—toolchain and generic type aliases. (If you missed it, it’s worth the click!)

In this post, I’ll be focusing on two new changes in the standard library, so grab your coffee and let’s get into it!

A finaliser walks into a bar, but the bartender says, "Sorry, you’re too late, the GC already cleared you out."

Finalisation Tale: From SetFinalizer to AddCleanup

Finalizers in Go provide a way to define cleanup logic that gets executed when an object is no longer reachable and the garbage collector (GC) wants to collect it. However, the GC cannot clean up everything. It’s excellent at managing Go objects in the heap but not other resources. For example, it cannot close a file that’s no longer in use, manage a database connection, or—heaven forbid if you’re using CGO—clean up C-allocated memory.

Therefore, you need to close and clean up resources either manually or by attaching a finalizer to an object. This way, when the GC wants to collect the object, the finalizer gets executed to clean up the associated resources.

Go provides a built-in function for this: runtime.SetFinalizer. With SetFinalizer, you can attach a function to an object, and the GC will execute it when cleaning up the object.

Let’s look at an example to clarify this:

type MyObject struct {
    Name string
}
obj := &MyObject{Name: "Finalizable"}
runtime.SetFinalizer(obj, func(o *MyObject) {
    fmt.Println("Finalizer running...")
})

obj = nil
runtime.GC()
fmt.Println("Finalizer done")

Here, I created an object and attached a finalizer function to it that simply prints a message. Then, I set the object to nil and manually triggered the GC. The output is:

Finalizer running...
Finalizer done

So far, everything works as expected. Now, let’s look at another example:

type MyObject struct {
	Name string
}

var resurrected *MyObject

func main() {
	obj := &MyObject{Name: "Finalizable"}
	runtime.SetFinalizer(obj, func(o *MyObject) {
		fmt.Println("Finalizer running...")
		resurrected = o
	})

	obj = nil
	runtime.GC()
	if resurrected != nil {
		fmt.Printf("Object resurrected: %+v\n", resurrected)
	}

	resurrected = nil
	runtime.GC()
}

Here, the object is set to be finalized. However, the finalizer function assigns the object to a global variable (resurrected), making it reachable again. This prevents the object from being truly garbage collected until the global variable is set to nil, which makes it error-prone.

A finalizer can also be called only once. In the above example, "Finalizer running..." gets printed only once. Why? Because when the GC encounters an object that it needs to collect but has a finalizer associated with it, it will execute the finalizer in a separate goroutine (which effectively "resurrects" the object, haha!), and then it removes the finalizer. So, when the GC sees the object the second time, it can collect it, as there’s no finalizer attached anymore.

Another issue is that an object can only have one associated finalizer. Imagine having an object that wraps many files and connections, and you need to clean up everything in one function. It’s not a deal breaker, but it could be better.

Moving to runtime.AddCleanup

So, we've seen how it's done so far. Go 1.24 is adding runtime.AddCleanup as an alternative. It takes a different approach to adding finalisers and is the preferred method for attaching finaliser functions to objects.

Let’s see an example of it and then explore the differences.

type FileWrapper struct {
	file *os.File
}

func NewFileWrapper(path string) *FileWrapper {
	file, _ := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0666)

	ptr := &FileWrapper{file: file}

	runtime.AddCleanup(ptr, func(f *os.File) {
		fmt.Println("Closing file:", f.Name())
		_ = f.Close()
	}, ptr.file)

	return ptr
}

func main() {
	obj := NewFileWrapper(os.DevNull)
	_ = obj
	obj = nil
	runtime.GC()
	fmt.Printf("Object is collected by GC %v\n", obj)
}

The output is:

Closing file: /dev/null
Object is collected by GC <nil>

Here, we attach a cleanup function to ptr, but instead of passing ptr itself, we pass the object we want to clean up. This way, the GC can make ptr unreachable and run AddCleanup in a separate goroutine with the file pointer.

What are the differences?

func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup

The function's signature is different. It takes ptr, a cleanup function, and arg. Interestingly, if arg == ptr (effectively making it similar to what Finalizer does), it panics.

This approach reduces the likelihood of resurrecting the object, but there are still tricky scenarios—like if we have cyclic references such as ptr → arg → ptr. However, simple cases of resurrection are no longer possible.We can attach multiple Cleanup functions to an object which

We can also attach more than one cleanup function to an object. One thing to keep in mind is that if a cleanup function is expensive, it’s better to run it in a separate goroutine. Why? Because the runtime uses a single goroutine to execute all cleanup functions. If one cleanup is slow, it can become a bottleneck, delaying the execution of other cleanups.

Before diving into the weak pointer package, we’re looking for a colleague at Quantcast in London. If you're into Golang, big data, and enjoy handling 5 million req/s, check this out and apply. The role offers visa sponsorship. 🇬🇧 🛂 

Separately, if you'd like to receive a weekly email with a collection of links and YouTube videos on software architecture, databases, DevOps, and more—things I read and find useful—subscribe to Architecture Nugget, my other newsletter.

Architecture Nugget5-min software architecture newsletter packed with bite-sized nuggets. Trusted by engineers at Google and Amazon.

A weak pointer walks into a room, but no one’s sure if it’ll stay.

New Weak Package

Go 1.23 added a new package called unique, which helps with canonicalising (interning). Interning means that only one unique copy of a value exists in memory, even if multiple variables reference it. For example, VictoriaMetrics uses string interning to ensure that only one instance of a string exists in memory, optimising performance.

You can think of the unique package as a massive concurrent map. But unlike regular maps, the unique package has a special feature: if a key isn't referenced anywhere in the program, the garbage collector can reclaim its memory.

This was implemented using weak pointers. Weak pointers have already been used internally in Go (the net/netip and unique packages are the ones I'm aware of). Now Go 1.24 is adding them to the std lib, so we can all enjoy it.

What exactly are weak pointers?

A weak pointer is a special type of pointer that doesn't prevent the garbage collector from reclaiming the memory it points to. If the memory a weak pointer references is no longer in use by any other part of the program (strong pointer), GC can free that memory, and the weak pointer will automatically become nil. This is unlike to regular (strong) pointers, that prevent GC from reclaiming the memory they reference.

Finding Nemo Hello GIF
  • weak.Make(ptr) creates a weak pointer, where ptr is a strong pointer.

  • Pointer.Value() returns the original pointer used to create the weak pointer. It returns nil if the pointer has been reclaimed by the garbage collector.

Here’s a quick snippet of how it works:

func TestWeakPointer(t *testing.T) {
	str := "I am still alive"
	println("Original str:", str)

	weakPtr := weak.Make[string](&str)
	if weakValue := weakPtr.Value(); weakValue != nil {
		println("Weak value:", weakValue, *weakValue)
	}

	runtime.GC()

	if weakValue := weakPtr.Value(); weakValue == nil {
		println("Weak value is nil:", weakValue)
	}
}

The output is:

Original str: I am still alive
Weak value: 0x1400008e2c0 I am still alive
Weak value is nil: 0x0code

As you can see, when GC was called explicitly, it reclaimed the memory, and the second call to Value() shows that the weak value is now nil.

Where can this be handy? It’s useful when you’re writing a caching or interning mechanism.

Thanks for reading! In the next posts, I'll cover more of the standard library changes, including post-quantum encryption. If you want to stay in the loop and get updates, feel free to follow me on X!

Also, if you're into Go and haven't subscribed yet, I think you'll really enjoy Golang Nugget.

References:

Reply

or to participate.