Go语言 为什么atomic.StoreUint32优先于synchron.Once中的普通分配?

velaa5lx  于 2022-12-16  发布在  Go
关注(0)|答案(4)|浏览(240)

在阅读Go语言的源代码时,我对src/sync/once.go中的代码有一个疑问:

func (o *Once) Do(f func()) {
    // Note: Here is an incorrect implementation of Do:
    //
    //  if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    //      f()
    //  }
    //
    // Do guarantees that when it returns, f has finished.
    // This implementation would not implement that guarantee:
    // given two simultaneous calls, the winner of the cas would
    // call f, and the second would return immediately, without
    // waiting for the first's call to f to complete.
    // This is why the slow path falls back to a mutex, and why
    // the atomic.StoreUint32 must be delayed until after f returns.

    if atomic.LoadUint32(&o.done) == 0 {
        // Outlined slow-path to allow inlining of the fast-path.
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

为什么使用atomic.StoreUint32,而不是o.done = 1?它们不等价吗?有什么区别?
在一台弱内存模型的机器上,我们必须使用原子操作(atomic.StoreUint32)来确保其他goroutine在o.done被设置为1之前能够观察到f()的效果吗?

v440hwme

v440hwme1#

请记住,除非你是手工编写程序集,否则你不是在机器的内存模型下编程,而是在Go语言的内存模型下编程,这意味着即使原语赋值在你的体系结构中是原子的,Go语言也需要使用原子包来确保在所有支持的体系结构中的正确性。
在互斥锁之外访问done标志只需要是安全的,而不是严格有序的,因此可以使用原子操作,而不是总是通过互斥锁获得锁。这是一种优化,使快速路径尽可能高效,允许sync.Once用于热路径。
用于doSlow的互斥锁仅用于该函数内部的互斥,以确保在设置done标志之前,只有一个调用方到达f()。该标志使用atomic.StoreUint32写入,因为它可能与atomic.LoadUint32同时发生,并且在互斥锁保护的临界区之外。
与写入(即使是原子写入)同时阅读done字段是一种数据竞争。仅仅因为该字段是原子读取的,并不意味着您可以使用正常赋值来写入它,因此首先使用atomic.LoadUint32检查标志,然后使用atomic.StoreUint32写入标志
doSlow * 中直接读取done是 * 安全的,因为互斥体保护它不被并发写入。与atomic.LoadUint32并发阅读值是安全的,因为两者都是读取操作。

yyyllmsg

yyyllmsg2#

在一台弱内存模型的机器上,我们必须使用原子操作(atomic.StoreUint32)来确保其他goroutine在o.done被设置为1之前能够观察到f()的效果吗?
是的,你的思路是对的,但是请注意,即使目标机器有强大的内存模型,只要结果符合Go memory model,Go语言编译器就可以重新排序指令;相反,即使机器内存模型比语言内存模型弱,编译器也必须设置额外的障碍,以使最终代码符合语言规范。
让我们考虑一下没有sync/atomicsync.Once的实现,为了更容易解释,我们做了一些修改:

func (o *Once) Do(f func()) {
    if o.done == 0 { // (1)
        o.m.Lock() // (2)
        defer o.m.Unlock() // (3)
        if o.done == 0 { // (4)
            f() // (5)
            o.done = 1 // (6)
        }
    }
}

如果一个goroutine观察到o.done != 0,它将返回,因此,函数必须确保f()发生在任何读操作可以从o.done观察到1之前。

  • 如果读操作发生在(4),那么它受到互斥锁的保护,这意味着它肯定会在先前获取执行f并将o.done设置为1的互斥锁之后发生。
  • 如果读取位于(1)、我们没有互斥锁的保护,所以我们必须构造写操作之间的同步关系(6)在书写时转到读(1)在当前goroutine上,在那之后,因为(5)排序在(6),则根据之前发生关系的传递性,在执行(5)之后,从(1)读取值1肯定会发生。

因此,write(6)必须有释放语义,read(1)必须有获取语义,因为Go语言不支持获取-读取和释放-存储,所以我们必须求助于atomic.(Load/Store)Uint32提供的更强的顺序,即序列一致性。
最后一点:由于对不大于一个机器字的内存位置的访问被保证是原子性的,所以这里使用atomic与原子性无关,而与同步有关。

6qfn3psc

6qfn3psc3#

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {       # 1
        // Outlined slow-path to allow inlining of the fast-path.
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {                            # 2
        defer atomic.StoreUint32(&o.done, 1)    # 3
        f()
    }
}
  • #1和#3:#1为读取,#3为写入,不安全,需要多文本保护
  • #2和#3:在临界区,由互斥锁保护,安全。
cbeh67ev

cbeh67ev4#

原子操作可以用来同步不同goroutine的执行。
如果没有同步,即使goroutine观测到o.done == 1,也不能保证它会观测到f()的影响。

相关问题