Go 后端锁机制详解

内容覆盖了 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 后端锁机制详解:原理与使用场景


目录

  1. 核心概念:为什么需要锁
  2. [sync.Mutex --- 互斥锁](#sync.Mutex — 互斥锁 "#2-syncmutex--%E4%BA%92%E6%96%A5%E9%94%81")
  3. [sync.RWMutex --- 读写锁](#sync.RWMutex — 读写锁 "#3-syncrwmutex--%E8%AF%BB%E5%86%99%E9%94%81")
  4. [sync.WaitGroup --- 等待组](#sync.WaitGroup — 等待组 "#4-syncwaitgroup--%E7%AD%89%E5%BE%85%E7%BB%84")
  5. [sync.Once --- 单次执行](#sync.Once — 单次执行 "#5-synconce--%E5%8D%95%E6%AC%A1%E6%89%A7%E8%A1%8C")
  6. [sync.Cond --- 条件变量](#sync.Cond — 条件变量 "#6-synccond--%E6%9D%A1%E4%BB%B6%E5%8F%98%E9%87%8F")
  7. [sync.Pool --- 对象池](#sync.Pool — 对象池 "#7-syncpool--%E5%AF%B9%E8%B1%A1%E6%B1%A0")
  8. [sync.Map --- 并发安全Map](#sync.Map — 并发安全Map "#8-syncmap--%E5%B9%B6%E5%8F%91%E5%AE%89%E5%85%A8map")
  9. [sync/atomic --- 原子操作](#sync/atomic — 原子操作 "#9-syncatomic--%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C")
  10. [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")
  11. [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")
  12. 分布式锁
  13. 死锁:成因、检测与避免
  14. 实战选择指南

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.Bufferstrings.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. 死锁:成因、检测与避免

成因

四个必要条件(全部满足才会死锁):

  1. 互斥:资源只能被一个 goroutine 持有
  2. 持有并等待:持有资源的同时等待其他资源
  3. 不可剥夺:资源不能被强制释放
  4. 循环等待: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 detectorgo test -racego run -race 是最可靠的并发 bug 探测器
  • Channel 不是银弹:保护共享状态时 Mutex 更直接、更快
相关推荐
挖坑的张师傅1 小时前
你的仓库 Agent Ready 了吗?
后端
客场消音器2 小时前
如何使用codex进行UI重构,让AI开发的前端页面不再千篇一律
前端·后端·微信小程序
Full Stack Developme2 小时前
spring-beans 解析
java·后端·spring
苏三说技术2 小时前
为什么大厂都不推荐在MySQL中使用NULL值?
后端
techdashen2 小时前
Rust 模块和文件不是一回事:一次讲清 `mod`、`use`、`pub use`
开发语言·后端·rust
爱勇宝3 小时前
别焦虑,也别躺平:给年轻程序员的一封信
前端·后端·架构
Full Stack Developme3 小时前
Spring 发展历史
java·后端·spring
ClouGence3 小时前
TiCDC 够用吗?聊聊 TiDB 同步的几个关键问题
数据库·分布式·后端
音符犹如代码3 小时前
Docker 一键部署带有 TimescaleDB 插件的 PostgreSQL
java·运维·数据库·后端·docker·postgresql·容器