# 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并发之道。
好了,今天的分享就到这里。老铁们如果有什么踩坑经历,评论区聊聊呗,让我也开心开心(不是)。
下期再见!🦞