Go 内存分配详解

发布于 2024-08-20 12:26:31 字数 17502 浏览 19 评论 0

这篇文章主要介绍 Go 内存分配和 Go 内存管理,会轻微涉及内存申请和释放,以及 Go 垃圾回收。从非常宏观的角度看,Go 的内存管理就是下图这个样子,我们今天主要关注其中标红的部分。

image

本文基于 go1.11.2,不同版本 Go 的内存管理可能存在差别,比如 1.9 与 1.11 的 mheap 定义就是差别比较大的,后续看源码的时候,请注意你的 go 版本,但无论你用哪个 go 版本,这都是一个优秀的资料,因为内存管理的思想和框架始终未变。

Go 这门语言抛弃了 C/C++中的开发者管理内存的方式:主动申请与主动释放,增加了逃逸分析和 GC,将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题。这是 Go 语言成为高生产力语言的原因之一。

我们不需要精通内存的管理,因为它确实很复杂,但掌握内存的管理,可以让你写出更高质量的代码,另外,还能助你定位 Bug。

这篇文章采用层层递进的方式,依次会介绍关于存储的基本知识,Go 内存管理的“前辈”TCMalloc,然后是 Go 的内存管理和分配,最后是总结。这么做的目的是,希望各位能通过全局的认识和思考,拥有更好的编码思维和架构思维。

1. 存储基础知识回顾

这部分我们简单回顾一下计算机存储体系、虚拟内存、栈和堆,以及堆内存的管理,这部分内容对理解和掌握 Go 内存管理比较重要,建议忘记或不熟悉的朋友不要跳过。

存储金字塔

image

这幅图表达了计算机的存储体系,从上至下依次是:

  • CPU 寄存器
  • Cache
  • 内存
  • 硬盘等辅助存储设备
  • 鼠标等外接设备

从上至下,访问速度越来越慢,访问时间越来越长。

你有没有思考过下面 2 个简单的问题,如果没有不妨想想:

  • 如果 CPU 直接访问硬盘,CPU 能充分利用吗?
  • 如果 CPU 直接访问内存,CPU 能充分利用吗?

CPU 速度很快,但硬盘等持久存储很慢,如果 CPU 直接访问磁盘,磁盘可以拉低 CPU 的速度,机器整体性能就会低下,为了弥补这 2 个硬件之间的速率差异,所以在 CPU 和磁盘之间增加了比磁盘快很多的内存。

image

然而,CPU 跟内存的速率也不是相同的,从上图可以看到,CPU 的速率提高的很快(摩尔定律),然而内存速率增长的很慢,虽然 CPU 的速率现在增加的很慢了,但是内存的速率也没增加多少,速率差距很大,从 1980 年开始 CPU 和内存速率差距在不断拉大,为了弥补这 2 个硬件之间的速率差异,所以在 CPU 跟内存之间增加了比内存更快的 Cache,Cache 是内存数据的缓存,可以降低 CPU 访问内存的时间。

不要以为有了 Cache 就万事大吉了,CPU 的速率还在不断增大,Cache 也在不断改变,从最初的 1 级,到后来的 2 级,到当代的 3 级 Cache.

image

三级 Cache 分别是 L1、L2、L3,它们的速率是三个不同的层级,L1 速率最快,与 CPU 速率最接近,是 RAM 速率的 100 倍,L2 速率就降到了 RAM 的 25 倍,L3 的速率更靠近 RAM 的速率。

看到这了,你有没有 Get 到整个存储体系的分层设计?自顶向下,速率越来越低,访问时间越来越长,从磁盘到 CPU 寄存器,上一层都可以看做是下一层的缓存。

看了分层设计,我们看一下内存,毕竟我们是介绍内存管理的文章。

虚拟内存

虚拟内存是当代操作系统必备的一项重要功能了,它向进程屏蔽了底层了 RAM 和磁盘,并向进程提供了远超物理内存大小的内存空间。我们看一下虚拟内存的分层设计。

image

上图展示了某进程访问数据,当 Cache 没有命中的时候,访问虚拟内存获取数据的过程。

访问内存,实际访问的是虚拟内存,虚拟内存通过页表查看,当前要访问的虚拟内存地址,是否已经加载到了物理内存,如果已经在物理内存,则取物理内存数据,如果没有对应的物理内存,则从磁盘加载数据到物理内存,并把物理内存地址和虚拟内存地址更新到页表。

有没有 Get 到:物理内存就是磁盘存储缓存层。

另外,在没有虚拟内存的时代,物理内存对所有进程是共享的,多进程同时访问同一个物理内存存在并发访问问题。引入虚拟内存后,每个进程都要各自的虚拟内存,内存的并发访问问题的粒度从多进程级别,可以降低到多线程级别。

栈和堆

我们现在从虚拟内存,再进一层,看虚拟内存中的栈和堆,也就是进程对内存的管理。

image

上图展示了一个进程的虚拟内存划分,代码中使用的内存地址都是虚拟内存地址,而不是实际的物理内存地址。栈和堆只是虚拟内存上 2 块不同功能的内存区域:

  • 栈在高地址,从高地址向低地址增长。
  • 堆在低地址,从低地址向高地址增长。

栈和堆相比有这么几个好处:

1、栈的内存管理简单,分配比堆上快。
2、栈的内存不需要回收,而堆需要,无论是主动 free,还是被动的垃圾回收,这都需要花费额外的 CPU。

3、栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU 访问的 2 块数据可能在不同的页上,CPU 访问数据的时间可能就上去了。

堆内存管理

image

我们再进一层,当我们说内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。这小节看下堆内存管理干的是啥,如上图所示主要是 3 部分:分配内存块,回收内存块和组织内存块。

在一个最简单的内存管理中,堆内存最初会是一个完整的大块,即未分配内存,当来申请的时候,就会从未分配内存,分割出一个小内存块(block),然后用链表把所有内存块连接起来。需要一些信息描述每个内存块的基本信息,比如大小(size)、是否使用中(used) 和下一个内存块的地址(next),内存块实际数据存储在 data 中。

image

一个内存块包含了 3 类信息,如下图所示,元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请 5Byte 内存的时候,就需要进行内存对齐。

image

释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。

上面这个简单的设计中还没考虑内存碎片的问题,因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将 2 个连续的未使用的内存块合并,减少碎片。

以上就是内存管理的基本思路,关于基本的内存管理,想了解更多,可以阅读这篇文章《Writing a Memory Allocator》,本节的 3 张图片也是来自这片文章。

2. TCMalloc

TCMalloc 是 Thread Cache Malloc 的简称,是 Go 内存管理的起源,Go 的内存管理是借鉴了 TCMalloc,随着 Go 的迭代,Go 的内存管理与 TCMalloc 不一致地方在不断扩大,但其主要思想、原理和概念都是和 TCMalloc 一致的,如果跳过 TCMalloc 直接去看 Go 的内存管理,也许你会似懂非懂。

掌握 TCMalloc 的理念,无需去关注过多的源码细节,就可以为掌握 Go 的内存管理打好基础,基础打好了,后面知识才扎实。

在 Linux 里,其实有不少的内存管理库,比如 glibc 的 ptmalloc,FreeBSD 的 jemalloc,Google 的 tcmalloc 等等,为何会出现这么多的内存管理库?本质都是在多线程编程下,追求更高内存管理效率:更快的分配是主要目的。

那如何更快的分配内存?

我们前面提到:

引入虚拟内存后,让内存的并发访问问题的粒度从多进程级别,降低到多线程级别。

这是更快分配内存的第一个层次。

同一进程的所有线程共享相同的内存空间,他们申请内存时需要加锁,如果不加锁就存在同一块内存被 2 个线程同时访问的问题。

TCMalloc 的做法是什么呢?为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有 2 个好处:

1、为线程预分配缓存需要进行 1 次系统调用,后续线程申请小内存时,从缓存分配,都是在用户态执行,没有系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次。

2、多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次。

基本原理

下面就简单介绍下 TCMalloc,细致程度够我们理解 Go 的内存管理即可。

声明:我没有研究过 TCMalloc,以下介绍根据 TCMalloc 官方资料和其他博主资料总结而来,错误之处请朋友告知我。

image

结合上图,介绍 TCMalloc 的几个重要概念:

  • Page:操作系统对内存管理以页为单位,TCMalloc 也是这样,只不过 TCMalloc 里的 Page 大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc 解密》里称 x64 下 Page 大小是 8KB。
  • Span:一组连续的 Page 被称为 Span,比如可以有 2 个页大小的 Span,也可以有 16 页大小的 Span,Span 比 Page 高一个层级,是为了方便管理一定大小的内存区域,Span 是 TCMalloc 中内存管理的基本单位。
  • ThreadCache:每个线程各自的 Cache,一个 Cache 包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的 ThreadCache,所以 ThreadCache 访问是无锁的。
  • CentralCache:是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与 ThreadCache 中链表数量相同,当 ThreadCache 内存块不足时,可以从 CentralCache 取,当 ThreadCache 内存块多时,可以放回 CentralCache。由于 CentralCache 是共享的,所以它的访问是要加锁的。
  • PageHeap:PageHeap 是堆内存的抽象,PageHeap 存的也是若干链表,链表保存的是 Span,当 CentralCache 没有内存的时,会从 PageHeap 取,把 1 个 Span 拆成若干内存块,添加到对应大小的链表中,当 CentralCache 内存多的时候,会放回 PageHeap。如下图,分别是 1 页 Page 的 Span 链表,2 页 Page 的 Span 链表等,最后是 large span set,这个是用来保存中大对象的。毫无疑问,PageHeap 也是要加锁的。

image

上文提到了小、中、大对象,Go 内存管理中也有类似的概念,我们瞄一眼 TCMalloc 的定义:

  • 小对象大小:0~256KB
  • 中对象大小:257~1MB
  • 大对象大小:>1MB

小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache 缓存都是足够的,不需要去访问 CentralCache 和 HeapPage,无锁分配加无系统调用,分配效率是非常高的。

中对象分配流程:直接在 PageHeap 中选择适当的大小即可,128 Page 的 Span 所保存的最大内存就是 1MB。

大对象分配流程:从 large span set 选择合适数量的页面组成 span,用来存储数据。

3. Go 内存管理

Go 内存管理的基本概念

前面计算机基础知识回顾,是一种自上而下,从宏观到微观的介绍方式,把目光引入到今天的主题。

Go 内存管理的许多概念在 TCMalloc 中已经有了,含义是相同的,只是名字有一些变化。先给大家上一幅宏观的图,借助图一起来介绍。

image

Page

与 TCMalloc 中的 Page 相同,x64 下 1 个 Page 的大小是 8KB。上图的最下方,1 个浅蓝色的长方形代表 1 个 Page。

Span

与 TCMalloc 中的 Span 相同,Span 是内存管理的基本单位,代码中为 mspan,一组连续的 Page 组成 1 个 Span,所以上图一组连续的浅蓝色长方形代表的是一组 Page 组成的 1 个 Span,另外,1 个淡紫色长方形为 1 个 Span。

mcache

mcache 与 TCMalloc 中的 ThreadCache 类似,mcache 保存的是各种大小的 Span,并按 Span class 分类,小对象直接从 mcache 分配内存,它起到了缓存的作用,并且可以无锁访问。

但 mcache 与 ThreadCache 也有不同点,TCMalloc 中是每个线程 1 个 ThreadCache,Go 中是每个 P 拥有 1 个 mcache,因为在 Go 程序中,当前最多有 GOMAXPROCS 个线程在用户态运行,所以最多需要 GOMAXPROCS 个 mcache 就可以保证各线程对 mcache 的无锁访问,线程的运行又是与 P 绑定的,把 mcache 交给 P 刚刚好。

mcentral

mcentral 与 TCMalloc 中的 CentralCache 类似,是所有线程共享的缓存,需要加锁访问,它按 Span class 对 Span 分类,串联成链表,当 mcache 的某个级别 Span 的内存被分配光时,它会向 mcentral 申请 1 个当前级别的 Span。

但 mcentral 与 CentralCache 也有不同点,CentralCache 是每个级别的 Span 有 1 个链表,mcache 是每个级别的 Span 有 2 个链表,这和 mcache 申请内存有关,稍后我们再解释。

mheap

mheap 与 TCMalloc 中的 PageHeap 类似,它是堆内存的抽象,把从 OS 申请出的内存页组织成 Span,并保存起来。当 mcentral 的 Span 不够用时会向 mheap 申请,mheap 的 Span 不够用时会向 OS 申请,向 OS 的内存申请是按页来的,然后把申请来的内存页生成 Span 组织起来,同样也是需要加锁访问的。

但 mheap 与 PageHeap 也有不同点:mheap 把 Span 组织成了树结构,而不是链表,并且还是 2 棵树,然后把 Span 分配到 heapArena 进行管理,它包含地址映射和 span 是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。

大小转换

除了以上内存块组织概念,还有几个重要的大小概念,一定要拿出来讲一下,不要忽视他们的重要性,他们是内存分配、组织和地址转换的基础。

image

  • object size:代码里简称 size,指申请内存的对象大小。
  • size class:代码里简称 class,它是 size 的级别,相当于把 size 归类到一定大小的区间段,比如 size[1,8]属于 size class 1,size(8,16]属于 size class 2。
  • span class:指 span 的级别,但 span class 的大小与 span 的大小并没有正比关系。span class 主要用来和 size class 做对应,1 个 size class 对应 2 个 span class,2 个 span class 的 span 大小相同,只是功能不同,1 个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的 Span 就无需 GC 扫描了。
  • num of page:代码里简称 npage,代表 Page 的数量,其实就是 Span 包含的页数,用来分配内存。

在介绍这几个大小之间的换算前,我们得先看下图这个表,这个表决定了映射关系。

最上面 2 行是我手动加的,前 3 列分别是 size class,object size 和 span size,根据这 3 列做 size、size class 和 num of page 之间的转换。

仔细看一遍这个表,再向下看转换是如何实现的。

image

在 Go 内存大小转换那幅图中已经标记各大小之间的转换,分别是数组:class_to_size,size_to_class*和 class_to_allocnpages,这 3 个数组内容,就是跟上表的映射关系匹配的。比如 class_to_size,从上表看 class 1 对应的保存对象大小为 8,所以 class_to_size[1]=8,span 大小为 8192Byte,即 8KB,为 1 页,所以 class_to_allocnpages[1]=1。

image

为何不使用函数计算各种转换,而是写成数组?

有 1 个很重要的原因:空间换时间。你如果仔细观察了,上表中的转换,并不能通过简单的公式进行转换,比如 size 和 size class 的关系,并不是正比的。这些数据是使用较复杂的公式计算出来的,公式在 makesizeclass.go 中,这其中存在指数运算与 for 循环,造成每次大小转换的时间复杂度为 O(N*2^N)。另外,对一个程序而言,内存的申请和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小转换写死到数组里,做到了把大小转换的时间复杂度直接降到 O(1)。

其他转换表字段

第 4 列 num of objects 代表是当前 size class 级别的 Span 可以保存多少对象数量,第 5 列 tail waste 是 span%obj 计算的结果,因为 span 的大小并不一定是对象大小的整数倍。

最后一列 max waste 代表最大浪费的内存百分比,计算方法在 printComment 函数中:

func printComment(w io.Writer, classes []class) {
	fmt.Fprintf(w, "// %-5s  %-9s  %-10s  %-7s  %-10s  %-9s\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
	prevSize := 0
	for i, c := range classes {
		if i == 0 {
			continue
		}
		spanSize := c.npages * pageSize
		objects := spanSize / c.size
		tailWaste := spanSize - c.size*(spanSize/c.size)
		maxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
		prevSize = c.size
		fmt.Fprintf(w, "// %5d  %9d  %10d  %7d  %10d  %8.2f%%\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
	}
	fmt.Fprintf(w, "\n")
}

Span 最浪费内存的场景是:Span 内的每一个对象空间保存的对象,实际占用内存是前一个 class 中对象的大小加 1,这样无法占用低一级的 Span。一个对象空间未被占用的内存就被浪费了,所以一个 Span 内对象空间所浪费的内存为:所有对象空间浪费的内存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize

image

Go 内存分配

涉及的概念已经讲完了,我们看下 Go 内存分配原理。

Go 中的内存分类并不像 TCMalloc 那样分成小、中、大对象,但是它的小对象里又细分了一个 Tiny 对象,Tiny 对象指大小在 1Byte 到 16Byte 之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。

image

小对象是在 mcache 中分配的,而大对象是直接从 mheap 分配的,从小对象的内存分配看起。

小对象分配

image

为对象寻找 span

寻找 span 的流程如下:

  • 计算对象所需内存大小 size
  • 根据 size 到 size class 映射,计算出所需的 size class
  • 根据 size class 和对象是否包含指针计算出 span class
  • 获取该 span class 指向的 span。
  • 以分配一个不包含指针的,大小为 24Byte 的对象为例。

根据映射表:

// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     1          8        8192     1024           0     87.50%
//     2         16        8192      512           0     43.75%
//     3         32        8192      256           0     46.88%
//     4         48        8192      170          32     31.52%

size class 3,它的对象大小范围是(16,32]Byte,24Byte 刚好在此区间,所以此对象的 size class 为 3。

Size class 到 span class 的计算如下:

// noscan 为 true 代表对象不包含指针
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
	return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以,对应的 span class 为:

span class = 3 << 1 | 1 = 7

所以该对象需要的是 span class 7 指向的 span。

从 span 分配对象空间

Span 可以按对象大小切成很多份,这些都可以从映射表上计算出来,以 size class 3 对应的 span 为例,span 大小是 8KB,每个对象实际所占空间为 32Byte,这个 span 就被分成了 256 块,可以根据 span 的起始地址计算出每个对象块的内存地址。

image

随着内存的分配,span 中的对象内存块,有些被占用,有些未被占用,比如上图,整体代表 1 个 span,蓝色块代表已被占用内存,绿色块代表未被占用内存。

当分配内存时,只要快速找到第一个可用的绿色块,并计算出内存地址即可,如果需要还可以对内存块数据清零。

span 没有空间怎么分配对象

span 内的所有内存块都被占用时,没有剩余空间继续分配对象,mcache 会向 mcentral 申请 1 个 span,mcache 拿到 span 后继续分配对象。

mcentral 向 mcache 提供 span

mcentral 和 mcache 一样,都是 0~133 这 134 个 span class 级别,但每个级别都保存了 2 个 span list,即 2 个 span 链表:

  • nonempty:这个链表里的 span,所有 span 都至少有 1 个空闲的对象空间。这些 span 是 mcache 释放 span 时加入到该链表的。
  • empty:这个链表里的 span,所有的 span 都不确定里面是否有空闲的对象空间。当一个 span 交给 mcache 的时候,就会加入到 empty 链表。

这 2 个东西名称一直有点绕,建议直接把 empty 理解为没有对象空间就好了。

image

实际代码中每 1 个 span class 对应 1 个 mcentral,图里把所有 mcentral 抽象成 1 个整体了。

mcache 向 mcentral 要 span 时,mcentral 会先从 nonempty 搜索满足条件的 span,如果没有找到再从 emtpy 搜索满足条件的 span,然后把找到的 span 交给 mcache。

mheap 的 span 管理

mheap 里保存了 2 棵二叉排序树,按 span 的 page 数量进行排序:

  • free:free 中保存的 span 是空闲并且非垃圾回收的 span。
  • scav:scav 中保存的是空闲并且已经垃圾回收的 span。

如果是垃圾回收导致的 span 释放,span 会被加入到 scav,否则加入到 free,比如刚从 OS 申请的的内存也组成的 Span。

image

mheap 中还有 arenas,有一组 heapArena 组成,每一个 heapArena 都包含了连续的 pagesPerArena 个 span,这个主要是为 mheap 管理 span 和垃圾回收服务。

mheap 本身是一个全局变量,它其中的数据,也都是从 OS 直接申请来的内存,并不在 mheap 所管理的那部分内存内。

mcentral 向 mheap 要 span

mcentral 向 mcache 提供 span 时,如果 emtpy 里也没有符合条件的 span,mcentral 会向 mheap 申请 span。

mcentral 需要向 mheap 提供需要的内存页数和 span class 级别,然后它优先从 free 中搜索可用的 span,如果没有找到,会从 scav 中搜索可用的 span,如果还没有找到,它会向 OS 申请内存,再重新搜索 2 棵树,必然能找到 span。如果找到的 span 比需求的 span 大,则把 span 进行分割成 2 个 span,其中 1 个刚好是需求大小,把剩下的 span 再加入到 free 中去,然后设置需求 span 的基本信息,然后交给 mcentral。

mheap 向 OS 申请内存

当 mheap 没有足够的内存时,mheap 会向 OS 申请内存,把申请的内存页保存到 span,然后把 span 插入到 free 树 。

在 32 位系统上,mheap 还会预留一部分空间,当 mheap 没有空间时,先从预留空间申请,如果预留空间内存也没有了,才向 OS 申请。

大对象分配

大对象的分配比小对象省事多了,99%的流程与 mcentral 向 mheap 申请内存的相同,所以不重复介绍了,不同的一点在于 mheap 会记录一点大对象的统计信息,见 mheap.alloc_m()。

Go 垃圾回收和内存释放

如果只申请和分配内存,内存终将枯竭,Go 使用垃圾回收收集不再使用的 span,调用 mspan.scavenge() 把 span 释放给 OS(并非真释放,只是告诉 OS 这片内存的信息无用了,如果你需要的话,收回去好了),然后交给 mheap,mheap 对 span 进行 span 的合并,把合并后的 span 加入 scav 树中,等待再分配内存时,由 mheap 进行内存再分配,Go 垃圾回收也是一个很强的主题,计划后面单独写一篇文章介绍。

现在我们关注一下,Go 程序是怎么把内存释放给操作系统的?

释放内存的函数是 sysUnused,它会被 mspan.scavenge() 调用:

// MAC 下的实现
func sysUnused(v unsafe.Pointer, n uintptr) {
	// MADV_FREE_REUSABLE is like MADV_FREE except it also propagates
	// accounting information about the process to task_info.
	madvise(v, n, _MADV_FREE_REUSABLE)
}

注释说_MADV_FREE_REUSABLE 与 MADV_FREE 的功能类似,它的功能是给内核提供一个建议:这个内存地址区间的内存已经不再使用,可以回收。但内核是否回收,以及什么时候回收,这就是内核的事情了。如果内核真把这片内存回收了,当 Go 程序再使用这个地址时,内核会重新进行虚拟地址到物理地址的映射。所以在内存充足的情况下,内核也没有必要立刻回收内存。

4. Go 栈内存

最后提一下栈内存。从一个宏观的角度看,内存管理不应当只有堆,也应当有栈。

每个 goroutine 都有自己的栈,栈的初始大小是 2KB,100 万的 goroutine 会占用 2G,但 goroutine 的栈会在 2KB 不够用时自动扩容,当扩容为 4KB 的时候,百万 goroutine 会占用 4GB。

关于 goroutine 栈内存管理,有篇很好的文章,饿了么框架技术部的专栏文章:《聊一聊 goroutine stack》,把里面的一段内容摘录下,你感受下:

可以看到在 rpc 调用(grpc invoke) 时,栈会发生扩容(runtime.morestack),也就意味着在读写 routine 内的任何 rpc 调用都会导致栈扩容, 占用的内存空间会扩大为原来的两倍,4kB 的栈会变为 8kB,100w 的连接的内存占用会从 8G 扩大为 16G(全双工,不考虑其他开销),这简直是噩梦。

另外,再推荐一篇曹大翻译的一篇汇编入门文章,里面也介绍了扩栈:第一章: Go 汇编入门 ,顺便入门一下汇编。

  1. 总结
    内存分配原理就不再回顾了,强调 2 个重要的思想:
  • 使用缓存提高效率。在存储的整个体系中到处可见缓存的思想,Go 内存分配和管理也使用了缓存,利用缓存一是减少了系统调用的次数,二是降低了锁的粒度,减少加锁的次数,从这 2 点提高了内存管理效率。
  • 以空间换时间,提高内存管理效率。空间换时间是一种常用的性能优化思想,这种思想其实非常普遍,比如 Hash、Map、二叉排序树等数据结构的本质就是空间换时间,在数据库中也很常见,比如数据库索引、索引视图和数据缓存等,再如 Redis 等缓存数据库也是空间换时间的思想。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

·深蓝

暂无简介

文章
评论
25 人气
更多

推荐作者

梦途

文章 0 评论 0

唐睦州

文章 0 评论 0

且行且努力

文章 0 评论 0

Yiu Peng

文章 0 评论 0

albertliao

文章 0 评论 0

逆夏时光

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文