Golang内部构件,第6部分:自举和内存分配器初始化

这篇文章是我们Golang Internals系列的延续。它探讨了引导过程,这是更详细了解Go运行时的关键。在这一部分中,我们将贯穿开始序列的第二部分,了解如何初始化参数,调用什么函数等。

起始顺序

我们将从上次中断的地方(runtime.rt0_go函数)开始探索。仍然有一部分我们没有看过。

CLD                         // convention is D is always left cleared`
CALL    runtime·check(SB)`
MOVL    16(SP), AX          // copy argc`
MOVL    AX, 0(SP)`
MOVQ    24(SP), AX          // copy argv`
MOVQ    AX, 8(SP)`
CALL    runtime·args(SB)`
CALL    runtime·osinit(SB)`
CALL    runtime·schedinit(SB)`

第一条指令(CLD)清除寄存器的方向标志FLAGS。该标志影响字符串处理的方向。

下一个函数是对该runtime.check函数的调用,这对我们的目的也不是很有价值。运行时仅尝试创建所有内置类型的实例,检查它们的大小和一些其他参数,等等,panics如果出现问题,它将进行检查。您可以自己轻松地探索此功能

分析参数

下一个函数runtime.Args更有趣。在Linux系统上,除了将参数(argcargv)存储在静态变量中外,它还负责分析ELF辅助向量和初始化syscall地址。

这需要一些解释。当操作系统将程序加载到内存中时,它将使用一些预定义格式的数据来初始化该程序的初始堆栈。在堆栈的顶部,放置参数-指向环境变量的指针。在底部,我们可以找到“ ELF辅助向量”,它实际上是一个记录数组,其中包含一些其他有用的信息,例如程序标头的数量和大小。(有关ELF辅助矢量格式的更多信息,请查阅本文

runtime.Args函数负责解析此向量。在其中包含的所有信息中,运行时仅使用startupRandomData,主要用于初始化哈希函数和一些系统调用的指向位置的指针。如下所示,初始化了以下变量。

__vdso_time_sym
__vdso_gettimeofday_sym
__vdso_clock_gettime_sym

它们用于以不同的功能获取当前时间。所有这些变量都有默认值。这允许Golang使用vsyscall机制来调用相应的函数。

runtime.osinitruntime.schedinit功能

在启动序列期间调用的下一个函数是runtime.osinit。在Linux系统上,它唯一要做的就是初始化ncpu保存系统中CPU数量的变量。这是通过syscall完成的。

runtime.schedinit-启动序列中的下一个功能-更有趣。首先从获取当前的goroutine开始,实际上,goroutine是指向g结构的指针。在讨论TLS实现时,我们已经讨论了该指针的存储方式。接下来,它调用runtime.raceinit。我们将省略对的讨论runtime.raceinit,因为在未启用检查竞争条件时通常不会调用此函数。之后,将调用其他一些初始化函数。

让我们一次探索它们。

初始化回溯

runtime.tracebackinit功能是负责初始化回溯。追溯是我们到达当前执行点之前调用的函数堆栈。例如,每次发生恐慌时,我们都可以看到它。由给定的程序计数器通过调用一个名为runtime.gentraceback的函数来生成跟踪。为了使此功能正常工作,我们需要知道一些内置功能的地址(例如,因为我们不希望它们包含在回溯中)。runtime.tracebackinit负责初始化这些地址。

验证链接器符号

链接器符号是链接器向可执行文件和目标文件发出的数据。这些符号的大部分内容已在Golang Internals,第3部分:链接器,目标文件和重定位中进行了讨论。在运行时包中,链接器符号映射到moduledata结构。该runtime.moduledataverify功能是负责对这些数据进行一些检查和验证,它具有正确的结构没有损坏。

初始化堆栈池

要了解下一个初始化步骤,您需要一些有关如何在Go中实现堆栈增长的知识。创建新的goroutine时,会为其分配一个小的固定大小的堆栈。当堆栈达到某个阈值时,其大小将增加一倍,并将堆栈复制到另一个位置。

关于如何确定达到此阈值以及Go如何调整堆栈中的指针,还有很多细节。在讨论stackguard0字段和函数元数据时,我们已经在之前的博客文章中谈到了其中一些内容。您还可以在本文档中找到许多有关此主题的有用信息。

Go使用堆栈池来缓存当前未使用的堆栈。堆栈池是在runtime.stackinit函数中初始化的数组。此数组中的每个项目都包含一个相同大小的堆栈的链接列表。

在此阶段初始化的另一个变量是runtime.stackFreeQueue。它还包含堆栈的链接列表,但是堆栈在垃圾回收期间添加到列表中,并在完成后清除。请注意,仅缓存了2 KB,4 KB,8 KB和16 KB堆栈。较大的直接分配。

初始化内存分配器和大小类

在此源代码注释中描述了内存分配的过程。如果您想了解内存分配的工作原理,我们强烈建议您阅读。内存分配器的初始化位于runtime.mallocinit函数中,因此让我们仔细看一下。

我们在这里看到的第一件事runtime.mallocinit是调用了另一个函数initSizes,该函数负责计算大小类。但是,班级的人数是多少?当分配一个小对象(小于32 KB)时,Go运行时首先将其大小四舍五入为预定义的类大小。因此,分配的内存块只能具有预定义大小之一,该大小通常大于对象本身所需的大小。这将导致少量的内存浪费,但是它使您可以轻松地将已分配的内存块重新用于不同的对象。

initSizes函数负责计算这些类。在此功能的顶部,我们可以看到以下代码。

    align := 8
    for size := align; size <= _MaxSmallSize; size += align {
        if size&(size-1) == 0 { 
            if size >= 2048 {
                align = 256
            } else if size >= 128 {
                align = size / 8
            } else if size >= 16 {
                align = 16 
…
            }
        }

如我们所见,最小的两个大小类是8和16字节。后续类位于每16个字节中,最大为128个字节。从128到2,048字节,类位于每个大小/ 8字节中。在2,048个字节之后,大小类位于每256个字节中。

initSizes方法初始化class_to_size数组,该数组将一个类(此处,按类,是指其在类列表中的索引)转换为其大小。它还会初始化class_to_allocnpages数组,该数组存储有关应从OS获取多少内存页以填充给定类的一个对象的数据以及另外两个数组(size_to_class8size_to_class128)。这些用于将对象大小转换为相应的类索引。第一个用于转换小于1 KB的对象大小,第二个用于1–32 KB的对象大小。

虚拟内存预留

mallocinit功能的下一步是为将来的分配保留虚拟内存。让我们看看如何在x64体系结构上完成此操作。首先,我们需要初始化以下变量:

arenaSize := round(_MaxMem, _PageSize)`
bitmapSize = arenaSize / (ptrSize * 8 / 4)`
spansSize = arenaSize / _PageSize * ptrSize`
spansSize = round(spansSize, _PageSize)`
  • arenaSize是可以为对象分配保留的最大虚拟内存量。在64位体系结构上,它等于512 GB。
  • bitmapSize对应于为垃圾收集器(GC)位图保留的内存量。这是一种特殊的内存类型,用于显示指针在内存中的确切位置以及所指向的对象是否由GC标记。
  • spansSize是保留用于存储指向所有内存范围的指针数组的内存量。内存范围是一种包装用于对象分配的内存块的结构。

一旦计算出所有这些变量,便完成了实际的保留。

pSize = bitmapSize + spansSize + arenaSize + _PageSize` 
p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved))`

最后,我们可以初始化mheap全局变量,该变量用作所有与内存相关的对象的中央存储。

查看源

打印

p1 := round(p, _PageSize)
mheap_.spans = (**mspan)(unsafe.Pointer(p1))
mheap_.bitmap = p1 + spansSize
mheap_.arena_start = p1 + (spansSize + bitmapSize)
mheap_.arena_used = mheap_.arena_start
mheap_.arena_end = p + pSize
mheap_.arena_reserved = reserved

请注意,从一开始mheap_.arena_used就使用与相同的地址进行初始化mheap_.arena_start,因为尚未分配任何内容。

初始化堆

接下来,调用mHeap_Init函数。此处要做的第一件事是分配器初始化。

fixAlloc_Init(&h.spanalloc, unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys)
fixAlloc_Init(&h.cachealloc, unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys)
fixAlloc_Init(&h.specialfinalizeralloc, unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys)
fixAlloc_Init(&h.specialprofilealloc, unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys)

为了更好地理解什么是分配器,让我们看看如何使用它。所有分配器均在fixAlloc_Alloc函数中运行,每次我们想要分配新的mspanmcachespecialfinalizerspecialprofile结构时都调用该函数。此功能的主要部分如下。

if uintptr(f.nchunk) < f.size {
    f.chunk = (*uint8)(persistentalloc(_FixAllocChunk, 0, f.stat))
    f.nchunk = _FixAllocChunk
}

它分配内存,但不是分配结构的实际大小(f.size字节),而是预留_FixAllocChunk字节(当前等于16 KB)。剩余的可用空间存储在分配器中。下次,我们需要分配一个相同类型的结构,它将不需要调用persistentalloc,这可能会很耗时。

persistentalloc函数负责分配不应被垃圾回收的内存。其工作流程如下。

  1. 如果分配的块大于64 KB,则会直接从OS内存中分配。
  2. 否则,我们首先需要找到一个持久分配器:
  • 持久性分配器连接到每个处理器。这样做是为了避免在使用持久分配器时使用锁。因此,我们尝试使用当前处理器中的持久分配器。
  • 如果我们无法获得有关当前处理器的信息,则使用全局系统分配器。
  1. 如果分配器的高速缓存中没有足够的可用内存,则可以从操作系统中预留更多的内存。
  2. 所需的内存量从分配器的缓存中返回。

persistentallocfixAlloc_Alloc以类似的方式职能的工作。可以说这些功能实现了两个缓存级别。您还应该知道,persistentalloc不仅在中使用了该方法fixAlloc_Alloc,还在需要分配持久性内存的许多其他地方使用了该方法。

让我们回到mHeap_Init函数。这里要回答的另一个重要问题是如何使用在此函数开始时已为其初始化了分配器的四个结构:

  • mspan是应该被垃圾回收的内存块的包装器。在讨论大小类时,我们已经讨论过了。一个新的mspan,当我们需要分配一个特定的大小类的新对象被创建。
  • mcache是附加到每个处理器的结构。它负责缓存范围。每个处理器具有单独的缓存的原因是为了避免锁定。
  • specialfinalizeralloc是在runtime.SetFinalizer调用函数时分配的结构。如果我们希望系统在清除对象时执行一些清理代码,则可以执行此操作。一个很好的例子是将os.NewFile终结器与每个新文件相关联的函数。该终结器应关闭OS文件描述符。
  • specialprofilealloc 是内存分析器中使用的结构。

初始化内存分配器后,该mHeap_Init函数通过调用mSpanList_Init来初始化列表,这非常简单。它所做的只是初始化链表的第一个条目。该mheap结构包含一些这样的链接列表。

  • mheap.free并且mheap.busy是包含_空闲_和_繁忙_列表的数组,这些列表具有跨度较大的对象(大于32 KB,但小于1 MB)。这些数组中的每一个在每种可能的大小中都包含一个项目。此处,尺寸以页为单位。一页等于32 KB。第一项包含一个跨度为32 KB的列表,第二项包含一个跨度为64 KB的列表,依此类推。
  • mheap.freelarge并且mheap.busylarge是大于1 MB的对象的忙/闲列表。

下一步是initialize mheap.central,它存储小对象(小于32 KB)的跨度。在中mheap.central,列表根据其大小级别进行分组。初始化与我们之前看到的非常相似。它只是对每个空闲列表的链表的初始化。

初始化缓存

现在,我们几乎完成了内存分配器初始化。mallocinit函数中剩下的最后一件事是mcache初始化。

_g_ := getg()
_g_.m.mcache = allocmcache()

在这里,我们首先获得当前的goroutine。每个goroutine都包含一个指向该m结构的链接,该结构是操作系统线程的包装器。在该结构内部,存在mcache在这些行中初始化的字段。该allocmcache函数调用fixAlloc_Alloc以初始化新的mcache结构。我们已经讨论了如何完成分配以及该结构的含义(请参见上文)。

细心的读者可能会注意到我们之前所说的mcache已连接到每个处理器,但是现在我们看到它已连接到m对应于OS进程而不是处理器的结构。没错-mcache仅针对当前正在执行的那些线程进行初始化,并在发生进程切换时将其重新定位到另一个线程。

在下一篇文章中,我们将通过查看如何初始化垃圾收集器以及如何启动主goroutine继续讨论引导过程。同时,请不要在下面的评论中分享您的想法和建议。

你可能感兴趣的:(golang)