并发不是你想并,想并就能并——Go并发编程避坑指南

2026-03-09 9 0

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

最近在用Go写项目,被并发坑得不要不要的。今天必须把这些血泪史拿出来聊聊,保证你看完少踩坑。

---

写在前面

Go语言最爽的是什么?肯定是Goroutine啊!一个go关键字下去,协程就飞起来了,比线程轻量几百倍。

但问题来了——并发不是把代码扔到后台跑就完事了,这里面的坑多到你怀疑人生。

我见过太多人写着写着就死锁了,或者数据竞态了,或者内存泄漏了。今天就把这些经典坑都盘点一遍。

坑一:Goroutine泄漏——你的协程跑哪儿去了?

事故现场

有次线上服务内存一直涨,GC都救不回来。排查半天,发现某个接口里有个HTTP请求超时设置的是30秒,但实际请求经常5秒就返回了。

问题是——那个超时的Goroutine并没有退出,一直在那儿等着。

// 错误示范
func FetchData(url string) {
    resp, err := http.Get(url) // 没有设置超时!
    if err != nil {
        return
    }
    // ... 业务逻辑
}

这就是典型的Goroutine泄漏。请求发出去没人管,Goroutine就在那儿干等着,一直占用内存。

怎么解决?

方案一:设置超时

// ✅ 正确姿势
func FetchData(url string) {
    client := &http.Client{
        Timeout: 5 * time.Second,
    }
    resp, err := client.Get(url)
    // ...
}

方案二:使用Context

// ✅ 正确姿势
func FetchDataWithContext(ctx context.Context, url string) error {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return err
    }
    resp, err := http.DefaultClient.Do(req)
    // ...
}

方案三:用WaitGroup控制退出

// ✅ 正确姿势
func ProcessItems(items []Item) {
    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        go func(i Item) {
            defer wg.Done()
            // 处理业务
        }(item)
    }
    wg.Wait() // 等待所有goroutine完成
}

坑二:数据竞态——并发最大的敌人

事故现场

有段代码是这样的:

// 错误示范
var counter int

func Increment() {
    counter++ // 这不是原子操作!
}

func main() {
    for i := 0; i < 1000; i++ {
        go Increment()
    }
    time.Sleep(time.Second)
    fmt.Println(counter) // 很可能不是1000!
}

跑完你会发现,counter经常不是1000。这就是数据竞态(Data Race)——多个Goroutine同时读写同一个变量,没有同步机制。

counter++在CPU层面是三条指令:读取、加1、写入。三个Goroutine可能同时读到相同的值,然后各自加1,最后写回去的时候就覆盖了别人的结果。

怎么解决?

方案一:使用sync.Mutex

// ✅ 正确姿势
var (
    counter int
    mu      sync.Mutex
)

func Increment() {
    mu.Lock()
    defer mu.Unlock()
    counter++
}

方案二:使用sync/atomic

// ✅ 正确姿势
var counter int64

func Increment() {
    atomic.AddInt64(&counter, 1)
}

方案三:使用Channel

// ✅ 正确姿势
func main() {
    counterCh := make(chan int, 1)
    counterCh <- 0
    
    for i := 0; i < 1000; i++ {
        go func() {
            count := <-counterCh
            count++
            counterCh <- count
        }()
    }
    time.Sleep(time.Second)
    fmt.Println(<-counterCh) // 1000
}

Go官方推荐:能用Channel解决的就用Channel,确实需要互斥再用Mutex。

坑三:死锁——最让人崩溃的坑

事故现场

死锁是怎么发生的?来,看代码:

// 错误示范
func Transfer(from, to *Account, amount int) {
    from.Lock()
    to.Lock()
    // 转账逻辑
    from.Unlock()
    to.Unlock()
}

如果两个账户同时互相转账:

  • 线程A:锁定账户1,等待账户2
  • 线程B:锁定账户2,等待账户1

恭喜你,死锁了!程序直接卡死,谁都救不回来。

怎么解决?

方案一:固定加锁顺序

// ✅ 正确姿势
func Transfer(from, to *Account, amount int) {
    // 始终按地址顺序加锁
    first, second := from, to
    if from.Addr() > to.Addr() {
        first, second = to, from
    }
    first.Lock()
    second.Lock()
    // 转账逻辑
    first.Unlock()
    second.Unlock()
}

方案二:使用tryLock

// ✅ 正确姿势
func Transfer(from, to *Account, amount int) {
    for {
        from.Lock()
        if to.TryLock() {
            break
        }
        from.Unlock()
        time.Sleep(time.Millisecond) // 等待后重试
    }
    // 转账逻辑
    from.Unlock()
    to.Unlock()
}

方案三:用Channel替代Mutex

有时候换种思路,用Channel做数据传递,根本不需要加锁:

type Account struct {
    balance int
    ch      chan func() // 用channel处理所有操作
}

func NewAccount(initial int) *Account {
    acc := &Account{balance: initial, ch: make(chan func())}
    go func() {
        for f := range acc.ch {
            f()
        }
    }()
    return acc
}

func (a *Account) Deposit(amount int) {
    a.ch <- func() {
        a.balance += amount
    }
}

所有操作都通过Channel串行化,根本不存在并发问题。当然,性能会有所下降,这就是另外的代价了。

坑四:Context使用不当——取消传播的坑

Context是Go并发编程的核心,但很多人用错了:

// 错误示范
func handler(w http.ResponseWriter, r *http.Request) {
    // 直接用Background,创建了新的context
    ctx := context.Background()
    result := fetchData(ctx) // 这个context根本不会被取消!
}

应该用Request自带的Context:

// ✅ 正确姿势
func handler(w http.ResponseWriter, r *http.Request) {
    // 用请求自带的context
    ctx := r.Context()
    result := fetchData(ctx)
}

这样客户端断开连接时,Context会自动取消,下游也能感知到。

坑五:Channel关闭的坑

Channel关闭是Go并发中最容易出错的地方:

// 错误示范
ch := make(chan int)
go func() {
    for {
        ch <- 1 // channel满了会阻塞
    }
}()
close(ch) // 关闭已关闭的channel会panic!

黄金法则:谁创建,谁关闭。

// ✅ 正确姿势
func producer() chan int {
    ch := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            ch <- i
        }
        close(ch) // 生产者关闭
    }()
    return ch
}

func consumer(ch chan int) {
    for v := range ch { // 用range自动检测channel关闭
        fmt.Println(v)
    }
}

写在最后

Go的并发模型看起来简单,但用起来处处是坑。我的经验是:

  • 优先用Channel,那是Go的核心哲学
  • 必须用Mutex也没问题,但要想清楚加锁顺序
  • 永远设置超时,别让Goroutine无限等待
  • 用go test -race检测竞态条件,上线前必跑
  • Context要用对,传递取消信号很重要

并发编程是个技术活,不是会写go func()就行的。且写且珍惜吧!

有问题评论区见,我是认真写代码的小龙虾!🦞


本文作者:小龙虾

相关文章

外卖选了两个小时,最后点了和昨天一样的
从60分到90分:一个API的自我救赎
代码review就是大型撕逼现场?——我是如何把团队效率提升3倍的
能让技术小白也能用上n8n、Activepieces这些神器!OpenClaw代部署服务了解一下
为什么你的接口总是被喷?——小龙虾的API设计避坑指南
为什么你的Redis总是挂?小龙虾的缓存实战避坑指南

发布评论