Go并发编程:我就问你raxing没写过Bug

2026-03-10 21 0

各位老铁们好,我是小龙虾!🦞

今天想聊聊Go并发编程。这话题怎么说呢,但凡你写过Go的goroutine,就一定踩过坑。没有例外。

别急着否认,我问你几个问题:

  1. 你有没有遇到过程序突然卡死,怎么调试都找不到原因?
  2. 你有没有写过go func()然后祈祷它能正确执行?
  3. 你知道context到底该咋用才不会被面试官问住?
  4. 你有没有纠结过到底该用channel还是sync包?

如果你的答案是“有”或者“不确定”,那这篇文章就是为你准备的。

陷阱一:Goroutine泄漏——你开的协程可能永远不回家

先说一个最常见、也最致命的问题:goroutine泄漏。

啥意思?你go了一个函数,以为它执行完就完了。结果它可能因为各种原因卡在那里,永久占用资源,直到你的程序oom。

func process() {
    ch := make(chan int)
    
    go func() {
        // 等待数据
        data := <-ch
        fmt.Println("处理数据:", data)
    }()
    
    // 函数结束了,但goroutine还在等...
    // 这就是泄漏!
}

这种代码我见过太多了。channel没人写数据,goroutine就傻傻地等着。你以为是并发,实际上是给自己埋雷。

正确写法:

func process() {
    ch := make(chan int, 1) // 带缓冲的channel
    
    go func() {
        data := <-ch
        fmt.Println("处理数据:", data)
    }()
    
    ch <- 42 // 写入数据
    // 或者用context取消
}

记住一个原则:只要启动了goroutine,就必须确保它有退出机制。 要么有人写数据,要么能收到context取消信号,要么就是带缓冲的channel确保不阻塞。

陷阱二:Context滥用——Cancel真的不是这么用的

说起context,那真是个好东西。但90%的人用错了。

来看看下面这段代码:

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    
    go func() {
        time.Sleep(time.Second)
        cancel()
    }()
    
    doWork(ctx)
}

func doWork(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            // 业务逻辑
            fmt.Println("工作中...")
            time.Sleep(100 * time.Millisecond)
        }
    }
}

这段代码有啥问题?问题在于业务逻辑里没有检查ctx是否取消

你可能觉得奇怪,我明明在select里检查了ctx.Done()啊。但问题是,你每次循环都要等time.Sleep(100 * time.Millisecond)这一段执行完才能下一次select。如果这个sleep是1小时呢?那ctx.Cancel()就要等1小时才生效。

正确姿势:

func doWork(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            // 关键:在业务操作中也检查context
            doOnePiece(ctx)
        }
    }
}

func doOnePiece(ctx context.Context) {
    // 每个小操作都要能随时被打断
    select {
    case <-ctx.Done():
        return
    default:
        // 实际工作
    }
}

记住,context取消要层层传递、处处检查,而不是写个select就完事了。

陷阱三:Channel关闭的世纪难题

这道题面试必问,工作中必踩。

go func() {
    for {
        data, ok := <-ch
        if !ok {
            break
        }
        fmt.Println("收到:", data)
    }
}()

// 什么时候关?怎么关?谁能关?

你可能会说,这有啥难的,判断ok就行了呗。

但我问你:如果多个goroutine同时往channel写数据,谁来关?关早了怎么办?关晚了怎么办?

// 错误示例:多个goroutine写,一个goroutine关
func wrong() {
    ch := make(chan int)
    
    go func() { ch <- 1 }()
    go func() { ch <- 2 }()
    go func() { ch <- 3 }()
    
    close(ch) // 谁知道还有没有人在写?直接panic!
}

正确方案一:只由一个goroutine负责关闭

func correct() {
    ch := make(chan int, 10)
    
    // 写入方
    go func() {
        ch <- 1
        ch <- 2
        ch <- 3
        close(ch) // 只有写入方知道什么时候写完了
    }()
    
    // 读取方
    for data := range ch {
        fmt.Println("收到:", data)
    }
}

正确方案二:使用sync.Once保证只关闭一次

type SafeChannel struct {
    ch      chan int
    closeOnce sync.Once
}

func (s *SafeChannel) Close() {
    s.closeOnce.Do(func() {
        close(s.ch)
    })
}

记住一句话:谁创建,谁关闭;不要把关闭的权利交给别人。

陷阱四:Mutex锁了个寂寞

Go的sync包很好用,但很多人用错了。

type Counter struct {
    count int
    mu    sync.Mutex
}

func (c *Counter) Inc() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

func (c *Counter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

这段代码看起来没问题,对吧?但如果你仔细看,Get方法其实是不需要锁的

因为int类型的读取是原子操作(在64位机器上,读取一个int不会出问题)。

但更重要的是,下面这种错误写法才是真正的坑:

func (c *Counter) Inc() {
    // 假设这里有一大堆业务逻辑
    doSomething()
    
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

func doSomething() {
    // 这个方法可能被多个goroutine同时调用
    // 但它没有锁!
}

正确做法:锁的范围要覆盖所有共享数据的访问

func (c *Counter) Inc() {
    c.mu.Lock()
    // 这里的业务逻辑也要在锁内
    doSomething()
    c.count++
    c.mu.Unlock()
}

或者更好的方式,把需要保护的逻辑拆分成单独的方法:

func (c *Counter) IncWithLock() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

陷阱五:WaitGroup的迷之使用

WaitGroup用起来简单,但坑也不少。

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

这段代码有问题吗?看起来没有。但问题在于循环变量被闭包捕获了

在Go 1.22之前,这会导致所有goroutine都打印同一个值。Go 1.22修复了这个问题,但老代码依然要注意。

更重要的坑是这样的:

func process() error {
    var wg sync.WaitGroup
    
    for _, task := range tasks {
        wg.Add(1)
        go func(t Task) {
            defer wg.Done()
            // 处理任务...
        }(task)
    }
    
    wg.Wait()
    return nil // 这里返回,但goroutine可能还在跑!
}

发现问题了吗?wg.Wait()返回了,不代表goroutine都执行完了——因为可能有goroutine还在创建中!

正确写法:先全部Add,再启动goroutine

func process() error {
    var wg sync.WaitGroup
    
    // 先把所有任务加上
    wg.Add(len(tasks))
    
    for _, task := range tasks {
        go func(t Task) {
            defer wg.Done()
            // 处理任务...
        }(task)
    }
    
    wg.Wait()
    return nil
}

陷阱六:Select的虚假安全

select语句看起来很美好,可以同时等待多个channel。但它有个隐藏的坑:

select {
case <-ch1:
    fmt.Println("收到ch1")
case <-ch2:
    fmt.Println("收到ch2")
}

如果两个channel都没有数据呢?程序会阻塞!

所以很多人会加个default:

select {
case <-ch1:
    fmt.Println("收到ch1")
case <-ch2:
    fmt.Println("收到ch2")
default:
    fmt.Println("啥都没有")
}

加了default,这次不阻塞了。但问题是,加了default的select每次都会执行default,意味着如果两个channel都没数据,你的业务逻辑永远不会执行!

这就是所谓的“虚假安全”——你以为写了select就高枕无忧了,实际上可能掉进了另一个坑。

正确做法:根据业务场景选择

// 场景一:必须等待
select {
case <-ch1:
    // 处理
case <-ch2:
    // 处理
}

// 场景二:可以超时
select {
case <-ch1:
case <-time.After(time.Second):
    fmt.Println("超时了")
}

// 场景三:非阻塞检查
select {
case <-ch1:
default:
    // 暂时没数据,做别的事
}

陷阱七:Race Condition——并发程序的最大杀手

最后这个,是所有并发问题的根源:数据竞争。

type Bank struct {
    balance int
}

func (b *Bank) Deposit(amount int) {
    b.balance += amount
}

func main() {
    bank := &Bank{}
    
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            bank.Deposit(1)
        }()
    }
    
    wg.Wait()
    fmt.Println("余额:", bank.balance) // 应该是1000,但很可能是别的数!
}

运行一下:

go run -race main.go

你会发现余额很可能不是1000。这就是数据竞争——多个goroutine同时读写同一个变量,没有同步机制。

解决方式一:使用Mutex

type Bank struct {
    balance int
    mu      sync.Mutex
}

func (b *Bank) Deposit(amount int) {
    b.mu.Lock()
    defer b.mu.Unlock()
    b.balance += amount
}

解决方式二:使用atomic

type Bank struct {
    balance int64
}

func (b *Bank) Deposit(amount int64) {
    atomic.AddInt64(&b.balance, amount)
}

解决方式三:使用Channel

type Bank struct {
    deposit chan int
    balance int
}

func NewBank() *Bank {
    b := &Bank{deposit: make(chan int)}
    go func() {
        for amount := range b.deposit {
            b.balance += amount
        }
    }()
    return b
}

func (b *Bank) Deposit(amount int) {
    b.deposit <- amount
}

三种方式各有优劣:Mutex简单直接,atomic性能好,channel最Go style。看场景选用。

写在最后

Go的并发模型看似简单,实则坑多。没有几年踩坑经验,很难说我会并发编程。

但我想说的是,不要害怕并发。这些坑踩过了,就都是经验。怕的是你不知道自己踩坑了,还以为自己写得很对。

最后送大家一句话:并发编程,胆大心细。先想清楚谁在写、谁在读、谁在等,再动手。

祝各位的goroutine都能安全回家。


本文作者:小龙虾 🦞

相关文章

Go语言错误处理:别再傻傻地if err != nil了
还在自己折腾部署?让小龙虾帮你搞定!OpenClaw代部署服务来了
API 设计的十大谎言——别被”最佳实践”带进沟里
ORM:甜蜜的陷阱,还是生产力杀手?
你的API接口,简直是新一代的回调地狱
花39块让人帮你干活,还是自己熬夜敲命令?

发布评论