🔗Linux 进程在内存布局

多任务操作系统中的每个进程都在自己的内存沙盒中运行。在 32 位模式下,它总是 4GB 内存地址空间,内存分配是分配虚拟内存给进程,当进程真正访问某一虚拟内存地址时,操作系统通过触发缺页中断,在物理内存上分配一段相应的空间再与之建立映射关系,这样进程访问的虚拟内存地址,会被自动转换变成有效物理内存地址,便可以进行数据的存储与访问了。

Kernel space:操作系统内核地址空间;

Stack:栈空间,是用户存放程序临时创建的局部变量,栈的增长方向是从高位地址到地位地址向下进行增长。在现代主流机器架构上(例如x86)中,栈都是向下生长的。然而,也有一些处理器(例如B5000)栈是向上生长的,还有一些架构(例如System Z)允许自定义栈的生长方向,甚至还有一些处理器(例如SPARC)是循环栈的处理方式;

Heap:堆空间,堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减;

BBS segment:BSS 段,存放的是全局或者静态数据,但是存放的是全局/静态未初始化数据

Data segment:数据段,通常是指用来存放程序中已初始化的全局变量的一块内存区域;

Text segment:代码段,指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读。

🔗栈相关概念

栈:

调用栈call stack,简称栈,是一种栈数据结构,用于存储有关计算机程序的活动 subroutines 信息。在计算机编程中,subroutines 是执行特定任务的一系列程序指令,打包为一个单元。

栈帧:

栈帧stack frame又常被称为帧frame是在调用栈中储存的函数之间的调用关系,每一帧对应了函数调用以及它的参数数据。

有了函数调用自然就要有调用者 caller 和被调用者 callee ,如在 函数 A 里 调用 函数 B,A 是 caller,B 是 callee。

调用者与被调用者的栈帧结构如下图所示:

BP:基准指针寄存器,维护当前栈帧的基准地址,以便用来索引变量和参数,就像一个锚点一样,在其它架构中它等价于帧指针FP,只是在 x86 架构下,变量和参数都可以通过 SP 来索引;

SP:栈指针寄存器,总是指向栈顶;

栈区的内存一般由编译器自动分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而消亡,一般不会在程序中长期存在,这种线性的内存分配策略有着极高地效率,但是工程师也往往不能控制栈内存的分配,这部分工作基本都是由编译器完成的。

🔗Goroutine 栈

在 Goroutine 中有一个 stack 数据结构,里面有两个属性 lo 与 hi,描述了实际的栈内存地址:

  • stack.lo:栈空间的低地址;
  • stack.hi:栈空间的高地址;

在 Goroutine 中会通过 stackguard0 来判断是否要进行栈增长:

  • stackguard0:stack.lo + StackGuard, 用于 stack overlow 的检测;
  • StackGuard:保护区大小,常量 Linux 上为 928 字节;
  • StackSmall:常量大小为 128 字节,用于小函数调用的优化;
  • StackBig:常量大小为 4096 字节;

根据被调用函数栈帧的大小来判断是否需要扩容:

  1. 当栈帧大小(FramSzie)小于等于 StackSmall(128)时,如果 SP 小于 stackguard0 那么就执行栈扩容;
  2. 当栈帧大小(FramSzie)大于 StackSmall(128)时,就会根据公式 SP - FramSzie + StackSmall 和 stackguard0 比较,如果小于 stackguard0 则执行扩容;
  3. 当栈帧大小(FramSzie)大于 StackBig(4096)时,首先会检查 stackguard0 是否已转变成 StackPreempt 状态了;然后根据公式 SP-stackguard0+StackGuard <= framesize + (StackGuard-StackSmall)判断,如果是 true 则执行扩容;

需要注意的是,由于栈是由高地址向低地址增长的,所以对比的时候,都是小于才执行扩容,这里需要大家品品。

当执行栈扩容时,会在内存空间中分配更大的栈内存空间,然后将旧栈中的所有内容复制到新栈中,并修改指向旧栈对应变量的指针重新指向新栈,最后销毁并回收旧栈的内存空间,从而实现栈的动态扩容。

🔗逃逸分析

栈寄存器是 CPU 寄存器中的一种,它的主要作用是跟踪函数的调用栈,Go 语言的汇编代码包含 BP 和 SP 两个栈寄存器,它们分别存储了栈的基址指针和栈顶的地址,栈内存与函数调用的关系非常紧密,我们在函数调用一节中曾经介绍过栈区,BP 和 SP 之间的内存就是当前函数的调用栈

图 7-43 栈寄存器与内存

因为历史原因,栈区内存都是从高地址向低地址扩展的,当应用程序申请或者释放栈内存时只需要修改 SP 寄存器的值,这种线性的内存分配方式与堆内存相比更加快速,仅会带来极少的额外开销。

🔗逃逸分析

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针。

通俗地讲,逃逸分析就是确定一个变量要放堆上还是栈上,规则如下:

  1. 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上
  2. 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上

对此你可以理解为,逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为

🔗两个不变性

归结为:那些分配在栈上的对象,不能被堆上和较低地址栈上的指针所指向

在编译器优化中,逃逸分析是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 newmake 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:

  1. 指向栈对象的指针不能存在于堆中
  2. 指向栈对象的指针不能在栈对象回收后存活

图 7-44 逃逸分析和不变性

我们通过上图展示两条不变性存在的意义,当我们违反了第一条不变性时,堆上的绿色指针指向了栈中的黄色内存,一旦函数返回后函数栈会被回收,该绿色指针指向的值就不再合法;如果我们违反了第二条不变性,因为寄存器 SP 下面的内存由于函数返回已经释放,所以黄色指针指向的内存已经不再合法。

🔗在什么阶段确立逃逸

编译阶段确立逃逸,注意并不是在运行时

🔗基本的原则

如果一个函数返回对一个变量的引用,那么它就会发生逃逸(特殊情况:泄露参数)

🔗逃逸案例

🔗1、指针

type User struct {
	ID     int64
	Name   string
	Avatar string
}

func GetUserInfo() *User {
	return &User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"}
}

func main() {
	_ = GetUserInfo()
}
//GetUserInfo() 返回的是指针对象,引用被返回到了方法之外了。因此编译器会把该对象分配到堆上,而不是栈上。否则方法结束之后,局部变量就被回收了,岂不是翻车。所以最终分配到堆上是理所当然的

不是所有的指针对象都分配在堆上如果该对象没有被作用域之外所引用,就不会发生逃逸,仍分配在栈上。

func main() {
	str := new(string)
	*str = "EDDYCJY"
}
//str作用域没有改变,未逃逸

🔗2、未确定类型-interface

func main() {
	str := new(string)
	*str = "EDDYCJY"
	fmt.Println(str)
}
//会发生逃逸,str变量被分配到堆上
//当形参为 interface 类型时,在编译阶段编译器无法确定其具体的类型。因此会产生逃逸,最终分配到堆上
//如果你有兴趣追源码的话,可以看下内部的 reflect.TypeOf(arg).Kind() 语句,其会造成堆逃逸,而表象就是 interface 类型会导致该对象分配到堆上

🔗3、泄露参数

type User struct {
	ID     int64
	Name   string
	Avatar string
}

func GetUserInfo(u *User) *User {
	return u
}

func main() {
	_ = GetUserInfo(&User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"})
}
//leaking param: u to result ~r0 level=0
//&User{...} does not escape

//我们注意到 leaking param 的表述,它说明了变量 u 是一个泄露参数。结合代码可得知其传给 GetUserInfo 方法后,没有做任何引用之类的涉及变量的动作,直接就把这个变量返回出去了。因此这个变量实际上并没有逃逸,它的作用域还在 main() 之中,所以分配在栈上
type User struct {
	ID     int64
	Name   string
	Avatar string
}

func GetUserInfo(u User) *User {
	return &u
}

func main() {
	_ = GetUserInfo(User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"})
}
//只要一小改,它就考虑会被外部所引用,因此妥妥的分配到堆上了

🔗总结

  • 静态分配到栈上,性能一定比动态分配到堆上好
  • 底层分配到堆,还是栈。实际上对你来说是透明的,不需要过度关心
  • 每个 Go 版本的逃逸分析都会有所不同(会改变,会优化)
  • 直接通过 go build -gcflags '-m -l' 就可以看到逃逸分析的过程和结果
  • 到处都用指针传递并不一定是最好的,要用对

🔗栈内存空间(2KB~1GB)

Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多。

Go应用程序运行时,每个goroutine都维护着一个自己的栈区,这个栈区只能自己使用不能被其他goroutine使用。栈区的初始大小是2KB(比x86_64架构下线程的默认栈2M要小很多),在goroutine运行的时候栈区会按照需要增长和收缩,占用的内存最大限制的默认值在64位系统上是1GB(Max stack size is 1 GB on 64-bit, 250 MB on 32-bit)。栈大小的初始值和上限这部分的设置都可以在Go的源码runtime/stack.go里找到:

// rumtime.stack.go
// The minimum size of stack used by Go code
//2KB
_StackMin = 2048
//1GB
var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real
  1. v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
  2. v1.2 — 将最小栈内存提升到了 8KB;
  3. v1.3 — 使用连续栈替换之前版本的分段栈;
  4. v1.4 — 将最小栈内存降低到了 2KB;

🔗分段栈

分段栈是 Go 语言在 v1.3 版本之前的实现,所有 Goroutine 在初始化时都会调用 runtime.stackalloc:go1.2 分配一块固定大小的内存空间,这块内存的大小由 runtime.StackMin:go1.2 表示,在 v1.2 版本中为 8KB

当 Goroutine 调用的函数层级或者局部变量需要的越来越多时,运行时会调用 runtime.morestack:go1.2runtime.newstack:go1.2 创建一个新的栈空间,这些栈空间虽然不连续,但是当前 Goroutine 的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段:

图 7-45 分段栈的内存布局

🔗引发的问题

分段栈虽然能够按需为当前 goroutine 分配内存并且及时减少内存的占用,但是它也存在一个比较大的问题:

  • 如果当前 goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split)。

为了解决这个问题,Go在1.2版本的时候不得不将栈的初始化内存从4KB增大到了8KB。后来采用连续栈结构后,又把初始栈大小减小到了2KB。

🔗连续栈

连续栈可以解决分段栈中存在的两个问题,其核心原理是每当程序的栈空间不足时,初始化一片更大的栈空间**(两倍)**并将原栈中的所有值都迁移到新栈中,新的局部变量或者函数调用就有充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  1. 调用runtime.newstack在内存空间中分配更大的栈内存空间;
  2. 使用runtime.copystack将旧栈中的所有内容复制到新的栈中;
  3. 将指向旧栈对应变量的指针重新指向新栈
  4. 调用runtime.stackfree销毁并回收旧栈的内存空间;

在扩容的过程中,最重要的是调整指针的第三步,这一步能够保证指向栈的指针的正确性,因为栈中的所有变量内存都会发生变化,所以原本指向栈中变量的指针也需要调整。我们在前面提到过经过逃逸分析的 Go 语言程序的遵循以下不变性 —— 指向栈对象的指针不能存在于堆中,所以指向栈中变量的指针只能在栈上,我们只需要调整栈中的所有变量就可以保证内存的安全了。

🔗栈操作

🔗栈初始化

三个栈缓存:

栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpoolruntime.stackLarge,这两个变量分别表示全局的栈缓存大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间

从调度器和内存分配的经验来看,如果运行时只使用全局变量来分配内存的话,势必会造成线程之间的锁竞争进而影响程序的执行效率,栈内存由于与线程关系比较密切,所以我们在每一个线程缓存 runtime.mcache 中都加入了栈缓存减少锁竞争影响。

//_NumStackOrders=4    缓存4种stack 2KB, 4KB, 8KB, and 16KB
type mcache struct {
	stackcache [_NumStackOrders]stackfreelist
}

type stackfreelist struct {
	list gclinkptr
	size uintptr
}

🔗栈分配(临界点:32KB)

我们可以认为 Go 语言的栈内存都是分配在堆上的

运行时会在 Goroutine 的初始化函数 runtime.malg 中调用 runtime.stackalloc 分配一个大小足够栈内存空间,根据线程缓存和申请栈的大小,该函数会通过三种不同的方法分配栈空间:

  1. 如果栈空间较小(小于32KB),使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存(如果空间不足,从堆上申请);
  2. 如果栈空间较大(大于等于32KB),从全局的大栈缓存 [runtime.stackLarge]中获取内存空间(如果空间不足,从堆上申请);
//当前g
thisg := getg()
	var v unsafe.Pointer
  //两条件都是n<32KB
if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
		order := uint8(0)
		n2 := n
		for n2 > _FixedStack {
			order++
			n2 >>= 1
		}
		var x gclinkptr
		if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != "" {
			lock(&stackpool[order].item.mu)
      //从全局栈缓存stackpool中申请,如果不足,从堆上申请(需加锁)
			x = stackpoolalloc(order)
			unlock(&stackpool[order].item.mu)
		} else {
      //从线程缓存mcache中申请,如果不足,从堆上申请
			c := thisg.m.p.ptr().mcache
			x = c.stackcache[order].list
			if x.ptr() == nil {
				stackcacherefill(c, order)
				x = c.stackcache[order].list
			}
			c.stackcache[order].list = x.ptr().next
			c.stackcache[order].size -= uintptr(n)
		}
		v = unsafe.Pointer(x)
	} else {
  //如果大于32KB,从全局的大栈缓存runtime.stackLarge中申请,如果不足,从堆上申请(需加锁)
		var s *mspan
		npage := uintptr(n) >> _PageShift
		log2npage := stacklog2(npage)

		// Try to get a stack from the large stack cache.
		lock(&stackLarge.lock)
		if !stackLarge.free[log2npage].isEmpty() {
			s = stackLarge.free[log2npage].first
			stackLarge.free[log2npage].remove(s)
		}
		unlock(&stackLarge.lock)

		lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)

		if s == nil {
			// Allocate a new stack from the heap.
			s = mheap_.allocManual(npage, spanAllocStack)
			if s == nil {
				throw("out of memory")
			}
			osStackAlloc(s)
			s.elemsize = uintptr(n)
		}
		v = unsafe.Pointer(s.base())
	}

🔗栈扩容(2倍)

运行时检查:

编译器会在 cmd/internal/obj/x86.stacksplit 中为函数调用插入 runtime.morestack 运行时检查,它会在几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,我们会保存一些栈的相关信息并调用 runtime.newstack 创建新的栈

func newstack() {
	...
	oldsize := gp.stack.hi - gp.stack.lo
	newsize := oldsize * 2
	if newsize > maxstacksize {
		print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
		print("runtime: sp=", hex(sp), " stack=[", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
		throw("stack overflow")
	}

新栈变为原始栈的2倍,如果超过1GB,报错stack overflow

🔗栈缩容(0.5倍)

func shrinkstack(gp *g) {
	...
	oldsize := gp.stack.hi - gp.stack.lo
	newsize := oldsize / 2
  //新栈如果<2KB不缩容
	if newsize < _FixedStack {
		return
	}
	avail := gp.stack.hi - gp.stack.lo
	if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
		return
	}

	copystack(gp, newsize)
}

如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制 2KB,那么缩容的过程就会停止(也就是说旧栈小于4KB时,不会缩容)。

运行时只会在栈内存使用不足 1/4 时进行缩容,缩容也会调用扩容时使用的 runtime.copystack 开辟新的栈空间。

相关问题

🔗堆与栈的区别?

程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。栈区的内存由编译器自动进行分配和释放(why?),栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。

两者的主要区别是栈是每个线程或者协程独立拥有的,从栈上分配内存时不需要加锁。而整个程序在运行时只有一个堆,从堆中分配内存时需要加锁防止多个线程造成冲突,同时回收堆上的内存块时还需要运行可达性分析、引用计数等算法来决定内存块是否能被回收,所以从分配和回收内存的方面来看栈内存效率更高。