Go: Under the Hood Under the Hood
中文

12.1 Design Principles

Every new, every variable that escapes to the heap, and every append that exceeds capacity in a Go program ends up calling the same runtime function, runtime.mallocgc. This chapter is about the memory allocator behind that function. This section does not yet touch individual components. It discusses the problem the allocator faces and the few basic design decisions Go made in response: which goals the allocator must balance, what it inherits from TCMalloc, why it allocates by size class, how it dispatches by an object’s size and type, and why it must be designed together with the garbage collector. The components and paths discussed in 12.2 through 12.8 all rest on these decisions.

12.1.1 The Problem the Allocator Faces

Allocation in this chapter means heap allocation only. A function’s local variables normally live on the goroutine’s stack and become invalid when the function returns, so they do not pass through the allocator (stack management is covered in Chapter 14). An object is placed on the heap only when the compiler cannot prove that its lifetime is confined to the current stack frame, a judgment made by escape analysis. Consider the following program:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

func f() int {
	x := 42
	return x
}

func g() *int {
	y := 42
	return &y
}

func main() {
	_ = f()
	_ = g()
}

The flag -gcflags=-m makes the compiler print its optimization decisions:

1
2
3
4
5
6
7
8
$ go build -gcflags=-m esc.go
# command-line-arguments
./esc.go:3:6: can inline f
./esc.go:8:6: can inline g
./esc.go:13:6: can inline main
./esc.go:14:7: inlining call to f
./esc.go:15:7: inlining call to g
./esc.go:9:2: moved to heap: y

The first five lines are inlining decisions, which are irrelevant here. The last line shows that because g returns the address of y, y may still be accessed after g returns, cannot stay in g’s stack frame, and is therefore moved to the heap. For this the compiler generates a call to runtime.newobject, and newobject merely calls mallocgc with the size of the type [Go Authors, 2026a]:

1
2
3
func newobject(typ *_type) unsafe.Pointer {
	return mallocgc(typ.Size_, typ, true)
}

x does not escape and stays on the stack. Other operations, such as make creating a slice and the growth of a slice by append beyond its capacity (growslice), also end up calling mallocgc. Escape analysis itself is discussed in 15.5 Escape Analysis; this chapter is concerned only with its result: every escaping object is allocated on the heap by mallocgc.

A general-purpose allocator must satisfy several conflicting goals at once:

  • Speed: allocation sits on the program’s hot path, and a service may perform millions of allocations per second. Ideally an allocation takes only a few instructions and no lock.
  • Scalability: Go programs usually run on multicore machines, and many goroutines may allocate at the same time. If all allocations share one global lock, contention worsens as the number of cores grows.
  • Space efficiency: the space reserved for an object should not be much larger than the object, and free memory should not be divided into too many small pieces. Otherwise a long-running service suffers continual memory bloat.
  • Cooperation with the garbage collector: Go has no explicit free, and the garbage collector decides when an object can be reused (Chapter 13). The allocator must record the information the collector needs, and the rate of allocation must influence when collection starts.

The first three goals are common to all allocators. The 1995 survey by Wilson et al. [Wilson et al., 1995] attributes space waste to fragmentation and keeps the traditional division into two kinds. Internal fragmentation occurs when an allocated block is larger than the request, so the excess is wasted inside the block. External fragmentation occurs when the total amount of free memory is sufficient but cannot satisfy a request because it is split into many smaller blocks. The survey also points out the constraints on an allocator: it cannot decide when the program allocates or frees, and it cannot compact memory by moving allocated blocks. It can only decide where in free memory the next request is placed. In the survey’s words:

An allocator is therefore an online algorithm, which must respond to requests in strict sequence, immediately, and its decisions are irrevocable.

The constraint holds for Go as well, because Go’s garbage collector does not move objects. The comment at the beginning of runtime/mgc.go reads [Go Authors, 2026d]:

1
2
// It is non-generational and non-compacting. Allocation is done using size segregated per P allocation
// areas to minimize fragmentation while eliminating locks in the common case.

That is, the collector is neither generational nor compacting. Allocation uses size-segregated, per-P allocation areas, which minimize fragmentation and eliminate locks in the common case. Since the collector does not compact the heap, fragmentation can be controlled only at allocation time. The comment also summarizes the main design of this chapter: size segregation controls fragmentation, and per-P allocation areas eliminate lock contention.

Wilson et al. further distinguish three levels of allocator design. A strategy exploits regularities in the program’s request sequence; a policy is an implementable rule that decides where a block is placed; a mechanism is the algorithms and data structures that implement the policy. The survey argues that earlier research focused too much on mechanism and neglected policy. In this vocabulary, the size classes below are a policy: a given stretch of memory holds objects of one size only. Spans, bitmaps and the layers of caches are the mechanisms that implement it.

12.1.2 From TCMalloc to Go

Go’s allocator descends from Google’s TCMalloc (thread-caching malloc). The TCMalloc design document first appeared in 2005 [Ghemawat and Menage, 2005]. Its approach is as follows. Each thread holds a local cache, and the allocation and release of small objects are first served from the local cache without locking. Only when the local cache is exhausted are objects fetched in batches from a central free list. When the central free list is also exhausted, a run of contiguous pages is requested from the page heap and divided into objects of equal size. The document reports that on a 2.8 GHz Pentium 4, glibc’s ptmalloc2 needs about 300 nanoseconds for a malloc and free pair while TCMalloc needs about 50 nanoseconds, and that on a 2.8 GHz Xeon a single lock and unlock pair alone costs about 100 nanoseconds. Avoiding locks is therefore the main source of TCMalloc’s speed advantage.

Other allocators of the same period were also trying to escape the global lock, by different means. Bonwick and Adams added a per-processor magazine layer to Solaris’s slab allocator, to solve the problem that a global lock does not scale to multiple processors [Bonwick and Adams, 2001]. FreeBSD’s jemalloc has no private per-thread cache. It builds multiple arenas (by default four times the number of processors on a multiprocessor) and assigns threads to the arenas in round-robin fashion, which lowers the probability that one arena is used concurrently [Evans, 2006]. The goal of these approaches is the same: replace a single global lock with many local structures that rarely see contention.

Go’s allocator was written by Russ Cox in late 2008. Its original header comment states plainly “Memory allocator, based on tcmalloc” and links to the TCMalloc document [Cox, 2008]. The structure at that time corresponded to TCMalloc almost one to one: about 100 size classes, a heap managed in units of 4096-byte pages, and a cache held by each thread (in Go, each M). Since then it has diverged from TCMalloc in three respects.

The first is who owns the cache. When Go 1.1 rewrote the scheduler and introduced the P, Vyukov moved the run queue and the memory cache from the M to the P [Vyukov, 2013]. An M may block in a system call, and the number of Ms is not bounded by GOMAXPROCS; the number of Ps equals GOMAXPROCS, and at any moment a P is held by at most one M (9.3 The MPG Model and the Units of Concurrent Scheduling). Once the cache belongs to the P, accessing it needs no lock, and the total size of the caches grows with GOMAXPROCS rather than with the number of threads. TCMalloc later evolved in the same direction. Its current design document [TCMalloc Authors, 2026] points out that per-thread caches make memory use grow with the number of threads: with many threads, either the total cache is large or each thread’s cache is small. TCMalloc therefore added a mode that partitions caches by logical CPU, using Linux restartable sequences to remain correct without locks or atomic instructions. In Go, the P plays the role of a logical processor in user space; because scheduling is under the runtime’s own control, this design needs no kernel support.

The second is metadata. TCMalloc serves explicit malloc and free, whereas Go’s allocator serves a precise garbage collector, so every layer of structures must record extra information the collector needs: which words are pointers, which objects have been marked, and which slots are free (12.1.5).

The third is a large number of implementation-level adjustments. The page size grew from 4096 to 8192 bytes in Go 1.3 [Vyukov, 2014]. From Go 1.7, free slots within a span are recorded by a bitmap rather than a linked list [Clements, 2015] [Hudson, 2016]. From Go 1.8, the size class table is generated at build time rather than computed at startup [Randall, 2016]. Go 1.15 replaced the locked lists in the central cache with sets that have an atomic fast path; with GOMAXPROCS of 16, the throughput of concurrently allocating 1 KiB objects rose from about 4220 MiB per second to about 10420 MiB per second [Knyszek, 2020]. In Go 1.8, Clements changed the header comment to its present wording [Clements, 2016]:

1
2
// This was originally based on tcmalloc, but has diverged quite a bit.
// http://goog-perftools.sourceforge.net/doc/tcmalloc.html

Even so, the layered structure has never changed. The same comment describes how allocating a small object proceeds up the hierarchy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Allocating a small object proceeds up a hierarchy of caches:
//
//	1. Round the size up to one of the small size classes
//	   and look in the corresponding mspan in this P's mcache.
//	   Scan the mspan's free bitmap to find a free slot.
//	   If there is a free slot, allocate it.
//	   This can all be done without acquiring a lock.
//
//	2. If the mspan has no free slots, obtain a new mspan
//	   from the mcentral's list of mspans of the required size
//	   class that have free space.
//	   Obtaining a whole span amortizes the cost of locking
//	   the mcentral.
//
//	3. If the mcentral's mspan list is empty, obtain a run
//	   of pages from the mheap to use for the mspan.
//
//	4. If the mheap is empty or has no page runs large enough,
//	   allocate a new group of pages (at least 1MB) from the
//	   operating system. Allocating a large run of pages
//	   amortizes the cost of talking to the operating system.

The first step is completed in the current P’s cache, the mcache, without locking. The second step obtains a whole span from the central cache mcentral of that size class; one refill serves many later allocations, so the cost of synchronization is amortized. The comment still says that the mcentral must be locked, which was true before Go 1.15; today its common path uses only atomic operations. The third step requests a run of contiguous pages from the global page heap mheap, and the fourth step grows the page heap, requesting memory from the operating system if necessary. The “at least 1MB” in the comment is the lower bound of Go 1.10 and earlier; today the page heap grows in units of 4 MiB on most platforms (12.2 Components). The further down the hierarchy, the higher the cost of a single operation and the lower its frequency:

flowchart LR
    A["mcache<br/>one per P, no lock"] -->|span full| B["mcentral<br/>one per span class<br/>takes a whole span"]
    B -->|no span available| C["mheap<br/>global, allocates by page"]
    C -->|not enough pages| D["operating system<br/>requested in chunks"]

The vast majority of allocations finish at the first step. This path, which touches only data local to the current P, is the fast path; the path that must go up the hierarchy to replenish is the slow path. Spreading global contention across the Ps is a design that appears in several places in the Go runtime: the scheduler keeps a local run queue per P (9.2 Work-Stealing Scheduling), sync.Pool keeps a local cache per P (11.6 Object Pool), and the allocator keeps an mcache per P. The components are detailed in 12.2 Components.

12.1.3 Size Classes

Wilson et al. call a simple and fast family of allocation policies simple segregated storage. A request is rounded up to one of several size classes, and each class has its own free list. When the free list is empty, one or two pages are requested from the operating system and divided into blocks of equal size. Blocks are neither split nor coalesced, so a page contains blocks of one class only [Wilson et al., 1995]. This is exactly the policy used by TCMalloc and Go. The classes are called size classes, and the contiguous pages that hold objects of one size class are called a span (12.2 Components).

The policy has three benefits. All slots in one span have the same size, so allocation only needs to find a free slot rather than search among free blocks of varying sizes. The size of an object is determined by the span it lives in, so it need not be recorded next to the object. And objects of the same size are stored together, so objects of different sizes do not fragment one another’s free memory. The cost is internal fragmentation, since every request must be rounded up to some size class. The finer the division into classes, the less is wasted by rounding; but each class needs its own spans and caches, and too fine a division scatters free memory across too many classes. The 2005 TCMalloc document uses about 170 size classes [Ghemawat and Menage, 2005], and the current document uses 60 to 80 [TCMalloc Authors, 2026].

Go 1.26 has 68 size classes. Class 0 corresponds to no size, and classes 1 through 67 are in use, covering 8 bytes to 32 KiB. The table is generated by runtime/_mkmalloc/mksizeclasses.go and written to internal/runtime/gc/sizeclasses.go [Go Authors, 2026b] [Go Authors, 2026c]. The comment at the start of the generator explains the trade-off:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// The size classes are chosen so that rounding an allocation
// request up to the next size class wastes at most 12.5% (1.125x).
//
// Each size class has its own page count that gets allocated
// and chopped up when new objects of the size class are needed.
// That page count is chosen so that chopping up the run of
// pages into objects of the given size wastes at most 12.5% (1.125x)
// of the memory. It is not necessary that the cutoff here be
// the same as above.
//
// The two sources of waste multiply, so the worst possible case
// for the above constraints would be that allocations of some
// size might have a 26.6% (1.266x) overhead.
// In practice, only one of the wastes comes into play for a
// given size (sizes < 512 waste mainly on the round-up,
// sizes > 512 waste mainly on the page chopping).
// For really small sizes, alignment constraints force the
// overhead higher.

There are two sources of waste. One is rounding: a request is placed in a slot no smaller than itself. The other is chopping: the length of a span is not necessarily a multiple of the object size, and the tail that cannot hold another object is called tail waste. The generator limits each to at most 12.5%, and since the two multiply, the worst case is 1.125 × 1.125 ≈ 1.266, that is, 26.6%.

The generation proceeds as follows. Sizes start at 8 bytes and grow by the current alignment step, which changes at each power of two: 16 from 32 bytes, 1/8 of that power of two from 128 bytes, and fixed at 256 from 2048 bytes. From 128 bytes on, therefore, the gap between adjacent candidate sizes is at most 1/8 of the size, which bounds the rounding waste; smaller sizes are constrained by 8-byte and 16-byte alignment, and their waste ratio is higher. For each candidate size the generator picks the smallest number of pages for which tail waste does not exceed 1/8. If a size needs the same number of pages as the previous class, each span holds the same number of objects, and the two are merged into one class. Finally, without changing the object count, the size is enlarged to fill the span as far as possible; the example in the comment enlarges 8448 bytes to 9472 bytes, both of which fit 6 objects in 7 pages. Merging and enlarging both widen the gaps between adjacent classes. The generator also checks that the result is exactly 68 classes and reports an error otherwise.

The generated file lists the parameters of each size class in a comment. The table below excerpts a few rows:

ClassObject size (bytes)Span size (bytes)ObjectsTail waste (bytes)Max wasteMin align (bytes)
1881921024087.50%8
2168192512043.75%16
54881921703231.52%16
10128819264011.72%128
1825681923205.86%256
32102481928012.40%1024
45486424576525616.65%256
6732768327681012.50%8192

The “Max waste” column adds up both kinds of waste and is computed for the worst case, in which every object in the span was requested with just 1 byte more than the previous size class. Take class 5 as an example. The previous class is 32 bytes, so a 33-byte request is placed in a 48-byte slot, wasting 15 bytes per object; 170 objects waste 2550 bytes, and with 32 bytes of tail waste this is 31.52% of 8192 bytes. The 87.50% of class 1 comes from the same calculation: a 1-byte request occupies an 8-byte slot, wasting 7/8. This is what the comment means by alignment constraints forcing the overhead higher for very small sizes, although the absolute waste is only a few bytes. Of the 58 size classes of 128 bytes and above, 13 exceed 12.5%, the highest being 16.65% for class 45, where both kinds of waste occur at the same time. The “Min align” column is the largest power-of-two factor of the size (capped at the page size), that is, the alignment the addresses of objects in that class are guaranteed to have. The object counts in the table are computed by the generator over the whole span; in Go 1.26 some spans must also reserve metadata at their end, so the actual number of slots is slightly smaller, as described in 12.2 Components.

Size classes can be observed directly in a program. When append grows a slice, the runtime rounds the required number of bytes up to a size class and derives the capacity from the rounded size. Therefore, after appending n bytes to an empty slice, cap is the size of the class that n falls into:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

import "fmt"

func main() {
	for _, n := range []int{1, 9, 17, 25, 33, 100, 1025, 5000, 33000} {
		b := append([]byte(nil), make([]byte, n)...)
		fmt.Printf("requested %5d bytes, got %5d bytes\n", n, cap(b))
	}
}

The output is:

requested     1 bytes, got     8 bytes
requested     9 bytes, got    16 bytes
requested    17 bytes, got    24 bytes
requested    25 bytes, got    32 bytes
requested    33 bytes, got    48 bytes
requested   100 bytes, got   112 bytes
requested  1025 bytes, got  1152 bytes
requested  5000 bytes, got  5376 bytes
requested 33000 bytes, got 40960 bytes

33 bytes fall into class 5 of 48 bytes, and 1025 bytes fall into class 33 of 1152 bytes. 33000 bytes exceed 32 KiB and belong to no size class; the object is a large object allocated in whole pages, and 40960 bytes is exactly five 8 KiB pages.

Every small-object allocation must derive the size class from the size, and this step should use neither division nor a loop of comparisons. The runtime pregenerates two index tables. A request of at most 1016 bytes looks up SizeToSizeClass8 in steps of 8 bytes, and a larger request looks up SizeToSizeClass128 in steps of 128 bytes, so one array index yields the size class. Computing an object’s index from its address during collection likewise avoids division: the generator precomputes a magic number for each size class that turns n / size into one multiplication and one shift, and verifies it for every possible offset within a span at generation time. Both details are covered in 12.5 Small Object Allocation.

12.1.4 Dispatch by Size and Type

Size classes solve the problem only for small objects. mallocgc also dispatches requests along two dimensions: the size of the object, and whether it contains pointers. The dispatch part of mallocgc in Go 1.26 is as follows [Go Authors, 2026a]:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	// ……
	// Short-circuit zero-sized allocation requests.
	if size == 0 {
		return unsafe.Pointer(&zerobase)
	}
	// ……
	// Actually do the allocation.
	var x unsafe.Pointer
	var elemsize uintptr
	if sizeSpecializedMallocEnabled {
		// ……
	} else {
		if size <= maxSmallSize-gc.MallocHeaderSize {
			if typ == nil || !typ.Pointers() {
				// tiny allocations might be kept alive by other co-located values.
				// Make sure secret allocations get zeroed by avoiding the tiny allocator
				// See go.dev/issue/76356
				gp := getg()
				if size < maxTinySize && gp.secret == 0 {
					x, elemsize = mallocgcTiny(size, typ)
				} else {
					x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
				}
			} else {
				if !needzero {
					throw("objects with pointers must be zeroed")
				}
				if heapBitsInSpan(size) {
					x, elemsize = mallocgcSmallScanNoHeader(size, typ)
				} else {
					x, elemsize = mallocgcSmallScanHeader(size, typ)
				}
			}
		} else {
			x, elemsize = mallocgcLarge(size, typ, needzero)
		}
	}
	// ……
}

A request of size zero always returns the same address, zerobase, and occupies no memory. The remaining requests take one of five paths:

PathConditionFunctionSee
Tiny objectno pointers, under 16 bytesmallocgcTiny12.6
Small object without pointersno pointers, 16 to 32760 bytesmallocgcSmallNoscan12.5
Small object with pointers, no malloc headerhas pointers, at most 512 bytesmallocgcSmallScanNoHeader12.5
Small object with pointers, with malloc headerhas pointers, over 512 and at most 32760 bytesmallocgcSmallScanHeader12.5
Large objectover 32760 bytesmallocgcLarge12.4

The upper limit for small objects is maxSmallSize-gc.MallocHeaderSize, that is, 32768 minus the 8-byte malloc header, and this limit applies to objects without pointers as well. Before Go 1.24, these paths were written in the single body of mallocgc, with the conditions for the various cases intertwined; Knyszek split them into the separate functions in the table above so that each could be optimized on its own [Knyszek, 2024].

Dispatch by size exists because objects of different sizes differ greatly in number and cost. Tiny objects are extremely numerous, and the comment names very short strings and standalone escaping variables as the main targets; if every tiny object occupied its own smallest slot, the proportion wasted would be high. The tiny allocator combines several tiny objects into one 16-byte block, and the block is reclaimed only after all objects in it are unreachable. The comment reports that on a JSON benchmark it reduced the number of allocations by about 12% and the heap size by about 20%. Tiny objects must contain no pointers; the reason given in the comment is that this keeps the potential waste bounded. An unreachable object may be retained because other objects in the same block are alive, and if it contained pointers, the objects it points to would be retained along with it. The gp.secret check in the code relates to the runtime/secret experiment in Go 1.26: other objects in the block could keep the whole block alive for a long time, whereas objects allocated in secret mode must be zeroed promptly when freed, so the tiny allocator is not used for them [Morsing, 2025]. Large objects are the opposite case: few in number but large individually, so caching them on every P would tie up a great deal of idle memory. Large objects therefore bypass mcache and mcentral, each takes a span of its own, and each is allocated directly from mheap by page. A single such allocation is expensive, but these allocations are rare, and the cost amortized over each byte is not significant.

Dispatch by the presence of pointers is driven by the collector. Objects without pointers are stored in separate spans, and during marking the collector never needs to scan them: a 1 MiB []byte only has to be marked, not inspected word by word. Objects with pointers must record which words are pointers, and where the record is kept depends on the object size [Go Authors, 2026e]:

  • Objects of at most 512 bytes have no header. Their pointer information is kept in a heap bitmap at the end of the span, with one bit for each pointer-sized word.
  • For small objects larger than 512 bytes, the first word of the slot holds a pointer to the type descriptor, called the malloc header, and the collector expands the pointer bitmap from the type descriptor on the fly while scanning.
  • A large object occupies a span by itself, and the type pointer is stored directly in the span’s descriptor structure.

The boundary is at 512 bytes because the space overhead of the two schemes is exactly equal at this size. On a 64-bit platform, an 8 KiB span contains 1024 words, and the heap bitmap at one bit per word takes 128 bytes in total. A span can hold 16 objects of 512 bytes; if each carries an 8-byte malloc header, the total is likewise 128 bytes. For smaller objects, the total overhead of headers would exceed that of the bitmap, so no header is used [Go Authors, 2026f].

The interactive figure below puts this dispatch together with the size classes of 12.1.3. Enter a request size and say whether the object contains pointers; the figure shows the path it takes, the size class found by the table lookup, the round-up waste in one slot, and how the whole span is divided. Compare 1020 bytes with and without pointers to see the malloc header push an object into a larger size class, or 32760 and 32761 bytes to see where small objects end and large ones begin.

This layout dates from Go 1.22. Before that, the whole heap shared one bitmap with one bit per word, and at allocation the pointer information had to be expanded from the type into the bitmap. Knyszek’s commit [Knyszek, 2023] lists the benefits of switching to malloc headers. Most size classes no longer need to expand a bitmap at allocation; a type descriptor is far more compact than the expanded bitmap, which eases TLB pressure; the header sits next to the object, so looking up the type has better cache locality; and without the bitmap that covered the whole heap, memory use drops by a fixed 1.5% or so. The cost is that some objects move up to a larger size class because of the extra 8 bytes. The Go 1.22 release notes [Go Authors, 2024] report that the change improved CPU performance of programs by 1% to 3% and reduced memory overhead by about 1% for most programs; a side effect is that some objects that were previously always 16-byte aligned are now guaranteed only 8-byte alignment.

The sizeSpecializedMallocEnabled branch omitted from the dispatch code is an optimization still under way. Matloob generated a dedicated allocation function for each size class of at most 512 bytes, such as mallocgcSmallScanNoHeaderSC3, called through a table indexed by request size; in these functions the size class and related information are compile-time constants [Matloob, 2025]. The experiment was planned to be enabled in Go 1.26, but before release large-scale deployments observed increased instruction cache footprint, so it was disabled by default [Knyszek, 2026]. Go 1.27 narrows the specialized functions to sizes within 80 bytes and enables them by default; according to Matloob, these allocations become up to 20% to 30% faster, and allocation-heavy programs overall up to 1% faster [Matloob, 2026]. This book follows Go 1.26, and the rest of the discussion covers only the general path.

12.1.5 Coexistence with the Garbage Collector

The coupling between allocator and collector goes beyond the dispatch above. The name mallocgc itself reflects it. It first appeared in Russ Cox’s commit of January 2009 that implemented the first mark-and-sweep collector, and it means “a malloc that may trigger garbage collection”: after allocating, the function checks the number of pages in use and starts a collection when a threshold is exceeded [Cox, 2009]. By Go 1 it took a dogc parameter, which allocations inside the runtime that must not trigger collection set to 0. Today the coupling shows up in two areas, metadata and collection pacing.

On the metadata side, besides the pointer information described in the previous subsection, each span records which slots are allocated and which objects were marked in the current collection. The span’s class, spanClass, is composed of the size class and a flag for whether the span is free of pointers; each size class has one span class with pointers and one without:

1
2
3
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
	return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

During marking, newly allocated objects are marked directly, which the comment calls “allocate black”, on the grounds that all words of a new object are still nil and no scanning is needed:

1
2
3
4
5
6
7
	if writeBarrier.enabled {
		// Allocate black during GC.
		// All slots hold nil so no scanning is needed.
		// This may be racing with GC so do it atomically if there can be
		// a race marking the bit.
		gcmarknewobject(span, uintptr(x))
	}

On the pacing side, Go starts collection mainly according to heap growth rather than time: the next collection starts when the heap grows to a target, and the target is determined by the live heap after the previous collection (13.3 Trigger Frequency and the Pacing Algorithm). Time-based triggering is only a fallback: if no collection has occurred for two minutes, one is forced. The timing of the check against the target is also a trade-off. Small objects are checked only when a span in the mcache is exhausted and needs refilling, that is, once per span used up; large objects are checked at every allocation. During marking, allocation must also bear part of the marking work, a mechanism called mark assist. Before allocating, mallocgc deducts the number of bytes of this allocation from the current goroutine’s assist credit:

1
2
3
4
5
	// Assist the GC if needed. (On the reuse path, we currently compensate for this;
	// changes here might require changes there.)
	if gcBlackenEnabled != 0 {
		deductAssistCredit(size)
	}

When the credit is negative, the goroutine must first help the collector replenish it before it can continue allocating. It first tries to steal credit accumulated by background marking work, and performs marking work itself if that is not enough. After the allocation, the internal fragmentation caused by rounding is also charged against the credit. The faster a goroutine allocates, the more marking work it takes on, which ensures that marking keeps pace with allocation (13.4 Scan Marking and Mark Assist).

Finally, zeroing. Go guarantees that newly allocated memory is zero, but memory freshly obtained from the operating system is already zero, so a span records in needzero whether zeroing is actually needed, which avoids unnecessary writes. Zeroing a large object must also take its effect on the collector into account: the zeroing function has no safepoints, so zeroing a very large object at once leaves the goroutine unpreemptible for a long time, which delays the phases of the collector that must stop all goroutines. mallocgcLarge therefore zeroes only after restoring the preemptible state, and does so in chunks of 256 KiB, checking for preemption requests between chunks [Chase, 2020].

12.1.6 Summary

Go’s allocator is a layered cache: the most common allocations are completed in the local cache of each P without locking, and the further down the hierarchy, the higher the cost and the lower the frequency. It adopts the policy of simple segregated storage, trading bounded internal fragmentation for allocation speed and controllable external fragmentation. It divides requests into five paths by object size and by the presence of pointers, and it records the metadata the collector needs at every layer, so that the rate of allocation drives the pace of collection. It derives from TCMalloc, but because it serves a concurrent collector that does not move objects, it now differs greatly from TCMalloc. 12.2 Components introduces the components that implement this design one by one.

Further Reading