These are some of my own thinking notes as I went through the process of learning and working with Go.
Pointer Variables
In Go, all assignment is by value
Pointers are a special type that contains the memory address of the underlying value.
Pointer variables must be declared using var a *B or using short-hand declaration and assignment e.g. a := &B{val: 5}
To explain further:
d := &a
d is a pointer variable whose value is the address of a, i.e. the variable d does not have the same address as the variable a
d is now a kind of "soft link" to a, so changing a via d is possible d.val = 6
but we can change d's value to be the address of another struct c
d = &c
and d no longer points to a and is now a soft link to c, so changing c via d is now possible,
Pass-By-Value
d := a
All assignment is by value, unless we declare a variable as a pointer variable
Quirky Default Values
In Go the value of the uninitialized int is 0, not nil
type a struct { val int }
e := a{}
Likewise, an uninitialized bool is false, not nil.
In Go, only pointer and interface types can have a value of nil, which they will have if they're uninitialized.
Go's "any" Type The empty Interface{} is like "any" in TypeScript, a dynamic blackhole in a supposedly statically typed language. It is an improvement over TypeScript in that you can use runtime type assertions on passed structs which is automatically checked using the Go's reflection facilities, whereas in TypeScript 2.0 the best you can do is tagged union types where the tag says Trust Me "I'm This Type"
Slices When we pass a slice to a function as an argument the slice's value is a pointer to the backing array, so modifying the value is reflected in the calling scope, but all the metadata describing the slice itself are just copies.
To modify its structure, size, or location of the slice in memory and have it be seen from the calling scope, we must pass the slice as a pointer.
Channels and Pointers When using channels, if we send a pointer to a thing instead of the thing itself by value and then change the thing after that we could end up with a data race between the goroutines unless we use a second channel to synchronize the goroutines. That's always the scenario when concurrently operating on shared data, i.e. some kind of sync (on the shared state, e.g. mutex, or between the goroutines, via channels) is needed.
Moreover, when we send a pointer or a value containing a pointer via a channel there’s no way to know which goroutine will receive the data on a channel. Therefore the compiler cannot determine when this data will no longer be referenced, so it allocates it expensively on the heap.
GC and Heap
The GC in the most current release of the Go compiler may consume up to 30% of CPU in highly concurrent programs as it aggressively searches for and purges unreachable memory across countless goroutines. Using a Heap Ballast (large upfront allocation that is never written to, so never actually allocated in memory but still visible to the GC) is the current workaround (see: https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap-26c2462549a2/)
We can work around this lazy workaround by minimizing heap allocations. The following is a deep dive into that from the Segment.io blog:
"Go allocates memory in two places: a global heap for dynamic allocations and a local stack for each goroutine. Go prefers allocation on the stack — most of the allocations within a given Go program will be on the stack. It’s cheap because it only requires two CPU instructions: one to push onto the stack for allocation, and another to release from the stack.
Unfortunately not all data can use memory allocated on the stack. Stack allocation requires that the lifetime and memory footprint of a variable can be determined at compile time.
Otherwise a dynamic allocation onto the heap occurs at runtime. malloc must search for a chunk of free memory large enough to hold the new value. Later down the line, the garbage collector scans the heap for objects which are no longer referenced. It probably goes without saying that it is significantly more expensive than the two instructions used by stack allocation.
The compiler uses a technique called escape analysis to choose between these two options. The basic idea is to do the work of garbage collection at compile time. The compiler tracks the scope of variables across regions of code. It uses this data to determine which variables hold to a set of checks that prove their lifetime is entirely knowable at runtime. If the variable passes these checks, the value can be allocated on the stack. If not, it is said to escape, and must be heap allocated."
"The heap escape rules may continue to seem arbitrary at first, but after some trial and error with these tools, patterns do begin to emerge. For those short on time, here’s a list of some patterns we’ve found which typically cause variables to escape to the heap:
Sending pointers or values containing pointers to channels. At compile time there’s no way to know which goroutine will receive the data on a channel. Therefore the compiler cannot determine when this data will no longer be referenced.
Storing pointers or values containing pointers in a slice. An example of this is a type like []*string. This always causes the contents of the slice to escape. Even though the backing array of the slice may still be on the stack, the referenced data escapes to the heap.
Backing arrays of slices that get reallocated because an append would exceed their capacity. In cases where the initial size of a slice is known at compile time, it will begin its allocation on the stack. If this slice’s underlying storage must be expanded based on data only known at runtime, it will be allocated on the heap.
Calling methods on an interface type. Method calls on interface types are a dynamic dispatch — the actual concrete implementation to use is only determinable at runtime. Consider a variable r with an interface type of io.Reader. A call to r.Read(b) will cause both the value of r and the backing array of the byte slice b to escape and therefore be allocated on the heap."
End of quote from Segment.io blog (full article here: https://segment.com/blog/allocation-efficiency-in-high-performance-go-services/)
Golang interfaces. It’s a nice to have statically checked parametric polymorphism in theory but what I found most people do with interfaces in Go is one of two things: 1) runtime polymorphism with the empty interface as “any” type which is an expensive way to do Generics, as it requires runtime type checking (see Gotcha #3) 2) use the Interface type to expose only the relevant implementation from a given type In #2, when implementation is changed later on it would be straight forward if no interface is used to track where it’s used, by struct name, and update the code accordingly, but if you specify an interface it’s an extra level of indirection, and you have to walk backwards to find which functions are dependent on the then-changed implementation, so while the theory that statically checked parametric polymorphism is useful, if done sparingly and when needed, the Go code I’ve worked with so far uses interfaces for “design/intent/purpose” conveyance, not for parametric polymorphism per se, which adds an extra level of indirection and invisible fragile joints, which I find to be counter productive in “move fast and break things” scenarios. On the flip side, if you define interfaces from the client only (the consuming side) and things break because the package being consumed has changed then interfaces work great!
Another problem with interfaces is the case where a function takes an empty interface (like type "any" in TypeScript) and the variable to be passed to it is declared with a type of empty interface or any non-empty interface but then assigned to a value that happens to be uninitialized (a typed nil) or null-able (also a typed nil) so comparing against a literal nil won't catch the nil value and one has to go through a more elaborate approach (see: https://medium.com/@mangatmodi/go-check-nil-interface-the-right-way-d142776edef1)
Pointers Are Costly
Using pointers is not cheaper than copying. In fact, the opposite is often true, and that's due to the following reasons (quoting again from the aforementioned Segment.io artucle):
"
The compiler generates checks when dereferencing a pointer. The purpose is to avoid memory corruption by running panic() if the pointer is nil. This is extra code that must be executed at runtime. When data is passed by value, it cannot be nil.
Pointers often have poor locality of reference. All of the values used within a function are collocated in memory on the stack. Locality of reference is an important aspect of efficient code. It dramatically increases the chance that a value is warm in CPU caches and reduces the risk of a miss penalty during prefetching.
Copying objects within a cache line is the roughly equivalent to copying a single pointer. CPUs move memory between caching layers and main memory on cache lines of constant size. On x86 this is 64 bytes. Further, Go uses a technique called Duff’s device to make common memory operations like copies very efficient.
"
Use pointers only for sharing mutable state and not for anything else. This way you'll also save yourself from accidentally de-pointerizing a nil pointer, i.e. accessing the value at the address it holds, which would cause a runtime segfault.
Domain Aggregates for Managing Shared Mutable State
Shared state needs to be modeled using Domain Aggregates (see Domain Driven Design) and broken up into Aggregate Roots (each Root being an independent struct) so that we can map each set of related reads and writes to one Aggregate Root. This allows us to have a snapshot of each root, via some generic deep copy routine, to avoid having to lock the struct while we carry out related reads and/or writes in our goroutine, assuming Last Writer Wins (LWW) is an acceptable concurrency model...
We can also lock the given Root struct during related reads and writes and use only the first part of this pattern (i.e. the Aggregate Root pattern) which simplifies state management as it localizes each given sequence of related changes to one struct.
There are performance and complexity reduction benefits to using Domain Driven Design for our app state model, be it in the UI, database or stateful services.
Comments