Skip to content

memory

В отличие от традиционного C/C++, Go — это язык с GC. В большинстве случаев выделение и освобождение памяти автоматически управляется Go. Компилятор решает, должна ли память объекта быть выделена в стеке или куче, в основном без участия пользователя в управлении памятью. Пользователям нужно только использовать память. В Go управление памятью кучи в основном состоит из двух больших компонентов: аллокатор памяти отвечает за выделение памяти кучи, а сборщик мусора отвечает за переработку и освобождение бесполезной памяти кучи. Эта статья в основном обсуждает, как работает аллокатор памяти. Аллокатор памяти Go в значительной степени находится под влиянием аллокатора памяти TCMalloc от Google.

Аллокаторы

В Go есть два типа аллокаторов памяти: один — линейный аллокатор, а другой — связный аллокатор.

Линейное выделение

Линейный аллокатор соответствует структуре runtime.linearAlloc, как показано ниже:

go
type linearAlloc struct {
  next   uintptr // следующий свободный байт
  mapped uintptr // один байт за концом отображённого пространства
  end    uintptr // конец зарезервированного пространства

  mapMemory bool // переключить память из Reserved в Ready, если true
}

Этот аллокатор предварительно выделяет непрерывное пространство памяти от операционной системы. next указывает на используемый адрес памяти, а end указывает на конечный адрес пространства памяти, что можно понять, как показано на рисунке ниже.

Метод выделения памяти линейного аллокатора очень легко понять. Он проверяет, достаточно ли оставшегося пространства для размещения на основе запрошенного размера памяти. Если достаточно, обновляет поле next и возвращает начальный адрес оставшегося пространства. Код следующий:

go
func (l *linearAlloc) alloc(size, align uintptr, sysStat *sysMemStat) unsafe.Pointer {
  p := alignUp(l.next, align)
  if p+size > l.end {
    return nil
  }
  l.next = p + size
  return unsafe.Pointer(p)
}

Преимущество этого метода выделения в том, что он быстрый и простой. Недостаток также довольно очевиден: он не может переиспользовать освобождённую память, потому что поле next указывает только на адрес памяти оставшегося пространства. Он не может воспринимать ранее использованное и освобождённое пространство памяти, что вызывает большую трату пространства памяти, как показано на рисунке ниже.

Поэтому линейное выделение не является основным методом выделения в Go. Оно используется только как функция предварительного выделения памяти на 32-битных машинах.

Связное выделение

Связный аллокатор соответствует структуре runtime.fixalloc. Память, выделенная связным аллокатором, не является непрерывной и существует как односвязный список. Связный аллокатор состоит из нескольких блоков памяти фиксированного размера, и каждый блок памяти состоит из нескольких чанков памяти фиксированного размера. Каждый раз при выделении памяти используется чанк памяти фиксированного размера.

go
type fixalloc struct {
  size   uintptr
  first  func(arg, p unsafe.Pointer) // вызывается первый раз, когда p возвращается
  arg    unsafe.Pointer
  list   *mlink
  chunk  uintptr // использовать uintptr вместо unsafe.Pointer, чтобы избежать write barriers
  nchunk uint32  // байт осталось в текущем чанке
  nalloc uint32  // размер новых чанков в байтах
  inuse  uintptr // байт в использовании сейчас
  stat   *sysMemStat
  zero   bool // обнулять выделения
}

type mlink struct {
  _    sys.NotInHeap
  next *mlink
}

Его поля не так просты и легки для понимания, как у линейного аллокатора. Здесь кратко представлены важные:

  • size: Сколько памяти используется каждый раз при выделении памяти.
  • list: Указывает на головной узел повторно используемых чанков памяти. Каждый размер чанка памяти определяется size.
  • chunk: Указывает на свободный адрес в текущем используемом блоке памяти.
  • nchunk: Оставшиеся доступные байты в текущем блоке памяти.
  • nalloc: Размер блока памяти, фиксированный на 16KB.
  • inuse: Общее количество байт памяти в настоящее время в использовании.
  • zero: Очищать ли память при повторном использовании блоков памяти.

Связный аллокатор держит ссылки на текущий блок памяти и повторно используемые чанки памяти. Каждый размер блока памяти фиксирован на 16KB, что устанавливается во время инициализации.

go
const _FixAllocChunk = 16 << 10

func (f *fixalloc) init(size uintptr, first func(arg, p unsafe.Pointer), arg unsafe.Pointer, stat *sysMemStat) {
  if size > _FixAllocChunk {
    throw("runtime: fixalloc size too large")
  }
  if min := unsafe.Sizeof(mlink{}); size < min {
    size = min
  }

  f.size = size
  f.first = first
  f.arg = arg
  f.list = nil
  f.chunk = 0
  f.nchunk = 0
  f.nalloc = uint32(_FixAllocChunk / size * size)
  f.inuse = 0
  f.stat = stat
  f.zero = true
}

Распределение блоков памяти показано на рисунке ниже. Блоки памяти на рисунке расположены согласно времени создания. В реальности их адреса не являются непрерывными.

Размер памяти, выделяемый связным аллокатором каждый раз, также фиксирован, определяется fixalloc.size. Во время выделения сначала проверяет, есть ли повторно используемые блоки памяти. Если есть, приоритетно использует повторно используемые блоки памяти, затем использует текущий блок памяти. Если оставшееся пространство текущего блока памяти недостаточно для размещения, создаёт новый блок памяти. Эта часть логики соответствует следующему коду:

go
func (f *fixalloc) alloc() unsafe.Pointer {
  if f.size == 0 {
    print("runtime: use of FixAlloc_Alloc before FixAlloc_Init\n")
    throw("runtime: internal error")
  }

  if f.list != nil {
    v := unsafe.Pointer(f.list)
    f.list = f.list.next
    f.inuse += f.size
    if f.zero {
      memclrNoHeapPointers(v, f.size)
    }
    return v
  }
  if uintptr(f.nchunk) < f.size {
    f.chunk = uintptr(persistentalloc(uintptr(f.nalloc), 0, f.stat))
    f.nchunk = f.nalloc
  }

  v := unsafe.Pointer(f.chunk)
  if f.first != nil {
    f.first(f.arg, v)
  }
  f.chunk = f.chunk + f.size
  f.nchunk -= uint32(f.size)
  f.inuse += f.size
  return v
}

Преимущество связного аллокатора именно в том, что он может переиспользовать освобождённую память. Базовой единицей для повторного использования памяти является чанк памяти фиксированного размера, чей размер определяется fixalloc.size. При освобождении памяти связный аллокатор добавляет чанк памяти как головной узел в связный список свободных чанков памяти. Код следующий:

go
func (f *fixalloc) free(p unsafe.Pointer) {
  f.inuse -= f.size
  v := (*mlink)(p)
  v.next = f.list
  f.list = v
}

Компоненты памяти

Аллкатор памяти Go в основном состоит из следующих компонентов: mspan, heaparena, mcache, mcentral и mheap. Они работают слой за слоем для управления памятью кучи Go.

mspan

runtime.mspan — это базовая единица в выделении памяти Go. Его структура следующая:

go
type mspan struct {
    next *mspan     // следующий span в списке, или nil если нет
    prev *mspan     // предыдущий span в списке, или nil если нет

    startAddr uintptr // адрес первого байта span aka s.base()
    npages    uintptr // количество страниц в span
    freeindex uintptr

    spanclass             spanClass     // класс размера и noscan (uint8)
    needzero              uint8         // нужно обнулить перед выделением
    elemsize              uintptr       // вычисляется из sizeclass или из npages
    limit                 uintptr       // конец данных в span
    state                 mSpanStateBox // mSpanInUse и т.д.; доступ атомарный (методы get/set)

    nelems uintptr // количество объектов в span.
    allocCache uint64
    allocCount            uint16        // количество выделенных объектов
    ...
}

mspan и mspan связаны через next и prev как двусвязный список. Адреса памяти не являются непрерывными. Каждый mspan управляет mspan.npages страницами памяти размера runtime.pageSize. Обычно размер страницы — 8KB. mspan.startAddr записывает начальный адрес этих страниц, а mspan.limit записывает конечный адрес используемой памяти. Каждый mspan хранит элементы фиксированного размера elemsize, поэтому количество элементов, которое он может вместить, также фиксировано. Из-за фиксированного количества объекты распределены в mspan как массив, в диапазоне [0, nelems]. freeindex записывает индекс следующего доступного слота для хранения объектов. mspan имеет три состояния:

  • mSpanDead: Память была освобождена.
  • mSpanInUse: Выделено в кучу.
  • mSpanManual: Выделено в части памяти с ручным управлением, такие как стек.

Определение размера элемента mspanspanClass. spanClass сам по себе является целым числом типа uint8. Высокие семь бит хранят значение класса 0-67, а последний бит используется для указания noscan, т.е. содержит ли указатели.

go
type spanClass uint8

func (sc spanClass) sizeclass() int8 {
  return int8(sc >> 1)
}

func (sc spanClass) noscan() bool {
  return sc&1 != 0
}

Он имеет в общей сложности 68 различных значений. Все значения хранятся в табличной форме в файле runtime.sizeclasses.go. Во время выполнения, используя spanClass через runtime.class_to_size, можно получить размер объекта mspan, а через class_to_allocnpages можно получить количество страниц mspan.

classmax object sizespan sizeobject counttail wastemax memory waste ratemin alignment
1881921024087.50%8
2168192512043.75%16
3248192341829.24%8
4328192256021.88%32
54881921703231.52%16
6648192128023.44%64
78081921023219.07%16
8968192853215.95%32
91128192731613.56%16
10128819264011.72%128
1114481925612811.82%16
12160819251329.73%32
13176819246969.59%16
141928192421289.25%64
15208819239808.12%16
162248192361288.15%32
17240819234326.62%16
1825681923205.86%256
1928881922812812.16%32
2032081922519211.80%64
21352819223969.88%32
223848192211289.51%128
2341681921928810.71%32
244488192181288.37%64
25480819217326.82%32
2651281921606.05%512
2757681921412812.33%64
2864081921251215.48%128
2970481921144813.93%64
3076881921051213.94%256
318968192912815.52%128
32102481928012.40%1024
3311528192712812.41%128
3412808192651215.55%256
351408163841189614.00%128
3615368192551214.00%512
37179216384925615.57%256
38204881924012.45%2048
39230416384725612.46%256
4026888192312815.59%128
413072245768012.47%1024
4232001638453846.22%128
4334562457673848.83%128
44409681922015.60%4096
45486424576525616.65%256
46537616384325610.92%256
476144245764012.48%2048
4865283276851286.23%128
4967844096062564.36%128
5069124915277683.37%256
51819281921015.61%8192
52947257344651214.28%256
5397284915255123.64%512
541024040960404.99%2048
55108803276831286.24%128
5612288245762011.45%4096
57135684096032569.99%256
581433657344405.35%2048
5916384163841012.49%8192
6018432737284011.11%2048
61190725734431283.57%128
622048040960206.87%4096
63217606553632566.25%256
6424576245761011.45%8192
652726481920312810.00%128
662867257344204.91%4096
6732768327681012.50%8192

Логика вычисления этих значений может быть найдена в функции printComment файла runtime.mksizeclasses.go. Формула максимальной скорости траты памяти:

go
float64((size-prevSize-1)*objects+tailWaste) / float64(spanSize)

Например, когда class равен 2, его максимальная скорость траты памяти:

((16-8-1)*512+0)/8192 = 0.4375

Когда значение class равно 0, это spanClass, используемый для выделения больших объектов выше 32KB. В основном один большой объект занимает один mspan. Поэтому память кучи Go на самом деле состоит из нескольких связных списков mspan разных фиксированных размеров.

heaparena

Как упоминалось ранее, mspan состоит из нескольких страниц, но mspan только держит ссылки на адреса страниц и не отвечает за управление этими страницами. Тот, кто действительно отвечает за управление памятью этих страниц — runtime.heaparena. Каждый heaparena управляет несколькими страницами. Размер heaparena определяется runtime.heapArenaBytes, обычно 64MB. bitmap используется для идентификации, хранит ли соответствующий адрес в странице объекты. zeroedBase — начальный адрес памяти страницы, управляемой этим heaparena. spans записывает, какой mspan использует каждую страницу.

go
type heapArena struct {
  _ sys.NotInHeap
  bitmap [heapArenaBitmapWords]uintptr
  noMorePtrs [heapArenaBitmapWords / 8]uint8
  spans [pagesPerArena]*mspan
  pageInUse [pagesPerArena / 8]uint8
  pageMarks [pagesPerArena / 8]uint8
  pageSpecials [pagesPerArena / 8]uint8
  checkmarks *checkmarksMap
  zeroedBase uintptr
}

Логика о страницах и записях mspan может быть найдена в методе mheap.setSpans, следующим образом:

go
func (h *mheap) setSpans(base, npage uintptr, s *mspan) {
  p := base / pageSize
  ai := arenaIndex(base)
  ha := h.arenas[ai.l1()][ai.l2()]
  for n := uintptr(0); n < npage; n++ {
    i := (p + n) % pagesPerArena
    if i == 0 {
      ai = arenaIndex(base + n*pageSize)
      ha = h.arenas[ai.l1()][ai.l2()]
    }
    ha.spans[i] = s
  }
}

В куче Go вся память страниц управляется двумерным массивом heaparena. См. поле mheap.arenas:

go
type mheap struct {
  arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
}

На 64-битных платформах Windows первый размерность массива — 1 << 6, а второй размерность — 1 << 16. На 64-битных платформах Linux первый размерность — 1, а второй размерность — 1 << 22. Этот двумерный массив, состоящий из всех heaparena, формирует пространство виртуальной памяти runtime Go. В целом, это выглядит как рисунок ниже.

Хотя heaparena相邻, памяти страниц, которыми они управляют, не являются непрерывными.

mcache

mcache соответствует структуре runtime.mcache, которая появилась в статье о конкурентном планировании. Хотя его имя — mcache, он фактически связан с процессором P. mcache — это кэш памяти на каждом процессоре P, содержащий массив связных списков mspan alloc. Размер массива фиксирован на 136, что в точности в два раза больше количества spanClass. Также есть кэш крошечных объектов tiny, где tiny указывает на начальный адрес памяти крошечных объектов, tinyoffset — свободное смещение памяти относительно начального адреса, а tinyAllocs указывает, сколько крошечных объектов было выделено. Для кэша стека stackcache вы можете узнать об этом в Выделение памяти стека.

go
type mcache struct {
    _ sys.NotInHeap

    nextSample uintptr // триггер выборки кучи после выделения этого количества байт
    scanAlloc  uintptr // байт сканируемой кучи выделено
    tiny       uintptr
    tinyoffset uintptr
    tinyAllocs uintptr

    alloc [numSpanClasses]*mspan
    stackcache [_NumStackOrders]stackfreelist
    flushGen atomic.Uint32
}

Во время инициализации связные списки в alloc из mcache содержат только пустой головной узел runtime.emptymspan, который является mspan без доступной памяти.

go
func allocmcache() *mcache {
  var c *mcache
  systemstack(func() {
    lock(&mheap_.lock)
    c = (*mcache)(mheap_.cachealloc.alloc())
    c.flushGen.Store(mheap_.sweepgen)
    unlock(&mheap_.lock)
  })
  for i := range c.alloc {
    c.alloc[i] = &emptymspan
  }
  c.nextSample = nextSample()
  return c
}

Только когда нужно выделение памяти, он запрашивает новый mspan от mcentral для замены оригинального пустого span. Эта работа завершается методом mcache.refill. Его единственный вызов — функция runtime.mallocgc. Ниже приведён упрощённый код:

go
func (c *mcache) refill(spc spanClass) {
  // Возвращаем текущий кэшированный span в центральные списки.
  s := c.alloc[spc]

  // Получаем новый кэшированный span из центральных списков.
  s = mheap_.central[spc].mcentral.cacheSpan()
  if s == nil {
    throw("out of memory")
  }

  c.scanAlloc = 0

  c.alloc[spc] = s
}

Преимущество использования mcache в том, что выделение памяти не требует глобальной блокировки. Однако когда его памяти недостаточно и нужно обращаться к mcentral, блокировка всё ещё требуется.

mcentral

runtime.mcentral управляет всеми mspan, хранящими маленькие объекты в куче. Когда mcache запрашивает память, это выделяется mcentral.

go
type mcentral struct {
    _         sys.NotInHeap
    spanclass spanClass
    partial [2]spanSet
    full    [2]spanSet
}

У mcentral мало полей. spanClass указывает тип хранимого mspan. partial и full — два spanSet. Первый хранит mspan со свободной памятью, а второй хранит mspan без свободной памяти. mcentral напрямую управляется кучей mheap. Во время выполнения всего 136 mcentral.

go
type mheap struct {
    central [numSpanClasses]struct {
        mcentral mcentral
        pad      [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
    }
}

mcentral в основном отвечает за две задачи: выделение доступного mspan для mcache, когда памяти достаточно, и запрос нового mspan от mheap, когда памяти недостаточно. Работа выделения mspan для mcache завершается методом mcentral.cacheSpan. Сначала пытается найти доступный mspan в отсканированном наборе свободного списка:

go
// Сначала пробуем частичные отсканированные span.
sg := mheap_.sweepgen
if s = c.partialSwept(sg).pop(); s != nil {
    goto havespan
}

Если не найдено, ищет доступный mspan в неотсканированном наборе свободного списка:

go
for ; spanBudget >= 0; spanBudget-- {
    s = c.partialUnswept(sg).pop()
    if s == nil {
        break
    }
    if s, ok := sl.tryAcquire(s); ok {
        s.sweep(true)
        sweep.active.end(sl)
        goto havespan
    }
}

Если всё ещё не найдено, идёт в неотсканированный набор не свободного списка для поиска:

go
for ; spanBudget >= 0; spanBudget-- {
    s = c.fullUnswept(sg).pop()
    if s == nil {
        break
    }
    if s, ok := sl.tryAcquire(s); ok {
        s.sweep(true)
        freeIndex := s.nextFreeIndex()
        if freeIndex != s.nelems {
            s.freeindex = freeIndex
            sweep.active.end(sl)
            goto havespan
        }
        c.fullSwept(sg).push(s.mspan)
    }
}

Если в конце всё ещё не найдено, метод mcentral.grow запрашивает новый mspan от mheap:

go
s = c.grow()
if s == nil {
    return nil
}

При нормальных обстоятельствах доступный mspan будет возвращён тем или иным способом:

go
havespan:
  freeByteBase := s.freeindex &^ (64 - 1)
  whichByte := freeByteBase / 8
  // Инициализируем кэш битов alloc.
  s.refillAllocCache(whichByte)
  s.allocCache >>= s.freeindex % 64

  return s

Для процесса запроса mspan от mheap, он фактически вызывает метод mheap.alloc, который возвращает новый mspan:

go
func (c *mcentral) grow() *mspan {
  npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
  size := uintptr(class_to_size[c.spanclass.sizeclass()])

  s := mheap_.alloc(npages, c.spanclass)
  if s == nil {
    return nil
  }

  n := s.divideByElemSize(npages << _PageShift)
  s.limit = s.base() + size*n
  s.initHeapBits(false)
  return s
}

После правильной инициализации он может быть выделен для использования mcache.

mheap

runtime.mheap — это менеджер памяти кучи языка Go. Во время выполнения он существует как глобальная переменная runtime.mheap_:

go
var mheap_ mheap

Он управляет всеми созданными mspan, всеми mcentral и всеми heaparena, а также многими другими различными аллокаторами. Его упрощённая структура следующая:

go
type mheap struct {
    _ sys.NotInHeap

    lock mutex

    allspans []*mspan // все span там

    pagesInUse         atomic.Uintptr // страниц span в статистике mSpanInUse
    pagesSwept         atomic.Uint64  // страниц отсканировано в этом цикле
    pagesSweptBasis    atomic.Uint64  // pagesSwept для использования как основа отношения сканирования

    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
    allArenas []arenaIdx
    sweepArenas []arenaIdx
    central [numSpanClasses]struct {
        mcentral mcentral
        pad      [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
    }

    pages            pageAlloc // структура данных выделения страниц
    spanalloc              fixalloc // аллокатор для span*
    cachealloc             fixalloc // аллокатор для mcache*
    specialfinalizeralloc  fixalloc // аллокатор для specialfinalizer*
    specialprofilealloc    fixalloc // аллокатор для specialprofile*
    specialReachableAlloc  fixalloc // аллокатор для specialReachable
    specialPinCounterAlloc fixalloc // аллокатор для specialPinCounter
    arenaHintAlloc         fixalloc // аллокатор для arenaHints
}

Для mheap есть в основном четыре задачи для выполнения во время выполнения:

  • Инициализация кучи
  • Выделение mspan
  • Освобождение mspan
  • Расширение кучи

Обсудим эти четыре вещи по порядку.

Инициализация

Инициализация кучи происходит во время фазы bootstrap программы, что также является фазой инициализации планировщика. Порядок вызова:

go
schedinit() -> mallocinit() -> mheap_.init()

Во время инициализации в основном отвечает за выполнение работы инициализации различных аллокаторов:

go
func (h *mheap) init() {
  h.spanalloc.init(unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys)
  h.cachealloc.init(unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys)
  h.specialfinalizeralloc.init(unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys)
  h.specialprofilealloc.init(unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys)
  h.specialReachableAlloc.init(unsafe.Sizeof(specialReachable{}), nil, nil, &memstats.other_sys)
  h.specialPinCounterAlloc.init(unsafe.Sizeof(specialPinCounter{}), nil, nil, &memstats.other_sys)
  h.arenaHintAlloc.init(unsafe.Sizeof(arenaHint{}), nil, nil, &memstats.other_sys)

  h.spanalloc.zero = false
  for i := range h.central {
    h.central[i].mcentral.init(spanClass(i))
  }

  h.pages.init(&h.lock, &memstats.gcMiscSys, false)
}

Это включает аллокатор mheap.spanalloc, отвечающий за выделение mspan, аллокатор mheap.pages, отвечающий за выделение страниц, и инициализацию всех mcentral.

Выделение

В mheap выделение mspan завершается методом mheap.allocSpan:

go
func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan)

Если запрошенная память достаточно мала, т.е. удовлетворяет npages < pageCachePages/4, пытается получить доступный mspan из кэша mspan локального P без блокировки. Если кэш P пуст, сначала инициализирует его:

go
// Если кэш пуст, пополняем его.
if c.empty() {
    lock(&h.lock)
    *c = h.pages.allocToCache()
    unlock(&h.lock)
}

Затем получает из кэша P, завершается методом mheap.tryAllocMSpan:

go
pp := gp.m.p.ptr()
if !needPhysPageAlign && pp != nil && npages < pageCachePages/4 {
    c := &pp.pcache
    base, scav = c.alloc(npages)
    if base != 0 {
        s = h.tryAllocMSpan()
        if s != nil {
            goto HaveSpan
        }
    }
}

Код получения mspan из кэша P следующий. Пытается получить последний mspan в кэше:

go
func (h *mheap) tryAllocMSpan() *mspan {
  pp := getg().m.p.ptr()
  // Если у нас нет p или кэш пуст, мы не можем ничего сделать здесь.
  if pp == nil || pp.mspancache.len == 0 {
    return nil
  }
  // Берём последнюю запись в кэше.
  s := pp.mspancache.buf[pp.mspancache.len-1]
  pp.mspancache.len--
  return s
}

Если запрошенная память относительно велика, она будет выделена в куче. Этот процесс требует удержания блокировки:

go
lock(&h.lock)
if base == 0 {
    // Пытаемся захватить базовый адрес.
    base, scav = h.pages.alloc(npages)
    if base == 0 {
        var ok bool
        growth, ok = h.grow(npages)
        if !ok {
            unlock(&h.lock)
            return nil
        }
        base, scav = h.pages.alloc(npages)
        if base == 0 {
            throw("grew heap, but no adequate free space found")
        }
    }
}
if s == nil {
    // Мы не смогли получить mspan ранее, поэтому берём
    // один теперь, когда у нас есть блокировка кучи.
    s = h.allocMSpanLocked()
}
unlock(&h.lock)

Сначала использует pageAlloc.alloc для выделения достаточной памяти страницы. Если памяти кучи недостаточно, mheap.grow выполняет расширение. После завершения выделения памяти страницы связный аллокатор mheap.spanalloc выделяет 64 mspan в локальный кэш P (64 — это в точности половина длины массива кэша), затем возвращает доступный mspan из кэша P:

go
func (h *mheap) allocMSpanLocked() *mspan {
  assertLockHeld(&h.lock)

  pp := getg().m.p.ptr()
  if pp == nil {
    // У нас нет p, поэтому просто делаем обычное дело.
    return (*mspan)(h.spanalloc.alloc())
  }
  // Пополняем кэш, если необходимо.
  if pp.mspancache.len == 0 {
    const refillCount = len(pp.mspancache.buf) / 2
    for i := 0; i < refillCount; i++ {
      pp.mspancache.buf[i] = (*mspan)(h.spanalloc.alloc())
    }
    pp.mspancache.len = refillCount
  }
  // Берём последнюю запись в кэше.
  s := pp.mspancache.buf[pp.mspancache.len-1]
  pp.mspancache.len--
  return s
}

Согласно вышеуказанным двум ситуациям, в конечном итоге можно получить доступный mspan. Наконец, после инициализации mspan, он может быть возвращён:

go
HaveSpan:
  h.initSpan(s, typ, spanclass, base, npages)
  return s

Освобождение

Поскольку mspan выделен связным аллокатором, естественно при освобождении памяти он также освобождается им:

go
func (h *mheap) freeSpanLocked(s *mspan, typ spanAllocType) {
  assertLockHeld(&h.lock)
  // Помечаем пространство как свободное.
  h.pages.free(s.base(), s.npages)
  s.state.set(mSpanDead)
  h.freeMSpanLocked(s)
}

Сначала помечает указанную память страницы как освобождённую через аллокатор страниц mheap.pages, затем устанавливает состояние mspan в mSpanDead, и наконец аллокатор mheap.spanalloc освобождает mspan:

go
func (h *mheap) freeMSpanLocked(s *mspan) {
  assertLockHeld(&h.lock)

  pp := getg().m.p.ptr()
  // Сначала пытаемся освободить mspan напрямую в кэш.
  if pp != nil && pp.mspancache.len < len(pp.mspancache.buf) {
    pp.mspancache.buf[pp.mspancache.len] = s
    pp.mspancache.len++
    return
  }
  // В противном случае (или если у нас нет p), просто освобождаем его в кучу.
  h.spanalloc.free(unsafe.Pointer(s))
}

Если кэш P не заполнен, он будет помещён в локальный кэш P для продолжения использования. В противном случае он будет освобождён обратно в память кучи.

Расширение

Пространство памяти страниц, управляемое heaparena, не всё выделено в начале. Оно выделяется только когда нужна память. Тот, кто отвечает за расширение памяти кучи — метод mheap.grow. Ниже приведён упрощённый код:

go
func (h *mheap) grow(npage uintptr) (uintptr, bool) {
  assertLockHeld(&h.lock)
  ask := alignUp(npage, pallocChunkPages) * pageSize
  totalGrowth := uintptr(0)
  end := h.curArena.base + ask
  nBase := alignUp(end, physPageSize)

  if nBase > h.curArena.end || end < h.curArena.base {
    av, asize := h.sysAlloc(ask, &h.arenaHints, true)
        if uintptr(av) == h.curArena.end {
      h.curArena.end = uintptr(av) + asize
    } else {
      // Переключаемся на новое пространство.
      h.curArena.base = uintptr(av)
      h.curArena.end = uintptr(av) + asize
    }
    nBase = alignUp(h.curArena.base+ask, physPageSize)
  }
  ...
}

Сначала вычисляет требуемую память на основе npage и выравнивает её, затем определяет, достаточно ли памяти в текущем heaparena. Если недостаточно, mheap.sysAlloc запрашивает больше памяти для текущего heaparena или выделяет новый heaparena:

go
func (h *mheap) sysAlloc(n uintptr, hintList **arenaHint, register bool) (v unsafe.Pointer, size uintptr) {
  n = alignUp(n, heapArenaBytes)
  if hintList == &h.arenaHints {
    v = h.arena.alloc(n, heapArenaBytes, &gcController.heapReleased)
    if v != nil {
      size = n
      goto mapped
    }
  }
    ...
}

Сначала пытается использовать линейный аллокатор mheap.arena для запроса куска памяти в предварительно выделенном пространстве памяти. Если не удаётся, расширяет на основе hintList. Тип hintListruntime.arenaHint, который конкретно записывает информацию об адресе, связанную с расширением heaparena:

go
for *hintList != nil {
    hint := *hintList
    p := hint.addr
  v = sysReserve(unsafe.Pointer(p), n)
    if p == uintptr(v) {
        hint.addr = p
        size = n
        break
    }
    if v != nil {
        sysFreeOS(v, n)
    }
    *hintList = hint.next
    h.arenaHintAlloc.free(unsafe.Pointer(hint))
}

После завершения запроса памяти обновляется в двумерный массив arenas:

go
for ri := arenaIndex(uintptr(v)); ri <= arenaIndex(uintptr(v)+size-1); ri++ {
    l2 := h.arenas[ri.l1()]
    var r *heapArena
    r = (*heapArena)(h.heapArenaAlloc.alloc(unsafe.Sizeof(*r), goarch.PtrSize, &memstats.gcMiscSys))
    atomic.StorepNoWB(unsafe.Pointer(&l2[ri.l2()]), unsafe.Pointer(r))
}

Наконец, аллокатор страниц помечает этот кусок памяти как готовый:

go
// Обновляем структуры аллокатора страниц, чтобы сделать это
// пространство готовым для выделения.
h.pages.grow(v, nBase-v)
totalGrowth += nBase - v

Выделение объектов

Когда Go выделяет память для объектов, он делит их на три разных типа на основе размера:

  • Крошечные объекты - tiny, менее 16B
  • Маленькие объекты - small, менее 32KB
  • Большие объекты - large, более 32KB

Согласно этим трём разным типам, выполняется разная логика во время выделения памяти. Функция, отвечающая за выделение памяти объектов — runtime.mallocgc, со следующей сигнатурой функции:

go
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer

Она имеет только три параметра: размер памяти, тип и булево значение, указывающее, нужно ли очищать память. Это входная функция для всего выделения памяти объектов Go. При использовании функции new для создания указателей она также входит в эту функцию. Когда выделение памяти успешно, возвращённый указатель — это адрес объекта. В разделе mspan упоминалось, что каждый mspan имеет spanClass. spanClass определяет фиксированный размер mspan. Go делит диапазон от [0, 32KB] на 68 разных размеров. Поэтому память Go состоит из нескольких связных списков mspan разных фиксированных размеров. При выделении памяти объектов просто вычисляет соответствующий spanClass согласно размеру объекта, затем находит соответствующий связный список mspan согласно spanClass, и наконец ищет доступный mspan из связного списка. Этот иерархический подход может более эффективно решить проблему фрагментации памяти.

Крошечные объекты

Все не указательные крошечные объекты менее 16B выделяются в одну и ту же непрерывную память аллокатором tiny в P. В runtime.mcache поле tiny записывает базовый адрес этой памяти:

go
type mcache struct {
  tiny       uintptr
  tinyoffset uintptr
  tinyAllocs uintptr
}

Размер крошечных объектов определяется константой runtime.maxTinySize, которая равна 16B. Блок памяти, используемый для хранения крошечных объектов, также этого размера. Обычно объекты, хранящиеся здесь — это некоторые небольшие строки. Часть кода, отвечающая за выделение крошечных объектов, следующая:

go
if size <= maxSmallSize {
    if noscan && size < maxTinySize {
      off := c.tinyoffset
      if off+size <= maxTinySize && c.tiny != 0 {
        x = unsafe.Pointer(c.tiny + off)
        c.tinyoffset = off + size
        c.tinyAllocs++
        mp.mallocing = 0
        releasem(mp)
        return x
      }

      // Выделяем новый блок maxTinySize.
      span = c.alloc[tinySpanClass]
      v := nextFreeFast(span)
      if v == 0 {
        v, span, shouldhelpgc = c.nextFree(tinySpanClass)
      }
      x = unsafe.Pointer(v)
      (*[2]uint64)(x)[0] = 0
      (*[2]uint64)(x)[1] = 0

      if (size < c.tinyoffset || c.tiny == 0) {
        c.tiny = uintptr(x)
        c.tinyoffset = size
      }
      size = maxTinySize

Если текущий блок крошечной памяти имеет достаточно пространства для размещения, т.е. off+size <= maxTinySize, напрямую использует текущий блок памяти. Если недостаточно, сначала пытается получить доступное пространство из кэша span mcache. Если это тоже не работает, запрашивает mspan от mcentral. В любом случае в конечном итоге получит доступный адрес, и наконец заменяет старый блок памяти крошечных объектов новым.

Маленькие объекты

Большинство объектов в runtime языка Go — это маленькие объекты в диапазоне [16B, 32KB]. Процесс выделения маленьких объектов самый хлопотный, но кода меньше всего. Часть кода, отвечающая за выделение маленьких объектов, следующая:

go
var sizeclass uint8
if size <= smallSizeMax-8 {
    sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
} else {
    sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
}
size = uintptr(class_to_size[sizeclass])
spc := makeSpanClass(sizeclass, noscan)
span = c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
    v, span, shouldhelpgc = c.nextFree(spc)
}
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
    memclrNoHeapPointers(x, size)
}

Сначала вычисляет, к какой категории spanClass должен использоваться на основе размера объекта. Затем runtime.nextFreeFast пытается получить доступное пространство памяти из соответствующего кэша mspan в mcache согласно spanClass:

go
func nextFreeFast(s *mspan) gclinkptr {
  theBit := sys.TrailingZeros64(s.allocCache) // Есть ли свободный объект в allocCache?
  if theBit < 64 {
    result := s.freeindex + uintptr(theBit)
    if result < s.nelems {
      freeidx := result + 1
      if freeidx%64 == 0 && freeidx != s.nelems {
        return 0
      }
      s.allocCache >>= uint(theBit + 1)
      s.freeindex = freeidx
      s.allocCount++
      return gclinkptr(result*s.elemsize + s.base())
    }
  }
  return 0
}

Роль mspan.allocCache — записывать, используется ли пространство памяти объектами. Он делит память один за другим согласно количеству объектов, а не по размеру пространства. Это эквивалентно просмотру mspan как массива объектов, как показано на рисунке ниже.

allocCache — это 64-битное число. Каждый бит соответствует куску пространства памяти. Если бит равен 0, это указывает, что память используется объектом. Если он равен 1, это указывает, что память свободна. Цель sys.TrailingZeros64(s.allocCache) — вычислить количество конечных нулей. Если результат 64, это указывает, что нет свободной памяти в наличии. Если есть, вычисляет смещение свободной памяти, добавляет базовый адрес mspan и возвращает.

Когда у mcache недостаточно пространства, идёт в mcentral для запроса. Эта работа завершается методом mcache.nextFree:

go
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
  s = c.alloc[spc]
  shouldhelpgc = false
  freeIndex := s.nextFreeIndex()
  if freeIndex == s.nelems {
    c.refill(spc)
    shouldhelpgc = true
    s = c.alloc[spc]

    freeIndex = s.nextFreeIndex()
  }
  v = gclinkptr(freeIndex*s.elemsize + s.base())
  s.allocCount++
  return
}

mcache.refill в нём отвечает за запрос доступного mspan от mcentral:

go
func (c *mcache) refill(spc spanClass) {
  ...
  s = mheap_.central[spc].mcentral.cacheSpan()
  ...
}

Метод mcentral.cacheSpan выполняет расширение через mcentral.grow, когда памяти недостаточно. Расширение запрашивает новый mspan от mheap:

go
func (c *mcentral) grow() *mspan {
  ...
  s := mheap_.alloc(npages, c.spanclass)
  ...
  return s
}

Поэтому в конце выделение памяти маленьких объектов идёт уровень за уровнем: сначала mcache, затем mcentral, и наконец mheap. Стоимость выделения mcache самая низкая, потому что это локальный кэш P, и выделение памяти не требует удержания блокировки. mcentral следующий. Прямой запрос памяти от mheap имеет самую высокую стоимость, потому что метод mheap.alloc конкурирует за глобальную блокировку всей кучи.

Большие объекты

Выделение больших объектов самое простое. Если размер объекта превышает 32KB, напрямую запрашивает новый mspan от mheap для размещения. Часть кода, отвечающая за выделение больших объектов, следующая:

go
shouldhelpgc = true
span = c.allocLarge(size, noscan)
span.freeindex = 1
span.allocCount = 1
size = span.elemsize
x = unsafe.Pointer(span.base())
if needzero && span.needzero != 0 {
    if noscan {
        delayedZeroing = true
    } else {
        memclrNoHeapPointers(x, size)
    }
}

Среди них mcache.allocLarge отвечает за запрос пространства памяти больших объектов от mheap:

go
func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
  ...
  spc := makeSpanClass(0, noscan)
  s := mheap_.alloc(npages, spc)
  ...
  return s
}

Из кода можно видеть, что большие объекты используют значение spanClass равное 0. В основном один большой объект занимает один mspan.

Другое

Статистика памяти

Runtime Go предоставляет пользователям функцию ReadMemStats, которая может быть использована для статистики условий памяти runtime:

go
func ReadMemStats(m *MemStats) {
  _ = m.Alloc // тест проверки на nil перед переключением стеков, см. issue 61158
  stopTheWorld(stwReadMemStats)

  systemstack(func() {
    readmemstats_m(m)
  })

  startTheWorld()
}

Но использование этого имеет очень высокую стоимость. Как видно из кода, требуется STW перед анализом условий памяти. Длительность STW может составлять от нескольких миллисекунд до сотен миллисекунд. Обычно используется только во время отладки и устранения неполадок. Структура runtime.MemStats записывает информацию, связанную с памятью кучи, памятью стека и GC:

go
type MemStats struct {
    // Общая статистика
    Alloc uint64
    TotalAlloc uint64
    Sys uint64
    Lookups uint64
    Mallocs uint64
    Frees uint64

    // Статистика памяти кучи
    HeapAlloc uint64
    HeapSys uint64
    HeapIdle uint64
    HeapInuse uint64
    HeapReleased uint64
    HeapObjects uint64

    // Статистика памяти стека
    StackInuse uint64
    StackSys uint64

    // Статистика компонентов памяти
    MSpanInuse uint64
    MSpanSys uint64
    MCacheInuse uint64
    MCacheSys uint64
    BuckHashSys uint64

    // Статистика, связанная с GC
    GCSys uint64
    OtherSys uint64
    NextGC uint64
    LastGC uint64
    PauseTotalNs uint64
    PauseNs [256]uint64
    PauseEnd [256]uint64
    NumGC uint32
    NumForcedGC uint32
    GCCPUFraction float64
    EnableGC bool
    DebugGC bool

    BySize [61]struct {
        Size uint32
        Mallocs uint64
        Frees uint64
    }
}

NotInHeap

Аллкатор памяти очевидно используется для выделения памяти кучи, но куча делится на две части: одна часть — это память кучи, нужная самому runtime Go, а другая часть — это память кучи, открытая для пользователей. Поэтому в некоторых структурах можно видеть такие встроенные поля:

go
_ sys.NotInHeap

Это указывает, что память этого типа не будет выделена в куче пользователя. Этот вид встроенного поля особенно распространён в компонентах выделения памяти, таких как структура, представляющая кучу пользователя runtime.mheap:

go
type mheap struct {
  _ sys.NotInHeap
}

Реальная роль sys.NotInHeap — избежать барьеров памяти для повышения эффективности runtime, в то время как куча пользователя нуждается в запуске GC, поэтому ей нужны барьеры памяти.

Golang by www.golangdev.cn edit