diva-notes
  • README
  • Ads
    • 定价策略
    • 广告层级
    • 归因模型
    • 买量
    • Chat GPT
    • Google
  • AI
    • 参考资料
    • Chat GPT
    • stable-diffusion-webui安装
  • Algorithm
    • 倍增
    • 并查集
    • 参考
    • 环的判断
    • 凸包
    • 蓄水池抽样
    • 最短路径
    • 最小生成树
    • KMP算法
    • Rabin-Karp算法
    • Tarjan桥算法
  • Architecture
    • Serverless
  • Career
  • CICD
    • 代码质量
    • CICD实践
  • Data Structure
    • 布谷鸟过滤器
    • 布隆过滤器
    • 浮点
    • 红黑树
    • 锁
    • LSM树
  • DB
    • My SQL
      • 隔离级别
      • 架构
      • 索引
      • 锁
      • 页结构
      • 主从同步
      • ACID
      • Log
      • MVCC
      • Questions
    • Postgres
      • 持久化
      • 对比MySQL
      • 隔离级别
      • 索引
      • Greenpulm
      • MVCC
    • 倒排索引
    • 列式存储
    • H Base
    • HDFS
    • MPP数据库选型
    • Questions
  • Distributed System
    • 分布式事务
    • 服务网格
    • BASE理论
    • CAP
    • Etcd
    • Raft协议
    • ZAB协议
  • Go
    • 1.语言基础
      • 1.CPU寄存器
      • 2-1.函数调用
      • 2-2.函数调用栈
      • 2.接口
      • 3.汇编
      • 4.调试
    • 2.编译
      • 1.编译
      • 2.词法与语法分析
      • 3.类型检查
      • 4.中间代码生成
      • 5.机器码生成
    • 3.数据结构
      • 1.数组array
      • 2.切片slice
      • 3.哈希表map
      • 4.字符串
    • 4.常用关键字
      • 1.循环
      • 2.defer
      • 3.panic和recover
      • 4.make和new
    • 5.并发编程
      • 1.上下文Context的实现
      • 2-1.runtime.sema信号量
      • 2-2.sync.Mutex的实现
      • 2-3.sync.WaitGroup
      • 2-4.sync.Once的实现
      • 2-5.sync.Map的实现
      • 2-6.sync.Cond
      • 2-7.sync.Pool的实现
      • 2-8.sync.Semaphore的实现
      • 2-9.sync.ErrGroup
      • 3.定时器Timer的实现
      • 4.Channel的实现
      • 5-1.调度-线程
      • 5-2.调度-MPG
      • 5-3.调度-程序及调度启动
      • 5-4.调度-调度策略
      • 5-5.调度-抢占
      • 6.netpoll实现
      • 7.atomic
    • 6.内存管理
      • 1-1.内存分配基础-TCmalloc
      • 1-2.内存分配
      • 2.垃圾回收
      • 3.栈内存管理
    • 参考
    • 各版本特性
    • 坑
    • Go程序性能优化
    • http.Client
    • net.http路由
    • profile采样的实现
    • Questions
    • time的设计
  • Kafka
    • 高可用
    • 架构
    • 消息队列选型
    • ISR
    • Questions
  • Network
    • ARP
    • DNS
    • DPVS
    • GET和POST
    • HTTP 2
    • HTTP 3
    • HTTPS
    • LVS的转发模式
    • NAT
    • Nginx
    • OSI七层模型
    • Protobuf
    • Questions
    • REST Ful
    • RPC
    • socket缓冲区
    • socket详解
    • TCP滑动窗口
    • TCP连接建立源码
    • TCP连接四元组
    • TCP三次握手
    • TCP数据结构
    • TCP四次挥手
    • TCP拥塞控制
    • TCP重传机制
    • UDP
  • OS
    • 磁盘IO
    • 调度
    • 进程VS线程
    • 零拷贝
    • 内存-虚拟内存
    • 内存分配
    • 用户态VS内核态
    • 中断
    • COW写时复制
    • IO多路复用
    • Questions
  • Redis
    • 安装
    • 参考
    • 高可用-持久化
    • 高可用-主从同步
    • 高可用-Cluster
    • 高可用-Sentinel
    • 缓存一致性
    • 事务
    • 数据结构-SDS
    • 数据结构-Skiplist
    • 数据结构-Ziplist
    • 数据结构
    • 数据类型-Hashtable
    • 数据类型-List
    • 数据类型-Set
    • 数据类型-Zset
    • 数据淘汰机制
    • 通信协议-RESP
    • Questions
    • Redis6.0多线程
    • Redis分布式锁
    • Redis分片
  • System Design
    • 本地缓存
    • 错误处理
    • 大文件处理
    • 点赞收藏关注
    • 短链接生成系统
    • 负载均衡
    • 高并发高可用
    • 规则引擎
    • 集卡活动
    • 秒杀系统
    • 评论系统
    • 熔断
    • 限流
    • 延迟队列
    • Docker
    • ES
    • K 8 S
    • Node.js
    • Questions
  • Work
    • Bash
    • Charles
    • Code Review
    • Ffmpeg
    • Git
    • intellij插件
    • I Term 2
    • Mac
    • mysql命令
    • Nginx
    • postgresql命令
    • Protoc
    • Ssh
    • Systemd
    • Tcp相关命令
    • Vim
Powered by GitBook
On this page
  1. Go
  2. 6.内存管理

3.栈内存管理

Previous2.垃圾回收Next参考

Last updated 2 years ago

逃逸分析

在 C 语言和 C++ 这类需要手动管理内存的编程语言中,将对象或者结构体分配到栈上或者堆上是由工程师自主决定的,这也为工程师的工作带来的挑战,如果工程师能够精准地为每一个变量分配合理的空间,那么整个程序的运行效率和内存使用效率一定是最高的,但是手动分配内存会导致如下的两个问题:

  1. 不需要分配到堆上的对象分配到了堆上 — 浪费内存空间;

  2. 需要分配到堆上的对象分配到了栈上 — 悬挂指针、影响内存安全;

与悬挂指针相比,浪费内存空间反而是小问题。在 C 语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误,在如下所示的代码中,栈上的变量 i 被错误返回:

int *dangling_pointer() {
    int i = 2;
    return &i;
}

当 dangling_pointer 函数返回后,它的本地变量会被编译器回收,调用方获取的是危险的悬挂指针,我们不确定当前指针指向的值是否合法时,这种问题在大型项目中是比较难以发现和定位的。

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

  1. 指向栈对象的指针不能存在于堆中;

  2. 指向栈对象的指针不能在栈对象回收后存活;

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

  1. 遍历对象分配图并查找违反两条不变性的变量分配关系,如果堆上的变量指向了栈上的变量,那么该变量需要分配在堆上;

  2. 记录从函数的调用参数到堆以及返回值的数据流,增强函数参数的逃逸分析;

决定变量是在栈上还是堆上虽然重要,但是这是一个定义相对清晰的问题,我们可以通过编译器统一作决策。为了保证内存的绝对安全,编译器可能会将一些变量错误地分配到堆上,但是因为堆也会被垃圾收集器扫描,所以不会造成内存泄露以及悬挂指针等安全问题,解放了工程师的生产力。

栈内存空间

Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多,然而 Goroutine 的栈内存空间和栈结构也在早期几个版本中发生过一些变化:

  1. v1.0 ~ v1.1 — 最小栈内存空间为 4KB;

Goroutine 的初始栈内存在最初的几个版本中多次修改,从 4KB 提升到 8KB 是临时的解决方案,其目的是为了减轻分段栈中的栈分裂对程序的性能影响;在 v1.3 版本引入连续栈之后,Goroutine 的初始栈大小降低到了 2KB,进一步减少了 Goroutine 占用的内存空间。

分段栈

void* runtime·stackalloc(uint32 n) {
	uint32 pos;
	void *v;
	if(n == FixedStack || m->mallocing || m->gcing) {
		if(m->stackcachecnt == 0)
			stackcacherefill();
		pos = m->stackcachepos;
		pos = (pos - 1) % StackCacheSize;
		v = m->stackcache[pos];
		m->stackcachepos = pos;
		m->stackcachecnt--;
		m->stackinuse++;
		return v;
	}
	return runtime·mallocgc(n, 0, FlagNoProfiling|FlagNoGC|FlagNoZero|FlagNoInvokeGC);
}

如果通过该方法申请的内存大小为固定的 8KB 或者满足其他的条件,运行时会在全局的栈缓存链表中找到空闲的内存块并作为新 Goroutine 的栈空间返回;在其余情况下,栈内存空间会从堆上申请一块合适的内存。

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

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

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

  2. 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时会触发栈的扩容和缩容,带来额外的工作量;

连续栈

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

  1. 在内存空间中分配更大的栈内存空间;

  2. 将旧栈中的所有内容复制到新栈中;

  3. 将指向旧栈对应变量的指针重新指向新栈;

  4. 销毁并回收旧栈的内存空间;

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

图 7-46 连续栈的内存布局

7.3.2 栈操作

type stack struct {
	lo uintptr
	hi uintptr
}

Go

栈的结构虽然非常简单,但是想要理解 Goroutine 栈的实现原理,还是需要我们从编译期间和运行时两个阶段入手:

栈初始化

var stackpool [_NumStackOrders]struct {
	item stackpoolItem
	_    [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte
}

type stackpoolItem struct {
	mu   mutex
	span mSpanList
}

var stackLarge struct {
	lock mutex
	free [heapAddrBits - pageShift]mSpanList
}
func stackinit() {
	for i := range stackpool {
		stackpool[i].item.span.init()
	}
	for i := range stackLarge.free {
		stackLarge.free[i].init()
	}
}
type mcache struct {
	stackcache [_NumStackOrders]stackfreelist
}

type stackfreelist struct {
	list gclinkptr
	size uintptr
}

图 7-47 线程栈缓存和全局栈缓存

栈分配

  1. 如果栈空间较小,使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存;

我们在这里会按照栈的大小分两部分介绍运行时对栈空间的分配。在 Linux 上,_FixedStack = 2048、_NumStackOrders = 4、_StackCacheSize = 32768,也就是如果申请的栈空间小于 32KB,我们会在全局栈缓存池或者线程的栈缓存中初始化内存:

func stackalloc(n uint32) stack {
	thisg := getg()
	var v unsafe.Pointer
	if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
		order := uint8(0)
		n2 := n
		for n2 > _FixedStack {
			order++
			n2 >>= 1
		}
		var x gclinkptr
		c := thisg.m.mcache
		if stackNoCache != 0 || c == nil || thisg.m.preemptoff != "" {
			x = stackpoolalloc(order)
		} else {
			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 {
		...
	}
	...
}
func stackalloc(n uint32) stack {
	...
	if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
		...
	} else {
		var s *mspan
		npage := uintptr(n) >> _PageShift
		log2npage := stacklog2(npage)

		if !stackLarge.free[log2npage].isEmpty() {
			s = stackLarge.free[log2npage].first
			stackLarge.free[log2npage].remove(s)
		}

		if s == nil {
			s = mheap_.allocManual(npage, &memstats.stacks_inuse)
			osStackAlloc(s)
			s.elemsize = uintptr(n)
		}
		v = unsafe.Pointer(s.base())
	}

	return stack{uintptr(v), uintptr(v) + uintptr(n)}
}

栈扩容

func newstack() {
	thisg := getg()
	gp := thisg.m.curg
	...
	preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
	if preempt {
		if !canPreemptM(thisg.m) {
			gp.stackguard0 = gp.stack.lo + _StackGuard
			gogo(&gp.sched)
		}
	}

	sp := gp.sched.sp
	if preempt {
		if gp.preemptShrink {
			gp.preemptShrink = false
			shrinkstack(gp)
		}

		if gp.preemptStop {
			preemptPark(gp)
		}

		gopreempt_m(gp)
	}
	...
}

如果当前 Goroutine 不需要被抢占,意味着我们需要新的栈空间来支持函数调用和本地变量的初始化,运行时会先检查目标大小的栈是否会溢出:

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")
	}

	casgstatus(gp, _Grunning, _Gcopystack)
	copystack(gp, newsize)
	casgstatus(gp, _Gcopystack, _Grunning)
	gogo(&gp.sched)
}
func copystack(gp *g, newsize uintptr) {
	old := gp.stack
	used := old.hi - gp.sched.sp

	new := stackalloc(uint32(newsize))
	...
}

新栈的初始化和数据的复制是一个比较简单的过程,不过这不是整个过程中最复杂的地方,我们还需要将指向源栈中内存指向新的栈,在这期间我们需要分别调整以下的指针:

func copystack(gp *g, newsize uintptr) {
	...
	var adjinfo adjustinfo
	adjinfo.old = old
	adjinfo.delta = new.hi - old.hi // 计算新栈和旧栈之间内存地址差

	ncopy := used
	if !gp.activeStackChans {
		adjustsudogs(gp, &adjinfo)
	} else {
		adjinfo.sghi = findsghi(gp, old)
		ncopy -= syncadjustsudogs(gp, used, &adjinfo)
	}

	memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)

	adjustctxt(gp, &adjinfo)
	adjustdefers(gp, &adjinfo)
	adjustpanics(gp, &adjinfo)

	gp.stack = new
	gp.stackguard0 = new.lo + _StackGuard
	gp.sched.sp = new.hi - used
	gp.stktopsp += adjinfo.delta
	...
	stackfree(old)
}

栈缩容

func shrinkstack(gp *g) {
	...
	oldsize := gp.stack.hi - gp.stack.lo
	newsize := oldsize / 2
	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,那么缩容的过程就会停止。

图 7-48 栈的缩容操作

原文链接

https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-stack-management/

逃逸分析是静态分析的一种,在编译器解析了 Go 语言源文件后,它可以获得整个程序的抽象语法树(Abstract syntax tree,AST),编译器可以根据抽象语法树分析静态的数据流,我们会通过以下几个步骤实现静态分析的全过程:

构建带权重的有向图,其中顶点 表示被分配的变量,边 表示变量之间的分配关系,权重表示寻址和取地址的次数;

v1.2 — 将最小栈内存提升到了 8KB;

v1.3 — 使用连续栈替换之前版本的分段栈;

v1.4 — 将最小栈内存降低到了 2KB;

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

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

一旦 Goroutine 申请的栈空间不在被需要,运行时会调用 和 释放不再使用的内存空间。

因为需要拷贝变量和调整指针,连续栈增加了栈扩容时的额外开销,但是通过合理栈缩容机制就能避免热分裂带来的性能问题,在 GC 期间如果 Goroutine 使用了栈内存的四分之一,那就将其内存减少一半,这样在栈内存几乎充满时也只会扩容一次,不会因为函数调用频繁扩缩容。

Go 语言中的执行栈由 表示,该结构体中只包含两个字段,分别表示栈的顶部和栈的底部,每个栈结构体都表示范围为 [lo, hi) 的内存空间:

编译器会在编译阶段会通过 在调用函数前插入 或者 函数;

运行时在创建新的 Goroutine 时会在 中调用 申请新的栈内存,并在编译器插入的 中检查栈空间是否充足;

需要注意的是,Go 语言的编译器不会为所有的函数插入 ,它只会在必要时插入指令以减少运行时的额外开销,编译指令 nosplit 可以跳过栈溢出的检查,虽然这能降低一些开销,不过固定大小的栈也存在溢出的风险。本节将分别分析栈的初始化、创建 Goroutine 时栈的分配、编译器和运行时协作完成的栈扩容以及当栈空间利用率不足时的缩容过程。

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

这两个用于分配空间的全局变量都与内存管理单元 有关,我们可以认为 Go 语言的栈内存都是分配在堆上的,运行时初始化会调用 初始化这些全局变量:

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

运行时使用全局的 和线程缓存中的空闲链表分配 32KB 以下的栈内存,使用全局的 和堆内存分配 32KB 以上的栈内存,提高本地分配栈内存的性能。

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

如果栈空间较大,从全局的大栈缓存 中获取内存空间;

如果栈空间较大并且 空间不足,在堆上申请一片大小足够内存空间;

会在全局的栈缓存池 中获取新的内存,如果栈缓存池中不包含剩余的内存,运行时会从堆上申请一片内存空间;如果线程缓存中包含足够的空间,我们可以从线程本地的缓存中获取内存,一旦发现空间不足就会调用 从堆上获取新的内存。

如果 Goroutine 申请的内存空间过大,运行时会查看 中是否有剩余的空间,如果不存在剩余空间,它也会从堆上申请新的内存:

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

会先做一些准备工作并检查当前 Goroutine 是否发出了抢占请求,如果发出了抢占请求:

当前线程可以被抢占时,直接调用 触发调度器的调度;

如果当前 Goroutine 在垃圾回收被 标记成了需要收缩栈,调用 ;

如果当前 Goroutine 被 函数挂起,调用 被动让出当前处理器的控制权并将 Goroutine 的状态修改至 _Gpreempted;

调用 主动让出当前处理器的控制权;

如果目标栈的大小没有超出程序的限制,我们会将 Goroutine 切换至 _Gcopystack 状态并调用 开始栈拷贝。在拷贝栈内存之前,运行时会通过 分配新的栈空间:

调用 或者 调整 结构体的指针;

调用 将源栈中的整片内存拷贝到新的栈中;

调用 、 和 调整剩余 Goroutine 相关数据结构的指针;

调整指向栈内存的指针都会调用 ,该函数会利用 计算的新栈和旧栈之间的内存地址差来调整指针。所有的指针都被调整后,我们就可以更新 Goroutine 的几个变量并通过 释放原始栈的内存空间了。

栈缩容时调用的函数,该函数的实现原理非常简单,其中大部分都是检查是否满足缩容前置条件的代码,核心逻辑只有以下这几行:

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

6
cmd/compile/internal/gc.EscLocation
cmd/compile/internal/gc.EscEdge
7
8
9
runtime.stackalloc:go1.2
runtime.StackMin:go1.2
runtime.morestack:go1.2
runtime.newstack:go1.2
runtime.lessstack:go1.2
runtime.oldstack:go1.2
10
runtime.stack
cmd/internal/obj/x86.stacksplit
runtime.morestack
runtime.morestack_noctxt
runtime.malg
runtime.stackalloc
runtime.morestack
runtime.morestack
runtime.stackpool
runtime.stackLarge
runtime.mspan
runtime.stackinit
runtime.mcache
runtime.stackpool
runtime.stackLarge
runtime.malg
runtime.stackalloc
runtime.stackLarge
runtime.stackLarge
runtime.stackpoolalloc
runtime.stackpool
runtime.stackcacherefill
runtime.stackLarge
cmd/internal/obj/x86.stacksplit
runtime.morestack
runtime.newstack
runtime.newstack
runtime.gogo
runtime.scanstack
runtime.shrinkstack
runtime.suspendG
runtime.preemptPark
runtime.gopreempt_m
runtime.copystack
runtime.stackalloc
runtime.adjustsudogs
runtime.syncadjustsudogs
runtime.sudog
runtime.memmove
runtime.adjustctxt
runtime.adjustdefers
runtime.adjustpanics
runtime.adjustpointer
runtime.adjustinfo
runtime.stackfree
runtime.shrinkstack
runtime.copystack
5
escape-analysis-and-key-invariants
segmented-stacks
continuous-stacks
stack-memory
shrink-stacks