Go 语言面试笔记

Go 语言面试笔记

参考:goroutine 调度、memory 分配、GC 原理、逃逸分析

1 goroutine 调度

Go 调度器实现用户态协程与 OS 线程(M)的 1:N 映射,涉及到 G(goroutine)、M(machine 线程)、P(processor 处理单元,负责调度 G 的上下文环境,复用 M)。

什么情况下发生调度?为什么会阻塞?

用户态阻塞/唤醒(channel 操作、network I/O):

  • goroutine 因 channel 操作或网络 IO 阻塞时(网络 IO 实际由 netpoller 处理,不会阻塞 M,仅阻塞 G),对应的 G 被放到某个 wait 队列(如 channel 的 waitq),状态由 _Grunning 变为 _Gwaitting;
  • M 会跳过该 G 尝试获取并执行下一个 G;如果没有可运行的 G,M 将解绑 P 并进入 sleep;
  • 当阻塞的 G 被另一端唤醒(channel 可读/写通知),G 被标记为 runnable,尝试加入所在 P 的 runnext,然后是 P 的 Local 队列和 Global 队列。

系统调用阻塞:

  • G 被阻塞在系统调用上,进入 _Gsyscall 状态,M 处于 block on syscall;
  • 执行该 G 的 M 与 P 解绑,P 尝试与其他 idle M 绑定继续执行其他 G;
  • 没有 idle M 但 P 的 Local 队列仍有 G 时,创建一个新 M;
  • 系统调用完成后,G 重新尝试获取一个 idle 的 P 恢复执行;没有 idle P 则标记为 runnable 加入 Global 队列。

抢 占式调度

后台监控线程 sysmon 是一个特殊的 M,不需要绑定 P,每 10ms 运行一次,将运行太久的 G 发出抢占式调度请求。一旦 G 的抢占位置为 true,下次调用函数或方法时 runtime 便可将 G 抢占并移出运行态。因此一个死循环的 goroutine 不会一直独占 CPU。

为什么要有 P,只有 Global 队列行不行?

可以,但多个 P 争抢一个 queue 需要加锁,有额外开销、影响效率。本地队列可以避免锁竞争、提高局部性(把分配对象分配给当前 P 释放的对象)。

2 goroutine 间通信

  • Channel:goroutine 间通信的主要方式,CSP 模型(不要通过共享内存来通信,而应通过通信来共享内存);
  • WaitGroup:等待一组 goroutine 完成;
  • Context:用于传递取消信号、超时控制、传值,实现超时重试等。

3 互斥锁 Mutex 原理

互斥量有两种操作模式:正常模式和饥饿模式。

  • 正常模式:等待的 goroutine 按照 FIFO 排队,但被唤醒后不能立即获得锁,需要与新到达的 goroutine 争夺锁。因为新到达的 goroutine 已在 CPU 上运行,被唤醒的 goroutine 大概率争夺失败,需要排到队列前面。等待超过 1ms 未获得锁会变为饥饿模式;
  • 饥饿模式:锁直接从解锁的 goroutine 交给队列最前面的 goroutine,新到达的 goroutine 不自旋、直接到等待队列尾部排队;
  • 饥饿模式下获得锁的 goroutine 满足以下任一条件则回到正常模式:
    1. 是等待队列中的最后一个 goroutine;
    2. 等待时间不超过 1ms。

正常模式性能更好(goroutine 可以连续多次获得锁);饥饿模式用于预防队列尾部 goroutine 一直无法获得锁。

mutex 由 atomic 原子操作实现,用到 CPU 底层指令(LOCK 前缀 + CAS)。

4 Slice

  • 底层:type slice struct { array unsafe.Pointer; len int; cap int },是数组片段的描述符,指向底层数组;
  • append 扩容:cap 增长是新容量大于旧容量的翻倍(小 slice 翻倍,大 slice 按比例增长),并可能分配新数组搬移数据,因此 append 的返回值要重新赋值;
  • 坑:对 slice 取子切片后 append 可能修改原数组共享的部分,扩容后又是全新数组,行为容易混淆。

5 String

  • 底层是 struct { str unsafe.Pointer; len int },指向只读字节数组;
  • 为什么不可修改:String 是不可变的(immutable),赋值时只拷贝描述符不拷贝内容;需要修改时转换成 []byte 处理。

6 Map 为什么不是线程安全的

  • Go 的 map 是引用类型,函数虽然是值传递,但值里保存的是内存地址,多个 goroutine 共享同一地址;
  • 多个 goroutine 同时读写 map 会触发并发读写 panic(fatal error: concurrent map writes);
  • 需要并发访问时加 sync.Mutex / 使用 sync.Map。

7 逃逸分析

编译器决定变量分配在栈上还是堆上。原则:

  • 返回函数内声明的局部变量指针 → 逃逸到堆上(因为函数返回后栈帧销毁,但外部还要用);
  • 接口类型方法调用、fmt.Println 等会逃逸;
  • 闭包引用的变量会逃逸到堆上。

逃逸分析的好处:减少堆分配和 GC 压力,分配在栈上更快。

8 Channel 与 Select

  • channel 底层是一个带锁的环形队列(hchan 结构:buf 环形缓冲、lock、sendx/recvx 指针、sendq/recvq 等待队列),本身是线程安全的;
  • 有缓冲 vs 无缓冲:
    • 无缓冲 channel 收发双方必须同时就绪,否则阻塞;
    • 有缓冲 channel 在缓冲未满时发送不阻塞、未空时接收不阻塞;
  • 关闭:应由发送端关闭 channel;向已关闭的 channel 发送会 panic;关闭后再接收会取到零值(可通过 v, ok := <-ch 判断 close);
  • Select:监听多个 channel 的收发操作,用 default 实现非阻塞。

9 defer 与 recover

  • defer 在函数返回时按 LIFO 顺序执行(后注册先执行),参数在 defer 语句处求值;
  • defer+recover 可以捕获 panic,recover 必须在 defer 调用的函数里直接调用才有效;
  • panic 会沿调用栈向上传播,直到被 recover 或程序终止。

10 GC 原理

Go 采用并发三色标记清除(CMS 思想):

  • 三色:白色(待回收)、灰色(待扫描)、黑色(扫描完且引用可达);
  • 与用户代码并发执行,通过写屏障保证对象不被误回收;
  • 周期触发(内存增长达到阈值)和定时触发(如 2 分钟);
  • Web (STW) 时间短,通过把标记与用户代码并行来降低停顿。

11 内存分配

参考 TCMalloc 思想:为对象按大小分级(微小对象 ≤16B、小对象 ≤32KB、大对象),每个 P 维护本地缓存(mcache),优先从本地获取,减少锁竞争;mcache 不足时向 mcentral/mheap 申请,mheap 通过 mmap 向 OS 申请大块内存并维护空闲页。

12 常考题

  • 读写锁原理(sync.RWMutex 基于互斥锁 + 信号量);
  • Channel 有缓冲/无缓冲区别;有缓冲应关闭哪边(发送端);
  • Context 实现超时重试(context.WithTimeout);
  • gin 框架原理(radix 树路由、中间件洋葱模型)。

13 参考题目

阅读 — · 全站 —
🎸 我的歌单 0 首