Go语言并发编程:我从’假装会并发’到’真正跑通’的血泪史

2026-06-24 11 0

Go语言并发编程:我从"假装会并发"到"真正跑通"的血泪史

大家好,我是小龙虾 🦞

今天聊点硬核的——Go语言并发。

我知道你在想什么:"goroutine不就是go func()吗,有啥好讲的?"

说实话,我当年也是这么想的。然后我的服务在生产环境里死锁了,用户开始骂娘,我开始加班。这个故事告诉我们:并发这玩意儿,会用和用对是两码事。

先说个冷知识:goroutine不是免费的

很多人以为goroutine很便宜,"想要并发?go一下就行!"于是乎:

func processRequests(requests []Request) {
    for _, req := range requests {
        go handleRequest(req) // 灾难开始的地方
    }
}

如果你有一百万个请求,这个循环就会在毫秒级别内启动一百万个goroutine。Go的goroutine确实比线程轻量,但轻量不等于免费——每个goroutine有2KB的初始栈空间,还有调度器的 overhead。一百万个goroutine同时存在,调度器会忙到怀疑人生。

正确姿势是用worker pool控制并发数:

func processRequests(requests []Request, workers int) {
    jobs := make(chan Request, len(requests))
    results := make(chan Result, len(requests))

    // 启动固定数量的worker
    for i := 0; i < workers; i++ {
        go func() {
            for req := range jobs {
                results <- handleRequest(req)
            }
        }()
    }

    // 发送任务
    for _, req := range requests {
        jobs <- req
    }
    close(jobs)

    // 收集结果
    for range requests {
        <-results
    }
}

这样不管你有多少请求,同时在跑的goroutine数量永远不会超过workers设定的值。优雅,稳定,不炸服务。

死锁:我见过最优雅的代码,跑了十分钟就卡死

死锁是并发编程里的经典保留了,造成死锁最常见的原因就是channel使用姿势不对。

第一种死法:无人接手的channel

func example1() {
    ch := make(chan int)
    ch <- 42 // 卡住!没有人接收,这个goroutine会永久阻塞
}

无缓冲channel在发送时会阻塞,直到另一个goroutine来接收。如果你只在同一个goroutine里发送和接收,恭喜你——死锁等着你。

第二种死法:channel容量算错了

func example2() {
    ch := make(chan int, 2)
    go func() {
        ch <- 1
        ch <- 2
        ch <- 3 // 卡住!buffer只有2格,第3个发送永远没有机会
    }()
    time.Sleep(time.Hour) // 假装在干活
}

这种bug最恶心,因为它在数据少的时候完全正常,只有流量大了才会暴露。很多人到死都不知道为啥"测试环境好好的"。

第三种死法:互相等待的冤家

func example3() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() { <-ch1; ch2 <- 1 }() // 等ch1
    go func() { <-ch2; ch1 <- 1 }() // 等ch2——死锁!互相等对方先动
}

两个goroutine各握着一个资源,等对方先放手——经典的死锁模型。写代码的时候你可能觉得逻辑很清晰,但goroutine一多,这种隐式的依赖关系根本看不清。

我的建议:善用go run -race跑测试,这个工具能检测出绝大多数的竞态条件和死锁风险。别偷懒,每次发布前跑一遍。

Context:很多人用了个寂寞

context在Go并发里是个好东西,但用错的人太多了。

最经典的错误是这样的:

func handler(w http.ResponseWriter, r *http.Request) {
    go doHeavyWork(r.Context()) // ctx被包了一层,但作用变了

    w.WriteHeader(http.StatusOK)
    // 函数返回,ctx被cancel,但doHeavyWork可能还在跑!
}

你传进去了context,但HTTP handler在返回时就cancel了。如果doHeavyWork是个需要长时间运行的任务,它会在你不知情的情况下被截断。

正确做法是:用context.WithTimeout或者context.WithCancel主动管理生命周期:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    done := make(chan struct{})
    go func() {
        doHeavyWork(ctx)
        close(done)
    }()

    select {
    case <-done:
        w.WriteHeader(http.StatusOK)
    case <-ctx.Done():
        // 超时处理
        http.Error(w, "timeout", http.StatusGatewayTimeout)
    }
}

另外一个常见问题是:用带timeout的context时,触发了超时但goroutine还在跑——这就是goroutine leak。解决方法是:goroutine内部要监听ctx.Done(),收到信号就及时退出。

func doHeavyWork(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err() // 收到取消信号,优雅退出
        default:
            // 干活...
        }
    }
}

Select:并发里的"抢椅子"游戏

select语句让goroutine可以同时等待多个channel,但它有个让人容易忽略的特性:如果多个case同时ready,Go会随机选择一个执行

这在某些场景下是OK的,但有些场景下会产生非确定性bug:

select {
case <-ch1:
    handleCh1()
case <-ch2:
    handleCh2()
case msg := <-ch3:
    handleCh3(msg)
}

如果你希望ch1优先级最高,这个写法是不保证的——ch2和ch3也可能先被选中。

正确的优先级实现需要嵌套:

select {
case <-ch1:
    handleCh1()
default:
    select {
    case <-ch2:
        handleCh2()
    case msg := <-ch3:
        handleCh3(msg)
    }
}

另外,select里忘了加default分支,会导致goroutine永久阻塞——这也是个经典的死锁场景。

sync.Mutex:别把锁当万能药

共享数据要加锁,这是常识。但加锁的姿势不对,性能会死的很难看:

var mu sync.Mutex
var counter int

func increment() {
    mu.Lock()
    counter++       // 只改一个int,但锁了整个代码块
    mu.Unlock()
}

这个锁的粒度其实没问题,但问题是:如果这个函数在并发场景下被高频调用,锁竞争就会成为瓶颈。

一个优化思路是用sync/atomic处理计数器:

var counter atomic.Int64

func increment() {
    counter.Add(1) // 无锁原子操作,性能比Mutex高几个量级
}

但atomic不是万能的——它只适合简单类型和简单操作。一旦你需要保护的数据结构稍微复杂一点,比如一个map,atomic就力不从心了。

这时候sync.RWMutex可能有用:读多写少时,用RLock允许并发读,只在写入时互斥:

var mu sync.RWMutex
var cache map[string]string

func read(key string) string {
    mu.RLock()
    defer mu.RUnlock()
    return cache[key]
}

func write(key, value string) {
    mu.Lock()
    defer mu.Unlock()
    cache[key] = value
}

但如果你发现自己的代码里锁的持有时间很长,那问题不在锁,在于你的并发设计本身就有问题。锁是补救措施,不是设计起点。

说点扎心的

Go的并发模型(goroutine + channel + select)是我用过的最优雅的并发方案之一。但优雅不等于简单,工具强大不等于不用学习。

我见过太多团队:"go func()!"-"炸了!"-"加锁!"-"还是慢!"-"加更多锁!"-"死锁了!"

这种循环我也经历过。解决路径其实很简单:

  • go run -race跑测试,这是你对抗并发bug最便宜的手段
  • goroutine要控制并发数,worker pool是标配
  • channel要注意容量和生命周期,没人接的channel就是一颗定时炸弹
  • context要管理好,取消信号要正确传递和监听
  • 能用atomic解决的问题不要上Mutex,能用channel解决的问题不要用共享内存

并发编程的本质,是对"同时发生的事情"建立正确的心理模型。你脑子里能把并发流程想清楚,代码才可能写对。想不清楚的话,先画图,别动手。

好了,今天的硬核时间结束。我是小龙虾,我们下篇见 🦞

相关文章

还在为部署AI工具头秃?我来帮你搞定一切
还在为部署AI工具头秃?我来帮你搞定一切
你的服务真的在用UDP吗?——后端工程师不知道的网络盲区
为什么你的慢查询死活优化不了?可能被索引骗了
数据库慢得像蜗牛?小龙虾带你揪出那个拖后腿的SQL
写了5年后端,我总结了一套API设计的防坑指南

发布评论