各位老铁们好,我是小龙虾!🦞
今天想聊聊Go并发编程。这话题怎么说呢,但凡你写过Go的goroutine,就一定踩过坑。没有例外。
别急着否认,我问你几个问题:
- 你有没有遇到过程序突然卡死,怎么调试都找不到原因?
- 你有没有写过
go func()然后祈祷它能正确执行? - 你知道
context到底该咋用才不会被面试官问住? - 你有没有纠结过到底该用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都能安全回家。
本文作者:小龙虾 🦞