要搞明白 Go 语言的内存管理,就必须先理解操作系统以及机器硬件是如何管理内存的。因为 Go 语言的内部机制是建立在这个基础之上的,它的设计,本质上就是尽可能的会发挥操作系统层面的优势,而避开导致低效情况。
何为内存?
这个是“内存条”,是计算机硬件组成的一个部分,也是真正给我们提供“物理内存”的空间。
如果你的计算机没有这个条条,那么根本谈不上有“内存”之说。
那么“内存”的作用在于什么呢?我们可以将计算机的存储媒介中的处理性能与容量做一个对比,会出现如下的金字塔模型:
可以看出来,处理速度与存储容量是成反比的。那么也就是说,性能越大的计算机硬件他的合理的利用和分配就越重要。
我们只重点看内存与硬盘的对比,因为硬盘的容量是非常廉价的,但是内存目前也可以用到10G级别的使用,但是从处理速度来看的话:
DDR3内存读写速度大概10G每秒(10000M)
固态硬盘速度是300M每秒,是内存的三十分之一
机械硬盘的速度是100M每秒,是内存的百分之一
DDR4内存读写速度大概50G每秒(50000M)
固态硬盘速度是300M每秒,是内存的二百分之一
机械硬盘的速度是100M每秒,是内存的五百分之一
所以我们将大部分程序逻辑临时用的数据,全部都存在内存之中,
比如,变量
、全局变量
、函数跳转地址
、静态库
、执行代码
、临时开辟的内存结构体(对象)
等。
内存为什么需要管理?
当我们希望存储的东西越来越多,也就发现物理内存的容量依然是不够用,那么对物理内存的利用率和合理的分配,管理就变得非常的重要。
1、首先操作系统就会对内存进行非常详细的管理,
2、其次基于操作系统的基础上,不同语言的内存管理机制也应允而生,但是有的一些语言并没有提供自动的内存管理模式,有的语言就已经提供了自身程序的内存管理模式:
内存自动管理的语言(部分) | 非自动管理的语言(部分) |
---|---|
Golang | C |
Java | C++ |
Python | Rust |
所以为了降低内存管理的难度,像C、C++完全将分配和回收内存的权限交给开发者,而Rust则是通过生命周期限定开发者对非法权限内存的访问来自动回收,因而并没有提供自动管理的一套机制。但是像Golang、Java、Python这类为了完全让开发则关注代码逻辑本身,语言层提供了一套管理模式。
既然Golang给开发者提供了一套内存管理模式,我们现在就应该看看他究竟帮助我们做了哪些好事? 先不要着急,因为在我们理解Golang语言层内存管理之前,我们得先了解 操作系统针对物理内存做了哪些管理的方式。
那么我们接下来需要理解一下,当我们插上内存条之后,通过操作系统是如何将我们的软件数据,最终存放在这个绿色的条子中去呢?
操作系统是如何管理内存的?
刚才我们说了,我们计算机对于内存真正的载体是“内存条”,这个是实打实的物理硬件容量,所以,在操作系统中,我们定义为这部门的容量叫“物理内存”。
物理内存的布局实际上就是一个内存大“数组”。
每一个元素都会对应一个地址,这个我们称之为物理内存地址。那么cpu在运算的过程中,如果需要从内存中取1个字节的数据,就需要机制这个数据的物理内存地址就好了,而且物理内存地址是连续的,可以根据一个基准地址进行偏移来取得相应的一块连续内存数据。
但我们知道,我们的一个操作系统是不可能只运行一个程序的,那么这个“大数组”物理内存势必要被n个程序分成N分,供每个程序使用。
但是程序是“活”的,他可能一会需要1G内存,一会需要1MB内存,我们只能取这个程序允许的最大内存极限来分配内存给这个进程,那么很显然,每个进程都会多要去一大部分内存,却不常使用。
但如果N个程序同时使用同一块内存,那么读写的冲突也在所难免。
这些昂贵的内存条,几乎跑不了几个程序,内存的利用率也提高不上来。
所以就需要所谓的操作系统的内存管理方式了, 他就是“虚拟内存”。
虚拟内存
对比上个图,你可以大致理解为虚拟内存的表现方式如下:
这样,用户程序(进程)只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成实际的物理地址。
并且这里面每一个程序统一使用一套连续虚拟地址,比如 0x 0000 0000 ~ 0x ffff ffff。从程序的角度来看,它觉得自己独享了一整块内存。不用考虑访问冲突的问题。系统会将虚拟地址翻译成物理地址,从内存上加载数据。
虚拟内存的目的一共是为了解决以下几件事:
- 物理内存无法被最大化利用。
- 程序逻辑内存空间使用独立。
- 内存不够,继续虚拟磁盘空间
对于1,2两点,上述应该已经有一定的描述了,其中针对1的“最大化" 虚拟内存还实现了”读时共享,写时复制”的机制,可以在物理层同一个字节的内存地址被多个虚拟内存空间映射,如下:
如果一个内存几乎大量都是被读取的,那么可能会多个进程共享同一块物理内存,但是他们的各自虚拟内存是不同的。当然这个共享并不是永久的,当其中有一个进程对这个内存发生写,就会复制一份,执行写操作的进程就会将虚拟内存地址映射到新的物理内存地址上。
对于第3点,就是虚拟内存为了最大化利用物理内存,但是如果进程使用的内存足够大,导致物理内存短暂的“供不应求”,那么虚拟内存也会“开疆拓土”从磁盘(硬盘)上虚拟出一定量的空间,挂在虚拟地址上,当然,这个偷摸的动作,进程本身是不知道的,因为进程只能够看见自己的虚拟内存空间。
MMU内存管理单元
那么对于虚拟内存地址是如何映射到物理内存地址上的呢?难道就是硬代码写死的吗?这样会不会出现很多虚拟内存打到同一个物理内存上,然后发现被占用再重新打,这样貌似对映射的寻址的代价有些大,所以操作系统又加了一层专门用来管理虚拟内存和物理内存映射关系的东西,就是MMU(Memory Management Unit).
MMU是在CPU里的,或者说是CPU具有一个内存管理单元MMU。
虚拟内存本身怎么存放
虚拟内存本身是通过一个叫页表(Page Table)
的东西来实现的,这里就要介绍两个词页
和页表
。
页:
实际上就是操作系统的一个用来描述"内存大小"的一个单位名称。 我们称为一个"页"的含义是 大小为"4K(1024*4=4096字节)"的内存空间。操作系统对虚拟内存空间是按照这个单位来管理的。
页表:
页表实际上就是"页"的集合,就是基于"页"的一个数组, 但是页只是表示内存的大小,而页表条目(PTE), 才是页表数组中的一个元素。
虚拟内存的实现方式,大多数都是通过页表来实现的。操作系统虚拟内存空间分成一页一页的来管理,每页的大小为 4K(当然这是可以配置的,不同操作系统不一样)。磁盘和主内存之间的置换也是以页为单位来操作的。4K 算是通过实践折中出来的通用值,太小了会出现频繁的置换,太大了又浪费内存。
虚拟地址 -> 物理地址 的映射关系由页表(Page Table)记录,它其实就是一个数组,数组中每个元素叫做页表条目(Page Table Entry,简称 PTE),PTE 由一个有效位和 n 位地址字段构成,有效位标识这个虚拟地址是否分配了物理内存。
页表被操作系统放在物理内存的指定位置,CPU 上有个 Memory Management Unit(MMU) 单元,CPU 把虚拟地址给 MMU,MMU 去物理内存中查询页表,得到实际的物理地址。当然 MMU 不会每次都去查的,它自己也有一份缓存叫Translation Lookaside Buffer (TLB),是为了加速地址翻译。
虚拟地址翻译
你慢慢会发现整个计算机体系里面,缓存是无处不在的,整个计算机体系就是建立在一级级的缓存之上的,无论软硬件。
来看一下 CPU 内存访问的完整过程:
- CPU 使用虚拟地址访问数据,比如执行了 MOV 指令加载数据到寄存器,把地址传递给 MMU。
- MMU 生成 PTE 地址,并从主存(或自己的 Cache)中得到它。
- 如果 MMU 根据 PTE 得到真实的物理地址,正常读取数据。流程到此结束。
- 如果 PTE 信息表示没有关联的物理地址,MMU 则触发一个缺页异常。
- 操作系统捕获到这个异常,开始执行异常处理程序。在物理内存上创建一页内存,并更新页表。
- 缺页处理程序在物理内存中确定一个牺牲页,如果这个牺牲页上有数据,则把数据保存到磁盘上。
- 缺页处理程序更新 PTE。
- 缺页处理程序结束,再回去执行上一条指令(导致缺页异常的那个指令,也就是 MOV 指令)。这次肯定命中了。
内存命中率:
你可能已经发现,上述的访问步骤中,从第 4 步开始都是些很繁琐的操作,频繁的执行对性能影响很大。毕竟访问磁盘是非常慢的,它会引发程序性能的急剧下降。如果内存访问到第 3 步成功结束了,我们就说页命中了;反之就是未命中,或者说缺页,表示它开始执行第 4 步了。
假设在 n 次内存访问中,出现命中的次数是 m,那么 m / n * 100% 就表示命中率,这是衡量内存管理程序好坏的一个很重要的指标。
如果物理内存不足了,数据会在主存和磁盘之间频繁交换,命中率很低,性能出现急剧下降,我们称这种现象叫内存颠簸。这时你会发现系统的 swap 空间利用率开始增高, CPU 利用率中 iowait 占比开始增高。
大多数情况下,只要物理内存够用,页命中率不会非常低,不会出现内存颠簸的情况。因为大多数程序都有一个特点,就是局部性。
局部性就是说被引用过一次的存储器位置,很可能在后续再被引用多次;而且在该位置附近的其他位置,也很可能会在后续一段时间内被引用。
前面说过计算机到处使用一级级的缓存来提升性能,归根结底就是利用了局部性的特征,如果没有这个特性,一级级的缓存不会有那么大的作用。所以一个局部性很好的程序运行速度会更快。
CPU Cache
随着技术发展,CPU 的运算速度越来越快,但内存访问的速度却一直没什么突破。最终导致了 CPU 访问主存就成了整个机器的性能瓶颈。CPU Cache 的出现就是为了解决这个问题,在 CPU 和 主存之间再加了 Cache,用来缓存一块内存中的数据,而且还不只一个,现代计算机一般都有 3 级 Cache,其中 L1 Cache 的访问速度和寄存器差不多。
现在访问数据的大致的顺序是 CPU --> L1 Cache --> L2 Cache --> L3 Cache --> 主存 --> 磁盘。从左到右,访问速度越来越慢,空间越来越大,单位空间(比如每字节)的价格越来越低。
在这种架构下,缓存的命中率就更加重要了,因为系统会假定所有程序都是有局部性特征的。如果某一级出现了未命中,他就会将该级存储的数据更新成最近使用的数据。
主存与存储器之间以 page(通常是 4K) 为单位进行交换,cache 与 主存之间是以 cache line(通常 64 byte) 为单位交换的。
举个例子
func Loop(nums []int, step int) {
l := len(nums)
for i := 0; i < step; i++ {
for j := i; j < l; j += step {
nums[j] = 4
}
}
}
参数 step 为 1 时,和普通一层循环一样。假设 step 为 2 ,则效果就是跳跃式遍历数组,如 1,3,5,7,9,2,4,6,8,10 这样,step 越大,访问跨度也就越大,程序的局部性也就越不好。
下面是 nums 长度为 10000, step = 1 和 step = 16 时的压测结果:
goos: darwin
goarch: amd64
BenchmarkLoopStep1-4 300000 5241 ns/op
BenchmarkLoopStep16-4 100000 22670 ns/op
可以看出,2 种遍历方式会出现 3 倍的性能差距。这种问题最容易出现在多维数组的处理上,比如遍历一个二维数组很容易就写出局部性很差的代码。
程序的内存布局
最后看一下程序的内存布局。现在我们知道了每个程序都有自己一套独立的地址空间可以使用,比如 0x0000 ~ 0xffff,但我们在用高级语言,无论是 C 还是 Go 写程序的时候,很少直接使用这些地址。我们都是通过变量名来访问数据的,编译器会自动将我们的变量名转换成真正的虚拟地址。
那最终编译出来的二进制文件,是如何被操作系统加载到内存中并执行的呢?
其实,操作系统已经将一整块内存划分好了区域,每个区域用来做不同的事情。
内存布局
- text 段:存储程序的二进制指令,及其他的一些静态内容
- data 段:用来存储已被初始化的全局变量。比如常量(const)。
- bss 段:用来存放未被初始化的全局变量。和 .data 段一样都属于静态分配,在这里面的变量数据在编译就确定了大小,不释放。
- stack 段:栈空间,主要用于函数调用时存储临时变量的。这部分的内存是自动分配自动释放的。
- heap 段:堆空间,用于动态分配,C 语言中 malloc 和 free 操作的内存就在这里;Go 语言主要靠 GC 自动管理这部分。
这里只需要记住堆空间和栈空间即可。
- 栈空间是通过压栈出栈方式自动分配释放的,由系统管理,使用起来高效无感知。
- 堆空间是用以动态分配的,由程序自己管理分配和释放。Go 语言虽然可以帮我们自动管理分配和释放,但是代价也是很高的。
小结
局部性好的程序,可以提高缓存命中率,这对底层系统的内存管理是很友好的,可以提高程序的性能。CPU Cache 层面的低命中率导致的是程序运行缓慢,内存层面的低命中率会出现内存颠簸,出现这种现象时你的服务基本上已经瘫痪了。Go 语言的内存管理是参考 tcmalloc 实现的,它其实就是利用好了 OS 管理内存的这些特点,来最大化内存分配性能的。