Skip to content

Go 并发编程

Go 并发是面试的重中之重。GMP 调度、channel、锁、context、并发模式——掌握这些才算真正理解 Go 的核心优势。

Q1: goroutine 和线程的区别?为什么 goroutine 轻量? 「🟢 校招/初级」

考察点:对 Go 并发基石的理解,初级必问题。

参考答案

goroutine 是 Go 运行时管理的用户态轻量级协程,和操作系统线程有本质区别。

维度goroutine操作系统线程
栈大小初始 2KB,可动态伸缩(最大可达 GB 级)MB 级(通常 1~8MB),固定大小
调度方式Go 运行时调度(用户态),M:N 映射操作系统内核调度(内核态),1:1 映射
切换开销极小,只保存 PC、SP 等几个寄存器很大,内核态切换、缓存失效、TLB 刷新
创建销毁成本极低,几纳秒级成本高,需要系统调用、分配栈空间
数量上限几万到几十万都没问题几千个就很吃资源了
数据结构runtime.g,是一个结构体对象内核 task_struct,是操作系统实体

为什么 goroutine 轻量:

  1. 栈小且可伸缩:初始只有 2KB,用多少分配多少,不够了再扩容(复制到更大的栈)。
  2. 用户态调度:由 Go runtime 在用户态调度,不经过内核,切换成本极低。
  3. M:N 映射:多个 goroutine 复用到少数几个 OS 线程上,不需要每个 goroutine 一个线程。
  4. 创建销毁快:不需要系统调用,在用户态就能完成。

追问延伸

  • goroutine 的栈是怎么增长的?(函数序言检查栈空间,不够就调用 runtime.morestack 分配新栈并复制)
  • 一个 goroutine 占多少内存?(初始 2KB 栈 + g 结构体几十字节,总共很小)
  • goroutine 调度是抢占式的吗?(Go 1.14 后是基于信号的异步抢占)

Q2: GMP 调度模型的详细原理? 「🟡 中级」

考察点:Go 运行时核心机制,中级工程师必问。

参考答案

GMP 是 Go 的调度模型,由三部分组成:

  • G(goroutine):协程,Go 并发的基本执行单元,初始栈 2KB。
  • M(Machine):OS 线程,真正执行代码的载体,受操作系统调度。
  • P(Processor):逻辑处理器,持有本地 G 队列,M 必须绑定 P 才能执行 G。数量默认为 CPU 核数(GOMAXPROCS)。

核心数据结构:

  • P 的本地队列:每个 P 有一个本地 G 队列(容量 256),存取不需要加锁,性能高。
  • 全局队列:所有 P 共享的 G 队列,存取需要加锁。
  • M 的数量:通常比 P 多,因为 M 可能因为系统调用阻塞,需要额外的 M 来绑定 P 继续运行。

调度流程:

  1. M 绑定一个 P,从 P 的本地队列头部取一个 G 执行。
  2. 如果本地队列为空:去全局队列取一批 G(拿 len(global)/GOMAXPROCS + 1 个),放到本地队列。
  3. 如果全局队列也空:从其他 P 的本地队列偷一半的 G(work stealing 机制),均衡负载。

系统调用时的调度:

  • 发起系统调用:M 和 P 解绑,P 找一个空闲的 M(或新建 M)继续执行其他 G。
  • 系统调用返回:M 尝试找一个空闲的 P 绑定;如果找不到,就把 G 放到全局队列,M 进入休眠。

调度时机:

  • 函数调用时的栈检查(可能触发抢占)
  • channel 操作(阻塞时让出)
  • 系统调用(前后都有调度点)
  • runtime.Gosched() 主动让出
  • 定时调度检查(sysmon 监控线程)
go
// 查看 GOMAXPROCS
runtime.GOMAXPROCS(0) // 0 表示获取当前值,不修改

追问延伸

  • 为什么要有 P 这一层?没有 P 行不行?(P 持有本地队列减少锁争用,实现 work stealing,绑定上下文资源)
  • work stealing 偷哪个 P 的?偷多少?(随机选一个 P,偷它本地队列的一半)
  • M 阻塞在系统调用上时,它上面的 G 怎么办?(P 和 M 解绑,P 带着队列找新 M 继续跑;旧 M 阻塞,系统调用返回后再找 P)

Q3: Go 的抢占式调度是怎么实现的? 「🔴 高级」

考察点:对调度器深入理解的标志,高级面试常考。

参考答案

Go 的抢占式调度经历了两个阶段的演进:

Go 1.14 之前:协作式抢占

  • 原理:在函数序言插入栈检查指令,如果检测到需要抢占,就调用 runtime.morestack 触发调度。
  • 问题:如果 goroutine 长时间执行循环且不调用函数(如纯计算的死循环),就不会有抢占点,会长时间占用 P。
  • 典型场景:for {} 空循环会永久占用一个 P,无法被抢占。

Go 1.14 及以后:基于信号的异步抢占

  • 原理:使用 SIGURG(紧急信号)实现真正的异步抢占。
  • 关键角色sysmon(监控线程),独立于 GMP 之外运行,定期检查:
    • 检测长时间运行的 G(超过 10ms)
    • 向对应的 M 发送 SIGURG 信号
  • 抢占流程
    1. M 收到信号,触发信号处理函数
    2. 信号处理函数修改 G 的执行上下文,设置抢占标志
    3. G 在安全点(safe-point)被挂起,切换到调度器
    4. 调度器把 G 放回队列,调度其他 G 执行

抢占点(安全点):

  • 函数序言(最常见)
  • 循环的回边(对循环也能抢占了,这是 1.14 的重大改进)
  • 非内联函数调用

注意事项:

  • 抢占不是"立即"的:需要等到安全点才能保存和恢复状态
  • 原子操作序列、极短的纯计算片段可能仍然无法被及时抢占
  • 栈扫描时也需要安全点,保证指针位置可识别
go
// 演示:Go 1.14 后这个循环不会永久阻塞调度器了
func busyLoop() {
    for {
        // 1.14 后循环回边也可能成为抢占点
    }
}

追问延伸

  • 为什么用 SIGURG 而不是其他信号?(SIGURG 默认被忽略,不影响用户程序;不用于其他常规用途)
  • 抢占式调度和协作式调度的优缺点?(抢占式更公平但有开销,协作式开销小但可能不公平)
  • sysmon 线程还做什么?(监控 GC、netpoller 轮询、检测死锁等)

Q4: channel 的底层结构?发送和接收的过程? 「🟡 中级」

考察点:CSP 模型核心,Go 并发的精髓。

参考答案

channel 的底层是 runtime.hchan 结构体,核心字段:

go
type hchan struct {
    qcount   uint           // 缓冲区中元素个数
    dataqsiz uint           // 缓冲区大小(容量)
    buf      unsafe.Pointer // 环形缓冲区指针
    elemsize uint16         // 元素大小
    closed   uint32         // 是否已关闭
    elemtype *_type         // 元素类型
    sendx    uint           // 发送索引
    recvx    uint           // 接收索引
    recvq    waitq          // 接收等待队列(sudog 链表)
    sendq    waitq          // 发送等待队列(sudog 链表)
    lock     mutex          // 互斥锁
}

发送过程(ch <- x)

  1. 有接收者等待(recvq 非空)

    • 直接从 recvq 取出第一个等待的 goroutine
    • 把数据直接复制给它(不走缓冲区)
    • 唤醒该 goroutine,设置它的返回值
    • 返回
  2. 缓冲区有空位(qcount < dataqsiz)

    • 把数据放到 buf[sendx] 位置
    • sendx++,如果到末尾就回绕到 0
    • qcount++
    • 返回
  3. 缓冲区满了(无缓冲或有缓冲但满了)

    • 当前 goroutine 封装成 sudog 加入 sendq
    • 挂起等待,让出 P
    • 被唤醒时数据已被取走,继续执行

接收过程(x := <-ch)

  1. 有发送者等待(sendq 非空)

    • 直接从 sendq 取出第一个等待的 goroutine
    • 直接从它那里拿数据(无缓冲时)或从缓冲区头部拿、把发送者的数据放到尾部(有缓冲时)
    • 唤醒该发送者
    • 返回数据
  2. 缓冲区有数据(qcount > 0)

    • 从 buf[recvx] 取数据
    • recvx++,回绕
    • qcount--
    • 返回
  3. 缓冲区空

    • 当前 goroutine 封装成 sudog 加入 recvq
    • 挂起等待
    • 被唤醒时数据已就绪

关闭过程(close(ch))

  1. 设置 closed = 1
  2. 唤醒所有 recvq 中的接收者(返回零值 + ok=false)
  3. 如果 sendq 中有发送者,全部唤醒 → 它们会 panic(向已关闭的 channel 发送)

追问延伸

  • 为什么 channel 需要 lock?不是说 CSP 不需要锁吗?(channel 内部维护队列和缓冲区,还是需要锁保护;但对用户来说是封装好的,使用层面不用手动加锁)
  • 向 nil channel 发送/接收会怎样?(永久阻塞)
  • 关闭已关闭的 channel 会怎样?(panic)
  • sudog 是什么?为什么不用 g 直接排队?(sudog 是 g 的包装,包含等待的元素、channel 等信息;一个 g 可能同时在多个等待队列里,所以需要 sudog)

Q5: 有缓冲 channel 和无缓冲 channel 的区别?使用场景? 「🟢 校招/初级」

考察点:channel 基础概念,初级必问。

参考答案

维度无缓冲 channel有缓冲 channel
创建方式make(chan T)make(chan T, 0)make(chan T, n),n >= 1
发送条件必须有接收者同时在等,否则阻塞缓冲区未满即可发送,满了才阻塞
接收条件必须有发送者同时在等,否则阻塞缓冲区非空即可接收,空了才阻塞
作用同步通信(握手)异步解耦、削峰
典型场景信号传递、同步等待、保证顺序生产者消费者、工作池、限流

无缓冲 channel 的使用场景

go
// 场景1:同步等待(握手)
done := make(chan struct{})
go func() {
    // 做一些工作
    close(done) // 发信号
}()
<-done // 等待完成

// 场景2:传递信号(零成本)
sig := make(chan struct{})
// 用 struct{} 不占内存

有缓冲 channel 的使用场景

go
// 场景1:工作池限流
sem := make(chan struct{}, 10) // 同时最多 10 个并发
for _, task := range tasks {
    sem <- struct{}{} // 占一个位置
    go func(t Task) {
        defer func() { <-sem }() // 释放
        process(t)
    }(task)
}

// 场景2:生产者消费者
queue := make(chan Item, 100)
// 生产者:往 queue 里放
// 消费者:从 queue 里取

选择原则

  • 需要同步、保证顺序、传递信号 → 无缓冲
  • 需要异步、解耦、削峰、限流 → 有缓冲
  • 不确定用哪个 → 先从无缓冲开始,它的语义更明确

追问延伸

  • 有缓冲 channel 满了之后发送方阻塞,这时候如果没人消费会怎样?(goroutine 泄漏)
  • 怎么实现一个超时的 channel 操作?(select + time.After
  • 单向 channel(chan<-<-chan)有什么用?(限制权限,防止误用,更清晰的接口语义)

Q6: Go 中的锁有哪些?sync.Mutex 和 sync.RWMutex? 「🟡 中级」

考察点:并发编程基础工具的理解和选型能力。

参考答案

Go 标准库提供了多种同步原语,常用的锁有以下几种:

sync.Mutex(互斥锁)

最基本的互斥锁,同一时间只能有一个 goroutine 持有。

go
var mu sync.Mutex
var count int

func increment() {
    mu.Lock()
    defer mu.Unlock()
    count++
}

特点:

  • Lock() 加锁,Unlock() 解锁
  • 不可重入:同一个 goroutine 再次 Lock 会死锁(和 Java 的 ReentrantLock 不同)
  • 两种模式:
    • 正常模式:新来的 goroutine 和唤醒的 goroutine 竞争,可能新来的先拿到(吞吐量高)
    • 饥饿模式:如果有 goroutine 等待超过 1ms,切换到饥饿模式,等待最久的 goroutine 优先获得锁(保证公平)

sync.RWMutex(读写锁)

读共享、写独占的锁,适用于读多写少的场景。

go
var rw sync.RWMutex
var data map[string]string

func read(key string) string {
    rw.RLock()
    defer rw.RUnlock()
    return data[key]
}

func write(key, val string) {
    rw.Lock()
    defer rw.Unlock()
    data[key] = val
}

特点:

  • RLock() / RUnlock():读锁,多个 goroutine 可以同时持有
  • Lock() / Unlock():写锁,独占
  • 写锁优先级高于读锁:有写锁在等,新的读锁会被阻塞(防止写饥饿)
  • 读锁不能升级为写锁:已经持有读锁的情况下调用 Lock 会死锁

其他同步原语

原语用途特点
sync.Mutex互斥锁通用,写多读少
sync.RWMutex读写锁读多写少,读共享写独占
sync.WaitGroup等待一组任务完成Add/Done/Wait
sync.Once只执行一次单例、初始化
sync.Cond条件变量等待/通知模式
atomic原子操作无锁,简单变量

选型建议

  • 写多读少 → sync.Mutex
  • 读多写少 → sync.RWMutex
  • 简单计数器/标志位 → atomic
  • 传递数据所有权 → channel

重要注意:不要复制锁!

go
// 错误:值拷贝会复制锁状态,两个副本独立了
type BadCounter struct {
    mu sync.Mutex
    n  int
}

func (c BadCounter) Incr() { // 值接收者,拷贝了 mu!
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}

// 正确:用指针接收者
type GoodCounter struct {
    mu sync.Mutex
    n  int
}

func (c *GoodCounter) Incr() { // 指针接收者
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}

追问延伸

  • Mutex 的正常模式和饥饿模式的区别?为什么要有饥饿模式?
  • RWMutex 为什么写锁优先级高?可能导致读饥饿吗?(不会,写锁释放后所有等待的读锁一起放行)
  • 为什么 Mutex 不可重入?设计考量是什么?(Go 的哲学是锁应该简单,重入锁容易掩盖代码问题)
  • sync.Locker 接口是什么?(Mutex 和 RWMutex 都实现了它,只有 Lock/Unlock 两个方法)

Q7: sync.Map 的原理?和 map+RWMutex 比哪个好? 「🟡 中级」

考察点:对并发安全 map 的理解深度和选型能力。

参考答案

sync.Map 是 Go 提供的并发安全 map,但它不是"万能药",有特定的适用场景。

内部结构

sync.Map 内部用了双 map 设计来减少锁竞争:

  • readatomic.Value 存 readOnly 结构体):只读 map,原子操作存取,不需要加锁
    • 包含一个 m map[any]*entry
    • amended 标记:dirty 里是否有 read 没有的 key
  • dirty(普通 map):读写都需要加 mu
  • misses(int):read 未命中的次数计数

entry 的三种状态:

  • nil:已删除(key 在 read 里标记删除)
  • expunged:已擦除(key 不在 dirty 中,彻底删除状态)
  • 正常指针:指向实际的值

读操作(Load)

  1. 先从 read 里找(原子读,无锁)
  2. 命中 → 直接返回
  3. 没命中 且 amended=true(dirty 有新 key) → 加锁,再检查一次 read(可能期间 read 被更新了)
  4. read 还是没有 → 从 dirty 里找
  5. miss 计数 +1,达到阈值(len(dirty))时触发 dirty 提升

写操作(Store)

  1. 如果 key 在 read 里,尝试直接更新(CAS 更新 entry,无锁)
  2. 如果 key 不在 read 里 → 加锁
  3. 再检查 read → 还没有就看 dirty
  4. dirty 里有就更新 dirty
  5. dirty 里没有就新增到 dirty,并设置 amended=true

删除操作(Delete)

  1. read 里有 → 直接把 entry 标记为 nil(延迟删除,无锁)
  2. read 里没有 且 amended → 加锁,从 dirty 里删除

dirty 提升(miss 太多时)

  • 把 dirty 整个赋值给 read(原子替换)
  • dirty 置为 nil
  • misses 清零
  • 下次写的时候再重建 dirty

适用场景对比

场景sync.Mapmap + RWMutex
读多写极少(大部分读无锁)也可以,但读需要加读锁
写多读少(频繁加锁 + dirty 重建开销)好(简单直接)
key 稳定(增删少)(read 命中率高)都可以
key 频繁增删(read 命中率低,频繁提升)
遍历多一般(需要锁)好(控制更灵活)

核心结论:sync.Map 不是银弹,大部分场景普通 map + 锁反而更简单、更快。只有在读极多、写极少、key 稳定的场景才考虑 sync.Map。

追问延伸

  • sync.Map 为什么不直接用一个 map + 锁?(双 map 设计让读路径无锁,在读多写少的场景性能更好)
  • entry 为什么有 expunged 状态?和 nil 有什么区别?(nil 是逻辑删除,dirty 提升时会被清理;expunged 是 key 不在 dirty 里的标记,用于区分"已删除"和"不存在于 dirty")
  • sync.Map 怎么遍历?(Range 方法,遍历的时候会先把 dirty 提升为 read)
  • sync.Map 的大小能直接获取吗?(不能,没有 Len 方法,需要自己计数或 Range 统计)

Q8: context 的底层原理?怎么实现取消和超时? 「🟡 中级」

考察点:Go 并发编程的基础设施,必问。

参考答案

context.Context 是 Go 中用于传递请求范围的取消信号、超时、截止时间和请求级值的接口。

Context 接口

go
type Context interface {
    Deadline() (deadline time.Time, ok bool)  // 返回截止时间
    Done() <-chan struct{}                     // 返回一个 channel,取消时关闭
    Err() error                                // 返回取消原因
    Value(key any) any                         // 获取绑定的值
}

四种实现类型

1. emptyCtx(空 context)

  • context.Background()context.TODO() 都是 emptyCtx
  • 永远不会取消,没有值,没有截止时间
  • 作为根 context 使用

2. cancelCtx(可取消的 context)

go
type cancelCtx struct {
    Context
    mu       sync.Mutex
    done     chan struct{}          // 懒初始化
    children map[canceler]struct{}  // 子 context 集合
    err      error
}

取消过程:

  1. 调用 cancel() → 加锁
  2. 关闭 done channel(所有监听 Done() 的 goroutine 都会收到信号)
  3. 遍历 children,递归取消所有子 context
  4. 从父 context 的 children 中移除自己
  5. 设置 err 为取消原因

3. timerCtx(带超时的 context)

go
type timerCtx struct {
    cancelCtx
    timer    *time.Timer
    deadline time.Time
}
  • 继承 cancelCtx 的所有能力
  • 额外有一个 time.Timer,到时间自动调用 cancel()
  • WithTimeoutWithDeadline 都是创建 timerCtx

4. valueCtx(带值的 context)

go
type valueCtx struct {
    Context
    key, val any
}
  • 包装父 context,附加一对 key-value
  • 查找 key 时,递归向上找(自己没有就找父 context)
  • 形成一条链式结构

取消传播机制

parent cancelCtx
  ├── child1 cancelCtx
  │     └── grandChild valueCtx
  └── child2 timerCtx

当 parent 被取消时:

  1. parent 关闭自己的 done channel
  2. parent 遍历 children,逐个调用 cancel
  3. child1 取消 → 它再取消自己的 children → grandChild 也被取消
  4. child2 取消 → 停止 timer → 它的 done 也关闭

所有下游 goroutine 通过 select <-ctx.Done() 感知到取消信号。

go
// 典型用法
func handleRequest(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    select {
    case result := <-doWork(ctx):
        // 处理结果
    case <-ctx.Done():
        // 超时或被取消
        log.Println(ctx.Err())
    }
}

追问延伸

  • 为什么 valueCtx 的 key 推荐用自定义类型而不是 string?(防止不同包之间的 key 冲突,用私有类型更安全)
  • context 应该作为第一个参数还是放在结构体里?(Go 官方推荐作为函数第一个参数,不要存到结构体里)
  • 取消是同步的还是异步的?(cancel() 是同步的,它会等所有子 context 都取消完再返回吗?不会,它触发取消就返回,子 goroutine 的退出是异步的)
  • context.Value 应该存什么?不应该存什么?(存请求级数据:trace_id、用户信息、超时配置;不应该存函数的必填参数、业务数据)

Q9: Go 中常见的并发模式? 「🟡 中级」

考察点:并发编程的实践经验和设计能力。

参考答案

Go 有一套独特的并发编程模式,核心是 goroutine + channel 的组合。

1. 生产者消费者模式

最经典的模式,用 channel 作为队列解耦生产和消费。

go
func producer(ch chan<- int) {
    for i := 0; i < 100; i++ {
        ch <- i
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for v := range ch { // channel 关闭后自动退出
        fmt.Println(v)
    }
}

2. 工作池(Worker Pool)

固定数量 worker 从 channel 取任务,控制并发度。

go
func workerPool(tasks <-chan Task, results chan<- Result, n int) {
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for task := range tasks {
                results <- process(task)
            }
        }()
    }
    wg.Wait()
    close(results)
}

3. 扇入/扇出(Fan-in / Fan-out)

  • 扇出:一个输入分发到多个 goroutine 并行处理
  • 扇入:多个输出合并到一个 channel
go
// 扇出:一个输入 channel,分发给多个 worker
func fanOut(in <-chan Task, n int) []<-chan Result {
    outs := make([]<-chan Result, n)
    for i := 0; i < n; i++ {
        outs[i] = worker(in)
    }
    return outs
}

// 扇入:多个输出 channel 合并成一个
func fanIn(chs ...<-chan Result) <-chan Result {
    out := make(chan Result)
    var wg sync.WaitGroup
    for _, ch := range chs {
        wg.Add(1)
        go func(c <-chan Result) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

4. 超时控制

select + time.Aftercontext.WithTimeout 实现超时。

go
// 方式1:time.After(简单场景)
select {
case result := <-ch:
    fmt.Println(result)
case <-time.After(3 * time.Second):
    fmt.Println("timeout")
}

// 方式2:context(推荐,可传播取消)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
select {
case result := <-ch:
    fmt.Println(result)
case <-ctx.Done():
    fmt.Println(ctx.Err())
}

5. 限流(信号量模式)

用带缓冲的 channel 作为信号量。

go
sem := make(chan struct{}, 10) // 最多 10 个并发

func doWork(task Task) {
    sem <- struct{}{} // 获取信号量
    defer func() { <-sem }() // 释放
    // 处理任务
}

6. 优雅退出

多种方式实现 goroutine 的优雅退出:

go
// 方式1:done channel
func worker(done <-chan struct{}) {
    for {
        select {
        case <-done:
            return
        default:
            // 干活
        }
    }
}

// 方式2:context(推荐,支持级联取消)
func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            // 干活
        }
    }
}

7. errgroup(一个出错就取消所有)

golang.org/x/sync/errgroup 包提供,一个 goroutine 出错就取消其他所有的。

go
import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(context.Background())

g.Go(func() error {
    return doTask1(ctx) // ctx 被取消时这个任务应该感知并退出
})
g.Go(func() error {
    return doTask2(ctx)
})

if err := g.Wait(); err != nil {
    // 有一个出错了,其他的已被取消
    log.Println(err)
}

8. 关闭 channel 广播

关闭 channel 会唤醒所有等待的接收者,利用这个特性实现一对多的通知。

go
stop := make(chan struct{})

// 多个 goroutine 都监听同一个 stop channel
go func() {
    <-stop
    fmt.Println("worker 1 stopped")
}()
go func() {
    <-stop
    fmt.Println("worker 2 stopped")
}()

// 关闭 channel,所有监听者都会收到信号
close(stop)

追问延伸

  • 工作池的 worker 数量怎么设置?(CPU 密集型设为 GOMAXPROCS,IO 密集型可以更多)
  • 扇入扇出适用于什么场景?(数据处理流水线、并行计算)
  • 除了 channel,还有什么方式实现优雅退出?(context、sync.Cond、atomic 标志位)
  • 什么是 pipeline 模式?(多个 channel 串联,前一个的输出是后一个的输入)

Q10: sync.WaitGroup 的用法和原理? 「🟢 校招/初级」

考察点:最常用的同步原语,初级必问。

参考答案

sync.WaitGroup 用于等待一组 goroutine 完成,类似于计数器。

基本用法

go
func main() {
    var wg sync.WaitGroup
    
    for i := 0; i < 5; i++ {
        wg.Add(1) // 计数 +1,要在 goroutine 启动前调用
        go func(i int) {
            defer wg.Done() // 计数 -1
            fmt.Println(i)
        }(i)
    }
    
    wg.Wait() // 阻塞直到计数为 0
    fmt.Println("all done")
}

三个方法

方法作用注意
Add(n)计数增加 n(可以是负数)一般在启动 goroutine 前调用
Done()计数减 1等价于 Add(-1),用 defer 最安全
Wait()阻塞直到计数变为 0不能和 Add 并发使用

底层原理

WaitGroup 内部用一个 state1 字段(uint64)同时存储两个值:

  • 高 32 位:计数器(counter)
  • 低 32 位:等待者数量(waiter count)
state1 (64位):
+-----------------------+-----------------------+
|      counter (32位)    |   waiter count (32位) |
+-----------------------+-----------------------+
  • Add:用 atomic.AddUint64 原子地修改 counter
  • Wait:检查 counter,如果 > 0 就增加 waiter 计数,然后阻塞等待
  • 当 counter 变为 0 时,如果有等待者,就唤醒所有等待者

常见坑

坑1:Add 在 goroutine 里面调用

go
// 错误:Wait 可能在 Add 之前执行,导致提前返回
var wg sync.WaitGroup
go func() {
    wg.Add(1) // 太晚了!主 goroutine 可能已经执行完 Wait 了
    defer wg.Done()
}()
wg.Wait() // 可能直接返回

坑2:复制 WaitGroup

go
// 错误:值拷贝会复制内部状态
func bad(wg sync.WaitGroup) { // 值传递,拷贝了一份
    wg.Wait()
}
// 正确:用指针
func good(wg *sync.WaitGroup) {
    wg.Wait()
}

坑3:计数变为负数

go
// 会 panic:sync: negative WaitGroup counter
var wg sync.WaitGroup
wg.Done() // 初始是 0,Done 变成 -1

追问延伸

  • WaitGroup 可以重用吗?(可以,计数回到 0 之后可以再次 Add 使用)
  • Wait 和 Add 可以并发调用吗?(可以,但有讲究:Add 正数要在 Wait 之前;Add 负数(Done)可以和 Wait 并发)
  • WaitGroup 内部为什么用 uint64 存两个值而不是两个字段?(可以用一条原子操作同时操作,效率更高)
  • 为什么 WaitGroup 不能值拷贝?(拷贝后两个 WaitGroup 状态独立,可能导致不可预期的行为)

Q11: sync.Once 的原理?怎么保证只执行一次? 「🟡 中级」

考察点:单例模式和原子操作的应用。

参考答案

sync.Once 保证某个函数只执行一次,常用于单例初始化、配置加载等场景。

基本用法

go
var once sync.Once
var config *Config

func getConfig() *Config {
    once.Do(func() {
        config = loadConfig() // 只会执行一次
    })
    return config
}

底层原理

go
type Once struct {
    done uint32    // 是否已执行的标记(0=未执行,1=已执行)
    m    Mutex     // 互斥锁
}

Do 方法的执行流程:

  1. 快速路径:原子读 done,如果是 1,直接返回(无锁,已执行过了)
  2. 慢速路径done 是 0,调用 doSlow
    • 加锁
    • 再次检查 done(双重检查锁定 DCL)
    • 如果还是 0,执行函数 f
    • 执行完后原子设置 done = 1
    • 解锁

为什么加锁后还要检查一次?

因为可能有两个 goroutine 同时读到 done=0,都进入慢速路径:

  • Goroutine A 先拿到锁,执行 f,设置 done=1,释放锁
  • Goroutine B 拿到锁,如果不再次检查,就会再执行一次 f
  • 所以加锁后必须再检查一次 done,这就是双重检查

注意事项

1. panic 也算"执行过了"

如果 f 内部 panic 了,done 仍然会被设为 1,后续的 Do 不会再执行 f。

2. 不要嵌套 Do 同一个 once

go
// 会死锁
once.Do(func() {
    once.Do(func() { // 同一个 once,嵌套调用
        // 想拿锁,但外层已经拿着了
    })
})

这会导致死锁,因为 Mutex 不可重入。

3. Once 是一次性的

一旦函数执行过(哪怕 panic 了),这个 Once 就"用完了",不能重置。

追问延伸

  • 为什么不用一个 Mutex 就完事,还要加 done 的原子检查?(快速路径无锁,性能好;如果已经执行过了,就不需要加锁了)
  • sync.Once 和自己写的单例有什么区别?(更安全、更高效,标准实现)
  • 如果 f 执行很慢,其他 goroutine 调用 Do 会怎样?(会阻塞在锁上,等第一个执行完)
  • 怎么实现一个可重置的 Once?(标准库没有,可以自己用 Mutex + 标志位实现,但要小心并发)

Q12: Go 中 goroutine 泄漏是什么?怎么排查和避免? 「🟡 中级」

考察点:实际工程经验和问题排查能力。

参考答案

什么是 goroutine 泄漏

goroutine 泄漏是指 goroutine 因为各种原因永远无法退出,它占用的栈内存和相关资源无法被回收,随着泄漏数量增加,最终可能导致内存耗尽、程序崩溃。

和内存泄漏类似,但泄漏的是 goroutine(每个 goroutine 至少占 2KB 栈 + g 结构体)。

常见原因

1. channel 永远阻塞

go
// 泄漏:没人往 ch 里发数据,这个 goroutine 永远等下去
func leak1() {
    ch := make(chan int)
    go func() {
        v := <-ch // 永远等不到
        fmt.Println(v)
    }()
    // 函数返回了,但 goroutine 还在等
}

// 泄漏:channel 满了,没人消费,发送方阻塞
func leak2() {
    ch := make(chan int, 1)
    go func() {
        ch <- 1
        ch <- 2 // 缓冲区满了,没人取,阻塞在这里
    }()
}

2. 死循环没有退出条件

go
// 泄漏:没有退出条件的死循环
func leak3(stop <-chan struct{}) {
    go func() {
        for {
            // 忘了检查 stop
            doWork()
        }
    }()
}

3. 死锁

多个 goroutine 互相等待对方释放资源,谁都无法继续。

4. 阻塞在系统调用

goroutine 阻塞在某个永远不会返回的系统调用上(如网络 IO 没有超时)。

5. 忘记关闭 channel

go
// 泄漏:range 一个永远不会关闭的 channel
func leak4(ch chan int) {
    go func() {
        for v := range ch { // ch 永远不关闭,永远循环
            fmt.Println(v)
        }
    }()
}

排查方法

1. pprof(最常用)

go
import _ "net/http/pprof"

// 然后浏览器访问:
// http://localhost:6060/debug/pprof/goroutine?debug=1
// 或者用命令行:
// go tool pprof http://localhost:6060/debug/pprof/goroutine

debug=1 看 goroutine 数量和概览,debug=2 看所有 goroutine 的完整堆栈。

2. runtime.NumGoroutine()

go
// 监控 goroutine 数量,如果异常增长就是有泄漏
log.Printf("goroutine count: %d", runtime.NumGoroutine())

3. goleak 库

go.uber.org/goleak 用于测试中检测 goroutine 泄漏:

go
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m) // 测试结束后检查有没有泄漏的 goroutine
}

避免方法

  1. 用 context 控制生命周期:每个 goroutine 都应该能被取消
  2. 确保 channel 有退出路径:不要让 goroutine 无限制地等一个 channel
  3. 超时机制:IO 操作、网络请求一定要设置超时
  4. 工作池限制数量:不要无限制地开 goroutine
  5. 关闭 channel 广播退出:用 close(ch) 通知所有接收者退出
  6. 代码审查:每个 go func() 都要想清楚它怎么退出

追问延伸

  • goroutine 泄漏会导致什么后果?(内存持续增长、GC 压力大、程序变慢甚至 OOM)
  • 怎么用 pprof 定位具体是哪里泄漏的?(看 goroutine 的堆栈,找到卡住的那个调用栈)
  • channel 的发送方和接收方,哪边更容易导致泄漏?(都可能,但发送方阻塞在满 channel 上更隐蔽)
  • 一个 goroutine 占多少内存?泄漏多少会有问题?(初始 2KB 栈,增长后更大;几千个可能不明显,上万个就有压力了)

Q13: Go 的 CSP 模型是什么?和共享内存有什么区别? 「🟡 中级」

考察点:对 Go 并发哲学的理解。

参考答案

什么是 CSP

CSP(Communicating Sequential Processes,通信顺序进程)是一种并发编程模型,核心思想是:

不要通过共享内存来通信,而要通过通信来共享内存。

Go 的 CSP 实现 = goroutine + channel

  • goroutine:独立的执行单元
  • channel:goroutine 之间通信的通道,传递数据(同时也传递数据所有权)

CSP vs 共享内存+锁

维度CSP(channel)共享内存 + 锁
核心思想通过通信共享内存通过共享内存通信
数据传递传递数据所有权多个 goroutine 同时访问
同步方式channel 天然同步需要手动加锁解锁
出错概率低(编译器和类型系统帮忙)高(死锁、竞态、遗忘解锁)
可组合性好(channel 可以组合出各种模式)差(锁的组合容易死锁)
调试难度相对容易难(竞态条件难以复现)
性能有 channel 开销锁竞争时开销也大

什么时候用 channel

  • 传递数据所有权:一个 goroutine 产生数据,交给另一个处理
  • 任务分发:工作池、生产者消费者
  • 同步信号:等待、通知、超时
  • 协调多个 goroutine:扇入扇出、流水线
go
// CSP 风格:通过 channel 传递结果
func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2 // 把结果发出去(传递出去就不再管了)
    }
}

什么时候用锁

不是说 CSP 就一定比锁好,有些场景锁更合适:

  • 性能极端敏感:临界区非常短,原子操作或自旋锁更快
  • 保护复杂共享数据结构:比如并发安全的缓存、计数器
  • 简单的状态标记:用 atomic 就够了
  • 高性能场景:channel 有拷贝和调度开销,极致性能用锁
go
// 共享内存风格:用锁保护共享变量
type Counter struct {
    mu    sync.Mutex
    count int
}

func (c *Counter) Incr() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

Go 的实践建议

Go 官方的建议是:能用 channel 就用 channel,但是不要为了用 channel 而用 channel。

选择的依据:

  • 数据在 goroutine 之间流转 → channel
  • 数据被多个 goroutine 同时访问 → 锁
  • 简单计数 → atomic
  • 想不清楚 → 先选简单的,能解决问题就行

追问延伸

  • Go 为什么选择 CSP 作为主要并发模型?(CSP 更符合人类思维、更容易写出正确的并发程序、channel 类型安全)
  • channel 内部不也用了锁吗?那和自己用锁有什么区别?(channel 封装了锁,对使用者透明;而且 channel 有队列、阻塞唤醒等机制,不是单纯的锁)
  • CSP 和 Actor 模型有什么区别?(Actor 是通过消息传递,每个 Actor 有邮箱;CSP 是通过 channel 通信,channel 是一等公民,和 goroutine 解耦)
  • "不要通过共享内存来通信"这句话绝对吗?(不是绝对的,是一种指导思想;Go 也提供了 Mutex 等工具,该用还是要用)

Q14: atomic 包是什么?和 mutex 比有什么区别? 「🟡 中级」

考察点:底层并发原语的理解和选型。

参考答案

atomic 包简介

sync/atomic 包提供了底层的原子操作,基于 CPU 的原子指令(如 CAS)实现,是无锁编程的基础。

常用操作

go
// 原子加
atomic.AddInt64(&counter, 1)

// 原子读
val := atomic.LoadInt64(&counter)

// 原子写
atomic.StoreInt64(&counter, 100)

// 原子交换
old := atomic.SwapInt64(&counter, 200)

// 比较并交换(CAS)
swapped := atomic.CompareAndSwapInt64(&counter, 100, 200)
// 如果 counter 当前值是 100,就设为 200,返回 true
// 否则不做任何操作,返回 false

Go 1.19 引入了类型安全的泛型版本:

go
var counter atomic.Int64
counter.Add(1)
val := counter.Load()

atomic vs Mutex

维度atomicMutex
实现方式CPU 原子指令,无锁操作系统互斥量,有锁
开销低,用户态高,可能内核态切换
适用场景单个变量的简单操作复杂临界区、多个变量
死锁风险
可组合性差(只能操作单个变量)可以保护复杂逻辑
内存序需要考虑(Go 1.19 类型安全版有明确语义)锁天然保证顺序

适用场景

atomic 适合:

  • 计数器(递增、递减)
  • 状态标志位(0/1 切换)
  • 简单的引用计数
  • 高性能场景下的无锁数据结构
  • 作为其他并发原语的基础构件(WaitGroup、Once、sync.Map 内部都用了 atomic)

Mutex 适合:

  • 保护复杂的数据结构
  • 临界区内有多个操作
  • 需要阻塞等待的场景
  • 逻辑比较复杂,用锁写更清晰

CAS(Compare And Swap)

CAS 是 atomic 的核心操作,也是无锁编程的基础:

go
// 乐观锁:先检查,再更新
for {
    old := atomic.LoadInt64(&val)
    new := old + 1
    if atomic.CompareAndSwapInt64(&val, old, new) {
        break // 成功
    }
    // 失败了说明期间被别人改了,重试
}

CAS 的问题:

  • ABA 问题(值从 A 变 B 又变回 A,CAS 认为没变)
  • 高并发下重试次数多,CPU 占用高
  • 只能保证单个变量的原子性

注意事项

  1. 不要滥用 atomic:复杂场景用 Mutex 更清晰、更不容易出错
  2. 内存序问题:atomic 操作涉及内存可见性,Go 保证了顺序一致性,但要理解 happens-before
  3. 64 位对齐:在 32 位系统上,64 位原子操作需要 8 字节对齐,否则可能 panic
  4. Value 类型atomic.Value 可以原子存取任意类型,但 Store 和 Load 的类型必须一致

追问延伸

  • 什么是无锁编程?有什么优缺点?(不用锁,用原子操作实现并发安全;优点是性能高、不死锁,缺点是难写难调试)
  • CAS 的 ABA 问题是什么?怎么解决?(值从 A→B→A,CAS 误以为没变;用版本号或指针解决)
  • WaitGroup、Once 内部哪里用了 atomic?(WaitGroup 的 state 计数用 atomic;Once 的 done 标记用 atomic)
  • atomic 和 channel 哪个更快?(简单操作 atomic 快;涉及同步等待 channel 更方便,不好直接比)
  • Go 的内存模型中,atomic 提供什么保证?(顺序一致性,atomic 的写 happens-before 于后续的读)