跳至主要内容

Go并发控制的常见用法和注意事项

Go 并发控制:常见用法与踩坑指南

Go 的并发模型基于 CSP(Communicating Sequential Processes)理念,核心口号是「不要通过共享内存来通信,而要通过通信来共享内存」。但实际项目中,sync 包和共享内存的用法同样常见。这份文档把常见工具和典型坑点放在一起,方便对照查阅。


一、基础并发原语

1. goroutine

go func() {
    doSomething()
}()
  • 启动成本很低(初始栈约 2KB,按需增长),但不代表可以无限制启动
  • go 关键字只是把函数丢给调度器,不保证执行顺序,也不保证主协程会等它结束。

常见坑:主协程提前退出

func main() {
    go fmt.Println("hello")
    // main 函数直接结束,goroutine 可能还没来得及执行
}

主协程退出时,其余 goroutine 会被直接杀死,不会等待。必须用 WaitGroupchannel 或其他同步手段显式等待。


2. channel

ch := make(chan int)        // 无缓冲,发送阻塞直到有人接收
ch := make(chan int, 10)    // 有缓冲,容量满了才阻塞
  • 无缓冲 channel 是同步点,发送方和接收方必须「握手」。
  • 有缓冲 channel 只在缓冲区满/空时才阻塞,容易掩盖设计问题(比如用大缓冲区掩盖生产消费速度不匹配)。

常见坑一:向已关闭的 channel 发送数据

close(ch)
ch <- 1 // panic: send on closed channel

常见坑二:重复关闭 channel

close(ch)
close(ch) // panic: close of closed channel

原则:由发送方关闭 channel,且只关闭一次;接收方永远不要关闭。如果有多个发送方,用 sync.Once 或专门的协调协程来关闭。

常见坑三:nil channel 导致永久阻塞

对 nil channel 的收发操作会永远阻塞(这在 select 里其实是一个有用的技巧,用来临时禁用某个 case,但如果是无意为之就是 bug)。

常见坑四:goroutine 泄漏

func leak() {
    ch := make(chan int)
    go func() {
        val := <-ch // 永远没人发送,这个 goroutine 永远卡在这里
        fmt.Println(val)
    }()
    // 函数返回,ch 再也没人用了,但 goroutine 还活着
}

goroutine 泄漏是 Go 里最隐蔽的内存泄漏之一,因为不会报错,只是慢慢堆积。每次开 goroutine 都要想清楚它的退出条件是什么,通常配合 context 或专门的 done channel


3. select

select {
case v := <-ch1:
    fmt.Println(v)
case ch2 <- 1:
    fmt.Println("sent")
case <-time.After(time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("nothing ready")
}
  • 多个 case 同时就绪时,Go 会随机选一个,不是按顺序。
  • time.After 每次调用都会创建一个新的 timer,在高频循环里反复使用会造成资源浪费,应改用 time.NewTimer 并手动 Stop()

常见坑:滥用 default 造成忙轮询(busy loop)

for {
    select {
    case v := <-ch:
        process(v)
    default:
        // 没有 sleep,CPU 会被打满
    }
}

如果确实需要非阻塞轮询,记得加退避(sleep 或 time.Ticker)。


二、sync 包常用工具

1. sync.WaitGroup

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

常见坑一:Add 放在 goroutine 内部

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    go func() {
        wg.Add(1) // 错误:Wait() 可能在 Add 之前就执行完了
        defer wg.Done()
    }()
}
wg.Wait()

Add 必须在 go 语句之前、在主协程里调用,否则会出现 Wait() 提前返回的竞态。

常见坑二:循环变量捕获问题

for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // Go 1.22 之前:大概率全部打印 5
    }()
}

Go 1.22 之前,for 循环的变量在每次迭代中是同一个变量,闭包捕获的是引用而非值,所有 goroutine 执行时 i 可能已经变成循环结束时的值。解决办法是显式传参 go func(i int) {...}(i),或者在循环体内 i := i 重新声明。Go 1.22 起循环变量改为每次迭代独立,这个坑在新版本里基本消失了,但线上很多代码库仍在用旧版本或历史代码,需要留意。

常见坑三:sync.WaitGroup 或含锁的 struct 被复制

type Job struct {
    wg sync.WaitGroup
}

func process(j Job) { // 值传递,wg 被复制!
    j.wg.Wait()
}

sync.Mutexsync.WaitGroup 等一旦被使用就不能复制。函数传参、结构体赋值都算复制。go vet 能检测出大部分这类问题,建议 CI 里跑一下。


2. sync.Mutex / sync.RWMutex

var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
counter++

常见坑一:忘记 Unlock 或者 defer 位置不对

mu.Lock()
if condition {
    return // 忘了 Unlock,直接死锁
}
mu.Unlock()

尽量在 Lock() 后立刻 defer Unlock(),除非有明确的性能理由需要提前释放。

常见坑二:锁的粒度太粗或太细

粒度太粗会让并发失去意义(整个函数一把大锁);粒度太细则容易漏加锁、引发死锁风险和维护成本上升。经验法则:锁只保护「真正共享的数据」,业务逻辑尽量放在锁外面。

常见坑三:重入死锁

Go 的 Mutex 不是可重入锁,同一个 goroutine 二次 Lock() 会直接死锁:

mu.Lock()
mu.Lock() // 死锁,不是 panic,是永久阻塞

常见坑四:RWMutex 使用误区

RWMutex 只有在「读多写少」且读操作本身耗时的场景下才有性能优势,否则读写锁的额外开销可能比普通 Mutex 更差。不要默认觉得 RWMutex 一定更快。


3. sync.Once

var once sync.Once
once.Do(func() {
    initConfig()
})

常用于单例初始化。坑点:如果 once.Do 里的函数 panic,Once 会认为已经执行过,之后不会重试,需要自行处理错误重试逻辑。

4. sync.Map

var m sync.Map
m.Store("key", 1)
v, ok := m.Load("key")

适合「写少读多」或者 key 集合稳定的场景。坑点sync.Map 没有泛型类型检查(any 类型),也没有 len() 方法,滥用会让代码可读性变差。大多数场景下,map + Mutex/RWMutex 反而更直观、更好维护,只有在实测有性能瓶颈时才换 sync.Map


三、atomic 包

var counter int64
atomic.AddInt64(&counter, 1)
val := atomic.LoadInt64(&counter)

或 Go 1.19+ 的类型化版本:

var counter atomic.Int64
counter.Add(1)
val := counter.Load()

适用场景:单个数值的原子读写(计数器、标志位)。不适用:需要保证多个字段一起变化的复合操作,这种情况还是要用 Mutex

常见坑atomic 操作的变量必须始终通过 atomic 函数访问,如果代码里有的地方用 atomic、有的地方直接读写,等于没加保护。


四、context.Context

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

select {
case <-ctx.Done():
    return ctx.Err()
case result := <-doWork(ctx):
    return result
}

常见坑一:忘记调用 cancel

WithCancel / WithTimeout / WithDeadline 返回的 cancel 函数必须调用,哪怫 context 已经自然超时。不调用会导致关联的定时器和资源无法及时释放,属于典型的隐性泄漏。

常见坑二:把 context 存进 struct

type Service struct {
    ctx context.Context // 反模式
}

官方建议 context 应该作为函数的第一个参数显式传递(func Do(ctx context.Context, ...)),而不是存成字段。存成字段会导致 context 的生命周期和对象生命周期绑定不清晰。

常见坑三:用 context 传递可选参数或必需的业务参数

context.WithValue 应该只用来传递「请求范围的元数据」(如 trace ID、认证信息),不要用它来传递函数的必需参数——这会让函数签名失去类型检查,调用方也很难知道到底需要传什么。

常见坑四:子 goroutine 不监听 ctx.Done()

启动的子任务如果不检查 ctx.Done(),父 context 取消了也不会停止执行,等于白设置了超时。


五、更高层的模式

1. errgroup(golang.org/x/sync/errgroup)

g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
if err := g.Wait(); err != nil {
    log.Fatal(err)
}

比手写 WaitGroup + 错误收集更简洁:任意一个任务出错会自动取消关联的 ctx,并且能拿到第一个错误。注意:默认只返回第一个错误,其余任务的错误会被丢弃,如果需要收集所有错误要自己处理。

2. Worker Pool

jobs := make(chan int, 100)
results := make(chan int, 100)

for w := 0; w < 5; w++ {
    go worker(jobs, results)
}

for _, j := range jobList {
    jobs <- j
}
close(jobs)

常见坑:忘记 close(jobs),导致所有 worker 永远阻塞在 range jobs 上,程序看起来「卡住不退出」。同时要注意 results channel 也需要有对应的消费者,否则会写满阻塞。

3. Pipeline 模式

多个 stage 之间用 channel 串联,每个 stage 负责关闭自己产出的 channel。坑点:如果中间某个 stage 提前退出(比如出错),后面的 stage 会一直等数据,前面的 stage 会因为没人接收而阻塞——需要用 context 或专门的 done channel 统一传播取消信号。


六、Data Race 与检测工具

Data race(数据竞争)指多个 goroutine 并发读写同一内存地址,且至少有一个是写操作,而没有同步保护。Go 不会自动帮你避免这种情况,编译器不报错,运行时也不一定报错——它是「未定义行为」,可能今天跑得好好的,换个机器或加大负载就出问题。

排查建议:

  • go build -race / go test -race:官方内置的竞态检测器,开发和 CI 阶段务必开启,虽然会拖慢运行速度,但能捕获大部分数据竞争。
  • go vet:能检测出锁的错误复制、常见的并发误用模式。
  • staticcheck:静态检查工具,能发现更多潜在并发问题(如未使用的 mutex、错误的 channel 用法)。
  • pprof:排查 goroutine 泄漏时,用 net/http/pprof 看 goroutine 数量是否随时间持续增长。

七、几条经验总结

  1. 每开一个 goroutine,先想清楚它怎么退出,不要只想它怎么启动。
  2. channel 由发送方关闭,且只关闭一次;不确定就别关,用其他方式表达「结束」。
  3. 不要复制已使用的 Mutex / WaitGroup,包括通过值传递函数参数。
  4. context 作为参数传递,不要塞进 structcancel() 必须调用。
  5. 能用简单的 channel/锁解决的问题,不要过早上 sync.Mapatomic 等「看起来更高级」的工具——可读性优先,性能问题实测后再优化。
  6. CI 里常态化跑 -race,比事后排查线上诡异 bug 便宜得多。

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。