并发 Bug 的原因

    1546
    最后修改于

    人类是以线性思维为主的动物,如果凡事都能有条不紊的进行,那便是好极了,在做这样的事情的时候,效率高,效果好,但是为了追求更高的性能,我们不得不向现实妥协。要去考虑并发可能存在的各种问题。

    Atomic is luxury#

    在单核时期,为了匹配较慢的 IO 速度,出现了多线程并发技术。这也是现在大多数并发 bug 出现的主要原因。
    这是 OS 为了满足多个进程 / 线程能够同时执行所作出的取舍,线程不知道自己何时将会被终止 / 暂停。在并发的条件下,线程在执行时随时都有可能被中断在某一条指令中,在高级语言中,几乎每一条语句都不是原子的。

    package main  
      
    var t *Box  
      
    type Box struct {  
        Num int  
    }  
      
    func getBox() *Box {  
        t = &Box{Num: 1}  
        return t  
    }  
      
    func useBox() {  
        if t != nil {  
           t.Num = t.Num + 1  
        }  
    }  
      
    func main() {  
        go getBox()  
        go useBox()  
    }
    

    比如上面的语句,经过编译后

    0x0018 00024 (box.go:6)   MOVD    $type:main.Box(SB), R0
    0x0020 00032 (box.go:6)   PCDATA  $1, $0
    0x0020 00032 (box.go:6)   CALL    runtime.newobject(SB)
    0x0024 00036 (box.go:6)   MOVD    $1, R1
    0x0028 00040 (box.go:6)   MOVD    R1, (R0)
    
    main.useBox STEXT size=32 args=0x0 locals=0x0 funcid=0x0 align=0x0 leaf
    0x0000 00000 (box.go:14)  TEXT    main.useBox(SB), LEAF|NOFRAME|ABIInternal, $0-0
    0x0000 00000 (box.go:14)  FUNCDATA  $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
    0x0000 00000 (box.go:14)  FUNCDATA  $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
    0x0000 00000 (box.go:14)  PCDATA    $0, $-3
    0x0000 00000 (box.go:15)  MOVD      main.t(SB), R0
    0x0008 00008 (box.go:15)  PCDATA    $0, $-1
    0x0008 00008 (box.go:15)  CBZ       R0, 24
    0x000c 00012 (box.go:16)  MOVD      (R0), R1
    0x0010 00016 (box.go:16)  ADD       $1, R1, R1
    0x0014 00020 (box.go:16)  MOVD      R1, (R0)
    0x0018 00024 (box.go:18)  RET       (R30)
    

    第六行中的 b := &Box{ Num: 1} 会生成多条指令。如果线程执行了runtime.newobject(SB)之后,还没来得及为Num字段赋值,另外一个线程可能正在执行检查if t == nil,此时满足条件,但是b并没有被完全初始化。这就产生了预期之外的结果。这就是所谓的原子性违反(Atomicity Violation)

    Ordering is disordered#

    这是编译器和 CPU 在执行时为了追求速度作出的必要牺牲。这两者根据局部的视角对指令的执行的顺序进行(指令重排、分支预测等)。
    如下例子:

    var (
    x, y = 0, 0
    )
    
    func A() {
    	y = 1     // line A1
    	r1 := x   // line A2
    }
    func B() {
    	x = 2     // line B1
    	r2 := y   // line B2 
    }
    
    func main() {
    	go A()
    	go B()
    }
    
    

    在不违反原子性的情况下(假设A,B中每一条都原子性的完成),当A,B执行完毕,
    如果每个线程内部都按照顺序执行(即先A1A2,先B1B2)。无论线程间顺序如何打乱,可以预见,结果会是r1 = 2; r2 = 0或者r1 = 0; r2 = 1其中的一个。
    但意外的是,r1 = 0; r2 = 0也是可能的。
    这是因为在局部视角下 A2 的执行结果并不依赖于 A1。 因此即使先执行A2再执行A1,在单个线程的局部视角下,结果是对的。
    因此当两个线程指令顺序为(A2,B2) -> (B1,A1)时,就可以得到预期之外的结果r1 = 0; r2 = 0
    这就是所谓的顺序性违反(Ordering Violation)

    Invisible is possible#

    为了提高 CPU 的性能,在提升频率的同时,CPU 内部的结构也在不断更新升级。现代的 CPU 和内存间的结构从原来的统一内存视图变成了下图分多层次的缓存结构,每个核心都有自己的私有 cache,同时共享一块或多块大缓存,最后连接到 Main Memory。这直接导致了每个 CPU 看到的内存视图可能不一样。
    loading...
    此时如果有多个线程,分别运行在不同的核心上,且出现了共享内存的情况,就可能导致见到的内存视图不一样。比如 core1 和 core2 缓存同一个 cache line,在 core1 中,修改了该 cache 行之后,没来得及同步更新到 core2 中,此时 core2 再次读取该 cache line,就会得到不一样结果。

    loading...
    这种情况称为缓存不一致。为了维持缓存一致性,出现了缓存一致性协议,比如 MESI。这是由硬件保证的,对于软件程序而言,在满足 MESI 的情况下,不同线程看到的视图是一样的。
    但是,严格的 MESI 会导致 CPU 性能下滑,因此实践上,会对缓存一致性放宽限制,(比如存在 Invalid Queue),从而导致事实上出现内存视图不一致的可能。
    这就是所谓的可见性违反。

    Shared variables is root#

    究其根源,上面所述的种情况都出现了共享内存。当两个线程视图操纵同一块内存时,就有可能导致上面的问题。因此在长期实践下,出现了一些值得遵循的准则。

    • 不可变,函数式思想
    • 使用通信替代共享内存
      但是其底层仍然不可避免的出现共享内存的情况。
    • 对于原子性问题,CPU 提供一些原子性指令(如 CAS,TAS),保证部分复杂操作不会被中断,通过这些指令,在软件层面可以构建出以锁为基础的各种并发同步工具。从而保证原子性。
    • 对于有序性和可见性问题,CPU 提供了一种内存屏障指令。一方面,内存屏障前后的操作不会出现指令重排,以解决有序性问题。另外一方面,保证在其之前的写操作都被同步到其他核心的内存视图中。
    finally#

    机器永远是对的 —— jyy

    推荐阅读#

    kaitoukito/A-Primer-on-Memory-Consistency-and-Cache-Coherence: A Primer on Memory Consistency and Cache Coherence (Second Edition) 翻译计划 (github.com)
    内存连贯 (memory consistency) 性与缓存一致性 (cache coherence)

    • 🥳0
    • 👍0
    • 💩0
    • 🤩0
    总浏览量 4,271