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

一、内存同步

潜在问题

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

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

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