golang [ 内存管理 ]


引言

要搞明白 Go 语言的内存管理,就必须先理解操作系统以及机器硬件是如何管理内存的。因为 Go 语言的内部机制是建立在这个基础之上的,它的设计,本质上就是尽可能的会发挥操作系统层面的优势,而避开导致低效情况。

操作系统内存管理

其实现在计算机内存管理的方式都是一步步演变来的,最开始是非常简单的,后来为了满足各种需求而增加了各种各样的机制,越来越复杂。这里我们只介绍和开发者息息相关的几个机制。

最原始的方式

我们可以把内存看成一个数组,每个数组元素的大小是 1B,也就是 8 位(bit)。CPU 通过内存地址来获取内存中的数据,内存地址可以看做成数组的游标(index)。

CPU 在执行指令的时候,就是通过内存地址,将物理内存上的数据载入到寄存器,然后执行机器指令。但随着发展,出现了多任务的需求,也就是希望多个任务能同时在系统上运行。这就出现了一些问题:

  1. 内存访问冲突:程序很容易出现 bug,就是 2 或更多的程序使用了同一块内存空间,导致数据读写错乱,程序崩溃。更有一些黑客利用这个缺陷来制作病毒。
  2. 内存不够用:因为每个程序都需要自己单独使用的一块内存,内存的大小就成了任务数量的瓶颈。
  3. 程序开发成本高:你的程序要使用多少内存,内存地址是多少,这些都不能搞错,对于人来说,开发正确的程序很费脑子。

举个例子,假设有一个程序,当代码运行到某处时,需要使用 100M 内存,其他时候 1M 内存就够;为了避免和其他程序冲突,程序初始化时,就必须申请独立 100M 内存以保证正常运行,这就是一种很大的浪费,因为这 100M 它大多数时候用不上,其他程序还不能用。

虚拟内存

虚拟内存的出现,很好的为了解决上述的一些列问题。用户程序只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成实际的物理地址。

所有程序统一使用一套连续虚拟地址,比如 0x0000 ~ 0xffff。从程序的角度来看,它觉得自己独享了一整块内存。不用考虑访问冲突的问题。系统会将虚拟地址翻译成物理地址,从内存上加载数据。

对于内存不够用的问题,虚拟内存本质上是将磁盘当成最终存储,而主存作为了一个 cache。程序可以从虚拟内存上申请很大的空间使用,比如 1G;但操作系统不会真的在物理内存上开辟 1G 的空间,它只是开辟了很小一块,比如 1M 给程序使用。
这样程序在访问内存时,操作系统看访问的地址是否能转换成物理内存地址。能则正常访问,不能则再开辟。这使得内存得到了更高效的利用。

如下图所示,每个进程所使用的虚拟地址空间都是一样的,但他们的虚拟地址会被映射到主存上的不同区域,甚至映射到磁盘上(当内存不够用时)。

虚拟地址

其实本质上很简单,就是操作系统将程序常用的数据放到内存里加速访问,不常用的数据放在磁盘上。这一切对用户程序来说完全是透明的,用户程序可以假装所有数据都在内存里,然后通过虚拟内存地址去访问数据。在这背后,操作系统会自动将数据在主存和磁盘之间进行交换。

虚拟地址翻译

虚拟内存的实现方式,大多数都是通过页表来实现的。操作系统虚拟内存空间分成一页一页的来管理,每页的大小为 4K(当然这是可以配置的,不同操作系统不一样)。磁盘和主内存之间的置换也是以为单位来操作的。4K 算是通过实践折中出来的通用值,太小了会出现频繁的置换,太大了又浪费内存。

虚拟地址 -> 物理地址 的映射关系由页表(Page Table)记录,它其实就是一个数组,数组中每个元素叫做页表条目(Page Table Entry,简称 PTE),PTE 由一个有效位和 n 位地址字段构成,有效位标识这个虚拟地址是否分配了物理内存。

页表被操作系统放在物理内存的指定位置,CPU 上有个 Memory Management Unit(MMU) 单元,CPU 把虚拟地址给 MMU,MMU 去物理内存中查询页表,得到实际的物理地址。当然 MMU 不会每次都去查的,它自己也有一份缓存叫Translation Lookaside Buffer (TLB),是为了加速地址翻译。

虚拟地址翻译

你慢慢会发现整个计算机体系里面,缓存是无处不在的,整个计算机体系就是建立在一级级的缓存之上的,无论软硬件。

让我们来看一下 CPU 内存访问的完整过程:

  1. CPU 使用虚拟地址访问数据,比如执行了 MOV 指令加载数据到寄存器,把地址传递给 MMU。
  2. MMU 生成 PTE 地址,并从主存(或自己的 Cache)中得到它。
  3. 如果 MMU 根据 PTE 得到真实的物理地址,正常读取数据。流程到此结束。
  4. 如果 PTE 信息表示没有关联的物理地址,MMU 则触发一个缺页异常。
  5. 操作系统捕获到这个异常,开始执行异常处理程序。在物理内存上创建一页内存,并更新页表。
  6. 缺页处理程序在物理内存中确定一个牺牲页,如果这个牺牲页上有数据,则把数据保存到磁盘上。
  7. 缺页处理程序更新 PTE。
  8. 缺页处理程序结束,再回去执行上一条指令(导致缺页异常的那个指令,也就是 MOV 指令)。这次肯定命中了。

内存命中率

你可能已经发现,上述的访问步骤中,从第 4 步开始都是些很繁琐的操作,频繁的执行对性能影响很大。毕竟访问磁盘是非常慢的,它会引发程序性能的急剧下降。如果内存访问到第 3 步成功结束了,我们就说页命中了;反之就是未命中,或者说缺页,表示它开始执行第 4 步了。

假设在 n 次内存访问中,出现命中的次数是 m,那么 m / n * 100% 就表示命中率,这是衡量内存管理程序好坏的一个很重要的指标。

如果物理内存不足了,数据会在主存和磁盘之间频繁交换,命中率很低,性能出现急剧下降,我们称这种现象叫内存颠簸。这时你会发现系统的 swap 空间利用率开始增高, CPU 利用率中 iowait 占比开始增高。

大多数情况下,只要物理内存够用,页命中率不会非常低,不会出现内存颠簸的情况。因为大多数程序都有一个特点,就是局部性

局部性就是说被引用过一次的存储器位置,很可能在后续再被引用多次;而且在该位置附近的其他位置,也很可能会在后续一段时间内被引用。

前面说过计算机到处使用一级级的缓存来提升性能,归根结底就是利用了局部性的特征,如果没有这个特性,一级级的缓存不会有那么大的作用。所以一个局部性很好的程序运行速度会更快。


golang内存管理

了解操作系统对内存的管理机制后,现在可以去看下 Go 语言是如何利用底层的这些特性来优化内存的。Go 的内存管理基本上参考 tcmalloc 来实现的,只是细节上根据自身的需要做了一些小的优化调整。

Go 的内存是自动管理的,我们可以随意定义变量直接使用,不需要考虑变量背后的内存申请和释放的问题。本文意在搞清楚 Go 在方面帮我们做了什么,使我们不用关心那些复杂内存的问题,还依旧能写出较为高效的程序。

程序动态申请内存空间,是要使用系统调用的,比如 Linux 系统上是调用 mmap 方法实现的。但对于大型系统服务来说,直接调用 mmap 申请内存,会有一定的代价。比如:

  1. 系统调用会导致程序进入内核态,内核分配完内存后(也就是上篇所讲的,对虚拟地址和物理地址进行映射等操作),再返回到用户态。
  2. 频繁申请很小的内存空间,容易出现大量内存碎片,增大操作系统整理碎片的压力。
  3. 为了保证内存访问具有良好的局部性,开发者需要投入大量的精力去做优化,这是一个很重的负担。

如何解决上面的问题呢?有经验的人,可能很快就想到解决方案,那就是我们常说的对象池(也可以说是缓存)。

假设系统需要频繁动态申请内存来存放一个数据结构,比如 [10]int。那么我们完全可以在程序启动之初,一次性申请几百甚至上千个 [10]int。这样完美的解决了上面遇到的问题:

  1. 不需要频繁申请内存了,而是从对象池里拿,程序不会频繁进入内核态
  2. 因为一次性申请一个连续的大空间,对象池会被重复利用,不会出现碎片。
  3. 程序频繁访问的就是对象池背后的同一块内存空间,局部性良好。

这样做会造成一定的内存浪费,我们可以定时检测对象池的大小,保证可用对象的数量在一个合理的范围,少了就提前申请,多了就自动释放。

如果某种资源的申请和回收是昂贵的,我们都可以通过建立资源池的方式来解决,其他比如连接池内存池等等,都是一个思路。

Golang 内存管理

Golang 的内存管理本质上就是一个内存池,只不过内部做了很多的优化。比如自动伸缩内存池大小,合理的切割内存块等等。

内存池 mheap

Golang 的程序在启动之初,会一次性从操作系统那里申请一大块内存作为内存池。这块内存空间会放在一个叫 mheapstruct 中管理,mheap 负责将这一整块内存切割成不同的区域,并将其中一部分的内存切割成合适的大小,分配给用户使用。

我们需要先知道几个重要的概念:

  • page: 内存页,一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。
  • span: 内存块,一个或多个连续的 page 组成一个 span。如果把 page 比喻成工人,span 可看成是小队,工人被分成若干个队伍,不同的队伍干不同的活。
  • sizeclass: 空间规格,每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。使用上面的比喻,就是 sizeclass 标志着 span 是一个什么样的队伍。
  • object: 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大object。假设 object 的大小是 16Bspan 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512object。所谓内存分配,就是分配一个 object 出去。

示意图:

上图中,不同颜色代表不同的 span,不同 spansizeclass 不同,表示里面的 page 将会按照不同的规格切割成一个个等大的 object 用作分配。

使用 Go1.11.5 版本测试了下初始堆内存应该是 64M 左右,低版本会少点。

测试代码:

package main
import "runtime"
var stat runtime.MemStats
func main() {
    runtime.ReadMemStats(&stat)
    println(stat.HeapSys)
}

内部的整体内存布局如下图所示:

  • mheap.spans:用来存储 pagespan 信息,比如一个 span 的起始地址是多少,有几个 page,已使用了多大等等。
  • mheap.bitmap 存储着各个 span 中对象的标记信息,比如对象是否可回收等等。
  • mheap.arena_start: 将要分配给应用程序使用的空间。

再说明下,图中的空间大小,是 Go 向操作系统申请的虚拟内存地址空间,操作系统会将该段地址空间预留出来不做它用;而不是真的创建出这么大的虚拟内存,在页表中创建出这么大的映射关系。

mcentral

用途相同span 会以链表的形式组织在一起。 这里的用途用 sizeclass 来表示,就是指该 span 用来存储哪种大小的对象。比如当分配一块大小为 n 的内存时,系统计算 n 应该使用哪种 sizeclass,然后根据 sizeclass 的值去找到一个可用的 span 来用作分配。其中 sizeclass 一共有 67 种(Go1.5 版本,后续版本可能会不会改变不好说),如图所示:

找到合适的 span 后,会从中取一个 object 返回给上层使用。这些 span 被放在一个叫做 mcentral 的结构中管理。

mheap 将从 OS 那里申请过来的内存初始化成一个大 span(sizeclass=0)。然后根据需要从这个大 span 中切出小 span,放在 mcentral 中来管理。大 spanmheap.freelargemheap.busylarge 等管理。如果 mcentral 中的 span 不够用了,会从 mheap.freelarge 上再切一块,如果 mheap.freelarge 空间不够,会再次从 OS 那里申请内存重复上述步骤。下面是 mheap 和 mcentral 的数据结构:

type mheap struct {
    // other fields
    lock      mutex
    free      [_MaxMHeapList]mspan // free lists of given length, 1M 以下
    freelarge mspan                // free lists length >= _MaxMHeapList, >= 1M
    busy      [_MaxMHeapList]mspan // busy lists of large objects of given length
    busylarge mspan                // busy lists of large objects length >= _MaxMHeapList

    central [_NumSizeClasses]struct { // _NumSizeClasses = 67
        mcentral mcentral
        // other fields
    }
    // other fields
}

// Central list of free objects of a given size.
type mcentral struct {
    lock      mutex // 分配时需要加锁
    sizeclass int32 // 哪种 sizeclass
    nonempty  mspan // 还有可用的空间的 span 链表
    empty     mspan // 没有可用的空间的 span 列表
}

这种方式可以避免出现外部碎片(文章最后面有外部碎片的介绍),因为同一个 span 是按照固定大小分配和回收的,不会出现不可利用的一小块内存把内存分割掉。这个设计方式与现代操作系统中的伙伴系统有点类似。

mcache

如果你阅读的比较仔细,会发现上面的 mcentral 结构中有一个 lock 字段;因为并发情况下,很有可能多个线程同时从 mcentral 那里申请内存的,必须要用锁来避免冲突。

但锁是低效的,在高并发的服务中,它会使内存申请成为整个系统的瓶颈;所以在 mcentral 的前面又增加了一层 mcache。

每一个 mcache 和每一个处理器(P) 是一一对应的,也就是说每一个 P 都有一个 mcache 成员。 Goroutine 申请内存时,首先从其所在的 P 的 mcache 中分配,如果 mcache 没有可用 span,再从 mcentral 中获取,并填充到 mcache 中。

从 mcache 上分配内存空间是不需要加锁的,因为在同一时间里,一个 P 只有一个线程在其上面运行,不可能出现竞争。没有了锁的限制,大大加速了内存分配。

所以整体的内存分配模型大致如下图所示:

其他优化

zero size

有一些对象所需的内存大小是0,比如 [0]int, struct{},这种类型的数据根本就不需要内存,所以没必要走上面那么复杂的逻辑。

系统会直接返回一个固定的内存地址。源码如下:

func mallocgc(size uintptr, typ *_type, flags uint32) unsafe.Pointer {
    // 申请的 0 大小空间的内存
    if size == 0 {
        return unsafe.Pointer(&zerobase)
    }
    //.....
}

测试代码:

package main

import (
    "fmt"
)

func main() {
    var (
        a struct{}
        b [0]int
        c [100]struct{}
        d = make([]struct{}, 1024)
    )
    fmt.Printf("%p\n", &a)
    fmt.Printf("%p\n", &b)
    fmt.Printf("%p\n", &c)
    fmt.Printf("%p\n", &(d[0]))
    fmt.Printf("%p\n", &(d[1]))
    fmt.Printf("%p\n", &(d[1000]))
}
// 运行结果,6 个变量的内存地址是相同的:
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88

大对象

如上面所述,最大的 sizeclass 最大只能存放 32K 的对象。如果一次性申请超过 32K 的内存,系统会直接绕过 mcache 和 mcentral,直接从 mheap 上获取,mheap 中有一个 freelarge 字段管理着超大 span。

总结

内存的释放过程,没什么特别之处。就是分配的返过程,当 mcache 中存在较多空闲 span 时,会归还给 mcentral;而 mcentral 中存在较多空闲 span 时,会归还给 mheap;mheap 再归还给操作系统。这里就不详细介绍了。

总结一下,这种设计之所以快,主要有以下几个优势:

  1. 内存分配大多时候都是在用户态完成的,不需要频繁进入内核态。
  2. 每个 P 都有独立的 span cache,多个 CPU 不会并发读写同一块内存,进而减少 CPU L1 cache 的 cacheline 出现 dirty 情况,增大 cpu cache 命中率。
  3. 内存碎片的问题,Go 是自己在用户态管理的,在 OS 层面看是没有碎片的,使得操作系统层面对碎片的管理压力也会降低。
  4. mcache 的存在使得内存分配不需要加锁。

为什么内存碎片可能影响性能?

Linux 利用 Intel CPU的保护模式,采用页表的方式对内存进行管理。 虚拟线性地址对应着某个页。这之间的对应关系存在于页表之中。 由于几乎每次对虚拟内存中的页面访问都必须先解析页,从而得到物理内存中的对应地址,所以页表操作的性能非常关键。因此,Intel MMU 系统结构中实现了一个TLB(translate lookaside buffer)作为一个将虚拟地址映射到物理地址的硬件缓存,当请求访问一个虚拟地址时,处理器将首先检查TLB是否缓存了该虚拟地址到物理地址的映射,如果命中则直接返回,否则,就需要通过页表搜索需要的物理地址。

TLB很小,只有64 entries 。当内存碎片化后,一个进程的虚拟线性地址空间对应于数量众多的小片的页,TLB不能容纳这么多的页面表项,这就意味在这个进程的运行期内,MMU在寻址时,TLB总是不能命中,而需要不断的更新。这就大大的降低了执行的效率。


  目录