美文网首页
第九章 基于共享变量的并发(四)内存同步

第九章 基于共享变量的并发(四)内存同步

作者: HaoR_W | 来源:发表于2018-01-25 09:00 被阅读0次

    一、内存同步

    潜在问题

    问题:以下代码段的所有可能输出结果是什么

    var x, y int
    go func() {
        x = 1 // A1
        fmt.Print("y:", y, " ") // A2
    }()
    go func() {
        y = 1                   // B1
        fmt.Print("x:", x, " ") // B2
    }()
    
    一般会想到的:
    y:0 x:1
    x:0 y:1
    x:1 y:1
    y:1 x:1
    
    **注意** 以下的也会出现:
    x:0 y:0
    y:0 x:0
    

    内存中数据的变化不一定是实时的

    假设时间上B2在A1之后执行,B2读到的x的值不一定是A1修改后的,因为可能还没有同步

    原因

    并发 != 不同goroutines中的语句交错执行

    • 当编译器判断两条语句的顺序不会影响执行结果时,可能会交换顺序(为了提升性能)

    • 当goroutines在不同CPU上执行,每个CPU有自己的缓存时,一个goroutine写的数据只有在缓存与主存同步后才对其他goroutine可见

    在现代计算机中可能会有一堆处理器,每一个都会有其本地缓存(local cache)。为了效率,对内存的写入一般会在每一个处理器中缓冲,并在必要时一起flush到主存。这种情况下这些数据可能会以与当初goroutine写入顺序不同的顺序被提交到主存。像channel通信或者互斥量操作这样的原语会使处理器将其聚集的写入flush并commit,这样goroutine在某个时间点上的执行结果才能被其它处理器上运行的goroutine得到[1]

    解决思路

    在一个独立的goroutine中,语句的效果(effect)是被确保按顺序发生的,也就是说goroutine是顺序连贯(sequentially consistent)的。但如果没有使用mutex或者channel来进行显式的同步,就无法保证这些事件在其他的goroutine看来也是按照同样的顺序(核心还是主存与缓存的同步问题)。

    所有这些并发的问题都可以用简单、既定的模式来规避:尽量将变量限定在goroutine内部;如果是多个goroutines都需要访问的变量,则使用互斥条件来访问(无论是读还是写)。

    二、Happens Before

    为了更好地描述并发程序中事件的顺序关系,Go的文档中提到了“happens before”的概念[2]

    定义

    假设A和B表示一个多线程的程序执行的两个操作。如果A happens before B,那么A操作对内存的影响将在B被执行之前对执行B的线程可见。

    一些规则

    文档中也提到了判断 happens before的一些规则:

    • 在一个goroutine中,happens before的顺序与程序执行顺序相同
    • 如果一个package p被导入到package q中,q中init函数的末尾 happens before p中任一语句的开头
    • main函数的开头 happens after 所有init函数的末尾
    • 一个启动一个新的goroutine的go语句 happens before 这个goroutine的开头
    • 一个channel的发送操作 happens before 相应的接收操作完成
    • 关闭channel happens before 收到表明该channel已关闭的0值
    • 从无缓冲的channel接收操作 happens before 向该channel的发送操作完成
    • 容量为C的channel中第k个接收 happens before 第k+C个发送完成
    • sync.Mutexsync.RWMutex变量l,若n < m,第n个l.Unlock() happens before 第m个l.Lock()返回
    • 对一个sync.RWMutex变量l的任意l.RLock(),存在一个n使得l.RLock() happens(returns) after 第n个l.Unlock() 且满足l.RUnlock() happens before 第n + 1个l.Lock()
    • once.Do(f)f()的调用 happens(returns) before 任何其他的once.Do(f)返回(即once.Do(f)中的f()只执行一次且happens before其他的once.Do(f)完成)

    happens-before不是时序关系[3]

    • A happens-before B并不意味着A在B之前发生
      例:编译器调换同一goroutine中语句顺序
    • A在B之前发生并不意味着A happens-before B
      例:缓存与内存的同步问题

    关注的是对内存中数据的影响

    三、sync.Once

    为了实现变量的懒初始化(lazy initialization),且使之可被并发访问。

    var icons map[string]image.Image
    
    func loadIcons() {
        icons = map[string]image.Image{
            "spades.png":   loadIcon("spades.png"),
            "hearts.png":   loadIcon("hearts.png"),
            "diamonds.png": loadIcon("diamonds.png"),
            "clubs.png":    loadIcon("clubs.png"),
        }
    }
    
    // 不是并发安全的!!
    func Icon(name string) image.Image {
        if icons == nil {
            loadIcons() // one-time initialization
        }
        return icons[name]
    }
    

    除了竞争问题之外,还有一个问题。由于编译器和CPU可以重排语句顺序,loadIcons()可能实际变成:

    func loadIcons() {
        // 在这句之后在其他goroutines看来就可能不为nil了,但其实初始化并没有完成
        icons = make(map[string]image.Image)
        
        icons["spades.png"] = loadIcon("spades.png")
        icons["hearts.png"] = loadIcon("hearts.png")
        icons["diamonds.png"] = loadIcon("diamonds.png")
        icons["clubs.png"] = loadIcon("clubs.png")
    }
    

    修改1:使用互斥锁sync.Mutex

    var mu sync.Mutex // guards icons
    var icons map[string]image.Image
    
    // 并发安全的,因为Mutex会触发内存同步
    func Icon(name string) image.Image {
        mu.Lock()
        defer mu.Unlock()
        if icons == nil {
            loadIcons()
        }
        return icons[name]
    }
    

    问题:不能并发访问

    修改2:使用读写锁sync.RWMutex

    var mu sync.RWMutex // guards icons
    var icons map[string]image.Image
    // Concurrency-safe.
    func Icon(name string) image.Image {
        mu.RLock()
        if icons != nil {
            icon := icons[name]
            mu.RUnlock()
            return icon
        }
        mu.RUnlock()
    
        // acquire an exclusive lock
        mu.Lock()
        // NOTE: must recheck for nil 因为之前释放过锁,故可能已被其他goroutine初始化过了
        if icons == nil { 
            loadIcons()
        }
        icon := icons[name]
        mu.Unlock()
        return icon
    }
    

    问题:太复杂,容易写错

    推荐方案:使用sync.Once(原因在“happens before”的讨论中提到过)

    var loadIconsOnce sync.Once
    var icons map[string]image.Image
    // Concurrency-safe.
    func Icon(name string) image.Image {
        loadIconsOnce.Do(loadIcons)
        return icons[name]
    }
    

    每一次对Do(loadIcons)的调用都会锁定mutex,并会检查boolean变量。在第一次调用时,变量的值是falseDo会调用loadIcons并会将boolean设置为true。随后的调用什么都不会做,但是mutex同步会保证loadIcons对内存(这里其实就是指icons变量啦)产生的效果能够对所有goroutine可见。用这种方式来使用sync.Once的话,我们能够避免在变量被构建完成之前和其它goroutine共享该变量[4]





    1/24/2018


    1. Go语言圣经 - 9.4

    2. The Go Memory Model

    3. 深入解析Go - 10.1

    4. Go语言圣经 - 9.5

    相关文章

      网友评论

          本文标题:第九章 基于共享变量的并发(四)内存同步

          本文链接:https://www.haomeiwen.com/subject/xhemaxtx.html