规避 Go 中的常见并发 bug

Understanding Real-World Concurrency Bugs in Go这篇论文中,几名研究人员分析了常见的Go并发bug,并在最流行的几个Go开源项目中进行了验证。本文梳理了论文中提到的常见的bug并给出解决方法的分析。

论文中对bugs进行了分类,分为阻塞式和非阻塞式两种:
阻塞式:goroutine发生阻塞无法继续执行(例如死锁)
非阻塞式:不会阻塞执行,但存在潜在的数据冲突(例如并发写)

阻塞式bug

阻塞式bug发生的根因有两种,一种是共享内存(例如卡在了意图保护共享内存的锁操作上),一种是消息传递(比如等待chan)。同时研究发现共享内存和消息传递导致的bug数量不想上下,但是共享这种方法的使用量比消息传递使用的更频繁,所以也得出了共享内存方式更不容易导致bug的结论。

读写锁优先级导致的死锁

在Go中的写锁优先级高于读锁优先级,假设一个goroutine(goroutine A)连续获取两次读锁,而另一个goroutine(goroutine B)在gouroutine A两次获取读锁中间获取了写锁,就会导致死锁的发生。论文中没有针对这个bug给出示例代码,我写了一个简单的代码示意一下。

func gouroutine1() {
    m.RLock()
    m.RLock()
}

func gouroutine2() {
    m.WLock()
}

f1和f2都在goroutine中执行,当f1执行完第一个l.RLock()语句后,假设这时f2的m.WLock执行,由于写锁是排它的,WLock本身被f1的第一个m.RLock()阻塞,写锁操作本身又会阻塞f1中的第二个m.RLock

WaitGroup误用导致的死锁

这种情况就是比较典型的WaitGroup的误用了,提前执行group.Wait()会导致部分group.Done()无法执行到,进而导致程序被阻塞。

var group sync.WaitGroup
group.Add(len(pm.plugins))
for _, p := range pm.plugins {
    go func(p *plugin) {
        defer group.Done()
    }
    group.Wait()  // blocked
}
// group.Wait() should be here

for循环内的group.Wait()执行到的时候,循环内的部分goroutine还没有被创建出来,其中的group.Done()也就永远没法执行到,所以会导致永远阻塞在这一句,正确的写法是将group.Wait()移到for循环外。

Channel的误用

Channel是go支持并发的一个非常重要的特性,Channel虽然在很多场景下非常解决问题,但是误用也是不容易发现的。

func goroutine1() {
    m.Lock()
    ch <- request  // blocked
    m.Unlock()
}

func goroutine2() {
    for {
        m.Lock()  // 阻塞
        m.Unlock()
        request <- ch
    }
}

这段代码的业务语义是goroutine1会通过ch接收goroutine2发送的消息,但是当goroutine1执行到ch <- request时候会阻塞并等待ch,此时由于goroutine1没有释放锁,goroutine2的m.Lock()也会阻塞,形成死锁。

特殊库的误用

hctx, hcancel := context.WithCancel(ctx)
if timeout > 0 {
    hctx, hcancel = context.WithTimeout(ctx, timeout)
}

除了显式的使用channel,go提供了一些lib来在goroutine之间传递消息,上面代码在执行hctx, hcancel := context.WithCancel(ctx)时会创建一个goroutine出来,而当timeout>0时又回创建新的channel赋给同一个变量hcancel,这会导致第一行创建出的channel不会被关闭,也不能再给这个channel发消息。

非阻塞式bug

和阻塞式bug类似,非阻塞式bug也由共享内存和消息传递引起:当试图保护一个共享变量失败时候,或消息传递使用不当时候,都可能造成非阻塞式的bug。

匿名函数

虽然论文中将这一类错误归结为匿名函数的不正确使用,但实际上产生这类bug的原因是工程师忽略了实际上在跨goroutine共享的变量。

for i := 17; i <= 21; i++ { // write
    go func() { /* Create a new goroutine */ 
        apiVersion := fmt.Sprintf("v1.%d", i) // read
        ...
    }()
}

如这段代码(也经常出现在面试中),由于变量i在匿名函数构建出的goroutine和主goroutine共享,又不能保证goroutine什么时候执行,所以goroutine中拿到的i并不确定(大概率这几个循环创建出的goroutine拿到的都是21)。

WaitGroup的误用

func (p *peer) send() {
    p.mu.Lock()
    defer p.mu.Unlock()
    switch p.status {
        case idle:
        go func() {
            p.wg.Add(1)
            ...
            p.wg.Done()
        }()
        case stopped:
    }
}

func (p * peer) stop() {
    p.mu.Lock()
    p.status = stopped
    p.mu.Unlock()
    p.wg.Wait()
}

上面这段代码中,由于不能保证send方法的goroutine什么时候执行,所以可能导致stop函数的p.wg.Wait()在send函数的p.wg.Add(1)之前执行。

特殊库的误用

诸如context这样被设计会在多个goroutine间传递数据的库,在使用时也需要特别注意,可能会导致数据竞争。

Channel的误用

select {
    case <- c.closed:
    default:
        close(c.closed)
}

由于default语句可能被多次触发,导致一个channel可能被多次关闭,进而造成panic。

ticker := time.NewTicker()
for{
    f()  // heavy function
    select {
        case <- stopCh: return
        case <- ticker:
    }
}

对于上面这段代码,当f是一个耗时函数时,很可能出现一次for循环后stopCh和ticker两个case同时满足,这时是没法确认先进哪个case的。

特殊库的误用

timer := time.NewTimer(0)
if dur > 0 {
    timer = time.NewTimer(dur)
}

select {
    case <- timer.C:
    case <- ctx.Done():
        return nil
}

上面这段代码中,第一行创建的timer由于超时时间是0,所以会立刻触发select中的第一个case,导致和期望不符合的行为。

总结

Go的特性使得线程的创建和数据传递都非常容易,但是容易的背后线程间通信的那些坑依然是存在的,论文认为go的消息传递机制会导致更多的bug出现。在我看来,go的消息传递机制相比于传统的共享内存机制,相当于多了一层逻辑层面的封装,这种特性有时会让传统的多线程编程经验不能直接发挥价值,但是只要把握住底层的机制,可以很快积累基于go的语言特性的并发编程经验。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容

  • 参考码农翻身 当多线程并发遇到Actor goroutine, channel 和 CSP并发之痛 Thread,...
    合肥黑阅读 2,902评论 0 5
  • go并发编程入门到放弃 并发和并行 并发:一个处理器同时处理多个任务。 并行:多个处理器或者是多核的处理器同时处理...
    yangyunfeng阅读 545评论 0 2
  • [TOC] go的强大并发 在go语言中,go routine是并发的基本单位,是操作系统中提到的用户级线程,轻量...
    以梦为马驾驾驾阅读 588评论 0 1
  • Go 并发编程 选择 Go 编程的原因可能是看中它简单且强大,那么你其实可以选择C语言;除此之外,我看中 Go 的...
    PRE_ZHY阅读 878评论 1 6
  • 谢谢知乎不完善的文章编写系统把我赶到了简书:) 老规矩吧,废话也懒得说了。知乎白瞎了我一篇文章,现在也不说多废话。...
    甘蔗JS阅读 987评论 0 4