一、疑惑開篇
有了map為什么還要搞個sync.map 呢?它們之間有什么區別?
答:重要的一點是,map並發不是安全的。
在Go 1.6之前, 內置的map類型是部分goroutine安全的,並發的讀沒有問題,並發的寫可能有問題。自go 1.6之后, 並發地讀寫map會報錯,這在一些知名的開源庫中都存在這個問題,所以go 1.9之前的解決方案是額外綁定一個鎖,封裝成一個新的struct或者單獨使用鎖都可以。
go version go1.13.9 windows/amd64
測試一波
寫一個簡單的並發寫map的測試代碼看看:
testcurmap.go
package main
import (
"fmt"
"time"
)
func main() {
m := map[string]int{"age": 10}
go func() {
i := 0
for i < 1000 {
m["age"] = 10
i++
}
}()
go func() { //19 行
i := 0
for i < 1000 {
m["age"] = 11 //22 行
i++
}
}()
time.Sleep(time.Second * 3)
fmt.Println(m)
}
多運行幾次:go run testcurmap.go
會報錯,錯誤的扼要信息如下:
fatal error: concurrent map writes
goroutine 7 [running]:
runtime.throw(0x4d49a3, 0x15)
/go/src/runtime/panic.go:774 +0x79 fp=0xc000041f30 sp=0xc000041f00 pc=0x42cf19
runtime.mapassign_faststr(0x4b4360, 0xc000066330, 0x4d168a, 0x3, 0x0)
/go/src/runtime/map_faststr.go:211 +0x41e fp=0xc000041f98 sp=0xc000041f30 pc=0x410f8e
main.main.func2(0xc000066330)
/mygo/src/study/go-practice2/map/curmap/testcurmap.go:22 +0x5c fp=0xc000041fd8 sp=0xc000041f98 pc=0x49ac9c
runtime.goexit()
/go/src/runtime/asm_amd64.s:1357 +0x1 fp=0xc000041fe0 sp=0xc000041fd8 pc=0x455391
created by main.main
/mygo/src/study/go-practice2/map/curmap/testcurmap.go:19 +0xb0exit status 2
看報錯信息是src/runtime/map_faststr.go:211 這個函數runtime.mapassign_faststr,它在runtime/map_faststr.go 中,簡要代碼如下:
func mapassign_faststr(t *maptype, h *hmap, s string) unsafe.Pointer {
... ...
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
... ...
}
hashWriting = 4 // a goroutine is writing to the map goroutine寫的一個標識,
這里h.flags與自己進行與運算,判斷是否有其他goroutine在操作這個map,不是0說明有其他goroutine操作map,所以報錯。
那咋防止map並發呢,一般有幾種方式:
- map+Mutex:
給map加一把大鎖 - map+RWMutex
給map加一個讀寫鎖,給鎖細分。適合讀多寫少場景
修改一下程序
加一把讀寫鎖防止並發,修改程序 testcurmap2.go:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
m := map[string]int{"age": 10}
var s sync.RWMutex
go func() {
i := 0
for i < 1000 {
s.Lock()
m["age"] = 10
s.Unlock()
i++
}
}()
go func() {
i := 0
for i < 1000 {
s.Lock()
m["age"] = 11
s.Unlock()
i++
}
}()
time.Sleep(time.Second * 3)
fmt.Println(m)
}
運行結果:
map[age:11]
沒有報錯了。
就到這里了嗎?可以在思考思考,還有其他方法控制並發的方法沒?有的,sync.map 登場
控制並的第三種方式:
- sync.Map
官方實現的並發map。
原理是通過分離讀寫map和原子指令來實現讀的近似無鎖,並通過延遲更新的方式來保證讀的無鎖化。一般情況下可以替換上面2種鎖。
二、sync.map
先看一個簡單的代碼 testcurmap3.go
package main
import (
"fmt"
"sync"
"time"
)
func main() {
smap := sync.Map{}
smap.Store("age", 10)
go func() {
i := 0
for i < 1000 {
smap.Store("one", 10)
i++
}
}()
go func() {
i := 0
for i < 1000 {
smap.Store("one", 11)
i++
}
}()
time.Sleep(time.Second * 2)
fmt.Println(smap.Load("one"))
}
運行輸出:11 true
正常輸出,沒有報錯。
sync.Map 的主要思想就是讀寫分離,空間換時間。
看看 sync.map 優點:
- 空間換時間:通過冗余的兩個數據結構(read、dirty),實現加鎖對性能的影響。
- 使用只讀數據(read),避免讀寫沖突。
- 動態調整,miss次數多了之后,將dirty數據遷移到read中。
- double-checking。
- 延遲刪除。 刪除一個鍵值只是打標記,只有在遷移dirty數據的時候才清理刪除的數據。
- 優先從read讀取、更新、刪除,因為對read的讀取不需要鎖。
sync.Map 數據結構
Map 數據結構
在 src/sync/map.go 中
type Map struct {
// 當涉及到臟數據(dirty)操作時候,需要使用這個鎖
mu Mutex
// read是一個只讀數據結構,包含一個map結構,
// 讀不需要加鎖,只需要通過 atomic 加載最新的指正即可
read atomic.Value // readOnly
// dirty 包含部分map的鍵值對,如果操作需要mutex獲取鎖
// 最后dirty中的元素會被全部提升到read里的map去
dirty map[interface{}]*entry
// misses是一個計數器,用於記錄read中沒有的數據而在dirty中有的數據的數量。
// 也就是說如果read不包含這個數據,會從dirty中讀取,並misses+1
// 當misses的數量等於dirty的長度,就會將dirty中的數據遷移到read中
misses int
}
read的數據結構 readOnly:
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
// m包含所有只讀數據,不會進行任何的數據增加和刪除操作
// 但是可以修改entry的指針因為這個不會導致map的元素移動
m map[interface{}]*entry
// 標志位,如果為true則表明當前read只讀map的數據不完整,dirty map中包含部分數據
amended bool // true if the dirty map contains some key not in m.
}
只讀map,對該map的訪問不需要加鎖,但是這個map也不會增加元素,元素會被先增加到dirty中,然后后續會遷移到read只讀map中,通過原子操作所以不需要加鎖操作。
entry
readOnly.m和Map.dirty存儲的值類型是*entry,它包含一個指針p, 指向用戶存儲的value值,結構如下:
type entry struct {
p unsafe.Pointer // *interface{}
}
p有三種值:
- nil: entry已被刪除了,並且m.dirty為nil
- expunged: entry已被刪除了,並且m.dirty不為nil,而且這個entry不存在於m.dirty中
- 其它: entry是一個正常的值
三、查找
根據key來查找 value, 函數為 Load(),源碼如下:
// src/sync/map.go
// Load returns the value stored in the map for a key, or nil if no
// value is present.
// The ok result indicates whether value was found in the map.
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
// 首先從只讀ready的map中查找,這時不需要加鎖
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 如果沒有找到,並且read.amended為true,說明dirty中有新數據,從dirty中查找,開始加鎖了
if !ok && read.amended {
m.mu.Lock() // 加鎖
// 又在 readonly 中檢查一遍,因為在加鎖的時候 dirty 的數據可能已經遷移到了read中
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
// read 還沒有找到,並且dirty中有數據
if !ok && read.amended {
e, ok = m.dirty[key] //從 dirty 中查找數據
// 不管m.dirty中存不存在,都將misses + 1
// missLocked() 中滿足條件后就會把m.dirty中數據遷移到m.read中
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load()
}
從函數可以看出,如果查詢的鍵值正好在m.read中,不需要加鎖,直接返回結果,優化了性能。
即使不在read中,經過幾次miss后, m.dirty中的數據也會遷移到m.read中,這時又可以從read中查找。
所以對於更新/增加較少,加載存在的key很多的case,性能基本和無鎖的map類似。
missLockerd() 遷移數據:
// src/sync/map.go
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {//misses次數小於 dirty的長度,就不遷移數據,直接返回
return
}
m.read.Store(readOnly{m: m.dirty}) //開始遷移數據
m.dirty = nil //遷移完dirty就賦值為nil
m.misses = 0 //遷移完 misses歸0
}
四、新增和更新
方法是 Store(), 更新或者新增一個 entry, 源碼如下:
// src/sync/map.go
// Store sets the value for a key.
func (m *Map) Store(key, value interface{}) {
// 直接在read中查找值,找到了,就嘗試 tryStore() 更新值
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
// m.read 中不存在
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() { // 未被標記成刪除,前面講到entry數據結構時,里面的p值有3種。1.nil 2.expunged,這個值含義有點復雜,可以看看前面entry數據結構 3.正常值
m.dirty[key] = e // 加入到dirty里
}
e.storeLocked(&value) // 更新值
} else if e, ok := m.dirty[key]; ok { // 存在於 dirty 中,直接更新
e.storeLocked(&value)
} else { // 新的值
if !read.amended { // m.dirty 中沒有新數據,增加到 m.dirty 中
// We're adding the first new key to the dirty map.
// Make sure it is allocated and mark the read-only map as incomplete.
m.dirtyLocked() // 從 m.read中復制未刪除的數據
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value) //將這個entry加入到m.dirty中
}
m.mu.Unlock()
}
操作都是先從m.read開始,不滿足條件再加鎖,然后操作m.dirty。
五、刪除
根據key刪除一個值:
// src/sync/map.go
// Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
// 從 m.read 中開始查找
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended { // m.read中沒有找到,並且可能存在於m.dirty中,加鎖查找
m.mu.Lock() // 加鎖
read, _ = m.read.Load().(readOnly) // 再在m.read中查找一次
e, ok = read.m[key]
if !ok && read.amended { //m.read中又沒找到,amended標志位true,說明在m.dirty中
delete(m.dirty, key) // 刪除
}
m.mu.Unlock()
}
if ok { // 在 m.ready 中就直接刪除
e.delete()
}
}
還有更好的方法沒?java里面有一個分段鎖,保證在操作不同 map 段的時候, 可以並發執行, 操作同段 map 的時候,進行鎖的競爭和等待。從而達到線程安全, 且效率大於 synchronized。而不是直接加一把大鎖,鎖住整個map。
那go里面有木有?有人已經想到了
六、concurrent-map
項目地址:concurrent-map
中文wiki:地址
正如 這里 和 這里 所描述的, Go語言原生的map類型並不支持並發讀寫。concurrent-map提供了一種高性能的解決方案:通過對內部map進行分片,降低鎖粒度,從而達到最少的鎖等待時間(鎖沖突)。
在Go 1.9之前,go語言標准庫中並沒有實現並發map。在Go 1.9中,引入了sync.Map。新的sync.Map與此concurrent-map有幾個關鍵區別。標准庫中的sync.Map是專為append-only場景設計的。因此,如果您想將Map用於一個類似內存數據庫,那么使用我們的版本可能會受益。你可以在golang repo上讀到更多,這里 and 這里
譯注: sync.Map在讀多寫少性能比較好,否則並發性能很差
有興趣的可以自己研究下。