一站式Golang内存管理洗髓经

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

本章节会围绕以下六个话题逐步展开。

(1)何为内存。

(2)内存为什么需要管理。

(3)操作系统是如何管理内存的。

(4)如何用Golang自己实现一个内存管理模型。

(5)Golang内存管理之魂:TCMalloc。

(6)Golang中是如何管理内存的。

1 何为内存

说到内存,即使没有任何的软件基础知识,那么第一印象应该想到的是如下实物,如图1所示。

图1 物理内存条

图1中常被称之为内存条,是计算机硬件组成的一个部分,也是真正给软件提供内存的物理空间。如果计算机没有内存条,那么根本谈不上有内存之说。

那么内存的作用在于什么呢?如果将计算机的存储媒介中的处理性能与容量做一个对比,会出现如下的金字塔模型,如图2所示。

图2 计算机存储媒介金字塔模型

从图中可以得出处理速度与存储容量是成反比的。也就是说,性能越大的计算机硬件资源,越是稀缺,所以合理的利用和分配就越重要。

比如内存与硬盘的对比,因为硬盘的容量是非常廉价的,虽然内存目前也可以用到10G级别的使用,但是从处理速度来看的话,两者的差距还是相差甚大的,具体如表1所示。

表1 硬盘与内存对比表

DDR3内存读写速度大概10G/s10000M

DDR4内存读写速度大概50G/s50000M

固态硬盘速度是300M/s,是内存的三十分之一

固态硬盘速度是300M/s,是内存的二百分之一

机械硬盘的速度是100M/s,是内存的百分之一

机械硬盘的速度是100M/s,是内存的五百分之一

所以将大部分程序逻辑临时用的数据,全部都存在内存之中,比如,变量、全局变量、函数跳转地址、静态库、执行代码、临时开辟的内存结构体(对象)等。

2 内存为什么需要管理

当存储的东西越来越多,也就发现物理内存的容量依然是不够用,那么对物理内存的利用率和合理的分配,管理就变得非常的重要。

(1)操作系统就会对内存进行非常详细的管理。

(2)基于操作系统的基础上,不同语言的内存管理机制也应允而生,有的一些语言并没有提供自动的内存管理模式,有的语言就已经提供了自身程序的内存管理模式,如表2所示。

表2 自动与非自动内存管理的语言

内存自动管理的语言(部分)

内存非自动管理的语言(部分)

Golang

C

Java

C++

Python

Rust

所以为了降低内存管理的难度,像C、C++这样的编程语言会完全将分配和回收内存的权限交给开发者,而Rust则是通过生命周期限定开发者对非法权限内存的访问来自动回收,因而并没有提供自动管理的一套机制。但是像Golang、Java、Python这类为了完全让开发则关注代码逻辑本身,语言层提供了一套管理模式。因为Golang编程语言给开发者提供了一套内存管理模式,所以开发者有必要了解一下Golang做了哪些助力的功能。

在理解Golang语言层内存管理之前,应先了解操作系统针对物理内存做了哪些管理的方式。当插上内存条之后,通过操作系统是如何将软件存放在这个绿色的物理内存条中去的。

3 操作系统是如何管理内存的

计算机对于内存真正的载体是物理内存条,这个是实打实的物理硬件容量,所以在操作系统中定义这部门的容量叫物理内存。

实则物理内存的布局实际上就是一个内存大数组,如图3所示。

图3 物理内存布局

每一个元素都会对应一个地址,称之为物理内存地址。那么CPU在运算的过程中,如果需要从内存中取1个字节的数据,就需要基于这个数据的物理内存地址去运算即可,而且物理内存地址是连续的,可以根据一个基准地址进行偏移来取得相应的一块连续内存数据。

一个操作系统是不可能只运行一个程序的,那么这个大数组物理内存势必要被N个程序分成N分,供每个程序使用。但是程序是活的,一个程序可能一会需要1MB的内存,一会又需要1GB的内存。操作系统只能取这个程序允许的最大内存极限来分配内存给这个进程,但这样会导致每个进程都会多要去一大部分内存,而这些多要的内存却大概率不会被使用,如图4所示。

图4 物理内存分配的困局

当N个程序同时使用同一块内存时,那么产生读写的冲突也在所难免。这样就会导致这些昂贵的物理内存条,几乎跑不了几个程序,内存的利用率也就提高不上来。

所以就引出了操作系统的内存管理方式,操作系统提供了虚拟内存来解决这件事。

3.1 虚拟内存

所谓虚拟,类似是假、凭空而造的大致意思。对比上图3.3所示的物理内存布局,虚拟内存的大致表现方式如图5所示。

图5 虚拟内存布局

虚拟内存地址是基于物理内存地址之上凭空而造的一个新的逻辑地址,而操作系统暴露给用户进程的只是虚拟内存地址,操作系统内部会对虚拟内存地址和真实的物理内存地址做映射关系,来管理地址的分配,从而使物理内存的利用率提高。

这样用户程序(进程)只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成实际的物理地址。这里面每一个程序统一使用一套连续虚拟地址,比如 0x 0000 0000 ~ 0x ffff ffff。从程序的角度来看,它觉得自己独享了一整块内存,且不用考虑访问冲突的问题。系统会将虚拟地址翻译成物理地址,从内存上加载数据。

但如果仅仅把虚拟内存直接理解为地址的映射关系,那就是过于低估虚拟内存的作用了。

虚拟内存的目的是为了解决以下几件事:

(1)物理内存无法被最大化利用。

(2)程序逻辑内存空间使用独立。

(3)内存不够,继续虚拟磁盘空间。

对于(1),(2)两点,上述应该已经有一定的描述了,其中针对(1)的最大化,虚拟内存还实现了“读时共享,写时复制”的机制,可以在物理层同一个字节的内存地址被多个虚拟内存空间映射,表现方式如图6所示。

图6 读时共享,写时复制

上图所示如果一个进程需要进行写操作,则这个内存将会被复制一份,成为当前进程的独享内存。如果是读操作,可能会多个进程访问的物理空间是相同的空间。

如果一个内存几乎大量都是被读取的,则可能会多个进程共享同一块物理内存,但是他们的各自虚拟内存是不同的。当然这个共享并不是永久的,当其中有一个进程对这个内存发生写,就会复制一份,执行写操作的进程就会将虚拟内存地址映射到新的物理内存地址上。

对于第(3)点,是虚拟内存为了最大化利用物理内存,如果进程使用的内存足够大,则导致物理内存短暂的供不应求,那么虚拟内存也会“开疆拓土”从磁盘(硬盘)上虚拟出一定量的空间,挂在虚拟地址上,而且这个动作进程本身是不知道的,因为进程只能够看见自己的虚拟内存空间,如图7所示。

图7 虚拟内存从磁盘映射空间

综上可见虚拟内存的重要性,不仅提高了利用率而且整条内存调度的链路完全是对用户态物理内存透明,用户可以安心的使用自身进程独立的虚拟内存空间进行开发。

3.2 MMU内存管理单元

那么对于虚拟内存地址是如何映射到物理内存地址上的呢?会不会是一个固定匹配地址逻辑处理的?假设使用固定匹配地址逻辑做映射,可能会出现很多虚拟内存打到同一个物理内存上,如果发现被占用,则会再重新打。这样对映射地址寻址的代价极大,所以操作系统又加了一层专门用来管理虚拟内存和物理内存映射关系的东西,就是MMU(Memory Management Unit),如图8所示。

图 8 MMU内存管理单元

MMU是在CPU里的,或者说是CPU具有一个内存管理单元MMU,下面来介绍一下MMU具体的管理逻辑。

3.3虚拟内存本身怎么存放

虚拟内存本身是通过一个叫页表(Page Table)的东西来实现的,接下来介绍页和页表这两个概念。

1.

页是操作系统中用来描述内存大小的一个单位名称。一个页的含义是大小为4K(1024*4=4096字节)的内存空间。操作系统对虚拟内存空间是按照这个单位来管理的。

2.页表

页表实际上就是页的集合,就是基于页的一个数组。页只是表示内存的大小,而页表条目(PTE[1], 才是页表数组中的一个元素。

为了方便读者理解,下面用一个抽象的图来表示页、页表、和页表元素PTE的概念和关系,如图9所示。

图 9 页、页表、PTE之间的关系

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

虚拟内存到物理内存的映射关系的存储结构就是由类似上述图3.9中的页表记录,实则是一个数组。这里要注意的是,页是一次读取的内存单元,但是真正起到虚拟内存寻址的是PTE,也就是页表中的一个元素。PTE的大致内部结构如图10所示。

图 10 PTE内部构造

可以看出每个PTE是由一个有效位和一个包含物理页号或者磁盘地址组成,有效位表示当前虚拟页是否已经被缓存在主内存中(或者CPU的高速缓存Cache中)。

虚拟页为何有会是否已经被缓存在主内存中一说?虚拟页表(简称页表)虽然作为虚拟内存与物理内存的映射关系,但是本身也是需要存放在某个位置上,所以自身本身也是占用一定内存的。所以页表本身也是被操作系统放在物理内存的指定位置。CPU 把虚拟地址给MMU,MMU去物理内存中查询页表,得到实际的物理地址。当然 MMU 不会每次都去查的,它自己也有一份缓存叫Translation Lookaside Buffer (TLB)[2],是为了加速地址翻译。CPU、MMU与TLB的相互关系如图11所示。

图 11 CPU、MMU与TLB的交互关系

从上图可以看出,TLB是虚拟内存页,即虚拟地址和物理地址映射关系的缓存层。MMU当收到地址查询指令,第一时间是请求TLB的,如果没有才会进行从内存中的虚拟页进行查找,这样可能会触发多次内存读取,而读取TLB则不需要内存读取,所进程读取的步骤顺序为:

(1)CPU进行虚拟地址请求MMU。

(2)MMU优先从TLB中得到虚拟页。

(3)如果得到则返回给上层。

(4)如果没有则从主存的虚拟页表中查询关系。

下面继续分析PTE的内部构造,根据有效位的特征可以得到不同的含义如下:

(1)有效位为1,表示虚拟页已经被缓存在内存(或者CPU高速缓存TLB-Cache)中。

(2)有效位为0,表示虚拟页未被创建且没有占用内存(或者CPU高速缓存TLB-Cache),或者表示已经创建虚拟页但是并没有存储到内存(或者CPU高速缓存TLB-Cache)中。

通过上述的标识位,可以将虚拟页集合分成三个子集,如表3所示。

表3 虚拟页被分成的三种子集

有效位

集合特征

1

虚拟内存已创建和分配页,已缓存在物理内存(或TLB-Cache)中。

0

虚拟内存还未分配或创建。

0

虚拟内存已创建和分配页,但未缓存在物理内存(或TLB-Cache)中。

对于Golang开发者,对虚拟内存的存储结构了解到此步即可,如果想更深入的了解MMU存储结果可以翻阅其他操作系统或硬件相关书籍或资料。下面来分析一下在访问一次内存的整体流程。

3.4 CPU内存访问过程

一次CPU内存访问的流程如图12所示。

图 12 CPU内存访问的详细流程

当某个进程进行一次内存访问指令请求,将触发如图3.12的内存访问具体的访问流程如下:

(1)进程将内存相关的寄存器指令请求运算发送给CPU,CPU得到具体的指令请求。

(2)计算指令被CPU加载到寄存器当中,准备执行相关指令逻辑。

(3)CPU对相关可能请求的内存生成虚拟内存地址。一个虚拟内存地址包括虚拟页号VPN(Virtual Page Number)和虚拟页偏移量VPO(Virtual Page Offset)[3]

(4)从虚拟地址中得到虚拟页号VPN。

(5)通过虚拟页号VPN请求MMU内存管理单元。

(6)MMU通过虚拟页号查找对应的PTE条目(优先层TLB缓存查询)。

(7)通过得到对应的PTE上的有效位来判断当前虚拟页是否在主存中。

(8)如果索引到的PTE条目的有效位为1,则表示命中,将对应PTE上的物理页号PPN(Physical Page Number)和虚拟地址中的虚拟页偏移量VPO进行串联从而构造出主存中的物理地址PA(Physical Address)[4],进入步骤(9)。

(9)通过物理内存地址访问物理内存,当前的寻址流程结束。

(10)如果有效位为0,则表示未命中,一般称这种情况为缺页。此时MMU将产生一个缺页异常,抛给操作系统。

(11)操作系统捕获到缺页异常,开始执行异常处理程序。

(12)此时将选择一个牺牲页并将对应的所缺虚拟页调入并更正新页表上的PTE,如果当前牺牲页有数据,则写入磁盘,得到物理内存页号PPN(Physical Page Number)。

(13)缺页处理程序更新之前索引到的PTE,并且写入物理内存怒页号PPN,有效位设置为1。

(14)缺页处理程序再次返回到原来的进程,且再次执行缺页指令,CPU重新将虚拟地址发给MMU,此时虚拟页已经存在物理内存中,本次一定会命中,通过(1)~(9)流程,最终将请求的物理内存返回给处理器。

以上就是一次CPU访问内存的详细流程。可以看出来上述流程中,从第(10)步之后的流程就稍微有一些繁琐。类似产生异常信号、捕获异常,再处理缺页流程,如选择牺牲页,还要将牺牲页的数据存储到磁盘上等等。所以如果频繁的执行(10)~(14)步骤会对性能影响很大。因为牺牲页有可能会涉及到磁盘的访问,而磁盘的访问速度非常的慢,这样就会引发程序性能的急剧下降。

一般从(1)~(9)步流程结束则表示页命中,反之为未命中,所以就会出现一个新的性能级指标,即命中率。命中率是访问次数与页命中次数之比。一般命中率低说明物理内存不足,数据在内存和磁盘之间交换频繁,但如果物理内存充分,则不会出现频繁的内存颠簸现象。

3.4 内存的局部性

上述了解到内存的命中率实际上是一衡量每次内存访问均能被页直接寻址到而不是产生缺页的指标。所以如果经常在一定范围内的内存则出现缺页的情况就会降低。这就是程序的一种局部性特性的体现。

局部性就是在多次内存引用的时候,会出现有的内存被经常引用多次,而且在该位置附近的其他位置,也有可能接下来被引用到。一般大多数程序都会具备局部性的特点。

实际上操作系统在设计过程中经常会用到缓存来提升性能,或者在设计解决方案等架构的时候也会考虑到缓存或者缓冲层的概念,实则就是利用程序或业务天然的局部性特征。因为如果没有局部性的特性,则缓存级别将起不到太大的作用,所以在设计程序或者业务的时候应该多考虑增强程序局部性的特征,这样的程序会更快。

下面是一个非常典型的案例来验证程序局部性的程序示例,具体代码如下:

package MyGolang

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 //访问内存,并写入值
      }
   }
}

Loop()函数的功能是遍历数组nums,并且将nums中的每个元素均设置为4。但是这里用了一个step来规定每次遍历的跨度。可以跟读上述代码,如果step等于1,则外层for循环只会执行1次。内层for循环则正常遍历nums。实则相当于代码如下:

func Loop(nums []int, step int) {
   l := len(nums)
   for j := 0; j < l; j += 1 {
       nums[j] = 4 //访问内存,并写入值
   }
}

如果Step等于3,则表示外层for循环要一共完成3次,内层for循环每次遍历的数组下标值都相差3。第一次遍历会被遍历的nums下标为0、3、6、9、12……,第二次遍历会遍历的nums下标为1、4、7、10、13……,第三次遍历会遍历的nums下标为2、5、8、11、14……。那么三次外循环就会将全部遍历完整个nums数组。

上述的程序表示了访问数组的局部性,step跨度越小,则表示访问nums相邻内存的局部性约好,step越大则相反。

接下来用Golang的Benchmark性能测试来分别对step取不同的值进行压测,来看看通过Benchmark执行Loop()函数而统计出来的几种情况,最终消耗的时间差距为多少。首先创建loop_test.go文件,实现一个制作数组并且赋值初始化内存值的函数CreateSource(),代码如下:

package MyGolang

import "testing"

func CreateSource(len int) []int {
   nums := make([]int, 0, len)

   for i := 0 ; i < len; i++ {
      nums = append(nums, i)
   }

   return nums
}

其次实现一个Benchmark,制作一个长度为10000的数组,这里要注意的是创建完数组后要执行b.ResetTimer()重置计时,去掉CreateSource()消耗的时间,step跨度为1的代码如下:

//第一篇/chapter3/MyGolang/loop_test.go

func BenchmarkLoopStep1(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 1)
   }
}

Golang中的b.N表示Golang一次压测最终循环的次数。BenchmarkLoopStep1()会将N次的总耗时时间除以N得到平均一次执行Loop()函数的耗时。因为要对比多个step的耗时差距,按照上述代码再依次实现step为2、3、4、5、6、12、16等Benchmark性能测试代码,如下:

func BenchmarkLoopStep2(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 2)
   }
}

func BenchmarkLoopStep3(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 3)
   }
}

func BenchmarkLoopStep4(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 4)
   }
}

func BenchmarkLoopStep5(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 5)
   }
}

func BenchmarkLoopStep6(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 6)
   }
}

func BenchmarkLoopStep12(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 12)
   }
}

func BenchmarkLoopStep16(b *testing.B) {
   //制作源数据,长度为10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 16)
   }
}

上述每个Benchmark都是相似的代码,只有step传参不同,接下来通过执行下述指令来进行压测,指令如下:

$ go test -bench=.  -count=3

其中“count=3”表示每个Benchmark要执行3次,这样是更好验证上述的结果。具体的运行结果如下:

goos: darwin
goarch: amd64
pkg: MyGolang
BenchmarkLoopStep1-12            366787      2792 ns/op
BenchmarkLoopStep1-12            432235      2787 ns/op
BenchmarkLoopStep1-12    
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值