1. 前言
在學習 mutex後。 在讀源碼的時候發現裏面使用了很多atomaic
包的方法來保證原子。在併發編程中,常常提到的併發安全,具體數據就是 對數據的修改讀取是否是原子操作 所以也常說,併發是否能保證了原子操作。在Golang語言中,實現原子操作是在標準庫實現的,即 sync/atomic
2. 簡單的使用
2.1 mutex示例
在Mutex示例中,使用讀寫鎖和互斥鎖實現代碼如下
package syncDemo
import "sync"
// RWMutexConfig 讀寫鎖實現
type RWMutexConfig struct {
rw sync.RWMutex
data []int
}
// Get get config data
func (c *RWMutexConfig) Get() []int {
c.rw.RLock()
defer c.rw.RUnlock()
return c.data
}
// Set set config data
func (c *RWMutexConfig) Set(n []int) {
c.rw.Lock()
defer c.rw.Unlock()
c.data = n
}
// MutexConfig 互斥鎖實現
type MutexConfig struct {
data []int
mu sync.Mutex
}
// Get get config data
func (c *MutexConfig) Get() []int {
c.mu.Lock()
defer c.mu.Unlock()
return c.data
}
// Set set config data
func (c *MutexConfig) Set(n []int) {
c.mu.Lock()
defer c.mu.Unlock()
c.data = n
}
併發基準測試代碼如下
package syncDemo
import "testing"
type iConfig interface {
Get() []int
Set([]int)
}
func bench(b *testing.B, c iConfig) {
b.RunParallel(func(p *testing.PB) {
for p.Next() {
c.Set([]int{100})
c.Get()
c.Get()
c.Get()
c.Set([]int{100})
c.Get()
c.Get()
}
})
}
func BenchmarkMutexConfig(b *testing.B) {
conf := &MutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
}
func BenchmarkRWMutexConfig(b *testing.B) {
conf := &RWMutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
}
運行 基準測試 結果如下
D:\goProject\src\daily測試\syncDemo\mutex>go test -race -bench=.
goos: windows
goarch: amd64
pkg: syncDemo
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkMutexConfig-8 85347 15611 ns/op
BenchmarkRWMutexConfig-8 94676 11367 ns/op # 讀寫鎖實現性能高於互鎖
PASS
ok syncDemo 4.140s
2.2 sync/atomic
實現方式如下
// Config atomic 實現
type AtomaticConfig struct {
v atomic.Value // 假設 data 就是整個 config 了
}
// Get get config data
func (c *AtomaticConfig) Get() []int {
// 類型斷言
return (*c.v.Load().(*[]int))
}
// Set set config data
func (c *AtomaticConfig) Set(n []int) {
c.v.Store(&n)
}
基準測試代碼如下
package syncDemo
import "testing"
type iConfig interface {
Get() []int
Set([]int)
}
func bench(b *testing.B, c iConfig) {
b.RunParallel(func(p *testing.PB) {
for p.Next() {
c.Set([]int{100})
c.Get()
c.Get()
c.Get()
c.Set([]int{100})
c.Get()
c.Get()
}
})
}
func BenchmarkAtomaticConfig(b *testing.B) {
conf := &AtomaticConfig{}
bench(b, conf)
}
將 讀寫鎖, 互斥鎖,atomic 基準測試放一起測試,測試性能
package syncDemo
import "testing"
type iConfig interface {
Get() []int
Set([]int)
}
func bench(b *testing.B, c iConfig) {
b.RunParallel(func(p *testing.PB) {
for p.Next() {
c.Set([]int{100})
c.Get()
c.Get()
c.Get()
c.Set([]int{100})
c.Get()
c.Get()
}
})
}
// 互斥鎖
func BenchmarkMutexConfig(b *testing.B) {
conf := &MutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
}
// 讀寫鎖
func BenchmarkRWMutexConfig(b *testing.B) {
conf := &RWMutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
}
// atomic
func BenchmarkAtomicConfig(b *testing.B) {
conf := &AtomicConfig{}
bench(b, conf)
}
運行基準測試結果如下,可以發現 atomic 的性能又好上了許多
D:\goProject\src\daily測試\syncDemo\mutex>go test -race -bench=.
goos: windows
goarch: amd64
pkg: syncDemo
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkMutexConfig-8 75313 16914 ns/op
BenchmarkRWMutexConfig-8 88446 11719 ns/op
BenchmarkAtomicConfig-8 260695 4679 ns/op
PASS
ok syncDemo 5.362s
atomic.Value 這種適合配置文件這種讀特別多,寫特別少的場景,因爲他是 COW(Copy On Write)寫時複製的一種思想,COW 就是指我需要寫入的時候我先把老的數據複製一份到一個新的對象,然後再寫入新的值。
維基百科對COW(Copy On Write)的描述如下
寫入時複製(英語:Copy-on-write,簡稱 COW)是一種計算機程序設計領域的優化策略。其核心思想是,如果有多個調用者(callers)同時請求相同資源(如內存或磁盤上的數據存儲),他們會共同獲取相同的指針指向相同的資源,直到某個調用者試圖修改資源的內容時,系統纔會真正複製一份專用副本(private copy)給該調用者,而其他調用者所見到的最初的資源仍然保持不變。這過程對其他的調用者都是透明的。此作法主要的優點是如果調用者沒有修改該資源,就不會有副本(private copy)被創建,因此多個調用者只是讀取操作時可以共享同一份資源。
這種思路會有一個問題,就是可能有部分 goroutine 在使用老的對象,所以老的對象不會立即被回收,如果存在大量寫入的話,會導致產生大量的副本,性能反而不一定好 。
這種方式的好處就是不用加鎖,所以也不會有 goroutine 的上下文切換,並且在讀取的時候大家都讀取的相同的副本所以性能上回好一些。
COW 策略在 linux, redis 當中都用的很多
3. 源碼分析
3.1 方法總結
atomic 的函數簽名有很多,但是大部分都是重複的爲了不同的數據類型創建了不同的簽名,這就是沒有泛型的壞處了,基礎庫會比較麻煩
- 第一類 AddXXX 當需要添加的值爲負數的時候,做減法,正數做加法
// 第一類,AddXXX func AddInt32(addr *int32, delta int32) (new int32) func AddInt64(addr *int64, delta int64) (new int64) func AddUint32(addr *uint32, delta uint32) (new uint32) func AddUint64(addr *uint64, delta uint64) (new uint64) func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
- 第二類 CompareAndSwapXXX CAS 操作, 會先比較傳入的地址的值是否是 old,如果是的話就嘗試賦新值,如果不是的話就直接返回 false,返回 true 時表示賦值成功。
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool) func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool) func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool) func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool) func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
- 第三類 LoadXXX,從某個地址中取值
func LoadInt32(addr *int32) (val int32) func LoadInt64(addr *int64) (val int64) func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer) func LoadUint32(addr *uint32) (val uint32) func LoadUint64(addr *uint64) (val uint64) func LoadUintptr(addr *uintptr) (val uintptr)
- 第四類 StoreXXX ,給某個地址賦值
func StoreInt32(addr *int32, val int32) func StoreInt64(addr *int64, val int64) func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer) func StoreUint32(addr *uint32, val uint32) func StoreUint64(addr *uint64, val uint64) func StoreUintptr(addr *uintptr, val uintptr)
- 第五類 SwapXXX ,交換兩個值,並且返回老的值
func SwapInt32(addr *int32, new int32) (old int32) func SwapInt64(addr *int64, new int64) (old int64) func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer) func SwapUint32(addr *uint32, new uint32) (old uint32) func SwapUint64(addr *uint64, new uint64) (old uint64) func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
- 最後一類Value 用於任意類型的值的 Store、Load,我們開始的案例就用到了這個,這是 1.4 版本之後引入的,簽名的方法都只能作用於特定的類型,引入這個方法之後就可以用於任意類型了。
type Value func (v *Value) Load() (x interface{}) func (v *Value) Store(x interface{})
3.2 CAS
CAS(Compare-and-Swap),即比較並替換,是一種實現併發算法時常用到的技術,Java併發包中的很多類都使用了CAS技術。
CAS需要有3個操作數:內存地址V,舊的預期值A,即將要更新的目標值B。
CAS指令執行時,當且僅當內存地址V的值與預期值A相等時,將內存地址V的值修改爲B,否則就什麼都不做。整個比較並替換的操作是一個原子操作。
3.2.1 延申閱讀: CAS的缺點
CAS雖然很高效的解決了原子操作問題,但是CAS仍然存在三大問題。
-
循環時間長開銷很大。
循環時間長開銷很大:我們可以看到getAndAddInt方法執行時,如果CAS失敗,會一直進行嘗試。如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷。
-
只能保證一個共享變量的原子操作。
只能保證一個共享變量的原子操作:當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖來保證原子性。
-
ABA問題。
什麼是ABA問題?ABA問題怎麼解決?
CAS 的使用流程通常如下:
1)首先從地址 V 讀取值 A;
2)根據 A 計算目標值 B;
3)通過 CAS 以原子的方式將地址 V 中的值從 A 修改爲 B。
但是在第1步中讀取的值是A,並且在第3步修改成功了,我們就能說它的值在第1步和第3步之間沒有被其他線程改變過了嗎?
如果在這段期間它的值曾經被改成了B,後來又被改回爲A,那CAS操作就會誤認爲它從來沒有被改變過。這個漏洞稱爲CAS操作的“ABA”問題。Java併發包爲了解決這個問題,提供了一個帶有標記的原子引用類“AtomicStampedReference”,它可以通過控制變量值的版本來保證CAS的正確性。因此,在使用CAS前要考慮清楚“ABA”問題是否會影響程序併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。
在 sync/atomic
包中的源碼除了 Value
之外,其他的函數都是沒有直接的源碼的, 需要去 tuntime/internal/atomic
中尋找, CAS函數爲例, 其他都是大同小異
3.2.2 VALUE
Value 是個 結構體,結構體中只有唯一的成員是個 interface類型,也就意味着value可以是個任意類型的值
type VALUE struct {
v interface{}
}
v 用來保存 傳入的值
store
store方法是將值存儲爲x, 需要注意的是,每次傳入的x 不能爲nil, 並且他們的類型必須是相同的, 不讓會導致 panic異常
源碼如下:
func (v Value) Store(x interface{}){
if x == nil{
panic("sync/atomic: store of nil value into Value")
}
// ifaceWords 其實就是定義了一下 interface 的結構,包含 data 和 type 兩部分
// 這裏 vp 是原有值,xp 是傳入的值
vp := (*ifaceWords)(unsafe.Pointer(v))
xp := (*ifaceWords)(unsafe.Pointer(&x))
// for 循環不斷嘗試
for {
// 這裏先用原子方法取一下老的類型值
typ := LoadPointer(&vp.typ)
if typ == nil {
// 等於 nil 就說明原始值沒有,需要存儲新的值。
// 調用 runtime 的方法禁止搶佔,避免操作完成一半就被搶佔了
// 同時可以避免 GC 的時候看到 unsafe.Pointer(^uintptr(0)) 這個中間狀態的值
runtime_procPin()
if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {
runtime_procUnpin()
continue
}
// 分別把值和類型保存下來
StorePointer(&vp.data, xp.data)
StorePointer(&vp.typ, xp.typ)
runtime_procUnpin()
return
}
if uintptr(typ) == ^uintptr(0) {
// 如果判斷髮現這個類型是這個固定值,說明當前第一次賦值還沒有完成,所以進入自旋等待
continue
}
// 第一次賦值已經完成,判斷新的賦值的類型和之前是否一致,如果不一致就直接 panic
if typ != xp.typ {
panic("sync/atomic: store of inconsistently typed value into Value")
}
// 保存值
StorePointer(&vp.data, xp.data)
return
}
}
}
複雜邏輯在第一次寫入,因爲第一次寫入的時候有兩次原子寫操作,所以這個時候用 typ 值作爲一個判斷,通過不同值判斷當前所處的狀態,這個在我們業務代碼中其實也經常用到。然後因爲引入了這個中間狀態,所以又使用了 runtime_procPin
方法避免搶佔
func sync_runtime_procPin() int {
return procPin()
}
func procPin() int {
// 獲取到當前 goroutine 的 m
_g_ := getg()
mp := _g_.m
// unpin 的時候就是 locks--
mp.locks++
return int(mp.p.ptr().id)
}
Load
該方法使用來 加載數據,也就是讀取數據, 源碼如下
func (v *Value) Load() (x interface{}) {
// ifaceWords 是定義了一個 interface 的結構,包含 data 和 type 兩部分
vp := (*ifaceWords)(unsafe.Pointer(v))
//從結構體中獲取類型
typ := LoadPointer(&vp.typ)
// 這個說明還沒有第一次 store 或者是第一次 store 還沒有完成
if typ == nil || uintptr(typ) == ^uintptr(0) {
// First store not yet completed.
return nil
}
// 獲取值
data := LoadPointer(&vp.data)
// 構造 x 類型
xp := (*ifaceWords)(unsafe.Pointer(&x))
xp.typ = typ
xp.data = data
return
}
3. 實戰:實現一個“無鎖”的棧
3.1 無鎖棧
對多線程場景下的無鎖操作的研究一直是個熱點,理想中的無鎖操作,它應能天然地避開有鎖操作的一些缺陷,比如:
- 減少線程切換,能夠相對快速高效地讀寫(不使用 mutex, semaphore)
- 避免死鎖的可能,任何操作都應能在有限的等待時間內完成,
go實現無鎖棧代碼如下
package main
import (
"sync/atomic"
"unsafe"
)
// LFStack 無鎖棧
// 使用鏈表實現
type LFStack struct {
head unsafe.Pointer // 棧頂
}
// Node 節點
type Node struct {
val int32
next unsafe.Pointer
}
// NewLFStack NewLFStack
func NewLFStack() *LFStack {
n := unsafe.Pointer(&Node{})
return &LFStack{head: n}
}
// Push 入棧
func (s *LFStack) Push(v int32) {
n := &Node{val: v}
for {
// 先取出棧頂
old := atomic.LoadPointer(&s.head)
n.next = old
// CAS操作
if atomic.CompareAndSwapPointer(&s.head, old, unsafe.Pointer(n)) {
return
}
}
}
// Pop 出棧,沒有數據時返回 nil
func (s *LFStack) Pop() int32 {
for {
// 先取出棧頂
old := atomic.LoadPointer(&s.head)
if old == nil {
return 0
}
oldNode := (*Node)(old)
// 取出下一個節點
next := atomic.LoadPointer(&oldNode.next)
// 重置棧頂
if atomic.CompareAndSwapPointer(&s.head, old, next) {
return oldNode.val
}
}
}
3.2 擴展閱讀
棧(stack)又名堆棧,它是一種運算受限的線性表。限定僅在表尾進行插入和刪除操作的線性表。這一端被稱爲棧頂,相對地,把另一端稱爲棧底。向一個棧插入新元素又稱作進棧、入棧或壓棧,它是把新元素放到棧頂元素的上面,使之成爲新的棧頂元素;從一個棧刪除元素又稱作出棧或退棧,它是把棧頂元素刪除掉,使其相鄰的元素成爲新的棧頂元素。