GO语言基础篇(二十七)- 内存同步&延迟初始化

532 阅读8分钟

这是我参与8月更文挑战的第 27 天,活动详情查看: 8月更文挑战

还是以上一篇中的文章示例为例:go中的互斥锁&读写互斥锁

内存同步

可能你也像我一样对Balance方法也需要互斥锁(不管是基于通道的锁还是基于互斥量的锁)感到奇怪。毕竟,与Deposit不一样,它只包含单个操作,所以并不存在另外一个goroutine插在中间执行的风险

其实需要互斥锁的原因有两个

  1. 防止Balance插到其他操作中间也是很重要的,比如Withdraw
  2. 因为同步不仅涉及多个goroutine的执行顺序问题,同步还会影响到内存

现代的计算机一般都会有多个处理器,每个处理器都有内存的本地缓存。为了提高效率,对内存的写入是缓存在每个处理器中的,只在必要时才刷回内存。甚至刷回内存的顺序都可能与goroutine的写入顺序不一致。像通道通信或者互斥锁操作这样的同步原语,都会导致处理器把累积的写操作刷回内存并提交,所以这个时刻之前goroutine的执行结果就保证了对运行在其他处理器的goroutine可见

考虑如下代码片段的可能输出:

var x, y int
go func() {
    x = 1 // ------ A1
    fmt.Print("y:", y, " ")// ------ A2
}()

go func() {
    y = 1// ------ B1
    fmt.Print("x:", x, " ")// ------ B2
}()

由于这两个goroutine并发运行,且在没使用互斥锁的情况下访问共享变量,所以这里会有数据竞态。所以我们对程序每次的输出不一样不应该感到奇怪。根据对程序中标注语句不同的交错模式,我们可能会期望能看到如下四个结果中的一个:

y:0   x:1
x:0   y:1
x:1   y:1
y:1   x:1

第四行可以由A1、B1、A2、B2或B1、A1、A2、B2这样的执行顺序来产生。但是,程序产生的如下两个输出就在我们的意料之外了

x:0    y:0
y:0    x:0

但在某些特定的编译器、CPU或者其他情况下,这些确实可能发生。上面四个语句以什么样的顺序交错执行才能解释这个结果呢?

在单个goroutine内,每个语句的效果保证按照执行的顺序发生,也就是说,goroutine 是串行一致的。但在缺乏使用通道或者互斥量来显式同步的情况下, 并不能保证所有的goroutine看到的事件顺序都是一致的。尽管goroutine A肯定能在读取y之前能观察到x=1的效果,但它并不一定能观察到goroutine B对y写入的效果,所以A可能会输出y的一个过期值

尽管很容易把并发简单理解为多个goroutine中语句的某种交错执行方式,但正如上面的例子所显示的,这并不是一个现代编译器和CPU的工作方式。因为赋值和Print对应不同的变量,所以编译器就可能会认为两个语句的执行顺序不会影响结果,然后就交换了这 两个语句的执行顺序。CPU也有类似的问题,如果两个goroutine在不同的CPU上执行, 每个CPU都有自己的缓存,那么一个goroutine的写入操作在同步到内存之前对另外一个 goroutine的Print语句是不可见的

这些并发问题都可以通过采用简单、成熟的模式来避免,即在可能的情况下,把变量限制到单个goroutine中,对于其他变量,使用互斥锁

延迟初始化

延迟一个比较复杂的初始化步骤到有实际需求的时刻是一个很好的实践,预先初始化一个变量会增加程序的启动延时,并且如果实际执行时间有可能根本用不上这个变量,那么初始化也不是必须的

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()//一次性的初始化
    }
	
    return icons[name]
}

对于那些只被一个goroutine访问的变量,上面的模式是没有问题的,但对于这个例子在并发调用Icon时这个模式就是不安全的。类似于银行转账的例子中,最早版本的 Deposit函数(银行转账的并发问题点这里

Icon也包含多个步骤:检测 Icons是否为空,再加载图标,最后更新 Icons为一个非ni值。直觉可能会告诉你,竞态带来的最严重问题可能就是loadIcons函数会被调用多遍。当第个 goroutine正忙于加载图标时,其他goroutine进入Icon函数,会发现 icons仍然是nil,所以仍然会调用loadIcons

但这个直觉仍然是错的。回想一下前边关于内存的讨论,在缺乏显式同步的情况下,编译器和CPU在能保证每个 goroutine都满足串行一致性的基础上可以自由地重排访问内存的顺序。loadIcons一个可能的语句重排结果如下所示:它在填充数据之前把一个空map赋给icons

func loadIcons() {
    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")
}

因此,一个goroutine发现lcons不是nil并不意味着变量的初始化肯定已经完成。保证所有goroutine都能观察到loadIcons效果最简单的正确方法就是用一个互斥锁来做同步

var mu sync.Mutex
var icons map[string]image.Image

//并发安全
func Icon(name string) image.Image {
    mu.Lock()
    defer mu.Unlock()
    if icons == nil {
        loadIcons()
    }
	
    return icons[name]
}

使用互斥锁访问icons的代价就是,两个goroutine不能并发访问这个变量,即使在变量已经安全完成初始化且不再更改的情况下,也会造成这个后果。使用一个可以并发读的锁,就可以解决这个问题

var mu sync.RWMutex //保护icons
var icons map[string]image.Image

//并发安全
func Icon(name string) image.Image {
    mu.RLock()
    if icons != nil {
        icon := icons[name]
        mu.RUnlock()
        return icon
    }
    mu.RUnlock()

    //获取互斥锁

    mu.Lock()
    if icons == nil {
        loadIcons()
    }
    mu.Unlock()
    icon := icons[name]

    return icon
}

这里有两个临界区域。 goroutine首先获取一个读锁,查阅map,然后释放这个读锁。如果条目能找到(常见情况),就返回它。如果条目没找到,goroutine再获取一个写锁。由于不先释放一个共享锁就无法直接把它升级到互斥锁,为了避免在过渡期其他goroutine已经初始化了 icons,所以我们必须重新检查nil值

上面的模式具有更好的并发性,但它更复杂并且更容易出错。比较好的是,sync包提供了针对一次性初始化问题的特化解决方案:sync.Once。从概念上来讲,Once包含一个布尔变量和一个互斥量,布尔变量记录初始化是否已经完成,互斥量则负责保护这个布尔变量和客户端的数据结构。Once的唯一方法Do以初始化函数作为它的参数。对上边的代码用一次性初始化进行修改:

var loadIconsOnce sync.Once
var icons map[string]image.Image

//并发安全
func Icon(name string) image.Image{
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

每次调用Do(loadIcons)时会先锁定互斥量并检查里边的布尔变量。在第一次调用时,这个布尔变量为假,Do会调用loadIcons然后把变量设置为真。后续的调用相当于空操作,只是通过互斥量的同步来保证loadIcons对内存产生的效果(在这里就是icons变量)对所有的goroutine可见。以这种方式来使用sync.Once,可以避免变量在正确构造之前就被其它goroutine分享

竞态检测器

即使以最大努力的仔细,仍然很容易在并发上犯错误。比较幸运的是,Go语言运行时和工具链装备了一个精致并易于使用的动态分析工具:竞态检测器

简单地把-race命令行参数加到 go build、 go run、 go test命令里边即可使用该功能。它会让编译器为你的应用或测试构建一个修改后的版本,这个版本有额外的手法用于高效记录在执行时对共享变量的所有访问,以及读写这些变量的goroutine标识。除此之外,修改后的版本还会记录所有的同步事件,包括go语句、通道操作、(*sync. Mutex).Lock调用、(*sync.waitGroup).Wait调用等(关于这个-race,在之前的关于竞态的文章中有用到,感兴趣的点这里

竞态检测器会研究事件流,找到那些有问题的案例,即一个goroutine写入一个变量后,中间没有任何同步的操作,就有另外一个goroutine读写了该变量。这种案例表明有对共享变量的并发访问,即数据竞态。这个工具会输出一份报告,包括变量的标识以及读写goroutine当时的调用栈。通常情况下这些信息足以定位问题了

竞态检测器报告所有实际运行了的数据竞态。然而,它只能检测到那些在运行时发生的竞态无法用来保证肯定不会发生竞态

由于存在额外的记录工作,带竞态检测功能的程序在执行时需要更长的时间和更多的内存,但即使对于很多生产环境的任务,这种额外开支也是可以接受的。对于那些不常发生的竞态,使用竞态检测器可以帮你节省数小时甚至数天的调试时间

参考

《Go程序设计语言》—-艾伦 A. A. 多诺万

《Go语言学习笔记》—-雨痕