内容覆盖了 Go 后端工程师日常工作中涉及的所有锁机制,共 14 章:
| 章节 | 内容 |
|---|---|
sync.Mutex |
互斥锁原理(正常/饥饿模式)、API、不可重入特性 |
sync.RWMutex |
读写锁原理(写优先)、读多写少场景 |
sync.WaitGroup |
计数器 + 信号量机制,常见错误排查 |
sync.Once |
double-checked locking 实现,单例模式 |
sync.Cond |
条件变量、有界队列实现示例 |
sync.Pool |
per-P 无锁架构、GC 友好设计 |
sync.Map |
read/dirty 双 map 结构、适用/不适用场景对比 |
sync/atomic |
CAS 原理、无锁编程、泛型原子类型 |
| Channel | 底层 hchan 结构、信号量/通知/超时组合模式 |
| context.Context | goroutine 树取消机制、超时控制链 |
| 分布式锁 | Redis(SET NX PX + Lua)和 etcd(lease + revision)两种方案 |
| 死锁 | 四个必要条件、常见场景、检测与避免 |
| 实战指南 | 决策流程图 + 性能对比速查表 + 一句话总结 |
每章都包含原理图解、API 说明、代码示例和注意事项,尾部附有完整决策流程图和性能速查表。
Go 后端锁机制详解:原理与使用场景
目录
- 核心概念:为什么需要锁
- [sync.Mutex --- 互斥锁](#sync.Mutex — 互斥锁 "#2-syncmutex--%E4%BA%92%E6%96%A5%E9%94%81")
- [sync.RWMutex --- 读写锁](#sync.RWMutex — 读写锁 "#3-syncrwmutex--%E8%AF%BB%E5%86%99%E9%94%81")
- [sync.WaitGroup --- 等待组](#sync.WaitGroup — 等待组 "#4-syncwaitgroup--%E7%AD%89%E5%BE%85%E7%BB%84")
- [sync.Once --- 单次执行](#sync.Once — 单次执行 "#5-synconce--%E5%8D%95%E6%AC%A1%E6%89%A7%E8%A1%8C")
- [sync.Cond --- 条件变量](#sync.Cond — 条件变量 "#6-synccond--%E6%9D%A1%E4%BB%B6%E5%8F%98%E9%87%8F")
- [sync.Pool --- 对象池](#sync.Pool — 对象池 "#7-syncpool--%E5%AF%B9%E8%B1%A1%E6%B1%A0")
- [sync.Map --- 并发安全Map](#sync.Map — 并发安全Map "#8-syncmap--%E5%B9%B6%E5%8F%91%E5%AE%89%E5%85%A8map")
- [sync/atomic --- 原子操作](#sync/atomic — 原子操作 "#9-syncatomic--%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C")
- [Channel --- 通道作为同步原语](#Channel — 通道作为同步原语 "#10-channel--%E9%80%9A%E9%81%93%E4%BD%9C%E4%B8%BA%E5%90%8C%E6%AD%A5%E5%8E%9F%E8%AF%AD")
- [context.Context --- 取消与超时控制](#context.Context — 取消与超时控制 "#11-contextcontext--%E5%8F%96%E6%B6%88%E4%B8%8E%E8%B6%85%E6%97%B6%E6%8E%A7%E5%88%B6")
- 分布式锁
- 死锁:成因、检测与避免
- 实战选择指南
1. 核心概念:为什么需要锁
Go 的核心理念是 "不要通过共享内存来通信,而要通过通信来共享内存" 。但在实际工程中,共享内存仍然是最高效的并发模型。当多个 goroutine 同时读写同一块内存时,就会发生 数据竞争(Data Race),导致不可预期的结果。
锁的作用就是 保证同一时刻只有一个 goroutine 访问临界区,从而保证数据一致性。
数据竞争示例
go
var counter int
func main() {
for i := 0; i < 1000; i++ {
go func() { counter++ }() // 存在数据竞争
}
time.Sleep(time.Second)
fmt.Println(counter) // 结果不确定,通常 < 1000
}
用 go run -race main.go 可检测数据竞争。
2. sync.Mutex --- 互斥锁
原理
Mutex 是 Go 中最基础的锁,同一时刻最多只有一个 goroutine 能持有锁。底层通过原子操作 + 信号量(sema)实现:
- 正常模式:等待者按 FIFO 排队,新到达的 goroutine 有优势(自旋 + 抢锁)
- 饥饿模式:当有 goroutine 等待超过 1ms,锁进入饥饿模式,直接将锁交给队首等待者,避免尾延迟
scss
状态机简图:
未锁定(0) ──Lock()──▶ 已锁定(1)
已锁定(1) ──Unlock()──▶ 未锁定(0)
已锁定(1) ──Lock()──▶ 阻塞等待(信号量)
核心 API
go
var mu sync.Mutex
mu.Lock() // 加锁,如果已被锁定则阻塞等待
mu.Unlock() // 解锁,如果未锁定则 panic
mu.TryLock() // Go 1.18+ 尝试加锁,成功返回 true,失败立即返回 false(非阻塞)
使用规则
| 规则 | 说明 |
|---|---|
| 零值可用 | var mu sync.Mutex 直接可用,无需初始化 |
| 不可复制 | 拷贝 Mutex 会失去同步语义,go vet 会警告 |
| 成对使用 | Lock 和 Unlock 必须成对出现,推荐 defer mu.Unlock() |
| 不可重入 | Go 的 Mutex 不支持重入,同一 goroutine 重复 Lock 会死锁 |
使用场景
| 场景 | 说明 | 示例 |
|---|---|---|
| 保护共享变量 | 多个 goroutine 读写同一个变量 | 计数器、缓存 map |
| 保护临界区 | 一段代码同一时刻只能被一个 goroutine 执行 | 文件写入、DB 连接池操作 |
| 单例初始化(简单场景) | 确保某个资源只初始化一次 | 配置加载(不过 sync.Once 更适合) |
代码示例
go
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
性能特征
- 未发生竞争时,Lock/Unlock 约 ~10ns(纯原子操作)
- 发生竞争时,涉及系统调用和 goroutine 调度,约 ~1μs+
- 适合临界区短且不频繁的场景
3. sync.RWMutex --- 读写锁
原理
RWMutex 是 Mutex 的升级版,区分读锁 和写锁:
- 多读并发:多个 goroutine 可以同时持有读锁
- 写写互斥:同一时刻只有一个 goroutine 持有写锁
- 读写互斥:持有读锁时不能获取写锁,反之亦然
- 写优先:有等待的写锁时,新的读锁请求会被阻塞,防止写饥饿
scss
状态模型:
无锁 ──RLock()──▶ 读锁(计数+1,可多个)
无锁 ──Lock() ──▶ 写锁(独占)
读锁 ──Lock() ──▶ 阻塞等待(所有读锁释放后才可获得写锁)
写锁 ──RLock()──▶ 阻塞等待(写锁释放后才可获得读锁)
核心 API
go
var rw sync.RWMutex
rw.RLock() // 加读锁
rw.RUnlock() // 解读锁
rw.Lock() // 加写锁
rw.Unlock() // 解写锁
rw.TryLock() // Go 1.18+ 尝试加写锁
rw.TryRLock() // Go 1.18+ 尝试加读锁
使用场景
| 场景 | 为什么用读写锁 |
|---|---|
| 读多写少的缓存 | 99% 读、1% 写,用 Mutex 会让所有读串行;RWMutex 让所有读并发 |
| 配置管理器 | 配置变更少(写),读取频繁(读) |
| 路由表 | 路由注册少(写),请求路由多(读) |
代码示例
go
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
性能特征
- 读锁:约 ~15ns(无竞争时),比 Mutex 略慢(需要原子计数)
- 写锁:约 ~20ns(无竞争时),比 Mutex 稍慢(多了读锁计数检查)
- 高并发读场景下,RWMutex 的性能远超 Mutex(让读操作完全并行)
注意事项
- 不要过度使用:如果读写比例接近 1:1,用 Mutex 反而更快(RWMutex 有额外开销)
- 写优先可能导致读饥饿:如果写操作非常频繁,读操作可能被长期阻塞
4. sync.WaitGroup --- 等待组
原理
WaitGroup 协调多个 goroutine 的完成等待,底层是一个计数器(64 位原子值:高 32 位计数,低 32 位等待者数量)+ 信号量。
scss
工作机制:
Add(n) ──▶ 计数器 += n
Done() ──▶ 计数器 -= 1
↓
计数器 == 0 时,唤醒所有 Wait()
核心 API
go
var wg sync.WaitGroup
wg.Add(n) // 增加计数器,必须在 goroutine 启动前调用
wg.Done() // 计数器减 1,等价于 Add(-1)
wg.Wait() // 阻塞直到计数器归零
使用场景
| 场景 | 说明 |
|---|---|
| 并发任务等待 | 启动 N 个 goroutine 处理任务,主 goroutine 等待全部完成 |
| 批量 RPC 调用 | 并行调用多个下游服务,等待所有结果返回 |
| 分批数据处理 | 分片处理大量数据,等待所有分片完成 |
代码示例
go
func main() {
var wg sync.WaitGroup
urls := []string{"url1", "url2", "url3"}
for _, url := range urls {
wg.Add(1) // 在启动 goroutine 前 Add
go func(u string) {
defer wg.Done() // goroutine 结束时 Done
fetch(u)
}(url)
}
wg.Wait() // 等待所有 goroutine 完成
fmt.Println("所有请求完成")
}
常见错误
go
// 错误 1:Add 放在 goroutine 内部
go func() {
wg.Add(1) // ❌ 可能 Wait() 先执行,从而立即返回
defer wg.Done()
doWork()
}()
// 错误 2:计数变成负数
wg.Add(1)
wg.Done()
wg.Done() // ❌ panic: sync: negative WaitGroup counter
// 错误 3:复制 WaitGroup
var wg2 sync.WaitGroup
wg2 = wg // ❌ 拷贝后语义独立,应传指针
5. sync.Once --- 单次执行
原理
Once 确保某段代码只执行一次,即使被多个 goroutine 并发调用。底层使用原子操作 + Mutex + done 标志位实现。
ini
执行流程:
快速路径:原子读取 done == 1?→ 直接返回
慢速路径:加 Mutex → 再次检查 done → 执行 f() → 设置 done = 1 → 解锁
这是经典的 double-checked locking 模式,但 Go 的实现是正确的(内存顺序有保证)。
核心 API
go
var once sync.Once
once.Do(func() {
// 只会执行一次的代码
})
使用场景
| 场景 | 说明 |
|---|---|
| 单例初始化 | 全局配置、数据库连接池、Logger 等资源的懒加载 |
| 资源加载 | 加载一次文件、初始化一次连接 |
| 注册逻辑 | 只注册一次的处理器、中间件 |
代码示例
go
type Config struct {
DBHost string
DBPort int
}
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{
DBHost: os.Getenv("DB_HOST"),
DBPort: 3306,
}
})
return instance
}
注意事项
- Once 的 f 函数如果 panic,Once 会认为它已经执行完毕,不会再重试
- 如果需要支持重试,使用
sync.OnceFunc(Go 1.21+)或自行封装
6. sync.Cond --- 条件变量
原理
Cond 让一组 goroutine 在某个条件满足时被唤醒。底层是 Mutex + 等待者链表(信号量队列)。
css
工作流程:
goroutine A: Lock → 检查条件不满足 → Wait()(释放锁 + 阻塞)
goroutine B: Lock → 修改条件 → Signal()/Broadcast() → Unlock
goroutine A: 被唤醒 → 重新获取锁 → 检查条件满足 → 继续执行
关键设计:Wait() 调用会原子地释放锁并将 goroutine 挂起;被唤醒后会重新获取锁。
核心 API
go
cond := sync.NewCond(&sync.Mutex{})
cond.L.Lock() // 获取关联的锁
cond.Wait() // 等待条件(释放锁、挂起、被唤醒后重新获取锁)
cond.Signal() // 唤醒一个等待的 goroutine
cond.Broadcast() // 唤醒所有等待的 goroutine
cond.L.Unlock() // 释放锁
使用场景
| 场景 | 说明 |
|---|---|
| 生产者-消费者队列(有容量限制) | 队列满时生产者等待,队列空时消费者等待 |
| 限流器/令牌桶 | 没有令牌时阻塞等待,令牌补充时唤醒 |
| 连接池等待 | 连接池满时阻塞,有连接归还时唤醒 |
代码示例:有界队列
go
type BoundedQueue struct {
cond *sync.Cond
items []interface{}
capacity int
}
func NewBoundedQueue(cap int) *BoundedQueue {
return &BoundedQueue{
cond: sync.NewCond(&sync.Mutex{}),
capacity: cap,
}
}
func (q *BoundedQueue) Put(item interface{}) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
for len(q.items) == q.capacity { // 必须用 for 而非 if
q.cond.Wait() // 等待队列有空位
}
q.items = append(q.items, item)
q.cond.Signal() // 唤醒等待的消费者
}
func (q *BoundedQueue) Get() interface{} {
q.cond.L.Lock()
defer q.cond.L.Unlock()
for len(q.items) == 0 { // 必须用 for 而非 if
q.cond.Wait() // 等待队列有数据
}
item := q.items[0]
q.items = q.items[1:]
q.cond.Signal() // 唤醒等待的生产者
return item
}
注意事项
- Wait() 必须放在 for 循环中,不能是 if------因为 goroutine 可能被虚假唤醒,或条件在被唤醒时又已改变
- Cond 不能复制,必须通过
sync.NewCond()创建 - 大多数场景下 Channel 比 Cond 更简洁,Cond 主要用于需要 Broadcast 的场景
7. sync.Pool --- 对象池
原理
Pool 用于缓存可复用的临时对象,减少 GC 压力。它不是一个严格意义上的"锁",但内部使用了无锁数据结构(per-P 的 poolLocal)来实现高并发。
less
架构:
Pool
├── local [P]poolLocal // 每个 P(处理器)有自己的本地池,无锁访问
│ ├── private // 私有对象,完全无锁
│ └── shared // 共享链,使用单生产者多消费者无锁队列
└── victim // 上一轮 GC 保留的备用缓存
获取流程:
1. 从当前 P 的 private 取(无锁)
2. 从当前 P 的 shared 取(无锁)
3. 从其他 P 的 shared 偷取(无锁)
4. 从 victim 取
5. 调用 New() 创建
放回流程:
1. 放入当前 P 的 private(如为空)
2. 否则放入当前 P 的 shared(无锁)
核心价值:Pool 中的对象在两次 GC 之间存活。每次 GC 时,Pool 会把对象移入 victim,再下一次 GC 时才清理。这意味着对象至少能存活一轮 GC,达到复用效果。
核心 API
go
var pool sync.Pool
pool.New = func() interface{} { // 池为空时的工厂函数
return &MyStruct{}
}
obj := pool.Get() // 获取对象(可能为 nil)
pool.Put(obj) // 归还对象
使用场景
| 场景 | 说明 |
|---|---|
| 高频临时对象复用 | bytes.Buffer、strings.Builder |
| 序列化/反序列化缓冲区 | JSON/Protobuf 编解码用的 buffer |
| 网络包缓冲区 | TCP/UDP 读写 buffer |
| Logger 字段切片 | 结构化日志中的 []Field |
代码示例
go
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func ProcessJSON(data []byte) (string, error) {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
if err := json.Compact(buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
注意事项
- 不要假设 Put 和 Get 之间有状态关联------Get 返回的对象可能是任意 goroutine 之前 Put 的
- Get 返回后对象必须已重置------在 Put 之前调用 Reset()
- Pool 不适合用于需要持久化的对象(如数据库连接),它随时可能被 GC 清空
- 连接池应使用专门的连接池实现(如
database/sql内置的连接池)
8. sync.Map --- 并发安全 Map
原理
sync.Map 是为读多写少 且key 集合稳定的场景优化的并发安全 map,并不是 Mutex + map 的简单包装。
arduino
内部结构:
read map(只读,原子指针) --- 大部分读操作直接命中,无锁
dirty map(读写,需要 mu 保护) --- 新写入的 key 存在这
mu sync.Mutex --- 保护 dirty map
misses int --- read map 未命中次数
运作机制:
读:先查 read map(无锁),如果未命中就加锁查 dirty,并递增 misses
当 misses >= len(dirty) 时,将 dirty 提升为新的 read map(dirty 变为 nil)
写:如果 key 在 read 中 → 原子更新(无锁)
否则 → 加锁,如果 dirty 为 nil 则从 read 复制数据到 dirty,写入 dirty
删:先尝试 read 中标记为 nil(无锁),否则加锁从 dirty 中删除
核心 API
go
var sm sync.Map
sm.Store(key, value) // 存储
sm.Load(key) // 加载,返回 (value, bool)
sm.LoadOrStore(key, value) // 存在则返回,不存在则存储
sm.Delete(key) // 删除
sm.LoadAndDelete(key) // 加载并删除
sm.Range(func(key, value interface{}) bool { ... }) // 遍历
使用场景 vs 普通 map + Mutex
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| key 稳定、读多写少 | sync.Map | 大部分读无锁,性能更好 |
| 大量写入新 key | map + sync.RWMutex |
sync.Map 需要频繁复制 dirty map |
| 读多写多但 key 有限 | map + sync.RWMutex |
较均衡的场景,简单方案就行 |
| 需要类型安全 | map + sync.RWMutex |
sync.Map 使用 interface{},需类型断言 |
| 需要 Len() | map + sync.RWMutex |
sync.Map 没有 Len 方法 |
代码示例
go
// 适合的场景:缓存系统配置,key 基本不变,写操作极少
var configCache sync.Map
func GetConfig(key string) (string, bool) {
v, ok := configCache.Load(key)
if !ok {
return "", false
}
return v.(string), true
}
func SetConfig(key, value string) {
configCache.Store(key, value)
}
// 不适合的场景:key 动态变化
// 下面这种情况用 map + RWMutex 更好
// go func() {
// for i := 0; i < 1000000; i++ {
// sm.Store(strconv.Itoa(i), i) // 每次新 key 都触发 dirty map 复制
// }
// }()
性能数据参考
| 操作 | sync.Map | map + RWMutex | 结论 |
|---|---|---|---|
| 大量稳定 key 的读 | 极快(无锁) | 需 RLock | sync.Map 胜 |
| 大量新 key 写入 | 慢(频繁复制 dirty) | 快 | RWMutex 胜 |
| 删除 | 极快 | 需 Lock | sync.Map 胜 |
| Range 遍历 | 快 | 一般 | sync.Map 稍快 |
9. sync/atomic --- 原子操作
原理
原子操作是无锁并发 的基础,由 CPU 硬件指令直接支持(如 x86 的 LOCK CMPXCHG)。Go 的 sync/atomic 包提供对基本类型的原子读写操作。
内存顺序保证 :atomic 操作默认提供顺序一致性(sequentially consistent),即在所有 goroutine 看来,操作顺序是一致的。
核心 API
go
// 基本类型原子操作
atomic.AddInt32(&counter, 1) // 原子加法,返回新值
atomic.LoadInt32(&counter) // 原子读
atomic.StoreInt32(&counter, 100) // 原子写
atomic.SwapInt32(&counter, 200) // 原子交换,返回旧值
atomic.CompareAndSwapInt32(&c, 0, 1) // CAS:如果 c==0,设为 1,返回是否成功
// Go 1.19+ 泛型类型(更推荐)
var counter atomic.Int32
counter.Add(1) // 原子加法
counter.Load() // 原子读
counter.Store(100) // 原子写
counter.Swap(200) // 原子交换
counter.CompareAndSwap(0, 1) // CAS
// 其他泛型类型
var flag atomic.Bool // 原子布尔
var ptr atomic.Pointer[T] // 原子指针(Go 1.19+)
var val atomic.Value // 原子任意类型(Go 1.4+,存在写入类型不一致的 panic 风险)
CAS(Compare-And-Swap)详解
CAS 是无锁编程的基石,用于实现 lock-free 数据结构。
go
// 自旋锁的简化实现(仅示意,实际用 sync.Mutex)
type SpinLock struct {
flag atomic.Int32
}
func (s *SpinLock) Lock() {
for !s.flag.CompareAndSwap(0, 1) {
runtime.Gosched() // 让出 CPU,避免空转耗尽
}
}
func (s *SpinLock) Unlock() {
s.flag.Store(0)
}
使用场景
| 场景 | 示例 |
|---|---|
| 简单计数器 | 请求计数、在线人数、QPS 统计 |
| 状态标志位 | 服务是否就绪、是否关闭中 |
| 无锁数据结构 | Lock-free 队列、栈 |
| 热路径优化 | 性能要求极高、临界区极短的场景 |
| 避免锁竞争 | 用 atomic.Value 存储不可变快照,读取完全无锁 |
代码示例:无锁配置热更新
go
type Config struct {
DBHost string
DBPort int
}
var currentConfig atomic.Pointer[Config]
func init() {
// 初始化
currentConfig.Store(&Config{DBHost: "localhost", DBPort: 3306})
// 启动配置监听
go watchConfig()
}
// GetConfig 完全无锁读取
func GetConfig() *Config {
return currentConfig.Load()
}
func watchConfig() {
for newConf := range configChan {
currentConfig.Store(newConf) // 原子更新指针
}
}
注意事项
- 原子操作不能替代 Mutex:原子操作只保护单个变量,Mutex 保护一段代码(临界区)
- 不要混合使用原子和非原子操作:对同一个变量混用 atomic 和普通读写会产生数据竞争
- 复杂数据结构用 Mutex:当需要原子更新多个相关字段时,atomic 无法保证一致性
10. Channel --- 通道作为同步原语
原理
Channel 是 Go 中最核心 的并发原语,它不仅是数据管道,更是同步机制 。底层结构 hchan 包含:
perl
hchan:
├── buf (环形缓冲区)
├── sendx / recvx (发送/接收索引)
├── sendq (等待发送的 goroutine 队列)
├── recvq (等待接收的 goroutine 队列)
└── lock (内部互斥锁,保护字段)
- 无缓冲 channel:发送和接收必须同时就绪 → 天然同步
- 有缓冲 channel:缓冲未满/非空时可异步,满/空时同步阻塞
作为同步机制的使用场景
| 模式 | 说明 | 代码 |
|---|---|---|
| 完成信号 | goroutine 完成时发信号 | done <- struct{}{} |
| 限流/信号量 | 缓冲 channel 控制并发数 | make(chan struct{}, 10) |
| 互斥锁 | 缓冲为1的 channel 模拟锁 | make(chan struct{}, 1) |
| 事件通知 | close channel 广播 | close(stopCh) |
| 超时控制 | select + time.After | 见下文 |
代码示例
go
// 1. Channel 作为信号量(限流并发数为 5)
sem := make(chan struct{}, 5)
for _, task := range tasks {
sem <- struct{}{} // 获取信号量,满则阻塞
go func(t Task) {
defer func() { <-sem }() // 释放信号量
process(t)
}(task)
}
// 2. close channel 实现一键通知所有 goroutine 退出
stopCh := make(chan struct{})
for i := 0; i < 10; i++ {
go func() {
for {
select {
case <-stopCh:
return
default:
doWork()
}
}
}()
}
close(stopCh) // 所有 goroutine 同时收到信号
// 3. 超时 + 限流 + 取消 组合
select {
case result := <-resultCh:
handle(result)
case <-time.After(3 * time.Second):
log.Println("超时")
case <-ctx.Done():
log.Println("取消")
}
// 4. for-range 优雅等待
ch := make(chan int, 10)
go func() {
for v := range ch { // channel 关闭后自动退出
fmt.Println(v)
}
}()
Channel vs 传统锁
| 维度 | Channel | sync.Mutex |
|---|---|---|
| 哲学 | 通过通信共享内存 | 通过共享内存通信 |
| 所有权 | 数据所有权转移给接收者 | 所有权不转移,访问受保护 |
| 组合性 | 天然支持 select 多路复用 | 不支持 select |
| 可取消 | 配合 context 可取消 | 无法取消等待(除非用 TryLock) |
| 性能 | 涉及内存拷贝,慢于 Mutex | 纯原子/信号量操作,更快 |
| 适用场景 | goroutine 间协调、数据传递 | 保护共享数据结构 |
经验法则 :goroutine 之间的协调/编排 用 Channel;共享状态的保护用 Mutex。
11. context.Context --- 取消与超时控制
原理
Context 不是锁,但它是 Go 后端工程师最常用的并发控制原语之一。它解决了 goroutine 泄漏的核心问题:如何优雅地取消一个 goroutine 树。
scss
Context 树结构:
Background() / TODO()
├── WithCancel() --- 手动取消
├── WithDeadline() --- 指定时刻取消
├── WithTimeout() --- 指定时长后取消
└── WithValue() --- 携带请求范围数据
调用 cancel() 后:
ctx.Done() channel 被关闭 → 所有监听该 channel 的子 goroutine 收到信号
使用场景
| 场景 | Context 类型 |
|---|---|
| HTTP 请求超时 | WithTimeout(r.Context(), 5*time.Second) |
| RPC 调用链超时 | 从上到下传递 deadline |
| 服务优雅关闭 | 主 goroutine cancel,所有 worker 退出 |
| 请求范围数据传递 | traceID、userID 等(慎用 WithValue) |
代码示例:完整的超时控制链
go
func HandleRequest(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// 并行查询多个数据源
userCh := fetchUser(ctx, userID)
orderCh := fetchOrders(ctx, userID)
var user *User
var orders []Order
for i := 0; i < 2; i++ {
select {
case u := <-userCh:
user = u
case o := <-orderCh:
orders = o
case <-ctx.Done(): // 超时或取消
http.Error(w, "请求超时", http.StatusGatewayTimeout)
return
}
}
}
func fetchUser(ctx context.Context, id string) <-chan *User {
ch := make(chan *User, 1)
go func() {
defer close(ch)
// 模拟 DB 查询
result := db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", id)
ch <- result
}()
return ch
}
12. 分布式锁
12.1 为什么需要分布式锁
在单机中,Mutex 保护同一进程内的临界区;在分布式系统中,多个服务实例可能同时操作同一资源(如库存扣减、任务调度),需要跨进程/跨机器的锁。
12.2 Redis 分布式锁
原理
基于 SET NX PX 原子命令实现:
markdown
SET lock_key random_value NX PX 30000
↑ ↑ ↑
唯一标识 仅当不存在 过期时间(ms)
- NX:仅当 key 不存在时设置成功(互斥)
- PX:设置过期时间(防止死锁------持有锁的实例崩溃后锁自动释放)
- random_value:释放时用 Lua 脚本校验,防止误删别人的锁
Go 实现
go
// 使用 go-redis 实现分布式锁
type RedisLock struct {
client *redis.Client
key string
value string // 随机值,用于安全释放
ttl time.Duration
}
func NewRedisLock(client *redis.Client, key string, ttl time.Duration) *RedisLock {
return &RedisLock{
client: client,
key: key,
value: uuid.New().String(),
ttl: ttl,
}
}
func (l *RedisLock) TryLock(ctx context.Context) (bool, error) {
return l.client.SetNX(ctx, l.key, l.value, l.ttl).Result()
}
// Unlock 使用 Lua 脚本保证原子性:只有 value 匹配时才删除
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
func (l *RedisLock) Unlock(ctx context.Context) error {
return unlockScript.Run(ctx, l.client, []string{l.key}, l.value).Err()
}
使用场景
| 场景 | 说明 |
|---|---|
| 定时任务互斥 | 多实例部署时,确保定时任务只执行一次 |
| 库存扣减 | 秒杀场景下防止超卖 |
| 幂等性保证 | 防止重复提交、重复处理 |
Redlock 算法(Redis 官方推荐的多节点方案)
在 N 个独立的 Redis 节点上依次获取锁,超过半数成功且耗时小于 TTL 才算获取成功。适用于对一致性要求更高的场景。
12.3 etcd 分布式锁
原理
基于 etcd 的 lease(租约) + Revision(全局递增版本号) 实现:
markdown
流程:
1. 创建 lease(带 TTL)
2. 以 lease 为前缀创建 key,获得 revision
3. 获取同一前缀下所有 key,按 revision 排序
4. 如果自己的 revision 最小 → 获得锁
5. 否则 watch 前一个 revision 的 key,等待其被删除
这种方式天然实现公平锁(FIFO 等待队列),比 Redis 的抢锁模式更公平。
使用场景
| 场景 | 说明 |
|---|---|
| Leader 选举 | 分布式系统中选主 |
| 配置锁 | 确保同一时刻只有一个实例修改配置 |
| 需要强一致性的分布式锁 | etcd 基于 Raft,比 Redis 的 AP 模型更一致 |
13. 死锁:成因、检测与避免
成因
四个必要条件(全部满足才会死锁):
- 互斥:资源只能被一个 goroutine 持有
- 持有并等待:持有资源的同时等待其他资源
- 不可剥夺:资源不能被强制释放
- 循环等待:goroutine A 等 B,B 等 A
常见死锁场景
go
// 场景 1:Lock 顺序不一致
// goroutine A: Lock(a) → Lock(b)
// goroutine B: Lock(b) → Lock(a)
// → ABBA 死锁
// 场景 2:channel 循环等待
ch1, ch2 := make(chan int), make(chan int)
go func() { ch1 <- <-ch2 }()
go func() { ch2 <- <-ch1 }()
// → 互相等待对方写入
// 场景 3:Mutex 不可重入
mu.Lock()
mu.Lock() // 死锁!同一 goroutine 重复加锁
// 场景 4:sync.Cond 等待信号丢失
cond.L.Lock()
// Signal 在其他 goroutine 中已发出,但当前 goroutine 还没 Wait
cond.Wait() // 永远等不到下一个 Signal
检测手段
| 方法 | 说明 |
|---|---|
go run -race |
检测数据竞争 |
GODEBUG=schedtrace=1000 |
调度器追踪 |
pprof.Lookup("goroutine") |
查看所有 goroutine 堆栈 |
runtime.NumGoroutine() |
监控 goroutine 数量是否持续增长 |
| SIGQUIT 信号 | kill -QUIT <pid> 打印所有 goroutine 堆栈 |
避免策略
| 策略 | 说明 |
|---|---|
| 统一加锁顺序 | 所有代码以相同顺序获取锁 |
| 使用 TryLock | Go 1.18+ 尝试加锁,失败后释放已有锁重试 |
| 锁超时 | 自定义带超时的锁(通过 channel + select) |
| 减少锁粒度 | 大锁拆小锁、分段锁 |
| 尽量用 Channel | Channel 本身有死锁检测,运行时能发现 all goroutines asleep |
14. 实战选择指南
决策流程图
dart
需要并发控制?
├── 保护单个变量?(计数器、标志)
│ └── → sync/atomic(原子操作)
│
├── 保护共享数据结构?
│ ├── 读多写少?
│ │ ├── key 集合稳定? → sync.Map
│ │ └── key 经常变化? → map + sync.RWMutex
│ └── 读多写多、临界区短? → map + sync.Mutex
│
├── goroutine 之间传递数据 / 协调执行顺序?
│ └── → Channel
│
├── 等待多个 goroutine 完成?
│ └── → sync.WaitGroup
│
├── 只执行一次初始化?
│ └── → sync.Once
│
├── goroutine 等待某个条件满足?
│ ├── 单通知 → Channel (close(ch))
│ ├── 多通知(Broadcast) → sync.Cond
│ └── 带超时 → Channel + select + time.After
│
├── 跨进程/跨机器?
│ ├── AP 模型(性能优先) → Redis 分布式锁
│ └── CP 模型(一致性优先) → etcd 分布式锁
│
├── 对象频繁创建销毁,想减少 GC?
│ └── → sync.Pool(仅限临时对象)
│
└── 取消 goroutine 树?
└── → context.Context
性能对比速查表
| 原语 | 无竞争延迟 | 竞争下延迟 | CPU 开销 | 内存开销 |
|---|---|---|---|---|
atomic |
~1ns | ~1ns | 极低 | 无 |
sync.Mutex |
~10ns | ~1μs+ | 低 | 8 bytes |
sync.RWMutex(读) |
~15ns | ~50ns+ | 低 | 24 bytes |
sync.RWMutex(写) |
~20ns | ~1μs+ | 低 | 24 bytes |
channel(无缓冲) |
~50ns | ~200ns | 中 | 96+ bytes |
channel(有缓冲) |
~30ns | ~100ns | 中 | 96+ bytes |
sync.Map(读命中) |
~5ns | ~50ns | 极低 | 较大 |
sync.Map(写未命中) |
~200ns | ~1μs+ | 中 | 较大 |
以上数据为数量级参考,实际性能受 CPU 架构、Go 版本、系统负载等因素影响。
一句话总结
| 原语 | 一句话 |
|---|---|
| sync.Mutex | 互斥锁,保护临界区,最通用 |
| sync.RWMutex | 读写锁,读多写少时完胜 Mutex |
| sync.WaitGroup | 等所有 goroutine 干完活 |
| sync.Once | 某个事只干一次 |
| sync.Cond | 条件不满足就等着,等人喊你起来(多数情况用 Channel 更简单) |
| sync.Pool | 临时对象反复用,给 GC 减负 |
| sync.Map | 读多写少 key 稳定才用,否则老实 map+Mutex |
| sync/atomic | 单个变量无锁操作,极致性能 |
| Channel | Go 并发哲学的核心,传数据 + 同步一把梭 |
| context | 超时、取消、传值,控制 goroutine 生命周期 |
| 分布式锁 | 锁的范围扩展到多机,Redis/etcd 实现 |
核心原则:
- 简单优先:能用 atomic 不用 Mutex,能用 Mutex 不用 RWMutex,能用 Channel 不用 Cond
- 正确性第一:不要过早优化,先保证正确,再考虑性能
- 善用 race detector :
go test -race和go run -race是最可靠的并发 bug 探测器- Channel 不是银弹:保护共享状态时 Mutex 更直接、更快