Internals

What is inside a Go interface?

I lost half an hour today to an interface that was not nil even though the pointer I had put in it most certainly was. This is the smallest version of my mistake:

type File struct{}

func open() *File { return nil }

func main() {
	var f interface{} = open()
	fmt.Println(f == nil) // false
}

At first this looks like the language is being difficult merely to keep programmers alert. It is not. An interface value contains two things: a description of the concrete type and the concrete value itself. The interface above is approximately (*File, nil). A nil interface is (nil, nil). Those are not the same pair.

This is also why assigning an integer or a pointer to an interface is more than painting a different type on the same bits. The runtime needs enough information to identify the dynamic type, copy the value, compare it where permitted, and find methods. For an empty interface that amounts to a type descriptor and data. A non-empty interface also needs a table connecting its method set to implementations for the concrete type.

The table is prepared for a particular concrete-type/interface-type combination. A call such as

type Stringer interface { String() string }

func print(s Stringer) {
	fmt.Println(s.String())
}

can therefore dispatch through a known slot in that table. It does not have to search every method by name on every call. The implicit satisfaction of interfaces feels dynamic at the source level, but the machinery is quite disciplined.

There are practical consequences besides the nil trap. Copying an interface copies its pair of words, not necessarily an independent copy of everything reachable through the value. Comparing two interfaces first considers their dynamic types, then compares their dynamic values; putting a slice in an interface and comparing it will still panic because slices are not comparable. An interface does not confer diplomatic immunity on its contents.

Type assertions fit the same model. v, ok := x.(*File) asks whether the dynamic type in x is *File; it does not inspect the pointer and reject it merely because its value is nil. If the assertion succeeds, v may still be nil. The two-result form is useful because a failed assertion then reports ok == false instead of panicking and turning a routine check into theatre.

I like this representation because it explains several language rules at once. It also gives me a better debugging question. Instead of asking “is this interface nil?”, I now ask “what type and value did I put in it?” Usually that is the question I should have asked before blaming the compiler.

Batching calls across cgo

I wrapped a C checksum function and called it once per 32-byte record. The C loop won its isolated benchmark, yet the complete program lost. I had measured the work and ignored the doorway.

The fix was not exotic:

// One crossing for the whole buffer.
sum := C.checksum((*C.uchar)(unsafe.Pointer(&buf[0])), C.size_t(len(buf)))

I benchmarked batches of 1, 16, 256, and 4096 records from the same input. On my machine the one-record version managed about 7 MB/s; batches of 16 reached 58 MB/s, 256 reached 170 MB/s, and 4096 changed little at 174 MB/s. These are not portable constants. The flattening was the useful result: by 256 records, the checksum was work again and the bridge was no longer the benchmark.

I also timed the pure Go checksum in the same harness. It reached 112 MB/s. That made the decision less silly: either stay in Go and keep the simple boundary, or batch enough for C’s faster loop to pay its admission fee. Calling C per record was the one clearly bad option.

The batch API takes a contiguous buffer plus record metadata and returns all checksums at once. It also makes ownership easy to state: the buffer remains valid during the call, and C retains no Go pointer afterward.

The caveat is the empty slice: &buf[0] is invalid when len(buf) == 0, so real code checks length first. C memory needs C lifetime rules, callbacks need even more care, and a blocking C call affects runtime scheduling differently from an ordinary Go function.

I picked 256 records because larger batches bought almost nothing and delayed results. The benchmark supplied both halves of that choice; “fewer cgo calls” by itself would have pushed the batch size toward absurdity.

Go 1 stop-the-world garbage collection

I graphed request times for a Go 1 service and found narrow spikes hiding behind a good average. CPU was not saturated and the network was quiet. Allocation tracing pointed toward the garbage collector.

The reduced workload simply creates and drops temporary objects:

for _, line := range lines {
	fields := bytes.Split(line, []byte(","))
	consume(fields)
}

In Go 1, garbage collection stops ordinary Go execution while the collector identifies reachable heap objects and reclaims unreachable storage. The runtime coordinates goroutines at safe points, considers roots including stacks and global data, traces pointers through live objects, and sweeps storage that is no longer reachable. Those pauses are a direct property of the collector in today’s release.

The surprise was the difference between throughput and latency. The total collection work can be a tolerable fraction of a ten-second run while one request arriving during a stop sees the pause directly. An average conceals where time is concentrated. Percentiles and a timeline expose it.

Live heap size and allocation rate both matter, but differently. More live objects give the marker more reachable data to examine. A high rate of short-lived allocation fills available heap space quickly and brings the next collection nearer. Retaining a tiny pointer into a large structure can keep the structure reachable even when the programme considers most of it finished.

The collector does not compact the heap into one tidy block. That avoids rewriting every pointer but leaves allocation and free-space management to the runtime. Details of size classes, metadata, and precisely how particular regions are scanned are implementation-specific. The safe source-level rule is reachability, not a guessed address or object movement policy.

I prefer reducing obvious temporary allocation at a clear boundary. In this parser, reusing a scratch slice and avoiding repeated separator construction improved the latency graph without turning the package into an object pool. A profile before and after keeps that change honest.

The caveat is ownership. Reusing a buffer while another goroutine still reads it trades collection pauses for data races and corrupted requests, an impressively bad bargain. Pools can also retain far more memory than expected. Simpler allocation remains the right default until measurement says otherwise.

Go’s collector will certainly evolve, but application explanations should not arrive from the future. For Go 1 today, stop-the-world pauses are part of the runtime’s cost model. My service became steadier after allocating less, and the graph became considerably less interesting. Production graphs are at their best when they are poor conversationalists.

How Go 1 goroutine stacks grow

I launched one hundred thousand sleeping goroutines and expected memory use to resemble one hundred thousand operating-system threads. It did not. The programme was not free, but it was possible, which made the stack implementation more interesting than the programme.

The trigger can be reduced to this:

ch := make(chan bool)
for i := 0; i < 100000; i++ {
	go func() {
		<-ch
	}()
}

An operating-system thread traditionally reserves a comparatively large contiguous stack. Multiplying that reservation by one hundred thousand would end the experiment quickly. A Go 1 goroutine starts with a small stack segment and grows as calls require more space. Most of these goroutines call very little before blocking, so most never need a large stack.

The mechanism begins in compiled function entry code. Before a function consumes its frame, generated code checks whether the current stack segment has sufficient room. If it does, execution continues normally. If not, control enters the runtime, which arranges more stack space and resumes the call. The programme sees an ordinary function call; the compiler and runtime cooperate to make the stack conditional.

In this Go 1 implementation, growth uses segmented stacks. A new stack segment can be allocated and linked to the older segment rather than requiring one giant contiguous reservation from the start. The runtime records enough information to return across the segment boundary later. As calls unwind, it can move back to the preceding segment.

My surprise was that “small stacks” really means “small initial commitment plus machinery.” Each goroutine still needs a descriptor, scheduling state, stack memory, and any heap data referenced by its closure. One hundred thousand blocked goroutines consume real memory. They simply avoid paying in advance for deep call chains they may never execute.

Recursion demonstrates growth more directly:

func descend(n int) int {
	var pad [128]byte
	pad[0] = byte(n)
	if n == 0 {
		return int(pad[0])
	}
	return int(pad[0]) + descend(n-1)
}

Each call needs a frame, so eventually a stack check fails and the runtime grows the goroutine’s stack. The exact initial size, check sequence, and segment bookkeeping are implementation details. They are useful when reading assembly or runtime source, but they are not values a Go 1 programme should encode.

Stack growth interacts with pointers. The garbage collector and runtime must know where active frames and pointers live while goroutines stop, resume, and cross segments. A goroutine blocked in a channel operation still has reachable values on its stack. Those values cannot be reclaimed merely because the goroutine is not currently executing.

The scheduler is another separate layer. Cheap stacks make large goroutine counts feasible, but they do not create one kernel thread per goroutine. The old scheduler multiplexes runnable goroutines onto operating-system threads. A blocked goroutine can be parked while another runs. Later scheduler designs should not be projected backward onto the runtime installed here.

Segmented growth has a cost pattern worth noting. If execution repeatedly crosses a stack boundary near a hot call, allocating and discarding segments can become expensive. This is sometimes called a hot split. It is a runtime implementation concern rather than permission to avoid functions, but it explains why stack strategy can appear in profiles of otherwise ordinary code.

I prefer goroutines because they express independent activities and communication clearly, not because a benchmark proves I can manufacture six figures of them. A goroutine should still have a lifetime, an owner for cancellation, and a reason to exist. Leaking a cheap goroutine only makes the leak more affordable, not correct.

The caveat in my test is the closure and channel. Every goroutine waits forever unless the channel is closed, and closing it releases a runnable herd that has to be scheduled. Measuring only the quiet blocked state ignores startup and wake-up costs. A realistic service also has per-request buffers, queues, and other data that may dwarf the initial stack.

The result is a useful division of labour. I write straightforward calls and let stacks grow when necessary. The compiler inserts checks, the runtime supplies segments, and the scheduler runs whichever goroutines are ready. It is not magic; it is several carefully placed pieces of bookkeeping pretending not to interrupt the function. That is close enough to magic for a Wednesday, but much easier to profile.

Inside Go interfaces, slices, and maps

I passed a slice to a helper, changed an element, and watched the caller’s data change. Then I appended one more element and watched the helper apparently wander off with its own copy. The language rules explain both results, but the runtime representation makes them difficult to forget.

Start with the smallest example:

func alter(s []int) {
	s[0] = 9
	s = append(s, 4)
}

func main() {
	a := []int{1, 2, 3}
	alter(a)
	fmt.Println(a)
}

A slice value is a small descriptor: a pointer to an underlying array, a length, and a capacity. Passing the slice copies that descriptor, not all the elements. Both descriptors initially point into the same array, so assigning s[0] changes an element visible through a.

append is conditional. If the existing array has enough unused capacity, it can place new elements there and return a descriptor with a larger length. If capacity is exhausted, it allocates another array, copies elements, and returns a descriptor pointing to the new storage. Assigning that returned descriptor only to local s does not update the caller’s descriptor. This is why useful functions return the result of append.

My surprise was that a slice is neither an array nor a conventional pointer to a container object. It is a value describing a window onto array storage. Two slices can overlap, have different lengths, and still share elements. Capacity records how far that particular window may grow before another allocation is needed.

Interfaces add a different descriptor. Conceptually, an empty interface carries a dynamic type and a data word. A non-empty interface also needs information connecting the dynamic concrete type to the interface’s required methods. In the Go 1 runtime this is represented with internal tables and data pointers, but those names and layouts are implementation details, not structures application code may safely reproduce.

The crucial rule is that an interface value has both dynamic type and dynamic value. Assigning a typed nil pointer to an interface records the pointer’s concrete type even though the pointer value is nil:

var p *bytes.Buffer
var x interface{} = p
fmt.Println(x == nil) // false

The interface itself is not nil because its type component is populated. Calls and type assertions use that dynamic type information. This also explains why putting a value into an interface is not the same operation as erasing every fact about it.

Maps look simplest in source and hide the most machinery. A map value refers to a runtime-managed hash table. The runtime hashes a key, selects storage containing possible entries, and compares candidate keys. As entries accumulate, it allocates and organizes more bucket storage. The exact bucket layout, growth thresholds, and hash details belong to this Go implementation and can change without violating Go 1 compatibility.

Copying a map value therefore does not copy every entry. Both values refer to the same underlying map data:

a := map[string]int{"one": 1}
b := a
b["two"] = 2
fmt.Println(a["two"])

That prints 2. A nil map can be read and ranged over, but insertion requires an initialized map because there is no table to receive the entry. make creates the runtime map structure; it does not pre-create a fixed array with one slot per possible key.

Map iteration order is deliberately unspecified. The hash representation is designed for lookup, not for presenting keys in a stable narrative. If output order matters, I collect keys, sort them, and iterate over the sorted slice. Depending on the order observed in one run turns an internal accident into an external protocol.

All three types are cheap to pass as values compared with copying their referenced contents, but “cheap” is not “free” and “references storage” is not “is a reference variable.” The descriptor itself is copied. Reassigning a local slice or map variable does not reassign the caller’s variable. Mutating shared underlying storage may still be visible.

I prefer using these semantic rules in ordinary code and keeping the word-level model for debugging and performance questions. The model explains aliasing, nil interfaces, append, and map copying. The caveat is that runtime source is not the language specification. Code using unsafe to forge these headers ties itself to one compiler version, architecture, and a generous interpretation of luck.

My helper now returns the appended slice, and its caller assigns the result. No reflection and no unsafe arithmetic were required. Internals did their best work by making the simple rule memorable, then staying internal.