- Golang Nugget
- Posts
- Golang Nugget - December 6, 2024
Golang Nugget - December 6, 2024
Go's function parameters, optimisation tricks, and memory management—plus dives into union types and testing hacks!
Hello Gophers! Welcome to this Golang Nugget! I’ve got some great reads lined up for you. Some are older articles, but still solid and valuable. I hope you find them as valuable as I did.
If you’ve enjoyed this edition, I’d really appreciate it if you could share it with a friend, colleague, on your social or work Slack channels. Your support means a lot! 🤗
Also, stick around till the end—I’d love to hear how much you enjoyed this post and gather your thoughts in a quick poll!
Now let’s get some nuggets!
In Go, functions always work with copies of the arguments you pass in—no exceptions here. Check out this simple example to see what I mean:
func incrementScore(s int) {
s += 10
}
func main() {
score := 20
incrementScore(score)
fmt.Println(score) // Still prints 20!
}
If you actually want to change the values, there are two main ways to go about it:
Use pointer parameters for basic types, structs, and arrays:
func incrementScore(s *int) {
*s += 10
}
func main() {
score := 20
incrementScore(&score)
fmt.Println(score) // Now prints 30!
}
Work with maps, slices, and channels, which behave differently due to their internal workings:
func addBonus(scores map[string]int) {
for name := range scores {
scores[name] += 10
}
}
func main() {
scores := map[string]int{"Alice": 20}
addBonus(scores)
fmt.Println(scores) // Prints map[Alice:30]
}
But watch out for a little quirk with slices when using append()
. If you want the changes to show up in the calling function, you gotta use a pointer to the slice:
func addScores(s *[]int, values ...int) {
*s = append(*s, values...)
}
func main() {
scores := []int{10, 20}
addScores(&scores, 30, 40)
fmt.Println(scores) // Prints [10 20 30 40]
}
The big takeaway is that Go doesn’t have true “reference types”—it’s all about understanding how different types are built under the hood. Maps and channels are basically pointers inside, while slices are structs with pointers to arrays.
For more detailed explanations and edge cases, I’d suggest checking out Demystifying function parameters in Go for a deeper dive into these concepts.
Function inlining in Go is a neat optimisation trick, but it comes with its own set of trade-offs and limits. The compiler has to juggle between keeping the program size manageable and speeding up execution when deciding what to inline.
The tricky part is figuring out which functions should be inlined. Go uses an inlining budget system, where each function gets a cost based on its AST nodes. Here’s a simple example of how the compiler checks out different functions:
func small() string {
s := "hello, " + "world!"
return s
}
func large() string {
s := "a"
s += "b"
// ... many more concatenations
return s
}
The small
function scores a cost of 7, while the large
one goes over the budget of 80, so it can’t be inlined. You can tweak the compiler’s choices using the -gcflags=-l
flag, which lets you control the inlining behaviour.
Mid-stack inlining, which came with Go 1.9, was a big deal. Before, only leaf functions (those not calling other functions) could be inlined. Now, even functions in the middle of the call stack can be inlined, even if they call non-inlineable functions. This is super handy for optimising common code paths.
A great example of mid-stack inlining’s magic is the sync.Mutex.Lock()
optimisation. By splitting the function into two parts (called outlining), the fast path for uncontended locks can be inlined while keeping the complex slow path separate:
func (m *Mutex) Lock() {
// Fast path - can be inlined
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
// Slow path - separate function
m.lockSlow()
}
This smart restructuring led to a 14% performance boost for uncontended locks, which shows how strategic inlining can really speed things up in critical paths.
If you’re curious about the nitty-gritty of inlining budgets, tricky optimizations, and how Go’s inlining has evolved, check out Dave Cheney’s original article on Mid-stack inlining in Go.
Getting union types (like Rust’s Result or Option types) to work in Go isn’t as easy as it might seem.
The main hurdle is Go’s garbage collection system. While we could technically whip up a Result-like type using generics and a struct with private fields, it wouldn’t be as memory-efficient as Rust’s version. Here’s a basic idea of what it might look like:
type Result[T any] struct {
value T
err error
isError bool
}
The catch? This structure needs space for all fields, while proper union types (like in Rust) only need space for the largest possible type plus a small marker.
The real tricky part comes when we try to make this more efficient using unsafe operations. Let’s say we want to store either an error (interface value) or an int64[2] array in the same memory space. Both take up 16 bytes on 64-bit platforms, so it seems doable, right?
Well, here’s where it gets messy. The GC needs to know whether those 16 bytes contain pointers (like in an interface) or just plain integers. In Go, types are tied to storage locations, not values, and there’s no public API to change a storage location’s type.
The best workaround we’ve got right now is using interface values or unsafe.Pointer, but this usually forces heap allocation, making it less efficient than a proper compiler-supported union type.
Adding proper union types to Go would need big changes to the compiler and runtime, especially around garbage collection and memory allocation systems. That’s why we probably won’t see them anytime soon, especially since interface types and generics can handle many similar use cases.
Go’s current object finalization mechanism, runtime.SetFinalizer
, has some tricky limitations that make it tough to use right. So, here’s a proposal for a better solution called AddCleanup
that aims to fix these issues.
The new API looks like this:
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup
type Cleanup struct { ... }
func (c Cleanup) Stop() { ... }
The main improvements of AddCleanup
over SetFinalizer
are:
It prevents object resurrection, meaning cleanup happens faster
It can handle cycles of objects that need cleanup
You can attach cleanup functions to objects you don’t own
Multiple cleanup functions can be attached to one object
Here’s how it works under the hood:
The runtime uses a special off-heap linked-list structure to store cleanup info
For efficiency, word-sized cleanup values are stored directly in this structure
Larger values fall back to using
interface{}
storageA single goroutine handles all cleanup calls sequentially
The design makes some clever choices:
Using
func(S)
instead offunc()
helps avoid accidentally capturing the object being cleaned upReturning
Cleanup
as a value rather than a pointer saves allocationsThe cleanup function runs after any finalizers, tracking object resurrection properly
If you’re interested in deep implementation details about Go’s garbage collection and finalization mechanisms, check out the full proposal in issue #67535 and it’s successor issue #70425 on the Go GitHub repository. It contains lots more technical details about memory management and GC interactions.
Testing in Go often involves repetitive error-checking patterns that can make tests verbose and potentially error-prone. It’s common to pass *testing.T
through multiple layers of test functions:
func TestOpenFile(t *testing.T) {
f, err := os.Open("notfound")
check(t, err)
// ...
}
func check(t *testing.T, err error) {
if err != nil {
t.Helper()
t.Fatal(err)
}
}
While this works, it’s a bit cumbersome having to pass t
everywhere. Here’s where dynamic scoping comes in—a clever (though controversial) solution that lets us access the testing.T
instance without explicitly passing it around.
The trick uses runtime stack inspection to find the testing.T
pointer:
func getT() *testing.T {
var buf [8192]byte
n := runtime.Stack(buf[:], false)
sc := bufio.NewScanner(bytes.NewReader(buf[:n]))
for sc.Scan() {
var p uintptr
n, _ := fmt.Sscanf(sc.Text(), "testing.tRunner(%v", &p)
if n != 1 {
continue
}
return (*testing.T)(unsafe.Pointer(p))
}
return nil
}
This allows not to pass testing.T
pointer:
func TestOpenFile(t *testing.T) {
f, err := os.Open("notfound")
expect.Nil(err)
// ...
}
While this is quite hacky approach that I don’t think anyone should use (personal opinion), but it suggests a clever way of reducing boilerplate in test code. It works because tests run in their own goroutine with a predictable call stack pattern that we can exploit.
The main drawback is that it introduces behaviour that depends on the call stack context rather than explicit parameters, which goes against Go’s philosophy of clear and predictable code flow.
If you’re interested in diving deeper into this unconventional approach to test assertions and understanding the trade-offs involved, check out the original article Dynamically scoped variables in Go.
Thanks for reading! Before you go, I’d love to hear your opinion about this post.
Was this a good read for you? |
Reply