📖 故事开场:凌晨3点,运维群里突然炸锅:"数据库连接池爆了!" 小红揉着惺忪的睡眼打开日志,发现是昨天上线的"小优化"——给每个请求加了个
go——正在疯狂创建goroutine。她默默关掉报警,在笔记本上写下:"并发不是魔法,是责任"。
🎯 第一部分:并发避坑指南(经验法则篇)
🚫 法则1:能不用并发,就别用
很多新手(包括曾经的我)总觉得:加个go就能变快!但生产环境会教你做人:
// ❌ 为了并发而并发,纯属画蛇添足
var wg sync.WaitGroup
wg.Add(1)
go serve(&wg) // 就调用一次?那为啥不直接 serve()?
wg.Wait()
// ✅ 简单直接,测试友好,调试轻松
serve()
💡 个人看法:系统整体并发 ≠ 每个模块都要并发。先写同步代码,等性能瓶颈真正出现时再考虑并发,这才是成熟工程师的套路。"过早优化是万恶之源",并发尤其如此。
🔍 法则2:测试要"并行",问题才无处藏身
func TestSomething(t *testing.T) {
t.Parallel() // 加上这行,隐藏的数据竞争瑟瑟发抖
// 你的测试逻辑...
}
配合 go test -race ./...,就像给代码装了"并发雷达"。我们在生产中发现的不少bug,都是在并行测试中"现形"的。血泪教训:别等线上报警才想起加-race。
🌍 法则3:拒绝全局变量,拥抱依赖注入
// ❌ 全局logger,测试时输出乱成一锅粥
func TestAlpha(t *testing.T) {
t.Parallel()
log.Println("Alpha") // 输出顺序不可控
}
// ✅ 用t.Log,测试输出清晰可追踪
func TestAlpha(t *testing.T) {
t.Parallel()
t.Log("Alpha") // 输出归属明确
}
🎭 故事时间:曾经有个同事用全局缓存,结果测试时数据互相污染,排查3小时才发现是"全局"惹的祸。结论:全局变量就像公共厨房,用的人多了,迟早会乱。
⏰ 法则4:知道什么时候"停",比知道什么时候"起"更重要
// ❌ 启动一堆goroutine,然后select{}死等(还不一定等得到)
go ListenHTTP(ctx)
go ListenGRPC(ctx)
go ListenDebug(ctx)
select {
} // 优雅?不,是"摆烂"
// ✅ 用errgroup管理生命周期,错误自动传播
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return ListenHTTP(ctx) })
g.Go(func() error {
return ListenGRPC(ctx) })
return g.Wait() // 一个出错,全员有序撤退
💡 核心观点:goroutine不是"发射后不管"的导弹。不知道什么时候停,就不知道什么时候关数据库、关连接、关日志——资源泄漏的根源往往在此。
🧠 法则5:Context不是装饰,是"紧急制动"
// ❌ 硬睡一分钟,用户取消也拦不住
time.Sleep(time.Minute)
// ✅ 随时响应取消信号,优雅退出
tick := time.NewTimer(time.Minute)
defer tick.Stop()
select {
case <-tick.C:
// 正常执行
case <-ctx.Done():
return ctx.Err() // 用户说停,立刻停
}
🎯 个人感悟:context就像汽车的刹车系统。平时可能感觉不到它的存在,但关键时刻能救命。生产代码里,不处理ctx.Done()就像开车不系安全带。
🛠️ 第二部分:并发原语选择指南(工具篇)
📊 原语选择优先级(从高到低)
1️⃣ 无并发(最简单)
2️⃣ errgroup / sync.Once / x/sync工具包
3️⃣ 自定义高级原语
4️⃣ sync.Mutex(短临界区场景)
5️⃣ select + channel(最后的选择)
💡 为什么channel排这么低?因为太灵活=太容易出错。就像给你一把瑞士军刀,好用但容易割到手。
⚠️ sync.WaitGroup的"隐形陷阱"
// ❌ 经典错误:Add在goroutine里调用,可能来不及执行
func processConcurrently(items []*Item) {
var wg sync.WaitGroup
defer wg.Wait()
for _, item := range items {
go func() {
// goroutine启动时,Add可能还没执行!
wg.Add(1) // ⚠️ race condition!
defer wg.Done()
process(item)
}()
}
}
// ✅ 正确姿势:Add在启动前调用
func processConcurrently(items []*Item) {
var wg sync.WaitGroup
defer wg.Wait()
for _, item := range items {
wg.Add(1) // ✅ 先登记,再出发
go func(item *Item) {
defer wg.Done()
process(item)
}(item)
}
}
🎭 血泪故事:曾经有个"偶现"的bug,测试100次才复现1次,最后发现是wg.Add的时机问题。教训:并发代码的"偶现",往往就是"必现"的前兆。
🌟 强烈推荐:errgroup,WaitGroup的"智能升级版"
// ✅ errgroup:错误自动传播,代码更清爽
func processConcurrently(items []*Item) error {
var g errgroup.Group
for _, item := range items {
item := item // 闭包陷阱,记得copy
if filepath.Ext(item.Path) != ".go" {
continue
}
g.Go(func() error {
return process(item) // 错误自动收集
})
}
return g.Wait() // 一个出错,立刻返回
}
💡 进阶用法:errgroup.WithContext(ctx)可以让一个goroutine出错时,自动取消其他所有任务——级联取消,资源不浪费。
🔐 sync.Mutex:短平快,别玩花样
// ❌ 临界区里做耗时操作,还不管context
func (cache *Cache) Add(ctx context.Context, key, value string) {
cache.mu.Lock()
defer cache.mu.Unlock()
cache.evictOldItems() // ⚠️ 如果这步很慢,其他请求全堵死
cache.items[key] = entry{
value: value}
}
// ✅ 用channel封装状态,支持取消
func (cache *Cache) Add(ctx context.Context, key, value string) error {
select {
case <-ctx.Done():
return ctx.Err() // 用户取消,立刻返回
case state := <-cache.state:
defer func() {
cache.state <- state }()
// 短临界区,只改内存
cache.items[key] = entry{
value: value}
return nil
}
}
🎯 核心原则:sync.Mutex只适合纳秒级的临界区。如果临界区里有网络调用、文件读写,请立刻停下来想想有没有更好的设计。
🧰 第三部分:打造你的并发工具箱(进阶篇)
🎁 封装1:带取消的Sleep
func Sleep(ctx context.Context, duration time.Duration) error {
t := time.NewTimer(duration)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err() // 随时可中断
}
}
// 使用:if err := Sleep(ctx, time.Second); err != nil { return err }
💡 价值:把"重复的样板代码"封装成"一行调用",业务代码更干净。
🚦 封装2:并发度限流器(Limiter)
type Limiter struct {
limit chan struct{
} // 信号量实现限流
working sync.WaitGroup
}
func (lim *Limiter) Go(ctx context.Context, fn func()) bool {
if ctx.Err() != nil {
return false }
select {
case lim.limit <- struct{
}{
}: // 拿到"许可证"
case <-ctx.Done():
return false
}
lim.working.Add(1)
go func() {
defer func() {
<-lim.limit; lim.working.Done() }() // 用完归还
fn()
}()
return true
}
🎭 应用场景:批量处理1000个文件,但最多只允许8个并发。用Limiter比手写worker pool代码少一半,调试容易十倍。
🧱 封装3:泛型状态锁(Locked[T])
type Locked[T any] struct {
state chan *T // 用channel实现"独占访问"
}
func (s *Locked[T]) Modify(ctx context.Context, fn func(*T) error) error {
select {
case state := <-s.state:
defer func() {
s.state <- state }() // 用完放回
return fn(state) // 业务逻辑
case <-ctx.Done():
return ctx.Err()
}
}
// 使用:state.Modify(ctx, func(s *State) { s.Value++ })
💡 优势:比sync.Mutex多了一个天然支持取消的能力,而且不会忘记Unlock(channel的send/recv天然配对)。
🎯 终极心法:并发设计的"三不原则"
- 不暴露锁:把
mutex/channel藏在结构体内部,外部只暴露业务方法 - 不裸用原语:
go func()、make(chan)、wg.Add()尽量封装成高级API - 不忽略取消:任何可能阻塞的操作,都要问自己"用户取消时怎么办?"
🌟 一句话总结:生产级并发 = 最小必要并发 + 完善的取消机制 + 可测试的设计 + 适当的抽象封装