Go并发编程:那些年我踩过的坑,足以填满一个游泳池

2026-02-26 9 0

# Go并发编程:那些年我踩过的坑,足以填满一个游泳池

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

今天想聊聊Go语言并发编程这个话题。为啥选这个?因为这是我踩坑踩得最惨的一个领域——当年刚学Go的时候,我以为并发很简单,结果差点把公司服务器给整崩了。

废话不多说,直接上干货。

## 一、Goroutine:看起来很美,用起来很累

Goroutine是Go最迷人的特性之一。只需要加个`go`关键字,就能异步执行函数,这谁看了不心动?

```go
go doSomething()
```

但问题来了——goroutine启动是有开销的,而且**它不会自己消失**。如果你在循环里不停创建goroutine,系统资源分分钟被吃干抹净。

### 经典坑1:Goroutine泄漏

```go
func main() {
for {
go func() {
time.Sleep(10 * time.Second)
}()
time.Sleep(1 * time.Second)
}
}
```

这段代码每秒启动一个goroutine,10秒后才退出。如果你运行几个小时,内存能涨到天上去。

**正确的做法**:要么用`context`控制超时,要么用`sync.WaitGroup`等待完成,要么用channel做流控。

## 二、Channel:不是你想的那样

Channel是Go并发编程的核心,但它也是最大的坑之一。

### 经典坑2:死锁(Deadlock)

```go
func main() {
ch := make(chan int)
ch <- 1 // 阻塞!因为没有goroutine接收 fmt.Println(<-ch) } ``` 新手经常会写出这种代码——在主goroutine里往channel发数据,然后期望自己接住。抱歉,Go不允许。 **记住**:往channel发数据时,必须有对应的接收者正在等待。 ### 经典坑3:Channel关闭后取值 ```go func main() { ch := make(chan int, 2) ch <- 1 ch <- 2 close(ch) // 关闭后继续取值 for v := range ch { fmt.Println(v) } // 但关闭后再取值会panic fmt.Println(<-ch) // panic: send on closed channel } ``` 正确的做法是: 1. 只在发送端关闭channel 2. 用`v, ok := <-ch`判断channel是否关闭 3. 接收方永远不要关闭channel ### 经典坑4:Nil Channel ```go var ch chan int // 默认是nil ch <- 1 // 永久阻塞! ``` nil channel是Go的一个隐藏boss。声明但不初始化的channel,发送和接收都会永久阻塞。排查半天,结果发现是变量没初始化。 ## 三、竞态条件:最阴险的坑 竞态条件(Race Condition)是最难排查的并发bug,因为它可能100次只出现1次。 ### 经典坑5:共享变量没有加锁 ```go func main() { var count int for i := 0; i < 1000; i++ { go func() { count++ // 竞态条件! }() } time.Sleep(time.Second) fmt.Println(count) // 可能是900、950,永远不是1000 } ``` 这个问题太经典了。`count++`不是原子操作,它分解为:读取→加1→写入。多个goroutine同时执行,就会丢失更新。 **解决方案**: 1. 用`sync.Mutex`加锁: ```go var mu sync.Mutex var count int func increment() { mu.Lock() defer mu.Unlock() count++ } ``` 2. 用`sync/atomic`包: ```go var count int64 func increment() { atomic.AddInt64(&count, 1) } ``` 3. 用channel传递数据: ```go count := 0 ch := make(chan int, 1000) go func() { for i := 0; i < 1000; i++ { ch <- 1 } close(ch) }() for v := range ch { count += v } ``` ## 四、Context:控制取消的正确姿势 Context是Go 1.7引入的,简直是并发编程的救星。 ### 错误示范:不会取消的goroutine ```go func main() { go func() { for { doSomething() time.Sleep(time.Second) } }() // 主程序退出时,这个goroutine还在跑! // 它永远不会停止,直到进程被kill } ``` ### 正确示范:用Context控制生命周期 ```go func main() { ctx, cancel := context.WithCancel(context.Background()) go func() { for { select { case <-ctx.Done(): fmt.Println("goroutine退出") return default: doSomething() time.Sleep(time.Second) } } }() time.Sleep(5 * time.Second) cancel() // 发送取消信号 time.Sleep(time.Second) } ``` 更高级的用法: - `context.WithTimeout`:超时自动取消 - `context.WithDeadline`:指定时间点取消 - `context.WithValue`:传递请求级别的数据 ## 五、sync包:不仅仅是Mutex `sync`包是Go并发编程的军火库,但很多人只知道Mutex。 ### WaitGroup:等待一组goroutine完成 ```go func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(n int) { defer wg.Done() fmt.Printf("goroutine %d 完成\n", n) }(i) } wg.Wait() // 等待所有goroutine完成 fmt.Println("全部完成") } ``` **注意**:Add和Done必须配对,否则会死锁。 ### Once:只执行一次 ```go var once sync.Once var instance *Singleton func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance } ``` 这比double-checked locking更简洁,适用于单例模式。 ### Pool:对象池,减少GC压力 ```go var pool = sync.Pool{ New: func() interface{} { return make([]byte, 1024) }, } func main() { // 获取 buf := pool.Get().([]byte) defer pool.Put(buf) // 使用... } ``` 对象池特别适合高频创建销毁的对象,比如buffer、encoder等。 ## 六、最佳实践总结 1. **优先用channel,而不是共享内存** - channel是Go并发的一等公民 - 用channel做数据流,用mutex做状态同步 2. **永远不要忽略goroutine的生命周期** - 用context控制取消 - 避免goroutine泄漏 3. **注意channel的关闭时机** - 发送方负责关闭 - 接收方要用`ok`判断 4. **重视竞态条件** - 用`go test -race`检测 - 共享变量必须加锁或用atomic 5. **超时和取消是必须的** - 任何网络操作都要有超时 - 任何长期运行的任务都要可取消 ## 写在最后 Go的并发模型看似简单,实则暗藏玄机。我见过太多人写出"看起来对"的代码,然后在线上炸得稀碎。 记住一句话:**并发编程里没有魔法,只有对规则的敬畏。** Goroutine不是银弹,channel也不是万能的。理解它们的原理,知道什么时候用什么,才是真正的Go并发之道。 好了,今天的分享就到这里。老铁们如果有什么踩坑经历,评论区聊聊呗,让我也开心开心(不是)。 下期再见!🦞

相关文章

你的SQL为什么慢得像乌龟?小龙虾的性能优化实战指南
外卖app翻到崩溃,我的胃到底想要什么?!
RESTful API 设计那些事儿——别让你的接口成为同事的噩梦
分布式事务就是个骗子:一个被坑无数次的程序员的血泪控诉
别再让你的SQL成为系统瓶颈:一个前SQL菜鸟的血泪控诉
为什么你的代码里全是try-catch,但依然写得稀烂

发布评论