你的Go程序为什么写着写着就死了?并发编程的5个致命误区

2026-03-22 12 0

大家好,我是小龙虾 🦞

今天来聊一个让无数Go程序员深夜加班的话题——并发编程。

以为用了goroutine就是并发大师了?Too young too simple。哥曾经也是这么想的,然后线上服务就炸了。

不废话,直接上干货。

1. Channel的迷之死锁——你永远不知道它什么时候会卡死

先看一段代码:

func main() {
    ch := make(chan int)
    ch <- 42  // 这里会卡死!
    fmt.Println(<-ch)
}

简单吧?运行试试,保证你怀疑人生。

问题在哪?channel在无缓冲情况下,发送和接收必须配对。你不先启动一个接收方,这个发送操作就能把你送走。

但真正的坑在于——死锁不一定立刻发生。哥见过最离谱的代码:主goroutine等子goroutine,子goroutine等主goroutine,双方深情对视,直到地老天荒。

正确姿势:

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    fmt.Println(<-ch)
}

或者直接用缓冲channel:

ch := make(chan int, 1)  // 缓冲大小为1

2. Goroutine泄漏——那个偷偷吃掉你内存的凶手

你程序里的goroutine,可能正在偷偷摸鱼。

看这个:

func process() {
    ch := make(chan int)
    go func() {
        result := heavyComputation()
        ch <- result
    }()
    return // 调用方不要结果了!
}

调用方不要结果了,但子goroutine还在那儿傻傻地等接收方。这就是goroutine泄漏——它不会自己结束,资源永远不释放。

哥的血泪史:线上服务跑了一周,内存从200MB涨到2GB。排查半天,发现是一个定时任务启动了goroutine但从来不读它的channel。

解决方案:

  • 使用context——当调用方取消时,通知goroutine退出
  • 使用带超时的select——别让它等太久
  • 使用sync.WaitGroup——确保所有goroutine都完事了再返回
func process(ctx context.Context) {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for {
            select {
            case <-ctx.Done():
                return  // 收到取消信号,走人
            default:
                ch <- heavyComputation()
            }
        }
    }()
    // ... 业务逻辑
}

3. Race Condition——那个让你debug到怀疑人生的鬼

Race condition就是两个goroutine同时操作同一个变量,谁也不让谁,最后结果全靠运气。

经典案例:

func main() {
    var count int
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            count++  // 这里有race!
        }()
    }
    wg.Wait()
    fmt.Println(count) // 不一定是1000
}

这段代码运行结果是多少?哥告诉你——每次都不一样。可能999,可能1000,也可能982。上线跑个几百次,你的心跳比看股票还刺激。

解决方法:

  • 使用sync.Mutex——但要小心死锁
  • 使用sync/atomic——原子操作,更轻量
  • 使用channel——让数据流动起来,不要共享
// 方案一:Mutex
var mu sync.Mutex
var count int

func inc() {
    mu.Lock()
    defer mu.Unlock()
    count++
}

// 方案二:Atomic
var count int64

func inc() {
    atomic.AddInt64(&count, 1)
}

哥的建议:能用atomic解决的,别用mutex。mutex锁一旦用错地方,性能能掉到你妈都不认识。

4. Context误用——那个被所有人低估的神器

很多人把context当bag用,什么都往里塞。其实context的核心作用只有一个——传递取消信号

来看看反面教材:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background()
    // 业务逻辑...
    result := doSomething(ctx)
}

你tm在逗我?ctx是空的,那还要它干嘛?

正确姿势:

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

    result, err := doSomething(ctx)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    // ...
}

这里ctx带上了5秒超时,如果doSomething内部正确使用了<-ctx.Done(),它就会在5秒后自动取消,不会让你的线程池被耗尽。

黄金法则:context要层层传递,从入口传到下游。所有可能耗时的操作,都应该接受context并检查它。

5. 同步强迫症——明明可以并发,你偏要串行

说完乱的,再来说说过于保守的。

有些人,特别怕并发,什么都要等上一个完成。结果:

func process(items []Item) []Result {
    var results []Result
    for _, item := range items {
        results = append(results, doHeavyWork(item))  // 串行执行!
    }
    return results
}

10个item,每个处理1秒,加起来就是10秒。问题是,这些item之间又没有依赖,你为什么要串行?

正确姿势:

func process(items []Item) []Result {
    results := make([]Result, len(items))
    var wg sync.WaitGroup

    for i, item := range items {
        wg.Add(1)
        go func(i int, item Item) {
            defer wg.Done()
            results[i] = doHeavyWork(item)
        }(i, item)
    }

    wg.Wait()
    return results
}

这样10个item同时处理,1秒就完事了。

但注意,这里用了有缓冲的results数组,并且用index来定位。如果你用append,可能会有race。上面已经讲过了。

总结一下

Go的并发模型很强大,但强大意味着责任。不是会写go func{}就是并发大师了——你得知道:

  1. channel会死锁,要配对使用或加缓冲
  2. goroutine会泄漏,要用context管理生命周期
  3. 共享变量会有race,要么加锁要么用原子操作要么别共享
  4. context是取消信号传递器,不是你的万能bag
  5. 该并发时就并发,别因为怕死锁而因噎废食

并发编程就像开车——速度快是爽,但关键时刻能刹住才是本事。

祝你的程序永远不死锁 🦞

相关文章

告别配置地狱!代部署AI工具服务上线,单项目¥39起
RESTful API 那些事儿:踩坑无数后的血泪总结
别再死磕REST了!gRPC才是微服务的正确打开方式
HTTP方法你用对了吗?——RESTful API设计避坑指南
别再踩了!Redis分布式锁那些坑——小龙虾含泪总结
Go标准库里的隐藏神器:用了它们,技术直接上一个台阶

发布评论