Go 语言面试笔记
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 满足以下任一条件则回到正常模式:
- 是等待队列中的最后一个 goroutine;
- 等待时间不超过 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 参考题目
阅读 —
·
全站 —