通过单例探究 Go 可见性与内存屏障
date
Dec 24, 2023
slug
golang-singleton-visibility-memory-barrier
status
Published
tags
Go
设计模式
系统和网络
summary
从 sync.Once 看可见性和内存屏障
type
Post
Go 实现单例的 5 种方式
第一种,利用包的 init 函数
init
函数在包初始化时自动执行,这意味着它在程序开始执行前,由于 init
函数是由 Go 运行时自动调用的,并且在程序生命周期中只会被调用一次,它可以被用来初始化单例。输出
代码中由于
init
函数保证在包初始化时自动执行且只执行一次,这确保了 instance
只被初始化一次。不过这种方式不提供延迟初始化(lazy initialization)的能力,也被称为是饿汉模式,这意味着无论这个单例是否需要,它都会在程序启动时被创建。
而后面几种方式则具备延迟初始化能力,也被称为懒汉模式。
第二种,非并发安全的普通方式
判断实例对象是否为
nil
,如果没有实例化则进行实例化。这种方式的问题是并发环境下会重复创建,即非并发安全。
可以在
getInstance
函数中稍微 sleep 一下,就可以明显的看到结果中打印出多个初始化的输出。输出
第三种,加锁保证并发安全
上来就先加锁,这样可以保证并发安全。
这种做法虽然可以实现并发安全的单例,但效率相对低一些,因为每次都要加锁解锁,即使已经实例化之后还是要白浪费一次加锁解锁。
输出
第四种,双重检测锁 double-checked locking
这种做法相当于在前一种做法的最外层再加一次
nil
判断,目的就是为了避免如果已经完成实例化之后,后续的加锁解锁都是无效操作。这种做法实现了功能的同时,性能也有了保证,但写法稍微有点冗余。
输出
第五种,利用 sync.Once
sync.Once
的 Do
方法接收一个无参数无返回值的函数,并保证该函数在并发环境下只执行一次。这样就可以利用这个特性实现单例,这个写法功能和性能都有保证,而且代码也比较简洁。
输出
这种写法是最安全且效率最高的。
sync.Once 的实现原理
顺便看看
sync.Once
内部是怎么做的, sync.Once
结构体如下:Do
函数首先
Do
函数中使用 atomic.LoadUint32(&o.done)
原子读取 done
属性来验证函数 f
是否已经执行了,这一步使用原子操作而非互斥锁,目的也是为了提高性能,可以尽量少的避免加锁。这一步也可称之为快速路经检测。接下来如果发现
done
的值是 0
,则表示函数 f
还没有执行,那么将调用 doSlow
函数,注意这一步可能存在多个 goroutine 同时进入 doSlow
的情况,因为 atomic.LoadUint32(&o.done)
可以被多个 goroutine 先后执行,得到同样的结果,即 done
是 0
。doSlow
函数进入
doSlow
之后可以称之为慢速路径检测,首先会上一个互斥锁 o.m.Lock()
,然后在对 done
的值做一次检测,这一步就是双重检测锁定,跟前面介绍的一样,因为可能有并发多个 goroutine 进入 doSlow
,而只有一个 goroutine 成功获得互斥锁并设置 done
的值,当这个 goroutine 返回并释放锁之后,其他 goroutine 会再一次检测 done
的值,避免多次执行 f
。注意这里使用的是
if o.done == 0
而非原子操作,这是因为 Mutex 在提供同步保证的同时,还隐含的提供了可见性保证。可见性与内存屏障
可见性
互斥锁 Mutex 在提供同步保证的同时,还隐含的提供了可见性保证,即在互斥锁保护之下的数据写入,在锁释放之后,该写入对其他尝试获取同一把锁的 goroutine 是可见的。
对应到前面代码中,第一个成功获取锁的 goroutine 设置完
done
之后并释放锁,这次数据的变更对其他 goroutine 是可见的,之后其他的 goroutine 在查看 if o.done == 0
时就会发现值变成了 1
。内存屏障
在这背后涉及到内存屏障的概念,内存屏障 Memory Barrier 也称为内存栅栏 Memory Fence,是一种同步机制,用于控制指令和内存操作的执行顺序。在多处理器系统中,由于各种优化技术,如缓存、指令重排等,不同处理器上的线程可能看到内存操作以不同的顺序发生。内存屏障的作用是确保在屏障之前的所有内存操作如读写,在屏障之后的操作开始之前完成,并且对所有处理器可见。
底层硬件方面的支持
大多数现代处理器架构如 x86、ARM、PowerPC 等,提供了特定的指令来实现内存屏障。这些指令能够直接影响处理器如何处理接下来的内存操作,确保在内存屏障指令之前的所有操作都完成,并且结果对所有处理器核心可见,然后才执行屏障之后的操作。例如,在 x86 架构中,
MFENCE
、LFENCE
、 SFENCE
等指令用于不同类型的内存屏障。操作系统方面的支持
操作系统通过提供库函数或系统调用来支持内存屏障操作,特别是在处理器指令级别之上。例如,Linux 提供了如
mb()
, rmb()
, wmb()
等函数,分别用于全屏障、读屏障和写屏障。语言层面的实现
Go 中的互斥锁
sync.Mutex
不仅提供互斥,还隐式地提供内存屏障。当一个 goroutine 释放一个互斥锁时,实际上是在执行一个内存屏障操作。锁释放操作确保所有在该锁保护下进行的内存写入在锁被释放时对所有处理器都是可见的。这就建立了一个保证,在锁释放之后获取同一把锁的任何 goroutine 都能看到在锁保护下所做的内存修改。同样地,当一个 goroutine 尝试获取一个互斥锁时,也会有一个内存屏障操作,确保在它获取锁之后进行的内存读取操作能看到之前已释放该锁的 goroutine 所做的所有写入。
此外
sync/atomic
包提供了一系列原子操作函数,这些函数保证了在多 goroutine 环境中的安全和有序访问共享变量。这些原子操作在底层使用处理器提供的原子指令,这些指令的实现也隐含了必要的内存屏障语义。编译器在编译过程中负责正确地安排内存访问指令和插入必要的内存屏障指令,编译器会分析代码并会根据不同的底层环境,在必要的位置插入内存屏障指令,来保证内存操作的顺序和可见性。
同步保证意味着可见性保证
可以这样简单的理解:同步保证也提供了可见性保证,同步条件下的数据写入会被其他 goroutine 读取到。