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

一、内存同步

潜在问题

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

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 ↩

你可能感兴趣的:(第九章 基于共享变量的并发(四)内存同步)