Skip to content

Daily Study

更新: 3/31/2026 字数: 0 字 时长: 0 分钟

Daily Plan

#todo

  • [ ]

Go 知识回顾

Go GC

GC 主要回收的是「堆内存 (Heap Memory)」中那些「不可达 (Unreachable)」的对象。

针对栈内存:完全由编译器管理,回收方式通过SP寄存器的指针移动,不需要GC接入

针对堆内存,包括如下三种,由 Runtime 管理

  • 通过new,make创建的,且发生逃逸的对象
  • 体积过大无法在栈上分配的对象
  • 生命周期不确定的对象(被返回到函数外部引用)

Go的垃圾回收机制工作原理

Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。

三色标记法:Go将堆内存中的对象分为三种颜色:

  • 白色 :潜在的垃圾。GC 开始时,所有对象都是白色的。如果一轮标记结束后还是白色,说明不可达,会被回收。
  • 灰色 :活跃对象,但子对象还没扫描完。它是中间状态,相当于一个“任务队列”。
  • 黑色 :活跃对象,且子对象也扫描完了。绝对安全,不会被回收。

工作流程

  1. 初始状态:所有对象都是白色的。
  2. 根节点扫描:从 Root Set(全局变量、栈上的变量)出发,找到它们引用的对象,标记为灰色。
  3. 循环处理:从灰色集合里拿出一个对象,标记为黑色。然后,把这个对象引用的所有白色子对象,标记为灰色。
  4. 结束条件:当灰色集合为空时,标记结束。
  5. 清除:剩下的白色对象就是垃圾,直接清理。

为了解决并发问题,Go采用混合写屏障(1.8版本之后)核心逻辑是:只要你在 GC 期间修改指针,不管是新增引用还是删除引用,屏障都会把涉及到的对象强制染成灰色。

GC的生命周期

  1. 清理终止(STW):暂停程序,确保上一轮清理工作彻底完成
  2. 标记(并发):开启写屏障,GC协程和用户协程一起跑
  3. 标记终止(STW):关闭写屏障,标记完成
  4. 清除(并发 ):程序恢复运行,GC协程并发地把白色内存块回收给分配器

STW(Stop The World)

STW 是指在垃圾回收过程中,为了保证内存数据的准确性,Runtime 强制暂停所有的用户线程 (Goroutines),让应用程序“假死”的一种状态。

核心原因是:为了保证对象引用关系的一致性 。在并发标记过程中,用户代码和 GC 标记器是同时运行的。这会带来两个致命问题:

  1. 新对象的产生:GC 刚扫完一块区域认为是“白”的(垃圾),结果用户代码紧接着在那儿创建了一个新对象。如果 GC 不知道,就会把新对象当垃圾回收掉,导致程序崩溃。
  2. 引用关系的变更:GC 正在扫描链表,用户代码突然修改了指针指向,把一个还没扫描到的黑色对象挂到了一个已经扫描过的白色对象下面。GC 认为扫描完了,结果漏掉了那个对象。

Go 引入了写屏障技术,允许在堆上并发修改。但是,为了保证函数调用的极致性能,没有在栈 上开启写屏障。因为栈上的操作频率极高(变量赋值、函数传参),如果每一步都加个屏障检查,CPU 开销太大。因为栈上没有写屏障,栈上的指针变化 GC 无法实时得知。所以在 GC 开始和结束时,必须 STW,让栈停下来,以便快速扫描或重新扫描栈上的根指针。

总结:STW 是 Runtime 为了在垃圾回收时保证对象引用一致性而强制暂停用户线程的过程。

  • 为什么会有? 虽然 Go 使用了并发标记和写屏障,但为了保障函数调用的性能,Go 没有在栈 上开启写屏障。这就意味着栈上的指针变动 GC 是看不见的。因此,必须依靠 STW 来暂停栈的操作,以便安全地扫描栈上的根指针。
  • 有什么作用? 它主要发生 GC 的开始和结束两个时刻:
    1. 开始时:为了开启写屏障,并扫描全局根节点。
    2. 结束时 :为了做最后的核对,处理并发期间栈上的变化,并关闭写屏障。
  • 得益于 Go 1.8 引入的混合写屏障技术,消除了结束阶段的大规模栈重扫,现在的 STW 通常都在亚毫秒级,对 SCF 这种延迟敏感型业务的影响已经非常小了。

什么时候触发GC

根据堆内存的大小来触发,有一个环境变量GOGC来控制触发频率,默认值100,公式 下次 GC 触发堆大小 = 上次 GC 后的存活堆大小 * (1 + GOGC/100)

什么情况会导致频繁的GC

从内存出发,分析内存大小和内存分配速度,以下是5种典型场景:

  • GOGC设置过小,程序的常驻内存基数过高,逼近系统设定的硬性物理限制或运行时软限制,导致GC 抖动
  • 瞬间分配大量短命的小对象,解放方法:使用 sync.Pool 复用对象
  • 逃逸分析失败(写的太动态),使用 go build -gcflags =”-m" 进行逃逸分析
  • string[]byte 频繁转换,在 Go 中,string 是不可变的,而 []byte 是可变的。 s := string(bytes)b := []byte(str) 默认都会发生内存拷贝(Deep Copy)。在处理网络包、解析 JSON、日志拼接时,频繁进行类型转换。解决方法:使用strings.Builderbytes.Buffer 拼接字符串。
  • 内存/goroutine泄露:协程因死锁、系统调用阻塞或无缓冲 Channel 相互等待而进入永久阻塞 状态。每个泄露的 Goroutine 必定持有一块栈内存,并维系其作用域内堆对象的存活引用。这会不断抬高堆内存的底水位线。由于后续 GC 的触发阈值是基于底水位的百分比计算的,水位的异常抬高会导致后续 GC 的计算基数和清理成本同步增加。

发生逃逸的对象

逃逸:编译器在静态编译期通过分析变量的作用域和生命周期,决定将该变量分配在开销极低的栈区,还是交由垃圾回收器管理的堆区的代码优化过程。

  • 指针逃逸:当函数内部创建了一个局部变量,并将该变量的指针作为返回值暴露给外部调用者时,该变量的生命周期超出了当前栈帧必须分配到堆上(函数被调用时在内存栈区为其分配的专属物理连续空间,存放局部变量和参数,函数返回时通过移动栈顶指针瞬间自动回收,零 GC 负担)
  • 动态类型与空接口逃逸:当将一个具体的强类型变量赋值给 interface{} (或 Go 1.18+ 的 any) 时,由于 interface{} 底层的内部结构需要记录数据的实际类型和数据指针,且编译器在静态编译期无法预测这个接口最终会承载多大的数据结构,因此通常会引发对象逃逸。fmt.Println(user_id) 是生产环境中最容易被忽略的隐形逃逸陷阱。因为 fmt.Println 的入参定义是 a ...any,即使你传入一个最简单的整数局部变量,也会被强制打包进空接口,从而逃逸到堆上。
  • 大对象与动态大小分配逃逸:操作系统的线程栈空间是有限的(Go 协程初始栈大小通常仅为 2KB)。为了防止栈溢出,或者无法在栈上预留连续空间,以下两种情况的数据结构会直接逃逸:尺寸过大的局部变量、容量不确定的切片或哈希表
  • 闭包引用逃逸:闭包(匿名函数)内部如果引用了其外部包围函数的局部变量,即使外部函数已经执行完毕并销毁了其栈帧,闭包内部依然需要访问该变量。这就迫使被引用的外部局部变量生命周期被拉长,必须逃逸到堆上。

无分代和不整理的原因

  1. 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
  2. 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

GC调优

场景:在海外闪购一开启的瞬间,Go 进程内存中会疯狂产生上百万个临时的请求对象、订单上下文。Go 的三色标记清除 GC 会被极其频繁地触发。

解决方法:

  • 阻断内存分配
    • 对象复用:所有高频短生命周期的对象(如请求结构体、字节流 Buffer),必须池化,强制使用 sync.Pool。
    • 避免逃逸分析踩坑:在关键的热点路径上,尽量传递值或限制变量的作用域,让变量分配在栈上。栈内存随函数返回自动销毁,完全没有 GC 负担。一旦返回局部变量的指针,发生堆逃逸,就会加重 GC 压力。
  • 调整 GC 步调,空间换时间:Go 默认的 GC 触发阈值由环境变量 GOGC=100 控制(即当新分配的堆内存达到了上次 GC 存活内存的 100% 时触发)在内存资源充足(如 16G/32G 内存的容器)但对延迟极其敏感的闪购节点上,我们会将 GOGC 调大至 200、500 甚至更高。极大降低了 GC 触发的频率,减少了 STW 次数,将 CPU 算力更多地让给业务处理。这是一种典型的用内存换取 CPU 和低延迟的策略。
  • 防止 OOM:以前调大 GOGC 有一个致命风险:如果流量洪峰太大,内存增长极快,还没触发 GC,整个 Pod 就因为触达了 K8s 的 cgroup 内存上限,直接被系统底层 OOM Killer 强杀了。在 Go 1.19 之后,引入了软内存限制 GOMEMLIMIT。我们可以将 GOGC 设置得非常大(甚至设为 off 直接关闭基于比例的 GC),然后将 GOMEMLIMIT 设置为容器总物理内存的 80%-90%(例如 GOMEMLIMIT=7GiB)。 这样,Go 进程会尽可能多地吃掉内存而不触发 GC(保持极致性能),只有当内存水位逼近 7GiB 的危险线时,Runtime 才会紧急介入执行强力 GC,完美兼顾了极致低延迟与防崩溃安全底线。

GC 触发时的 STW (Stop The World,包括标记准备和标记终止阶段),以及并发标记阶段占用高达 25% 的 CPU 资源,是导致前端出现“请求超时(毛刺)”的罪魁祸首。

Go defer

执行顺序

执行顺序遵循后进先出,当一个函数中有多个 defer 语句时,它们会按“压栈”的方式注册。函数返回前,会从栈顶开始“弹栈”执行。

作用域

defer 的作用域是函数级别,而不是代码块级别。defer 注册的函数,只有在当前函数执行完毕(执行 return 或发生 panic)时才会执行。

错误示范

go
func processFiles(filenames []string) {
    for _, filename := range filenames {
        f, _ := os.Open(filename)
        // 【危险】defer 不会在单次循环结束时执行!
        // 而是要等 processFiles 函数彻底结束才执行。
        // 如果 filenames 有几万个,会迅速耗尽文件描述符 (File Descriptors),导致报错。
        defer f.Close() 
        
        // do something...
    }
}

正确用法,使用匿名函数出创造新的作用域

go
func processFiles(filenames []string) {
    for _, filename := range filenames {
        func() { // 定义一个匿名函数
            f, _ := os.Open(filename)
            defer f.Close() // defer 绑定在这个匿名函数上
            // do something...
        }() // 立即执行匿名函数,循环一次,defer 执行一次
    }
}

返回值

  • defer 后面函数的参数,在 defer 语句声明的那一刻 就已经计算好(求值)并固定下来了(值拷贝)
  • 如果 defer 后面跟的是闭包,且引用了外部变量,那么执行时会去取外部变量当前的值
go
func main() {
    i := 0
    // 场景 1: 直接传参
    // i 在这里被求值,复制了一份 0 进去。后续 i 怎么变跟它没关系。
    defer fmt.Println("Defer 1:", i) 

    // 场景 2: 闭包引用
    // 这里没有传参,func 内部引用了外部的变量 i (引用拷贝)。
    // 执行时会去读取 i 当前最新的值。
    defer func() {
        fmt.Println("Defer 2:", i)
    }()

    i++
    fmt.Println("Main:", i)
}

// 输出结果:
// Main: 1
// Defer 2: 1  (闭包引用了修改后的 i)
// Defer 1: 0  (参数在声明时已经固定为 0)
  • deferreturn 的执行顺序,defer 有机会修改返回值,但取决于返回值是有名还是无名
    • 设置返回值(将结果写入返回值内存地址)
    • 执行 defer 链表
    • 执行 ret 指令(将控制权交还给调用方)

场景A:无名返回值,defer无法修改返回值

go
func test() int {
    i := 10
    defer func() {
        i++ // 修改的是局部变量 i
    }()
    return i 
    // 流程:
    // 1. 建立一个临时栈空间存返回值,把 i(10) 拷贝进去。
    // 2. 执行 defer,i 变为 11。
    // 3. 返回临时栈空间里的值 (10)。
}
// 结果:10

场景B:有名返回值,可以修改返回值

go
func test() (result int) { // 返回值变量名为 result
    result = 10
    defer func() {
        result++ // 修改的是返回值变量 result
    }()
    return // 裸 return,默认返回 result
    // 流程:
    // 1. result 已经被赋值为 10。
    // 2. 执行 defer,result 变为 11。
    // 3. ret 指令返回 result (11)。
}
// 结果:11

面试回答参考:

  • 基本原理:defer 是基于栈的,后进先出(LIFO)。常用于资源释放(Close/Unlock)。
  • 作用域:它是函数级别的。要注意在循环中使用 defer 可能导致资源泄漏,解决方法是用匿名函数包裹。
  • 参数机制:defer 声明时会立即对参数进行求值(值拷贝),除非使用闭包引用外部变量。
  • Return 关系:return 不是原子的,defer 在赋值之后、返回之前执行。对于有名返回值,defer 可以修改最终结果;对于无名返回值则不能。

Go Runtime

Go 的 Runtime(运行时)是编译进你的二进制文件里的一段核心库代码。它像一个“微型操作系统”,在用户态管理着你的程序。 Go Runtime 主要由三大支柱构成:

  • 调度器 (Scheduler - GMP):管理 CPU。
  • 内存管理 (Memory Management - Allocator & GC):管理内存。
  • 网络轮询器 (Netpoller):管理 I/O。

调度器

详情见介绍一下go中的GMP模型

  • GMP
  • 核心调度策略

内存管理

内存分配器 (TCMalloc 变种):采用了 TCMalloc (Thread-Caching Malloc),核心目标是减少锁竞争,采用分级架构,并采用了微对象优化(小于16B的对象会合并存储)

  • mcache:每个 P 独有的缓存,无锁。小对象优先在这里分。
  • mcentral:中心缓存,有锁。当 mcache 缺货时来这里批发 Span。
  • mheap:全局堆,管理向 OS 申请的大块内存。

垃圾分类:无分代,不整理,并发的三色标记清除算法

网络轮询器(Netpoller)

这是 Go 能够处理 C10K / C100K 问题的关建。它将底层的 IO 多路复用(Linux epoll, Mac kqueue, Windows IOCP)封装成了 Runtime 的一部分。

工作流程:

  • 用户代码:当你写 conn.Read() 时,看起来是阻塞的。

  • Runtime 介入:

    • 如果 Socket 缓冲区没数据,Runtime 不会阻塞 M(线程)。
    • 它会把当前的 G 状态改为 Gwaiting,并把 G 的指针注册到 Netpoller (epoll) 中。
    • M 此时自由了,立刻去执行 P 队列里的下一个 G。
  • 数据到达:

    • 操作系统通过 epoll_wait 通知 Netpoller:“Socket 有数据了”。
    • Netpoller 将对应的 G 状态改回 Grunnable,并插入到 P 的队列中。
  • 恢复执行:G 被 M 再次拿到,就像从未阻塞过一样继续执行。

辅助组件Sysmon

Runtime 中还有一个后台线程叫 sysmon (System Monitor)。它不需要绑定 P,直接运行在系统栈上。它的职责包括:

  1. 检查死锁:如果所有 P 都闲置但还有 G 在等,报 deadlock。
  2. 强制 GC:如果长时间没触发 GC(默认 2 分钟),它会强制触发一次。
  3. 抢占调度:发现 G 运行太久,把它踢下去。
  4. 网络轮询:定期检查 Netpoller,防止网络任务饿死。

菜就多练

本站访客数 人次 本站总访问量