Skip to content

第09章 面试题精选 —— 并发编程

这是 Go 面试的核心——前半段几乎必问。


★ 基础题

Q1:goroutine 和线程的区别?

OS 线程goroutine
栈大小1-8 MB(固定)2KB 起步,按需扩
创建开销约 1-10 μs约 100 ns
切换开销进内核态 ~1μs用户态 ~10 ns
数量上限几千百万级
调度OS 内核Go runtime(GMP)

Q2:channel 是干什么的?

:goroutine 之间通信的"管道"。

  • 发送ch <- v
  • 接收v := <-ch
  • 无缓冲:发送和接收必须同时就位
  • 带缓冲:缓冲未满时发送不阻塞

Go 哲学:Don't communicate by sharing memory; share memory by communicating.


Q3:以下代码输出什么?

go
ch := make(chan int)
go func() { ch <- 1 }()
go func() { ch <- 2 }()
fmt.Println(<-ch, <-ch)

:可能是 1 2 也可能是 2 1取决于调度器。两个 goroutine 谁先发送是不确定的。


Q4:channel 关闭后会发生什么?

go
close(ch)

// 1. 再发送 → panic
ch <- 1   // panic!

// 2. 接收剩余数据
v, ok := <-ch   // ok = true,正常拿到数据

// 3. 接收完后再接收
v, ok := <-ch   // v = 0(零值),ok = false

重要:只让发送方关闭 channel,接收方关闭可能 panic。


Q5:用 channel 实现生产者-消费者。

go
ch := make(chan int, 10)

// 生产者
go func() {
    defer close(ch)
    for i := 1; i <= 5; i++ {
        ch <- i
    }
}()

// 消费者
for v := range ch {
    fmt.Println(v)
}

for range 会自动在 channel 关闭后退出。


★★ 进阶题

Q6:sync.WaitGroup 的常见错误?

go
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    go func() {
        wg.Add(1)        // ❌ 应该在 go 之前 Add
        defer wg.Done()
        // ...
    }()
}
wg.Wait()   // 可能 wg 还没 Add 就 Wait,立即返回

正确写法

go
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // ...
    }()
}

Q7:以下代码会输出什么?

go
func main() {
    var x int
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            x++
        }()
    }
    wg.Wait()
    fmt.Println(x)
}

:通常 < 1000,每次都不一样。经典数据竞争

x++ = 读 + 加 + 写,三步操作,多 goroutine 同时读到旧值,写回时发生丢失更新。

修复

go
var mu sync.Mutex
mu.Lock(); x++; mu.Unlock()

// 或用原子操作
atomic.AddInt64(&x, 1)

Q8:select 没有就绪的 case 时怎么办?

go
select {
case v := <-ch1: ...
case v := <-ch2: ...
}
// 阻塞,直到任一 case 就绪

带 default

go
select {
case v := <-ch1: ...
default:
    // 立即执行
}

default 让 select 变成非阻塞


Q9:什么情况下会发生 deadlock?

最常见:

go
// 1. 无缓冲 channel 自发自收
ch := make(chan int)
ch <- 1            // 永远阻塞,没接收方
fmt.Println(<-ch)

// 2. 主 goroutine 等不存在的接收
ch := make(chan int)
fmt.Println(<-ch)  // fatal error: all goroutines are asleep

// 3. WaitGroup Add 数量与 Done 不匹配
wg.Add(3)
wg.Done(); wg.Done()
wg.Wait()   // 永远等

Go 运行时能自动检测所有 goroutine 都阻塞的死锁,输出 fatal error: all goroutines are asleep - deadlock!


Q10:怎么实现一个超时控制?

go
ch := make(chan Result, 1)
go func() {
    ch <- doWork()
}()

select {
case r := <-ch:
    return r, nil
case <-time.After(3 * time.Second):
    return Result{}, errors.New("timeout")
}

或者用 context:

go
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return doWork(ctx)

★★★ 深度题

Q11:解释 Go 的 GMP 模型。

  • G Goroutine:用户的并发单元
  • M Machine:真正执行 G 的 OS 线程
  • P Processor:调度上下文(含本地 G 队列),数量 = GOMAXPROCS

调度流程

  1. 每个 P 维护一个本地 G 队列
  2. M 从 P 上拿 G 执行
  3. 本地队列空了,M 去全局队列偷 / 工作窃取(work-stealing)从其他 P 偷一半
  4. G 阻塞时(系统调用、channel 阻塞)M 释放 P,让别的 M 接管

这种 M:N 模型让 Go 能用少量线程支撑海量协程。


Q12:什么是"逃逸分析"?什么时候 goroutine 闭包会让变量逃逸?

go
func main() {
    x := 100
    go func() {
        fmt.Println(x)   // x 被 goroutine 引用
    }()
}

x 本来在栈上,但因为 goroutine 可能在 main 退出后还在用它,编译器会把它移到堆上——这就是逃逸。

go build -gcflags="-m" 可以查看逃逸分析结果。


Q13:channel 底层是怎么实现的?

chan 是一个指向 hchan 结构的指针:

go
type hchan struct {
    qcount   uint           // 当前元素数
    dataqsiz uint           // 缓冲容量
    buf      unsafe.Pointer // 环形缓冲数组
    elemsize uint16
    closed   uint32
    sendx    uint           // 发送索引
    recvx    uint           // 接收索引
    recvq    waitq          // 接收等待队列
    sendq    waitq          // 发送等待队列
    lock     mutex
}

发送/接收都需要先加锁,做完操作后解锁。所以 channel 不是免费的,但比互斥锁清晰。


Q14:以下代码有什么问题?

go
func process(items []int) []int {
    results := make([]int, len(items))
    var wg sync.WaitGroup
    for i, item := range items {
        wg.Add(1)
        go func() {
            defer wg.Done()
            results[i] = item * 2   // ❌
        }()
    }
    wg.Wait()
    return results
}

问题iitem 是循环变量,所有 goroutine 共享(Go 1.21 及之前)。可能多个 goroutine 看到同一个值。

修复

go
go func(i, item int) {
    defer wg.Done()
    results[i] = item * 2
}(i, item)

Go 1.22+ 已自动修复此问题。


Q15:sync.Map 和加锁的 map 哪个更好?

场景推荐
key 集合频繁变化map + sync.RWMutex
读多写少且 key 集合稳定sync.Map
同一个 key 反复修改map + Mutex
值是结构体而非简单类型map + Mutex(sync.Map 性能反而差)

sync.Map 内部用了两层 map(read + dirty),减少加锁,但通用性不如 RWMutex。


Q16:Go 1.21 引入的 sync.OnceFunc 是什么?

go
init := sync.OnceFunc(func() {
    fmt.Println("init")
})

init()  // 执行
init()  // 不执行

sync.Once.Do 更优雅,配合闭包用更顺手。


📌 速记口诀

"go 启程;channel 通信;select 多路;锁保数据;context 取消”


🎯 自测练习

  1. 用 channel 实现一个信号量,控制最多 N 个并发
  2. 写一个worker pool:M 个 worker 处理 N 个任务,等所有完成
  3. context.WithTimeout 实现 HTTP 请求超时
  4. 解释 for v := range ch 什么时候会自动退出
  5. sync.Once 实现一个并发安全的"全局配置加载器"

进入第10章 🚀