site logo

Keep doing what you want, never give up.

招聘会
文章配图

大厂面试 - 腾讯:如何避免Go内存逃逸

什么是内存逃逸? 内存逃逸是一种程序错误,它指程序在运行过程中分配了内存,但是之后没有正确的释放这些被分配的内存,从而导致这些内存一直被占用,这就有可能会引起系统内存耗尽,从而导致程序崩溃。 我们都知道程序运行的时候需要将其拷贝到内存中才能执行,而内存空间划分成了不同的区域,其中最重要的两个区域是:堆区 - Heap 和 栈区 - Stack 为什么要分成堆区和栈区呢? 主要是为了满足不同的编程需求: 1、效率:栈区自动管理,适合快速的内存分配和释放,而堆区则适合存储需要长时间存在的大量数据。 2、灵活性:堆区基本可以随心所欲动态的分配内存,适合需要灵活使用大块内存的情况,而栈区刚好相反适合局部短时间使用的情况。 3、内存管理:有助于更好的管理和优化内存使用,提高程序的性能和稳定性。 栈区:用来存放函数调用时的局部变量和函数调用信息的;一般是由系统自动管理的,当函数调用开始时分配内存,函数调用结束时自动释放内存;栈区的分配和释放速度都非常快;栈区一般是有大小限制的,所以如果函数嵌套调用过多,或者局部变量太多也可能会导致栈溢出。 堆区:用于存储动态分配的内存(比如使用new或者malloc等函数分配的内存);特别是C语言中是需要一由程序员手动管理分配和释放的;因为是手动管理分配和释放,所以速度一般比较慢;堆区的大小一般来说比栈区大,可以存储更多的数据。 分配快慢的问题: 栈分配内存只需要用到两个CPU指令,即PUSH分配和RELEASE释放; 堆分配内存首先需要找到一块大小合适的内存块,之后还要通过垃圾回收才能释放。 Go语言为了解决内存逃逸实现了一套内存逃逸分析,所谓逃逸分析就是指程序在编译阶段对代码中哪些变量需要在栈中分配,哪些变量需要在堆上分配进行静态分析的方法。逃逸分析的目的是做到更好内存释放分配,提高程序的运行效率。 Go逃逸分析的源码位于src/cmd/compile/internal/gc/escape.go,感兴趣的话可自行阅读研究,其逃逸分析原理在源码的注释中也已有说明: pointers to stack objects cannot be stored in the heap - 指向栈对象的指针不能存储在堆中 pointers to a stack object cannot outlive that object - 指向栈对象的指针不能超过该对象的生命周期 知道了以上知识点,我们再来看几个实际的例子,通过实例来说明如何避免Go内存逃逸? 1、避免返回函数内部的局部指针变量,比如下面这段代码就会产生内存逃逸: func Sum(a int, b int) *int { total := a + b return &total // 返回了指向局部变量的指针 } func main() { Sum(200, 50) } 这段 Go 代码会产生内存逃逸的原因是由于函数 Sum 返回的是指向局部变量 total 的指针,而这个局部变量会在函数退出时被销毁,因此 Go 会将 total 的内存分配(“迁移”)到堆上,从而避免访问已销毁的内存,这种现象就是内存逃逸(如果返回的指针指向了已经销毁的内存,这将导致访问非法内存,为了避免这种问题,Go 会将这个局部变量 total 从栈上“逃逸”到堆上,确保它在 main 函数结束前仍然有效。)。
文章配图

大厂面试 - 腾讯:Go线程模型M:N的理解

1、什么是M、P、G,它们分别代表什么,为什么 Go 会选择 M:N 模型,而不是 1:1 ? M:是操作系统线程,是Machine的缩写,一个M代表一个内核线程,也称工作线程(真正干活的); G:是Go语言的轻量级线程,是Goroutine的缩写,一个G代表一个Go代码片段; P:是Processor的缩写,负责G的调度,一个P代表执行一个Go代码片段所必须的资源; 如果选择 1:1 模型,意味着每个Goroutine都需要创建一个OS线程,这样的话不仅内存开销大,而且系统线程切换的成本也很高,不适合高并发场景;Go运行时是自己管理调度Goroutine,是用户级线程,可在用户动态实现高效的任务切换,从而使得调度更轻量,性能更好。 Go采用协作式调度 + 抢占式调度,这样可以减少OS系统线程切换的成本,P维护本地Goroutine队列,减少全局锁竞争,从而提高了调度效率。 M:N模型即保证了高并发性能,又避免了1:1模型的高成本,这样的设计可以让Go高效的管理成千上万的Goroutine,而不会过度依赖OS线程。 2、为什么 Go 要引入 P,直接用 M 和 G 不行吗? 如果没有P,M直接操作G,那Goroutine调度就会依赖OS系统的线程管理,会有高昂的线程切换开销。而P作为调度器,维护本地Goroutine队列,可以大大减少OS线程的调度开销,提高了性能。 3、Goroutine是如何被调度到M上执行的? P维护着一个本地的Goroutine队列,M通过绑定P获取G来执行。当P的任务队列空了,它会从全局队列或其它P那里“偷取”任务,这样可以保证负载均衡。 4、P的数量可以动态调整吗? P的数量由GOMAXPROCS决定,默认等于CPU核心数,但是可以用runtime.GOMAXPROCS(n)进行动态调整。在高并发情况下,调整P数量可能会影响性能。 5、Go什么时候会创建新的M ? 有Goroutine需要执行但没有空闲M时,Go运行时会创建新的M。 6、使用go关键字生成的goroutine是放置在P中,还是M中? 当一个G被创建并初始化完成后会立即被存储到本地P的runnext字段中,因为G必须先被添加入到P的可以运行G队列中才能在M中运行。 7、Goroutine调度有哪些优化点? 可以使用GOMAXPROCS调整P数量以适配不同的CPU负载;可以让Goroutine自己控制阻塞以减少调度器的上下文切换;可以减少Goroutine创建和销毁的开销(比如用sync.Pool复用)。 8、如何优化Go的高并发任务执行? 控制Goroutine数量,避免因创建过多Goroutine而导致运行时的调度开销增加;适当使用sync.WaitGroup或channel来管理任务的执行顺序;避免过度依赖全局变量,减少锁竞争。