同步原语和锁

0 阅读7分钟

image.png

sync.Mutex

Mutex 是「互斥锁」,用来保护一段代码或一块数据:同一时刻只有一个 goroutine 能持有这把锁,其他人想进这段临界区,就得等。

你会用到的 API

  • Lock():要进临界区时调用。若锁空闲,立刻拿到;若别人拿着,当前 goroutine 会阻塞,直到轮到它。
  • Unlock():离开临界区时调用,把锁交给下一个等待者(如果有)。必须在曾经 Lock 成功的那条路径上配对调用,且不要重复 Unlock 未加锁的 mutex,否则会 panic。
  • TryLock():试着拿锁,拿不到就立刻返回 false,不会排队等。适合「能拿就拿,拿不到我就干别的事」的场景;在饥饿模式下运行时也可能拿不到(实现细节会保证公平性,TryLock 不参与那种交接)。

加锁时在干什么

  1. 常见情况:没人占锁时,Lock() 很快成功,开销相对小。
  2. 竞争激烈时:拿不到锁的 goroutine 会先短暂自旋(空转几圈看锁会不会马上释放),还不行就进等待队列并休眠,让出 CPU;被唤醒后再参与竞争。
  3. 原理
    • 正常模式:被唤醒的等待者要和新来的 goroutine 一起抢锁;新到的往往还在跑,更容易先抢到,极端情况下某个等待者可能一直轮不到(「饿死」风险)。
    • 饥饿模式:若某个等待者等太久(实现里大约超过 1ms),运行时会把 mutex 切到饥饿模式——优先把锁交给排队最前面的等待者,新来的不再占便宜,避免尾延迟爆炸。队列变短或等待时间回落后,会再切回正常模式。

简单示例

var mu sync.Mutex
var counter int

func AddOne() {
    mu.Lock()
    defer mu.Unlock()
    counter++
}

func TryAdd() bool {
    if !mu.TryLock() {
        return false // 拿不到锁,直接放弃
    }
    defer mu.Unlock()
    counter++
    return true
}

sync.RWMutex

在普通互斥锁基础上区分「读」和「写」——多个 goroutine 可以同时只读,但只要有人在写,读和写都不能同时进行

适合读远多于写的场景(例如配置、缓存元数据频繁查、偶尔更新),读多时能比 Mutex 少很多阻塞。

你会用到的 API

  • RLock() / RUnlock():进入 / 离开只读临界区。只读时用这对,不要和写锁混用同一段逻辑时搞错配对。
  • Lock() / Unlock():进入 / 离开临界区(和 Mutex 语义一样,写时独占整把锁)。

读锁、写锁是同一把 RWMutex 上的两套操作,不能假设「我先 RLock 再在同一线程里 Lock 升级」——Go 里没有从读锁直接升级成写锁;需要写时,应先 RUnlock,再 Lock(中间可能被别人插入,这是设计如此,必要时要自己用额外逻辑保证一致性)。

Mutex 怎么选

  • 读少写多或读写差不多:用 Mutex 更简单,维护成本更低。
  • 读非常多、写很少RWMutex 可能更省等待时间;但实现比 Mutex 重,写锁会阻塞所有读者,若写频繁反而可能更差,需要结合实际压测。

简单示例

var rw sync.RWMutex
var data = map[string]int{"a": 1}

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

func Write(key string, v int) {
    rw.Lock()
    defer rw.Unlock()
    data[key] = v
}

image.png

sync.WaitGroup

用来等一组 goroutine 全部跑完再往下执行

你会用到的 API

  • Add(delta int):把等待计数加上 delta(常见是启动前 Add(1)Add(n))。一般在启动子任务之前调用,表示「还要等多个人」。
  • Done():等价于 Add(-1),表示一个子任务结束。通常在子 goroutine 里 defer wg.Done(),避免漏减导致永远等不到。
  • Wait():阻塞,直到计数变成 0

典型用法

并发拉一批 RPC / HTTP、并行算子任务时:主流程先 Add,每个子任务 go func(){ defer Done(); ... }(),最后 Wait() 收束。Add 的总量要和 Done 次数对上,否则要么提前 Wait 返回,要么永远卡住。

简单示例

var wg sync.WaitGroup
urls := []string{"https://a", "https://b"}

for _, u := range urls {
    wg.Add(1)
    go func(url string) {
        defer wg.Done()
        _, _ = http.Get(url)
    }(u)
}
wg.Wait() // 全部返回后再继续

errgroup(golang.org/x/sync/errgroup

WaitGroup 思路上,帮你顺便管错误:一组任务里谁先出错可以记下来,并常配合 Context 取消让其余任务收手。

它内部会用 WaitGroup 等子任务结束,再用 sync.Once 只保留第一个错误,若创建时带了可取消的 context,出错后可以 cancel,避免无意义的 goroutine 空跑。

什么时候用

  • 一批独立子任务,任意一个失败就想让整个批次失败,并取消兄弟任务。
  • 比手写「每个 goroutine 往 err channel 塞 + WaitGroup」少样板代码。

简单示例

import (
    "context"
    "net/http"

    "golang.org/x/sync/errgroup"
)

urls := []string{"https://a", "https://b"}
g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
    url := url
    g.Go(func() error {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return err
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        return resp.Body.Close()
    })
}
if err := g.Wait(); err != nil {
    // 第一个出错会走到这里,ctx 已被取消,其它请求会尽快结束
}

image.png

sync.Once

保证整个进程生命周期里,某段初始化逻辑只执行一次,多个 goroutine 同时调也安全。

你会用到的 API

  • Do(f func()):多次调用只会真正执行一次 f,其余调用会等那一次跑完(适合懒加载单例、一次性注册)。

注意:Do 里如果 f panic,仍算作已执行过,再次 Do 不会重试——初始化代码要自己保证可重试或在外层处理。

简单示例

var once sync.Once
var cached string // 假设初始化很贵

func Expensive() string {
    once.Do(func() {
        cached = "..." // 只执行一次的真实初始化放这里
    })
    return cached
}

sync.Cond

条件变量——「不满足条件就睡觉,条件可能满足时被人叫醒」,用来协调**「等某个条件成立」**的 goroutine。

你会用到的 API

  • Wait():当前 goroutine 挂起,直到被 Signal / Broadcast 唤醒。调用前必须先持有与 Cond 关联的那把 Mutex(已 LockWait 内部会短暂解锁,被唤醒后再加锁返回,所以醒来时仍处在锁保护下。
  • Signal():唤醒一个等待者(常用于「资源空出一个」)。
  • Broadcast():唤醒所有等待者(常用于「条件全局变化」)。

在 Go 里很多场景用 channel 就能表达「等待 / 通知」,Cond 相对少用手写;但当多个 goroutine 等的是同一把锁 + 复杂条件、又不想拆成很多 channel 时,Cond 仍有用。不要用空 for {} 忙等,CPU 会飙;等条件应 Wait 让出执行权。

简单示例

var mu sync.Mutex
cond := sync.NewCond(&mu)
var ready bool

// 等待方
go func() {
    mu.Lock()
    for !ready {
        cond.Wait() // 会暂时放开 mu,被唤醒后再持有 mu
    }
    mu.Unlock()
}()

// 通知方
mu.Lock()
ready = true
cond.Broadcast() // 或 Signal() 只叫醒一个
mu.Unlock()

Semaphore(golang.org/x/sync/semaphore

带权重的信号量——同一时刻只允许「总共不超过 N 个单位」的并发(例如总并发 10,每个任务占 3 个单位,就类似「名额池」)。

你会用到的 API

  • Acquire(ctx, n):尝试占用 n 个名额;不够就阻塞,直到够或 ctx 取消
  • Release(n):归还 n 个名额,和 Acquire 成对使用。

适合限流、连接池式控制、和 context 超时/取消绑在一起的资源申请。

简单示例

import (
    "context"

    "golang.org/x/sync/semaphore"
)

// 同一时刻最多 10 个并发「名额」
var sem = semaphore.NewWeighted(10)

func handle(ctx context.Context) error {
    if err := sem.Acquire(ctx, 1); err != nil {
        return err // 可能 ctx 取消或超时
    }
    defer sem.Release(1)
    // 执行业务…
    return nil
}

singleflight(golang.org/x/sync/singleflight

同一个 key,并发多次「去算同一个结果」时,只真正执行一次,其余调用共享这一次的结果(或共享这一次的错误)。

典型场景:缓存击穿——同一 key 大量并发未命中,只让一个 goroutine 回源加载,其他人等它的结果,避免把数据库打爆。和「锁」不同:它解决的是重复工作合并,不是通用互斥临界区。

简单示例

import "golang.org/x/sync/singleflight"

var g singleflight.Group

func Load(key string) (string, error) {
    v, err, shared := g.Do(key, func() (interface{}, error) {
        // 同一 key 并发时,这里只会执行一次(例如回源 DB)
        return "value-for-" + key, nil
    })
    if err != nil {
        return "", err
    }
    _ = shared // true 表示复用了其它 goroutine 的那次调用
    return v.(string), nil
}