Go's Page Allocator: Virtual Memory, Holes and the Scavenger
The Go runtime hands out 8 KiB pages from a radix tree of free-space summaries over 64 MB heap arenas, returns memory to the OS with madvise, and keeps RSS down with a background scavenger that targets either a percentage of the heap goal or a soft memory limit.
TL;DR: Underneath the size classes, Go’s runtime keeps a bitmap of which 8 KiB pages of the address space are in use, sharded into 4 MiB chunks and summarised in a five-level radix tree so it can find a run of free pages quickly. Memory is mapped in 64 MB “arenas”, returned to the OS with
madvise, and a background scavenger goroutine walks the free pages releasing them so that RSS stays near a target.
Every Go program eventually runs out of address space it can usefully
return. The size classes and the mcache decide which bytes of your
program’s heap are live; the page allocator underneath decides which pages of
the operating system’s address space those bytes live in, and — just as
importantly — which pages can be handed back.
This is the layer that shows up in a container as “the Go process is using
900 MB but HeapAlloc says 200 MB”. The difference is not a leak. It is the
page allocator holding on to address space that the OS would happily take
back.
The layers, again
application
|
mallocgc
|
size classes / mcache / mcentral
|
mheap.alloc(npages)
|
+--------+---------+
| |
pageAlloc mheap.grow
(find free pages) (map new address space)
| |
+--------+---------+
|
sysAlloc / sysMap
|
mmap / madvise
|
the kernelThe pageAlloc structure is the interesting one: it answers “where are n
contiguous free pages?” without ever walking the whole address space. The
kernel is what actually holds the physical pages.
Arenas: 64 MB of address space at a time
Go does not ask the operating system for memory one page at a time. It asks for chunks of 64 MB and carves them up:
// src/runtime/malloc.go:238
// heapArenaBytes is the size of a heap arena. The heap
// consists of mappings of size heapArenaBytes, aligned to
// heapArenaBytes. The initial heap mapping is one arena.
//
// This is currently 64MB on 64-bit non-Windows, 4MB on
// 32-bit and on Windows, and 512KB on Wasm. We use smaller
// arenas on Windows because all committed memory is charged
// to the process, even if it's not touched. Hence, for
// processes with small heaps, the mapped arena space needs
// to be commensurate. This is particularly important with
// the race detector, since it significantly amplifies the
// cost of committed memory. We use smaller arenas on Wasm
// because some Wasm programs have very small heap, and
// everything in the Wasm linear memory is charged.
heapArenaBytes = 1 << logHeapArenaBytes64 MB is not arbitrary. It is the unit at which the runtime maintains per-arena metadata — a span lookup table, three bitmaps, and a “first zeroed byte” watermark — so that the cost of that metadata is amortised over a lot of address space.
The metadata lives in a heapArena structure, which also holds the
zeroedBase watermark:
// src/runtime/mheap.go:268
type heapArena struct {
_ sys.NotInHeap
// spans maps from virtual address page ID within this arena to *mspan.
// For allocated spans, their pages map to the span itself.
// For free spans, only the lowest and highest pages map to the span itself.
// Internal pages map to an arbitrary span.
// For pages that have never been allocated, spans entries are nil.
//
// Modifications are protected by mheap.lock. Reads can be
// performed without locking, but ONLY from indexes that are
// known to contain in-use or stack spans. This means there
// must not be a safe-point between establishing that an
// address is live and looking it up in the spans array.
spans [pagesPerArena]*mspanThe comment on zeroedBase further down is worth repeating to yourself,
because it is the runtime’s one-line justification for a whole class of
optimisation:
This field is sufficient to determine if an allocation needs to be zeroed because the page allocator follows an address-ordered first-fit policy.
That is a load-bearing assumption. First-fit allocation means address order and “have I been used before?” order are the same order, which is what makes a single watermark sufficient.
The page allocator’s design
The file that implements all of this opens with a description of the data structure, and it is worth reading in full because it explains the whole design:
// src/runtime/mpagealloc.go:6
//
// The page allocator manages mapped pages (defined by pageSize, NOT
// physPageSize) for allocation and re-use. It is embedded into mheap.
//
// Pages are managed using a bitmap that is sharded into chunks.
// In the bitmap, 1 means in-use, and 0 means free. The bitmap spans the
// process's address space. Chunks are managed in a sparse-array-style structure
// similar to mheap.arenas, since the bitmap may be large on some systems.
//
// The bitmap is efficiently searched by using a radix tree in combination
// with fast bit-wise intrinsics. Allocation is performed using an address-ordered
// first-fit approach.
//
// Each entry in the radix tree is a summary that describes three properties of
// a particular region of the address space: the number of contiguous free pages
// at the start and end of the region it represents, and the maximum number of
// contiguous free pages found anywhere in that region.
//
// Each level of the radix tree is stored as one contiguous array, which represents
// a different granularity of subdivision of the processes' address space. Thus, this
// radix tree is actually implicit in these large arrays, as opposed to having explicit
// dynamically-allocated pointer-based node structures. Naturally, these arrays may be
// quite large for system with large address spaces, so in these cases they are mapped
// into memory as needed. The leaf summaries of the tree correspond to a bitmap chunk.
//
// The root level (referred to as L0 and index 0 in pageAlloc.summary) has each
// summary represent the largest section of address space (16 GiB on 64-bit systems),
// with each subsequent level representing successively smaller subsections until we
// reach the finest granularity at the leaves, a chunk.Three numbers per summary, packed into a single 64-bit word: how many contiguous free pages are at the start of the region, the maximum run of contiguous free pages anywhere inside it, and how many are at the end.
The start and end counts are there for the classic buddy-allocator trick: if
the free run at the end of region A is adjacent to the free run at the start
of region B, the two regions together have a run of A.end + B.start free
pages, and you can merge them without touching the leaves.
The radix tree
On a 64-bit platform the tree has five levels:
// src/runtime/mpagealloc_64bit.go:13
const (
// The number of levels in the radix tree.
summaryLevels = 5
// Constants for testing.
pageAlloc32Bit = 0
pageAlloc64Bit = 1
// Number of bits needed to represent all indices into the L1 of the
// chunks map.
//
// See (*pageAlloc).chunks for more details. Update the documentation
// there should this number change.
pallocChunksL1Bits = 13
)and the level geometry is spelled out in three parallel tables:
// src/runtime/mpagealloc_64bit.go:29
// levelBits is the number of bits in the radix for a given level in the super summary
// structure.
//
// The sum of all the entries of levelBits should equal heapAddrBits.
var levelBits = [summaryLevels]uint{
summaryL0Bits,
summaryLevelBits,
summaryLevelBits,
summaryLevelBits,
summaryLevelBits,
}
// levelShift is the number of bits to shift to acquire the radix for a given level
// in the super summary structure.
//
// With levelShift, one can compute the index of the summary at level l related to a
// pointer p by doing:
//
// p >> levelShift[l]
var levelShift = [summaryLevels]uint{
heapAddrBits - summaryL0Bits,
heapAddrBits - summaryL0Bits - 1*summaryLevelBits,
heapAddrBits - summaryL0Bits - 2*summaryLevelBits,
heapAddrBits - summaryL0Bits - 3*summaryLevelBits,
heapAddrBits - summaryL0Bits - 4*summaryLevelBits,
}
// levelLogPages is log2 the maximum number of runtime pages in the address space
// a summary in the given level represents.
//
// The leaf level always represents exactly log2 of 1 chunk's worth of pages.
var levelLogPages = [summaryLevels]uint{
logPallocChunkPages + 4*summaryLevelBits,
logPallocChunkPages + 3*summaryLevelBits,
logPallocChunkPages + 2*summaryLevelBits,
logPallocChunkPages + 1*summaryLevelBits,
logPallocChunkPages,
}Working the numbers out on linux/amd64: pageSize is 8 KiB, the leaf chunk
is 512 pages (4 MiB), each interior level has 3 radix bits (8 children), and
the address space is 48 bits.
| Level | Address space covered | Pages per summary |
|---|---|---|
| L0 | 16 GiB | 2 Mi pages |
| L1 | 2 GiB | 256 Ki pages |
| L2 | 256 MiB | 32 Ki pages |
| L3 | 32 MiB | 4 Ki pages |
| L4 (leaf) | 4 MiB (one chunk) | 512 pages |
So the question “is there a 100-page run anywhere in this 16 GiB region?” is answered by reading one 64-bit word at L0. And the question “where exactly?” is answered by descending five levels, each of which narrows the region eightfold, until you are inside one 4 MiB chunk and can use the bit tricks on the bitmap directly.
The leaf bitmap chunk itself holds two bitmaps — one for “in use”, one for “scavenged”:
// src/runtime/mpallocbits.go:412
// pallocData encapsulates pallocBits and a bitmap for
// whether or not a given page is scavenged in a single
// structure. It's effectively a pallocBits with
// additional functionality.
//
// Update the comment on (*pageAlloc).chunks should this
// structure change.
type pallocData struct {
pallocBits
scavenged pageBits
}Allocating pages
pageAlloc.alloc is the entry point. It takes the number of pages and
returns a base address plus the number of bytes of scavenged memory it
landed on:
// src/runtime/mpagealloc.go:879
func (p *pageAlloc) alloc(npages uintptr) (addr uintptr, scav uintptr) {
assertLockHeld(p.mheapLock)
// If the searchAddr refers to a region which has a higher address than
// any known chunk, then we know we're out of memory.
if chunkIndex(p.searchAddr.addr()) >= p.end {
return 0, 0
}
// If npages has a chance of fitting in the chunk where the searchAddr is,
// search it directly.
searchAddr := minOffAddr
if pallocChunkPages-chunkPageIndex(p.searchAddr.addr()) >= uint(npages) {
// npages is guaranteed to be no greater than pallocChunkPages here.
i := chunkIndex(p.searchAddr.addr())
if max := p.summary[len(p.summary)-1][i].max(); max >= uint(npages) {
j, searchIdx := p.chunkOf(i).find(npages, chunkPageIndex(p.searchAddr.addr()))
if j == ^uint(0) {
print("runtime: max = ", max, ", npages = ", npages, "\n")
print("runtime: searchIdx = ", chunkPageIndex(p.searchAddr.addr()), ", p.searchAddr = ", hex(p.searchAddr.addr()), "\n")
throw("bad summary data")
}
addr = chunkBase(i) + uintptr(j)*pageSize
searchAddr = offAddr{chunkBase(i) + uintptr(searchIdx)*pageSize}
goto Found
}
}Two-level search. First ask the leaf summary “does this chunk have a run of
npages free pages?”. If yes, drop into the chunk and find it exactly with
word-at-a-time bit tricks. If no, fall through to the slow path, which walks
the radix tree looking for a summary whose max field is big enough.
The searchAddr field is the first-fit cursor: everything below it in the
address space is known to be allocated, so the search never revisits it.
When the allocator frees a page below searchAddr, the cursor moves back
down — that is how holes get reused.
Marking the pages as used is a separate step:
// src/runtime/mpagealloc.go:577
func (p *pageAlloc) allocRange(base, npages uintptr) uintptr {
assertLockHeld(p.mheapLock)
limit := base + npages*pageSize - 1
sc, ec := chunkIndex(base), chunkIndex(limit)
si, ei := chunkPageIndex(base), chunkPageIndex(limit)
scav := uint(0)
if sc == ec {
// The range doesn't cross any chunk boundaries.
chunk := p.chunkOf(sc)
scav += chunk.scavenged.popcntRange(si, ei+1-si)
chunk.allocRange(si, ei+1-si)
p.scav.index.alloc(sc, ei+1-si)
} else {The scav accumulator is the interesting return value: it tells the caller
how much of the memory it just took was previously scavenged (returned to
the OS). That number flows into the memory accounting so the runtime knows
it is about to page memory back in — which is exactly the situation the
memory limit has to worry about.
Freeing pages, and the holes they leave
Freeing is the mirror image, plus the searchAddr fixup that makes first-fit
reuse holes:
// src/runtime/mpagealloc.go:940
func (p *pageAlloc) free(base, npages uintptr) {
assertLockHeld(p.mheapLock)
// If we're freeing pages below the p.searchAddr, update searchAddr.
if b := (offAddr{base}); b.lessThan(p.searchAddr) {
p.searchAddr = b
}
limit := base + npages*pageSize - 1
if npages == 1 {
// Fast path: we're clearing a single bit, and we know exactly
// where it is, so mark it directly.
i := chunkIndex(base)
pi := chunkPageIndex(base)
p.chunkOf(i).free1(pi)
p.scav.index.free(i, pi, 1)
} else {
// Slow path: we're clearing more bits so we may need to iterate.
sc, ec := chunkIndex(base), chunkIndex(limit)
si, ei := chunkPageIndex(base), chunkPageIndex(limit)Notice what freeing does not do: it does not release the memory back to the operating system. It just marks the bits free. The pages are still mapped, still backed by physical memory, and still counted in your RSS. Something else has to deal with that.
Growing the heap
When the page allocator runs out of free space, the mheap asks the
operating system for more address space:
// src/runtime/mheap.go:1544
func (h *mheap) grow(npage uintptr) (uintptr, bool) {
assertLockHeld(&h.lock)
firstGrow := h.curArena.base == 0
// We must grow the heap in whole palloc chunks.
// We call sysMap below but note that because we
// round up to pallocChunkPages which is on the order
// of MiB (generally >= to the huge page size) we
// won't be calling it too much.
ask := alignUp(npage, pallocChunkPages) * pageSize
totalGrowth := uintptr(0)
// This may overflow because ask could be very large
// and is otherwise unrelated to h.curArena.base.
end := h.curArena.base + ask
nBase := alignUp(end, physPageSize)
if nBase > h.curArena.end || /* overflow */ end < h.curArena.base {
// Not enough room in the current arena. Allocate more
// arena space. This may not be contiguous with the
// current arena, so we have to request the full ask.
av, asize := h.sysAlloc(ask, &h.arenaHints, &h.heapArenas)
if av == nil {
inUse := gcController.heapFree.load() + gcController.heapReleased.load() + gcController.heapInUse.load()
print("runtime: out of memory: cannot allocate ", ask, "-byte block (", inUse, " in use)\n")
return 0, false
}The heap grows in whole palloc chunks (4 MiB) or in whole arenas (64 MB),
never in the page-at-a-time increments you might expect from a general
purpose allocator. The arenaHints list holds addresses where the runtime
is willing to map new arenas; when the kernel hands back an arbitrary
address, the runtime records one hint to grow downwards from it and
another to grow upwards, which keeps the heap compact and away from the
program’s own mappings.
Note also what the error path prints: heapFree + heapReleased + heapInUse.
That is the runtime telling you, in the middle of an out-of-memory crash,
exactly how much of its address space is in each state.
The states of a mapping
The runtime distinguishes four states for a region of memory, and the names show up throughout the codebase:
| State | Meaning | How you get there |
|---|---|---|
| None | Not mapped at all | sysReserve → Reserved |
| Reserved | Address space claimed, no physical pages | sysMap → Prepared |
| Prepared | Address space claimed, pages committed but not in use | sysUsed → Ready |
| Ready | Pages in use by the program | sysUnused → Prepared |
// src/runtime/mem.go:68
func sysUnused(v unsafe.Pointer, n uintptr) {
gcController.mappedReady.Add(-int64(n))
sysUnusedOS(v, n)
}sysUnused is Ready → Prepared: the runtime tells the OS “you can have
these pages back”. On Linux that is a madvise:
// src/runtime/mem_linux.go:42
func sysUnusedOS(v unsafe.Pointer, n uintptr) {
if uintptr(v)&(physPageSize-1) != 0 || n&(physPageSize-1) != 0 {
// madvise will round this to any physical page
// *covered* by this range, so an unaligned madvise
// will release more memory than intended.
throw("unaligned sysUnused")
}
advise := atomic.Load(&adviseUnused)
if debug.madvdontneed != 0 && advise != madviseUnsupported {
advise = _MADV_DONTNEED
}
switch advise {
case _MADV_FREE:
if madvise(v, n, _MADV_FREE) == 0 {
break
}
atomic.Store(&adviseUnused, _MADV_DONTNEED)
fallthrough
case _MADV_DONTNEED:
// MADV_FREE was added in Linux 4.5. Fall back on MADV_DONTNEED if it's
// not supported.
if madvise(v, n, _MADV_DONTNEED) == 0 {
break
}
atomic.Store(&adviseUnused, madviseUnsupported)
fallthrough
case madviseUnsupported:
// Since Linux 3.18, support for madvise is optional.
// Fall back on mmap if it's not supported.
// _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE will unmap all the
// pages in the old mapping, and remap the memory region.
p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
if err == 0 && p != nil {
setVMAName(p, n, "unused")
}
}There are three levels of fallback, and they are not equivalent. MADV_FREE
marks the pages as reclaimable but leaves the contents intact for now — the
kernel may not actually take them back until it needs the memory, and when
it does, the contents are gone. So the runtime cannot assume the pages come
back zeroed and has to clear them itself on the way in. MADV_DONTNEED
actually frees the pages immediately, and the kernel guarantees zero-fill on
the next fault, so the runtime can skip the clear. And the final fallback —
remapping the region with mmap — works everywhere but is the most
expensive.
Which of those the runtime is using is decided by a single boolean:
// src/runtime/mem_linux.go:192
func needZeroAfterSysUnusedOS() bool {
return debug.madvdontneed == 0
}So with the default MADV_FREE the answer is “yes, zero it”; flip
GODEBUG=madvdontneed=1 and the answer becomes “no, the kernel will zero
it for us”. The trade is page faults on re-use versus an explicit memclr
on re-use, and the runtime lets you pick which one you would rather pay.
sysUsed is the reverse, and on Linux it is usually a no-op:
// src/runtime/mem.go:89
func sysUsed(v unsafe.Pointer, n, prepared uintptr) {
gcController.mappedReady.Add(int64(prepared))
sysUsedOS(v, n)
}The prepared argument is the accounting hook: it says how much of this
region is coming back into the Ready state, which is what the memory limit
watchdog measures.
The background scavenger
Freeing pages to the page allocator does not give them back to the OS.
Something has to walk the free pages and call madvise on them. That
something is a dedicated goroutine:
// src/runtime/mgcscavenge.go:648
func bgscavenge(c chan int) {
scavenger.init()
c <- 1
scavenger.park()
for {
released, workTime := scavenger.run()
if released == 0 {
scavenger.park()
continue
}
mheap_.pages.scav.releasedBg.Add(released)
scavenger.sleep(workTime)
}
}It sleeps between batches, and the sleep time is computed by a PI controller that tries to keep the scavenger’s CPU cost at 1% of the mutator’s:
// src/runtime/mgcscavenge.go:100
const (
// The background scavenger is paced according to these parameters.
//
// scavengePercent represents the portion of mutator time we're willing
// to spend on scavenging in percent.
scavengePercent = 1 // 1%
// retainExtraPercent represents the amount of memory over the heap goal
// that the scavenger should keep as a buffer space for the allocator.
// This constant is used when we do not have a memory limit set.
//
// The purpose of maintaining this overhead is to have a greater pool of
// unscavenged memory available for allocation (since using scavenged memory
// incurs an additional cost), to account for heap fragmentation and
// the ever-changing layout of the heap.
retainExtraPercent = 10
// reduceExtraPercent represents the amount of memory under the limit
// that the scavenger should target. For example, 5 means we target 95%
// of the limit.
//
// The purpose of shooting lower than the limit is to ensure that, once
// close to the limit, the scavenger is working hard to maintain it. If
// we have a memory limit set but are far away from it, there's no harm
// in leaving up to 100-retainExtraPercent live, and it's more efficient
// anyway, for the same reasons that retainExtraPercent exists.
reduceExtraPercent = 5Two different goals, and this is the part that surprises people:
- With no memory limit, the scavenger targets a percentage of the heap goal — keep 10% more than the goal around as a buffer.
- With a memory limit set, the scavenger targets 95% of the limit, so that it is always working to get back under.
// src/runtime/mgcscavenge.go:167
func gcPaceScavenger(memoryLimit int64, heapGoal, lastHeapGoal uint64) {
assertWorldStoppedOrLockHeld(&mheap_.lock)
// As described at the top of this file, there are two scavenge goals here: one
// for gcPercent and one for memoryLimit. Let's handle the latter first because
// it's simpler.
// We want to target retaining (100-reduceExtraPercent)% of the heap.
memoryLimitGoal := uint64(float64(memoryLimit) * (1 - reduceExtraPercent/100.0))
// mappedReady is comparable to memoryLimit, and represents how much total memory
// the Go runtime has committed now (estimated).
mappedReady := gcController.mappedReady.Load()
// If we're below the goal already indicate that we don't need the background
// scavenger for the memory limit. This may seems worrisome at first, but note
// that the allocator will assist the background scavenger in the face of a memory
// limit, so we'll be safe even if we stop the scavenger when we shouldn't have.
if mappedReady <= memoryLimitGoal {
scavenge.memoryLimitGoal.Store(^uint64(0))
} else {
scavenge.memoryLimitGoal.Store(memoryLimitGoal)
}The scavenger stops when both goals are met:
// src/runtime/mgcscavenge.go:404
if s.shouldStop == nil {
s.shouldStop = func() bool {
// If background scavenging is disabled or if there's no work to do just stop.
return heapRetained() <= scavenge.gcPercentGoal.Load() &&
gcController.mappedReady.Load() <= scavenge.memoryLimitGoal.Load()
}
}FreeOSMemory
If you want the scavenger to run now rather than in the background, there is a function for that:
// src/runtime/debug/garbage.go:97
// FreeOSMemory forces a garbage collection followed by an
// attempt to return as much memory to the operating system
// as possible. (Even if this is not called, the runtime gradually
// returns memory to the operating system in a background task.)
func FreeOSMemory() {
freeOSMemory()
}and the runtime side of it is a forced, unbounded scavenge:
// src/runtime/mheap.go:1786
func (h *mheap) scavengeAll() {
// Disallow malloc or panic while holding the heap lock. We do
// this here because this is a non-mallocgc entry-point to
// the mheap API.
gp := getg()
gp.m.mallocing++
// Force scavenge everything.
released := h.pages.scavenge(^uintptr(0), nil, true)
gp.m.mallocing--Note ^uintptr(0) — “scavenge as much as you possibly can”, with the true
forcing it to ignore the huge-page heuristics. It also runs a full GC first,
so that unreachable objects are actually unreachable before their pages are
released.
FreeOSMemory is a blunt instrument. It stops the world twice, and it does
not reduce the runtime’s reservation of address space — only the physical
pages behind it.
Trade-offs
What the radix tree buys you. A free-run search that costs a handful of memory reads regardless of how big the heap is. The cost is virtual address space: the summary arrays are reserved up front, and on a 48-bit address space that reservation is not small. The runtime only commits the parts it needs as the heap grows, but the reservation itself shows up in the process’s virtual size.
What first-fit buys you. Fragmentation that is bad for RSS but good for cache locality, because the heap stays roughly contiguous. The alternative, best-fit, would reduce fragmentation at the cost of scattering the heap across the address space and hurting TLB behaviour.
What the scavenger buys you. An RSS that tracks the live heap instead of the high-water mark. The cost is CPU: about 1% of mutator time, plus the page faults when the memory comes back.
What madvise(MADV_FREE) buys you. Laziness. The kernel reclaims the
pages only when it actually needs the memory, rather than immediately, and
the runtime does not pay a memclr on the way back in. The cost is that the
pages stay resident longer, so RSS stays higher than it strictly needs to.
GODEBUG=madvdontneed=1 swaps it for immediate reclamation with a
kernel-guaranteed zero fill.
Caveats
HeapAlloc is not your RSS. The gap between them is the page allocator
holding free-but-not-yet-released pages. /memory/classes/heap/released:bytes
tells you how much has been given back.
The scavenger is lazy. It targets a percentage of the heap goal, so a program whose heap spikes and then goes quiet will hold on to the peak for a while. This is intentional: the alternative is paying page faults on every subsequent allocation.
madvise support is optional. On Linux before 3.18, or on kernels
without MADV_FREE, the runtime falls back to MADV_DONTNEED and then to
mmap, which is slower. You can force the middle option with
GODEBUG=madvdontneed=1, which changes the zeroing behaviour too.
FreeOSMemory does not shrink the reservation. It releases physical
pages. The process’s virtual size stays where it was. In a container with a
virtual-memory limit rather than an RSS limit, that can still be a problem.
Arena size is not uniform. 64 MB on 64-bit Linux, 4 MB on Windows and on 32-bit platforms, 512 KB on Wasm. If you are reasoning about address space usage, the platform matters.
The scavenger’s CPU cost is real. The 1% figure is a target, not a guarantee, and the controller can overshoot when the heap is churning. Under a tight CPU quota, the scavenger competes with your application for the same cores.
Wrapping up
The one thing to remember: Go’s page allocator is a bitmap of 8 KiB pages
summarised in a five-level radix tree, and it uses a first-fit policy so
that address order and “already used” order agree. Everything else — the
arena size, the zeroedBase watermark, the summary packing — follows from
that one decision.
The part people get wrong: freeing memory in Go does not give it back to
the operating system. free only marks the pages free in the bitmap. The
background scavenger is a separate goroutine that walks free pages and
madvises them, and it only bothers when its target is not being met.
What to go do about it: if your container’s memory limit is being hit,
watch /memory/classes/heap/released:bytes and /memory/classes/total:bytes
rather than HeapAlloc. If the gap between total and released is large,
the scavenger is not keeping up — and the answer is usually GOMEMLIMIT,
not more aggressive manual frees.
Further reading
src/runtime/mpagealloc.go— the page allocator, summaries, alloc/freesrc/runtime/mpagealloc_64bit.go— 64-bit level geometrysrc/runtime/mpallocbits.go— the leaf bitmap chunksrc/runtime/mpagecache.go— the per-P page cachesrc/runtime/mranges.go— address range bookkeepingsrc/runtime/mheap.go—mheap.grow,heapArena,scavengeAllsrc/runtime/mem.go—sysReserve,sysMap,sysUsed,sysUnusedsrc/runtime/mem_linux.go— themadviseimplementationssrc/runtime/mgcscavenge.go— the background scavenger and its goalssrc/runtime/debug/garbage.go—FreeOSMemory