Grokking Advanced Go
Vote

0% completed

​

How Slices Grow

The Slice Header

The Growth Rule

Rounding to Size Classes

Append and Aliasing

Preallocating with make

A Small Slice That Keeps a Large Array

Three Tools from the slices Package

Using This in an Interview

Key Takeaways

Go
Go

. . . .

Output:

len 1 cap 1 len 2 cap 2 len 3 cap 4 len 5 cap 8 len 9 cap 16 len 17 cap 35 len 36 cap 71 len 72 cap 151 len 152 cap 303 len 304 cap 575 len 576 cap 1023 len 1024 cap 1536 len 1537 cap 2304

The loop appends 1,600 items to one slice and prints a line each time the capacity changes. The capacity starts by doubling, then changes from 16 to 35, and later grows by much less than double. Nothing in the code asks for these numbers.

They come from a short rule in the runtime and from the sizes of the memory blocks that the allocator (the part of the runtime that reserves heap memory) uses. How does append choose a new capacity, what happens to other slices of the same array, and how can a small slice keep a large array in memory?

The Slice Header

A slice value is a small struct of three words, called the slice header. The words are a pointer to the first element, the length (how many elements are in use), and the capacity (how many elements fit before the array ends). On amd64 each word is 8 bytes, so unsafe.Sizeof of any slice reports 24 bytes, whatever the slice contains.

When append has room, it writes the new element into the existing array and returns a header with a larger length. When it has no room, it allocates a larger array, copies every existing element, and returns a header that points to the new array. Copying is the expensive part, and so the runtime grows the array by more than one element each time. An append therefore costs a constant amount on average, even though an individual append sometimes copies everything.

The Growth Rule

The function nextslicecap in runtime/slice.go chooses the requested capacity. This is the Go 1.27 code, shortened by removing the check for overflow, and it can change between releases.

func nextslicecap(newLen, oldCap int) int { newcap := oldCap doublecap := newcap + newcap if newLen > doublecap { return newLen } const threshold = 256 if oldCap < threshold { return doublecap } for { newcap += (newcap + 3*threshold) >> 2 if uint(newcap) >= uint(newLen) { return newcap } } }

The rule has three cases:

  1. If the new length is more than double the old capacity, as when appending many elements at once, the capacity is the new length.
  2. If the old capacity is below 256, the capacity doubles.
  3. Otherwise, the capacity repeatedly grows by a quarter of itself plus 192, which is the expression (newcap + 768) / 4, until the new length fits. The growth factor therefore changes gradually from 2 for small slices toward 1.25 for very large ones.

Rounding to Size Classes

The rule above gives only a first answer. The allocator serves small requests from fixed block sizes called size classes (for example 1,024, 1,152, and 2,304 bytes, listed in internal/runtime/gc/sizeclasses.go). A request is rounded up to the next class. The append function then sets the capacity to use the whole block, since the extra bytes would otherwise be wasted.

The Item type adds one more detail. An Item is 32 bytes and contains a string, and therefore a pointer. For a block over 512 bytes that contains pointers, the allocator stores an 8-byte header in the block that describes where the pointers are. That header is added before rounding and subtracted afterward (roundupsize in runtime/msize.go). A request above 32 KB has no such header, and it rounds up to a multiple of the 8 KB page (the unit in which the runtime manages heap memory) instead.

Three of the jumps from the opening program show the arithmetic:

Old capRule givesBytes with headerBlock sizeNew cap
16321,024 + 81,152(1,152 - 8) / 32 = 35
303303 + (303 + 768) / 4 = 57018,240 + 818,432(18,432 - 8) / 32 = 575
1,0231,023 + (1,023 + 768) / 4 = 1,47047,040 (no header)49,15249,152 / 32 = 1,536

An element type with no pointers skips the header. A slice of int64 that is stored in a package variable, so that it must live on the heap, shows this:

Go
Go

. . . .

Output:

1 2 4 8 16 32 64 128 256 512 848 1280 1792

The change from 512 to 848 follows the same steps. The rule gives 512 + (512 + 768) / 4 = 832, which is 6,656 bytes, and the next size class is 6,784 bytes, which holds 848 values.

The package variable matters here. As of Go 1.27, a small slice that stays inside its function can start in a 32-byte buffer on the stack (growsliceBuf in runtime/slice.go). The same loop on a local []int64 therefore reports a first capacity of 4 instead of 1. Treat capacity as a value to read, never a value to predict. Code that depends on an exact growth sequence depends on details that the language does not promise.

Append and Aliasing

Two slices of one array share their elements, a situation called aliasing. Aliasing becomes dangerous with append, because when there is spare capacity it writes into the shared array without any warning. A third index in a slice expression prevents this. The full slice expression s[a:b:c] sets the capacity of the result to c - a.

Go
Go

. . . .

Output:

after plain slice: [A-100 B-200 NEW-1 D-400] after full slice: [A-100 B-200 NEW-1 D-400] limited: [CHANGED B-200 NEW-2] cap 4

The first append overwrote C-300 in the original slice, even though the call never names that element. The second append had no spare capacity, so it allocated a new array. Later changes to limited therefore do not affect skus.

This bug usually appears when a function returns part of an internal slice and the caller appends to the result. Returning s[a:b:b] from such a function stops append from overwriting the internal data, although the returned elements stay shared until an append copies them.

Two slices of one array
Two slices of one array

Preallocating with make

When you know the final length, or can estimate it, passing the capacity to make avoids every intermediate array and every copy. The program below counts allocations for both forms with testing.AllocsPerRun. Appending to a package variable keeps the slice on the heap in both cases.

Go
Go

. . . .

Output:

no capacity: 11 with capacity: 1

Every allocation in the first form except the last leaves behind an array for the garbage collector to free, and every allocation after the first copies the elements. A common error is to write make([]Item, n) and then append. That form creates n zero-valued items first, so the appended items follow them.

A Small Slice That Keeps a Large Array

The garbage collector frees an array only when nothing points into it. A slice of three elements therefore keeps its entire array in memory, however large that array is, because the array is one allocation. The program below loads one million items and keeps only three of them, first by slicing and then with slices.Clone, which copies the elements into a new array.

Go
Go

. . . .

One possible output:

after slicing: 30 MB, len 3 after cloning: 0 MB, len 3

Each Item is 32 bytes, so one million of them need 32,000,000 bytes, which the program prints as 30 MB because >> 20 divides by 1,048,576. Three of them need 96 bytes. The slices.Clone version allows the garbage collector to free the large array as soon as the clone has been made.

Three Tools from the slices Package

The slices package names the most common of these operations, so that the code states its purpose directly.

Go
Go

. . . .

Output:

Clip: 2 2 Grow: 2 true Clone: ""
FunctionWhat it doesAllocates
slices.Clip(s)Sets the capacity to the length, the same as s[:len(s):len(s)]No
slices.Grow(s, n)Guarantees room for n more appends without another allocationOnly if needed
slices.Clone(s)Copies the elements to a new array, a shallow copyYes, unless s is empty

slices.Clip protects against the aliasing bug, but it does not free anything, because the slice still points into the same array. Only a copy such as slices.Clone allows a large array to be freed.

Using This in an Interview

Say that a slice is a header of pointer, length, and capacity over an array. The append function writes in place when there is capacity, and it otherwise allocates a larger array and copies. Below a capacity of 256 the runtime doubles, above that it grows more slowly toward 1.25 times, and it then rounds up to an allocator size class. A likely follow-up asks how to stop append from overwriting a shared array. Use a full slice expression such as s[a:b:b], which leaves no spare capacity.

Key Takeaways

  • A slice is a 24-byte header on amd64, holding a pointer, a length, and a capacity.
  • nextslicecap doubles capacity below 256, then adds a quarter plus 192, and the allocator's size classes round the result up.
  • Two slices of one array share elements, and append with spare capacity overwrites what the other slice can see.
  • s[a:b:c] limits capacity, which forces the next append beyond it to copy.
  • Passing a capacity to make avoids repeated allocation and copying when the size is known.
  • A small slice keeps its whole array in memory, and only a copy allows the array to be freed.

A slice is a view of an array, and nearly every surprise from append follows from that one fact. The capacity determines whether append writes into the shared array or moves to a new one. Memory stays in use for as long as any view of the array remains. Read the capacity when you need it, limit it when you share a slice, and copy when you keep a small part of something large.

Reading Progress

0%


Vote for new content

On This Page

The Slice Header

The Growth Rule

Rounding to Size Classes

Append and Aliasing

Preallocating with make

A Small Slice That Keeps a Large Array

Three Tools from the slices Package

Using This in an Interview

Key Takeaways