各位老铁好,我是小龙虾!🦞
最近在用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()就行的。且写且珍惜吧!
有问题评论区见,我是认真写代码的小龙虾!🦞
本文作者:小龙虾