你还在用”协程随便开”这种玄学调优?Go并发三板斧砍掉你一半的bug

2026-07-23 23 0

你还在用"协程随便开"这种玄学调优?Go并发三板斧砍掉你一半的bug

上次有个哥们在技术群里说:"我的Go服务轻松扛10万并发,go func()随便写就行了。"我问他:"那你服务跑一天内存涨了吗?Goroutine数量监控了吗?context传了吗?"对面沉默了三十秒,然后悄悄退了群。

Go的并发模型是出了名的简洁——一个go关键字,三行代码,你就是并发大师。但正因为它太简单了,导致大量程序员产生了"我已经完全掌握了"的幻觉,直到线上内存爆了、goroutine炸了、channel死锁了,才哭着喊着找原因。

今天我们就来聊聊Go并发里三个最容易被忽视、但又最容易导致线上事故的点。我管这叫Go并发三板斧——不是因为它简单,而是因为它砍bug特别利索。


第一板斧:Goroutine调度——你以为的"并行"可能是个假象

很多人以为写go func()就等于"这段代码会同时执行"。Too young too simple sometimes naive。

Go的goroutine是协作式调度,不是抢占式——大部分情况下你不需要锁,因为goroutine会在safe point主动让出执行权。但问题是,这个safe point不是你决定的,是runtime决定的。

看这段经典的"协程泄漏"代码:

func processRequest(ctx context.Context) {
    ch := make(chan Result)

    go func() {
        // 模拟慢查询,假设平均耗时5秒
        result := db.Query("SELECT * FROM orders WHERE id = ?", 12345)
        ch <- result
    }()

    select {
    case <-ctx.Done():
        // 客户端断连了,直接返回
        return  // ⚠️ 这里ch没人读,goroutine泄漏了!
    case result := <-ch:
        fmt.Println(result)
    }
}

当客户端断连时,context被取消,函数直接return,但那个默默查数据库的goroutine还在跑,它的结果写不进已经没人读的channel——这个goroutine永远不会被回收。一个请求泄漏一个,线上QPS一上来,你的内存就是漏水的桶。

正确的做法是用buffered channel或者用context同时控制goroutine的生命周期:

func processRequest(ctx context.Context) {
    ch := make(chan Result, 1) // 加个缓冲,让goroutine不会阻塞在send

    go func() {
        result := db.Query("SELECT * FROM orders WHERE id = ?", 12345)
        select {
        case ch <- result:
        case <-ctx.Done():
            // context取消时,goroutine知道自己该收手了
            return
        }
    }()

    select {
    case <-ctx.Done():
        return
    case result := <-ch:
        fmt.Println(result)
    }
}

或者更彻底一点,用errgroup来管理并发任务:

func processRequest(ctx context.Context) error {
    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error {
        return fetchUser(ctx)
    })
    g.Go(func() error {
        return fetchOrders(ctx)
    })
    g.Go(func() error {
        return fetchRecommendations(ctx)
    })

    return g.Wait() // 任一任务失败,其他会被cancel掉
}

errgroup底层封装了context cancel逻辑,只要你传进去的ctx被cancel,所有goroutine都会收到退出信号——这就是Go并发哲学的精髓:通过context传递取消信号,而不是通过flag或者channel通知


第二板斧:select——你可能写了个假的多路复用

select语句是Go里处理多channel通信的核心语法,看起来像switch,但行为完全不一样。多数人用它做"多路复用",但稍不留神就会写出有问题的代码。

第一个坑:忘了default分支。select不带default时,如果所有case都阻塞,goroutine就会卡住。某些场景下这是有意的,但大多数时候你是忘了考虑"没有数据到达"的情况。

select {
case msg := <-ch1:
    handle(msg)
case msg := <-ch2:
    handle(msg)
// 如果两个channel都没数据,这里就死等了
}

更隐蔽的坑是nil channel。向nil channel发送或接收会永久阻塞,这个特性本来是Go设计来动态启用/禁用case的,但很多人不知道,结果埋下死锁的雷:

func worker(ch <-chan Task, done chan<- struct{}) {
    for {
        select {
        case task := <-ch:
            process(task)
        case <-done:
            return
        }
    }
}

// 调用者:
ch := make(chan Task)
done := make(chan struct{})
worker(ch, done) // 这个goroutine在等ch,但ch没人发数据,它就卡住了

// 正确做法:用nil channel禁用case,或者用break label
select {
case task := <-ch:
    process(task)
case <-done:
    return
default:
    // 加default防止阻塞
    time.Sleep(time.Millisecond * 100)
}

第三个坑更绝——在for循环里select without default,导致每次循环只处理一个事件就退出循环,而不是持续监听:

// 错误写法
for {
    select {
    case msg := <-ch:
        handle(msg)
        // 处理完一个就出去了?不对,下次循环还在这里,但select重新评估
        // 所以实际上行为是对的——但如果ch持续有数据,
        // 这个循环会忙等,CPU打满
    }
}

// 正确写法:加退出机制和default
for {
    select {
    case msg := <-ch:
        handle(msg)
    case <-quit:
        return
    default:
        // 没有数据时让出CPU,不要忙等
        time.Sleep(10 * time.Millisecond)
    }
}

如果你用的是Go 1.21以上的版本,有个更优雅的写法:for ch != nil { select { ... } }——给ch赋值nil就等于从select里移除这个case,runtime会自动处理。这是Go的一个高级特性,知道的人不多,用好的人更少。


第三板斧:Context——你可能传了个寂寞

context是Go并发里最重要但也是最容易被用错的组件。很多人把context当成"万能参数"到处传,但从来不cancel它;或者传了个context.Background(),以为这样就安全了,实际上完全失去了trace和取消能力。

最常见的错误是在HTTP handler里不用请求的context

// 错误:传了Background,数据库操作无法被客户端断开取消
func handler(w http.ResponseWriter, r *http.Request) {
    result, err := db.Query(context.Background(), "SELECT ...") // ❌

    // 正确:传 r.Context(),客户端断连时数据库查询会被取消
    result, err := db.Query(r.Context(), "SELECT ...") // ✅
}

这个错误有多致命?当客户端超时断开时,你的数据库还在傻乎乎地查,浪费连接资源,如果是大查询还可能锁表。而正确传递context之后,HTTP框架会在客户端断连时自动cancel这个context,数据库驱动收到信号后会中止查询——这就是context cancel的级联效应

第二个常见错误是在goroutine里用已经cancel的context

func parent(ctx context.Context) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    go child(ctx) // goroutine依赖ctx

    // 如果这里提前cancel了,child里的所有操作都会收到取消信号
    cancel()
}

func child(ctx context.Context) {
    // 这个ctx已经在parent里被cancel了
    // 所有用这个ctx的操作(db.Query、http.Get等)都会立即返回context canceled错误
    db.Query(ctx, "...")
}

这里有个原则:context是树形传播的,父context cancel,所有子context都会收到。所以你在启动goroutine之前,一定要想清楚这个goroutine的生命周期应该跟谁绑定。如果绑错了,一旦父操作取消,子goroutine会毫无预警地被杀掉——你以为是"优雅退出",实际上是"意外阵亡"。

第三个错误是把context塞进结构体然后忘记它。context有明确的使用规范:它应该作为函数的第一个参数传来传去,不应该被存在结构体里。一旦你把context塞进struct,它就成了一个隐形的依赖,后期维护者根本不知道这个组件依赖cancellation信号。

// 错误:不建议在结构体里存context
type Worker struct {
    ctx    context.Context  // ❌ 这样会让ctx的取消语义变得隐晦
    client *http.Client
}

// 正确:context通过参数传递,不存在结构体里
type Worker struct {
    client *http.Client  // ✅ 纯数据依赖,context由调用方控制
}
func (w *Worker) Do(ctx context.Context, task Task) error {
    // context明确传入,生命周期清晰
}

总结:三板斧砍掉的那一半bug,都是因为你迷信"Go并发很简单"

写了这么多年Go,我最大的感受是:Go的并发模型确实简单,但它对程序员的要求一点都不简单。你必须很清楚地知道:

  • goroutine什么时候退出,谁来通知它退出
  • channel有没有人读,会不会写阻塞
  • context的取消信号传递到哪一层了

这三个问题想不清楚,你的并发代码就是在赌运气——流量小的时候跑得挺欢,一上量就开始随机爆炸。

Go给你的不是"并发安全的保证",而是让你写出并发安全代码的工具。工具再好,也得会用才行。那些"协程随便开"的玄学调优大师们,建议你们跑一下这个命令:

go run -race your_program.go

-race是Go内置的数据竞争检测器,能帮你发现大部分并发访问问题。上线前跑一遍,能救你一命。

协程不难,但协程背后的工程意识,才是你真正要修炼的。

相关文章

MySQL事务隔离:那些年我把数据库读脏了的故事
别再写100个if-else了:我用策略模式把代码行数砍到脚踝价
API网关不会告诉你的5件事:生产环境教会我的那些”意外”
你的日志在骗你:后端可观测性的七个反直觉真相
还在为部署 AI 工具熬夜?小龙虾帮你躺平上线 🚀
REST很好,但别把它当成宗教来信

发布评论