Skip to content

memory

على عكس c/c++ التقليدية، go هي لغة ذات GC، في معظم الحالات تتم إدارة تخصيص وتحرير الذاكرة تلقائيًا بواسطة go، ما إذا كانت ذاكرة الكائن يجب تخصيصها على المكدس أم الكومة يقرره المترجم، ولا يحتاج المستخدم للمشاركة في إدارة الذاكرة تقريبًا، كل ما على المستخدم فعله هو استخدام الذاكرة. في go، تتكون إدارة ذاكرة الكومة بشكل رئيسي من مكونين كبيرين: مخصص الذاكرة المسؤول عن تخصيص ذاكرة الكومة، وجامع القمامة المسؤول عن استعادة وتحرير ذاكرة الكومة غير المستخدمة. يتحدث هذا المقال بشكل رئيسي عن طريقة عمل مخصص الذاكرة، تأثر مخصص ذاكرة go بشكل كبير بمخصص ذاكرة TCMalloc من جوجل.

المخصص

في go هناك نوعان من مخصصات الذاكرة، أحدهما المخصص الخطي، والآخر هو المخصص السلسلي.

التخصيص الخطي

المخصص الخطي يقابله البنية runtime.linearAlloc، كما هو موضح أدناه

go
type linearAlloc struct {
  next   uintptr // next free byte
  mapped uintptr // one byte past end of mapped space
  end    uintptr // end of reserved space

  mapMemory bool // transition memory from Reserved to Ready if 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-bit كوظيفة للتخصيص المسبق للذاكرة.

التخصيص السلسلي

المخصص السلسلي يقابله البنية runtime.fixalloc، الذاكرة التي يخصصها المخصص السلسلي غير متصلة، وتوجد على شكل قائمة أحادية. يتكون المخصص السلسلي من عدة كتل ذاكرة بحجم ثابت، وكل كتلة ذاكرة تتكون من عدة شرائح ذاكرة بحجم ثابت، عند كل عملية تخصيص ذاكرة، تُستخدم شريحة ذاكرة بحجم ثابت.

go
type fixalloc struct {
  size   uintptr
  first  func(arg, p unsafe.Pointer) // called first time p is returned
  arg    unsafe.Pointer
  list   *mlink
  chunk  uintptr // use uintptr instead of unsafe.Pointer to avoid write barriers
  nchunk uint32  // bytes remaining in current chunk
  nalloc uint32  // size of new chunks in bytes
  inuse  uintptr // in-use bytes now
  stat   *sysMemStat
  zero   bool // zero allocations
}

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     // next span in list, or nil if none
    prev *mspan     // previous span in list, or nil if none

    startAddr uintptr // address of first byte of span aka s.base()
    npages    uintptr // number of pages in span
    freeindex uintptr

    spanclass             spanClass     // size class and noscan (uint8)
    needzero              uint8         // needs to be zeroed before allocation
    elemsize              uintptr       // computed from sizeclass or from npages
    limit                 uintptr       // end of data in span
    state                 mSpanStateBox // mSpanInUse etc; accessed atomically (get/set methods)

    nelems uintptr // number of object in the span.
    allocCache uint64
    allocCount            uint16        // number of allocated objects
    ...
}

الروابط بين mspan و mspan على شكل قائمة ثنائية الاتجاه عبر next و prev، وعناوين الذاكرة غير متصلة. كل mspan يدير mspan.npages صفحة ذاكرة بحجم runtime.pageSize، عادةً حجم الصفحة هو 8KB، ويُسجل بواسطة mspan.startAddr عنوان بداية هذه الصفحات وبواسطة mspan.limit عنوان نهاية الذاكرة المستخدمة. حجم العناصر التي يخزنها كل mspan elemsize ثابت، لذا عدد العناصر التي يمكن استيعابها ثابت أيضًا. وبسبب ثبات العدد، تخزين الكائنات يشبه توزيع المصفوفة في mspan، النطاق [0, nelems]، ويُسجل بواسطة freeindex الفهرس التالي المتاح لتخزين كائن. لـ mspan ثلاث حالات إجمالًا:

  • mSpanDead، الذاكرة حُررت
  • mSpanInUse، خُصصت على الكومة
  • mSpanManual، خُصصت لجزء يُدار يدويًا، مثل المكدس.

ما يحدد حجم عناصر mspan هو spanClass، spanClass نفسه عدد صحيح من النوع uint8، البتات السبع العليا تخزن قيمة class من 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 يمكن الحصول على حجم كائن mspan عبر runtime.class_to_size، والحصول على عدد صفحات mspan عبر class_to_allocnpages.

classحجم الكائن الأقصىحجم spanعدد الكائناتهدر الذيلأقصى نسبة هدرأدنى محاذاة
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
}

على منصة windows 64-bit، البعد الأول للمصفوفة هو 1 << 6، والبعد الثاني هو 1 << 16، على منصة linux 64-bit، البعد الأول هو 1، والبعد الثاني هو 1 << 22. هذه المصفوفة ثنائية الأبعاد المكونة من كل heaparena تشكل مساحة الذاكرة الافتراضية لبيئة تشغيل go، بشكل عام كما في الشكل التالي.

رغم أن heaparena متجاورة، إلا أن ذاكرة الصفحات التي تديرها غير متصلة.

mcache

mcache يقابله البنية runtime.mcache، ظهرت بالفعل في مقال الجدولة المتزامنة، رغم أن اسمها mcache لكنها في الواقع مرتبطة بالمعالج P. mcache هو مخبأ الذاكرة على كل معالج P، يحتوي على مصفوفة قوائم mspan المسماة alloc، حجم المصفوفة ثابت عند 136، وهو ضعف عدد spanClass بالضبط، ومخبأ الكائنات الدقيقة tiny، حيث tiny يشير لعنوان بداية ذاكرة الكائنات الدقيقة، وtinyoffset هو الإزاحة بالنسبة لعنوان البداية للذاكرة الحرة، وtinyAllocs ي表示 عدد الكائنات الدقيقة المخصصة. لمخبأ المكدس stackcached، يمكن مراجعة تخصيص ذاكرة المكدس.

go
type mcache struct {
    _ sys.NotInHeap

    nextSample uintptr // trigger heap sample after allocating this many bytes
    scanAlloc  uintptr // bytes of scannable heap allocated
    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) {
  // Return the current cached span to the central lists.
  s := c.alloc[spc]

  // Get a new cached span from the central lists.
  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، عندما لا تكفي الذاكرة يطلب من mheap تخصيص mspan جديد. عمل تخصيص mspan لـ mcache تُنجزه طريقة mcentral.cacheSpan. أولًا يبحث عن mspan متاح في مجموعة الممسوحة في القائمة الحرة.

go
// Try partial swept spans first.
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 تطلب من mheap تخصيص mspan جديد.

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

في الحالات العادية، مهما كان سيعاد mspan متاح.

go
havespan:
  freeByteBase := s.freeindex &^ (64 - 1)
  whichByte := freeByteBase / 8
  // Init alloc bits cache.
  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

runtimme.mheap هو مدير ذاكرة الكومة في لغة go، في وقت التشغيل يوجد كمتغير عام runtime.mheap_.

go
var mheap_ mheap

يدير كل mspan المُنشأ، وكل mcentral، وكل heaparena، والعديد من المخصصات المختلفة الأخرى، بنيته المبسطة كالتالي

go
type mheap struct {
    _ sys.NotInHeap

    lock mutex

    allspans []*mspan // all spans out there

    pagesInUse         atomic.Uintptr // pages of spans in stats mSpanInUse
    pagesSwept         atomic.Uint64  // pages swept this cycle
    pagesSweptBasis    atomic.Uint64  // pagesSwept to use as the origin of the sweep ratio

    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 // page allocation data structure
    spanalloc              fixalloc // allocator for span*
    cachealloc             fixalloc // allocator for mcache*
    specialfinalizeralloc  fixalloc // allocator for specialfinalizer*
    specialprofilealloc    fixalloc // allocator for specialprofile*
    specialReachableAlloc  fixalloc // allocator for specialReachable
    specialPinCounterAlloc fixalloc // allocator for specialPinCounter
    arenaHintAlloc         fixalloc // allocator for arenaHints
}

بالنسبة لـ mheap، في وقت التشغيل له أربعة أعمال رئيسية:

  • تهيئة الكومة
  • تخصيص mspan
  • تحرير mspan
  • توسيع الكومة

فيما يلي شرح لهذه الأربعة بالترتيب.

التهيئة

تهيئة الكومة تكون في مرحلة توجيه البرنامج، وهي أيضًا مرحلة تهيئة المجدول، ترتيب الاستدعاء كالتالي

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 the cache is empty, refill it.
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()
  // If we don't have a p or the cache is empty, we can't do
  // anything here.
  if pp == nil || pp.mspancache.len == 0 {
    return nil
  }
  // Pull off the last entry in the cache.
  s := pp.mspancache.buf[pp.mspancache.len-1]
  pp.mspancache.len--
  return s
}

إذا كانت الذاكرة المطلوبة كبيرة، ستُخصص الذاكرة على الكومة، هذه العملية تحتاج قفل

go
lock(&h.lock)
if base == 0 {
    // Try to acquire a base address.
    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 {
    // We failed to get an mspan earlier, so grab
    // one now that we have the heap lock.
    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 {
    // We don't have a p so just do the normal thing.
    return (*mspan)(h.spanalloc.alloc())
  }
  // Refill the cache if necessary.
  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
  }
  // Pull off the last entry in the cache.
  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)
  // Mark the space as free.
  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()
  // First try to free the mspan directly to the cache.
  if pp != nil && pp.mspancache.len < len(pp.mspancache.buf) {
    pp.mspancache.buf[pp.mspancache.len] = s
    pp.mspancache.len++
    return
  }
  // Failing that (or if we don't have a p), just free it to
  // the heap.
  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 {
      // Switch to the new space.
      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، نوع hintList هو runtime.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
// Update the page allocator's structures to make this
// space ready for allocation.
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 تُخصص بواسطة المخصص الدقيق في 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
      }

      // Allocate a new maxTinySize block.
      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، مهما كان في النهاية يُحصل على عنوان متاح، أخيرًا تُستبدل كتلة الذاكرة الدقيقة القديمة بالجديدة.

الكائنات الصغيرة

معظم الكائنات في بيئة تشغيل 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 حسب spanClass يحاول الحصول على مساحة ذاكرة متاحة من mspan المخبأ المقابل في mcache.

go
func nextFreeFast(s *mspan) gclinkptr {
  theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the 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-bit، كل bit يقابله مساحة ذاكرة، إذا كان bit معين 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، يُطلب مباشرة من mheap تخصيص mspan جديد للاستيعاب، الكود المسؤول عن تخصيص الكائنات الكبيرة كالتالي.

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 واحد.

أخرى

إحصائيات الذاكرة

بيئة تشغيل go تكشف للمستخدم دالة ReadMemStats، يمكن استخدامها لإحصاء حالة الذاكرة في وقت التشغيل.

go
func ReadMemStats(m *MemStats) {
  _ = m.Alloc // nil check test before we switch stacks, see 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

مخصص الذاكرة بوضوح يُستخدم لتخصيص ذاكرة الكومة، لكن الكومة تُقسم لجزأين، جزء لذاكرة الكومة التي تحتاجها بيئة تشغيل go نفسها، وجزء مفتوح للمستخدم لاستخدامه. لذا في بعض الهياكل نرى مثل هذا الحقل المضمن

go
_ sys.NotInHeap

يعني أن ذاكرة هذا النوع لن تُخصص على كومة المستخدم، هذا الحقل المضمن شائع جدًا في مكونات تخصيص الذاكرة، مثل البنية التي تمثل كومة المستخدم runtime.mheap

go
type mheap struct {
  _ sys.NotInHeap
}

الغرض الحقيقي من sys.NotInHeap هو تجنب حواجز الذاكرة لتحسين كفاءة وقت التشغيل، بينما كومة المستخدم تحتاج لتشغيل GC لذا تحتاج حواجز الذاكرة.

Golang تم تحريره بواسطة www.golangdev.cn