Files
cs-note/hzh/GolangStar/Go面试题库/Sync面试题.md
T

7.8 KiB
Raw Blame History

tags, create time
tags create time
go
golang
interview
sync-questions
2026-06-07 14:30

Sync 面试题 🔒

概述

本文件涵盖 Go sync 包的 13 道高频面试题,涉及 Mutex、Once、WaitGroup、sync.Map 等同步原语的原理与应用。这些是并发编程中保证数据一致性的核心工具。

关联笔记

正文

Q1:除了 Mutex 还有哪些方式安全读写共享变量? 🟢简单

参考答案

方式 适用场景 性能
Channel Go 推崇的方式,通过通信传递所有权 中
原子操作(atomic) 简单的整型/指针操作(计数器、状态位) 最高
Mutex/RWMutex 保护复杂数据结构或多变量一致性 中
sync.Map 读多写少的并发 Map 高(特定场景)

[!tip] 💡 面试技巧 选择策略:"简单计数用 atomic,复杂逻辑用 Mutex,读多写少用 sync.Map,跨 goroutine 通信用 Channel。"


Q2-Q3:原子操作 vs 锁的区别? 🟡中等

[!question] ❓ 思考一下 为什么原子操作比锁快?它们各自能保护什么范围的内容?

参考答案

维度 原子操作 锁
实现层级 CPU 硬件指令(如 LOCK ADD) 语言运行时 + 操作系统
保护范围 单个变量的单次读写 一段代码块(临界区)
性能 极高,无内核介入 较低,失败时 goroutine 挂起
适用场景 计数器、标志位 多变量一致性、复杂逻辑
// 原子操作:高性能计数器
var count int64
atomic.AddInt64(&count, 1)

// 锁:保护多个变量的一致性
mu.Lock()
balance -= amount
transactions = append(transactions, tx)
mu.Unlock()

[!note] 📝 核心考点 原子操作是"微观"的,锁是"宏观"的。原子操作不挂起 goroutine,锁失败时会 park。


Q4:Mutex 底层怎么实现的? 🟡中等

参考答案

Mutex 通过原子操作 + 信号量实现:

type Mutex struct {
    state int32  // 锁的状态(锁定/唤醒/饥饿模式等,用二进制位标识)
    sema  uint32 // 信号量,用于 goroutine 的阻塞和唤醒
}

[!info] 🔗 延伸阅读


Q5:Mutex 有几种模式? 🟡中等

参考答案

两种模式,动态切换:

模式 特点 公平性
正常模式 新来的 goroutine 可以和队列头竞争,吞吐量高 不公平
饥饿模式 等待超过 1ms 后进入,锁直接交给队头,新来者排尾部 绝对公平

切换规则:

  • 进入饥饿:等待时间 > 1ms
  • 退出饥饿:等待队列空 或 等待时间 < 1ms

[!tip] 💡 面试技巧 "Go 在性能和公平之间做了精妙的平衡:正常情况下追求吞吐,发现有人被饿死时就切换到公平模式。"


Q6:自旋的 goroutine 会占用太多资源吗? 🟡中等

参考答案

不会。 Go 的自旋设计非常克制:

  1. 只在特定条件下触发(CPU 核数 > 1 且排队 goroutine 不多)
  2. 持续时间极短(几十纳秒)
  3. 目的是避免更昂贵的上下文切换开销

[!warning] ⚠️ 高频陷阱 自旋是"机会主义的短线优化"——赌锁马上就释放了,与其花大代价挂起+唤醒 goroutine,不如原地稍等一下。


Q7:锁释放后哪个 goroutine 优先获取? 🟡中等

参考答案

取决于当前模式:

模式 谁先拿到锁
正常模式 队头 goroutine 和新来的自旋 goroutine 竞争,新来的可能插队
饥饿模式 队头 goroutine 直接获得,新来者排尾部

Q8:sync.Once 的作用和底层原理? 🟡中等

[!question] ❓ 思考一下 如果两个 goroutine 同时调用 Once.Do(f),f 会被执行几次?如何实现这个保证?

参考答案

作用:确保一个函数在整个程序生命周期内只执行一次,常用于单例初始化。

type Once struct {
    done uint32  // 标识位:0=未执行,1=已执行
    m    Mutex
}

执行流程:

graph TD
    A[Once.Do f] --> B{atomic.Load done == 1?}
    B -->|是| C[直接返回, 无锁]
    B -->|否| D[加锁 doSlow]
    D --> E{再次检查 done == 1?}
    E -->|是| F[解锁, 返回]
    E -->|否| G[执行 f]
    G --> H[atomic.Store done = 1]
    H --> I[解锁]

[!note] 📝 核心考点 **双重检查(Double-Checked Locking)**是关键:第一次用原子操作快速判断(无锁路径),第二次加锁后再判断防止重复执行。


Q9:WaitGroup 怎样实现协程等待? 🟡中等

参考答案

WaitGroup 本质是原子计数器 + 信号量的协作:

type WaitGroup struct {
    noCopy noCopy        // vet 静态检查防复制
    state  atomic.Uint64 // 64位:高32位=计数器, 低32位=等待者数量
    sema   uint32         // 信号量
}

工作流程:

  1. Add(n):计数器 += n
  2. Done():计数器 -= 1
  3. Wait():计数器 != 0 则通过信号量挂起当前 goroutine
  4. 最后一个 Done() 将计数器归零 → 信号量一次性唤醒所有等待者

[!warning] ⚠️ 高频陷阱 WaitGroup 不能被复制!一旦使用过就不能拷贝,否则会导致状态不一致。noCopy 字段让 go vet 能在编译期检测这个问题。


Q10:sync.Map 的底层原理? 🟡中等

参考答案

核心思想:空间换时间,通过 read/dirty 双 map 实现读操作的无锁优化。

type Map struct {
    mu     Mutex              // 保护 dirty
    read   atomic.Value        // 实际存储 readOnly
    dirty  map[interface{}]*entry // 需要加锁访问
    misses int                  // read 未命中次数
}

type readOnly struct {
    m       map[interface{}]*entry
    amended bool // true 表示 dirty 中有 read 没有的数据
}

type entry struct {
    p unsafe.Pointer // 指向真正的 value
}

[!info] 🔗 延伸阅读


Q11:read map 和 dirty map 的关联? 🟢简单

参考答案

Map 角色 特性
read 只读缓存 无锁读取,可能是过期快照
dirty 最新全集 需加锁,包含所有最新数据

当 misses == len(dirty) 时,dirty 晋升为新的 read。


Q12:为什么设计 nil 和 expunged 两种删除状态? 🟡中等

参考答案

为了解决 "如何在只读的 read map 上高效删除" 的问题:

  • expunged:逻辑删除标记。key 只存在于 read 中时,不能物理删除,标记为 expunged,读操作看到它就直接返回 nil, false
  • nil:中间状态,用于 dirty 和 read 同步过程中表示 key 正在被删除或迁移

[!tip] 💡 面试技巧 "这是典型的用状态标记换取无锁性能的设计。一次 Delete 不需要加锁复制整个 read map,而是打一个标记,延迟到 dirty 晋升时再做物理删除。"


Q13:sync.Map 适用的场景? 🟢简单

参考答案

读多写少的场景。期望流量在 read map 层被拦截,避免频繁加锁访问 dirty map。

[!warning] ⚠️ 高频陷阱 如果写操作过多,sync.Map 基本等价于一把互斥锁 + map,效率反而比普通 map + Mutex 更低。

关联笔记