Golang - sync.map 設計思想和底層源碼分析


Golang - sync.map 設計思想和底層源碼分析

一.引言

  • 在Go v1.6之前,內置map是部分goroutine安全的,並發讀沒有問題,並發寫可能有問題

  • 在Go v1.6之后,並發讀寫內置map會報錯,在一些知名的開源庫都有這個問題,所以在Go v1.9之前,解決方案是加一個額外的大鎖,鎖住map。

  • 在Go v1.9中,go官方提供了並發安全的map,sync.map。

本文Go版本:v1.14.4

二. sync.map的設計思想

在map內數據非常大的時候,采用一個大鎖,會使得鎖的競爭十分激烈,存在性能問題

  • Java內的解決方案是分段鎖機制,比如ConcurrentHashMap,內部使用多個鎖,每個區間共用一把鎖,這樣鎖的粒度更小了,減少了數據共享一把大鎖帶來的性能影響

但是由於其實現的復雜性和其他因素,Go官方並沒有采用上述方案,而是另辟蹊徑,采用讀寫分離的形式,來實現了一個並發安全的map

后續筆者會考慮自己實現一個分段鎖機制的map,然后和sync.map進行一下比較,觀察在不同場景下的性能差異,敬請期待

1. 空間換時間

如果采用傳統的大鎖方案,其鎖的競爭十分激烈,也就意味着需要花在鎖上的時間很多,我們要盡可能的減少時間消耗,針對耗時太長的情況,算法中有一種常見的解決方案,空間換時間,采用冗余的數據結構,來減少時間的消耗。

sync.map中冗余的數據結構就是dirty和read,二者存放的都是key-entry,entry其實是一個指針,指向value,read和dirty各自維護一套key,key指向的都是同一個value,也就是說,只要修改了這個entry,對read和dirty都是可見的

那空間換時間策略在sync.map中到底是如何體現的呢?到底在哪些地方減少了耗時?

  • 遍歷操作:只需遍歷read即可,而read是並發讀安全的,沒有鎖,相比於加鎖方案,性能大為提升
  • 查找操作:先在read中查找,read中找不到再去dirty中找

核心思想就是一切操作先去read中執行,因為read是並發讀安全的,無需鎖,實在在read中找不到,再去dirty中。read在sycn.map中是一種冗余的數據結構,因為read和dirty中數據有很大一部分是重復的,而且二者還會進行數據同步

2.讀寫分離

sync.map中有專門用於讀的數據結構:read,將其和寫操作分離開來,可以避免讀寫沖突

而采用讀寫分離策略的代價就是冗余的數據結構,其實還是空間換時間的思想。

3.雙檢查機制

通過額外的一次檢查操作,來避免在第一次檢查操作完成后,其他的操作使得檢查條件產生突然符合要求的可能。

在sync.map中,每次當read不符合要求要去操作dirty前,都會上鎖,上鎖后再次判斷是否符合要求,因為read有可能在上鎖期間,產生了變化,突然又符合要求了,read符合要求了,盡量還是在read中操作,因為read並發讀安全。

4. 延遲刪除

在刪除操作中,刪除kv,僅僅只是先將需要刪除的kv打一個標記,這樣可以盡快的讓delete操作先返回,減少耗時,在后面提升dirty時,再一次性的刪除需要刪除的kv

5.read優先

需要進行讀取,刪除,更新操作時,優先操作read,因為read無鎖的,更快,實在在read中得不到結果,再去dirty中

read的修改操作需要加鎖,read只是並發讀安全,並發寫並不安全

6. 狀態機機制

entry的指針p,是有狀態的,nil,expunged(指向被刪除的元素),正常,三種狀態.
那其狀態在sync.map各個操作間又是怎么變化的呢?

主要是兩個操作會引起p狀態的變化:Store(新增/修改) 和 刪除

我們先來看看第一個操作 Store(新增/修改)

  • 在Store更新時,如果key在read中存在,並且被標記為已刪除,會將kv加入dirty,此時read中key的p指向的是expunged,經過unexpungeLocked函數,read中的key的p指向會從expunged改為nil,然后經過storeLocked更新value值,p從指向nil,改為指向正常
  • (p->expunged) =====> (p->nil) =====> (p->正常)
  • 在Store增加時,如果需要從read中刷新dirty數據,會將read中未刪除的元素加入dirty,此時會將所有指向nil的p指針,改為指向expunged
  • (p->nil) =====> (p->expunged)

我們再來看看第二個操作:

  • 在Delete時,刪除value時,p從指向正常值,改為指向nil
  • (p->正常) =====> (p->nil)

p的狀態轉換如下:

從上圖我們可以看出

  • update時:p的狀態從expunged轉為nil,然后又轉為正常值
  • add時:當需要刷新dirty,p的狀態從nil轉為expunged
  • delete時:p的狀態從正常值轉為nil

三. sync.map 源碼分析

1. 基礎數據結構

  • entry
// entry 鍵值對中的值結構體
type entry struct {
	p unsafe.Pointer // 指針,指向實際存儲value值的地方
}

sync.map中key和value是分開存放的,key通過內置map指向entry,entry通過指針,指向value實際內存地址

  • Map
// Map 並發安全的map結構體
type Map struct {
	mu sync.Mutex // 鎖,保護read和dirty字段

	read atomic.Value // 存僅讀數據,原子操作,並發讀安全,實際存儲readOnly類型的數據

	dirty map[interface{}]*entry // 存最新寫入的數據

	misses int // 計數器,每次在read字段中沒找所需數據時,+1
	// 當此值到達一定閾值時,將dirty字段賦值給read
}

// readOnly 存儲mao中僅讀數據的結構體
type readOnly struct {
	m       map[interface{}]*entry // 其底層依然是個最簡單的map
	amended bool                   // 標志位,標識m.dirty中存儲的數據是否和m.read中的不一樣,flase 相同,true不相同
}

需要注意的地方:

  • read在進行非讀操作時,需要鎖mu進行保護

  • 寫入的數據,都是直接寫到dirty,后面根據read miss次數達到閾值,會進行read和dirty數據的同步

  • readOnly中專門有一個標志位,用來標注read和dirty中是否有不同,以便進行read和dirty數據同步

2. sync.map中查找 k-v

// Load 查詢key是否存在
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {

	// 1.先在read中查找key
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]

	// 2. 在read中沒有找到,並且read和dirty數據不一樣(dirty中有read中不存在的數據,因為寫數據是直接往dirty中寫的)
	if !ok && read.amended {
		m.mu.Lock() // 鎖住,因為要操作dirty中數據

		// 3.雙檢查機制,再次在read中查找key,因為有可能read從dirty中更新了數據
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]

		// 4.在read中還是沒有找到,並且read和dirty數據仍然不一致
		if !ok && read.amended {
			e, ok = m.dirty[key] // 直接從dirty獲取數據

			// read 不命中次數 +1,到達閾值后,為避免read命中率太低,會從dirty中更新read數據
			m.missLocked()
		}

		m.mu.Unlock() // 解鎖,后續不再操作dirty數據
	}

	// 5.最后仍然沒有找到key,說明key在map中確實不存在,返回nil
	if !ok {
		return nil, false
	}

	// 6.找到key了,返回value
	return e.load()
}
// missLocked readmiss次數+1 ,並且判斷dirty是否需要晉升(dirty置給read)
func (m *Map) missLocked() {
	m.misses++ // read 沒命中次數統計+1
	if m.misses < len(m.dirty) {
		return
	}

	// dirty 置給read ,因為read沒有命中的次數太多了,原子操作
	m.read.Store(readOnly{m: m.dirty})
	m.dirty = nil // dirty 也置空
	m.misses = 0
}

通過對源碼的分析,我們可以在宏觀上總結一下搜索的流程:先在read中搜,搜不到再去dirty中搜,但是這個太宏觀了,有些東西沒有討論到,比如

  • 雙檢查機制

  • read miss次數達到閾值,刷新read數據

上面兩項操作,其實歸根結底都是為了提升搜索的效率,比如read miss的統計和read數據的刷新,都是為了讓直接可以在read中找到key,盡可能不去dirty中找,因為read並發讀是安全的,性能很高,而去dirty中找,則需要加鎖,耗時就增加了

調用Load或LoadOrStore函數時,如果在read中沒有找到key,則會將miss值原子增加1,當miss值增加到和dirty長度相等時,會將dirty提升為read,以期望減少 "讀 miss"。

3. sync.map中添加或修改 k-v

// Store 添加/修改 key-value
func (m *Map) Store(key, value interface{}) {
	// 1. 在read中查找key,找到了則嘗試更新value
	read, _ := m.read.Load().(readOnly)
	if e, ok := read.m[key]; ok && e.tryStore(&value) {
		return
	}

	m.mu.Lock() // 操作dirty,鎖住先

	// 2. 雙檢查機制,再次在read中查找key
	read, _ = m.read.Load().(readOnly)

	// 3. key在read中存在
	if e, ok := read.m[key]; ok {
		if e.unexpungeLocked() { // key被標記為已刪除,則將k/v加入dirty中
			m.dirty[key] = e
		}
		e.storeLocked(&value) // 無論key是否為已刪除狀態,都要更新key的value值
	} else if e, ok := m.dirty[key]; ok {
		// 4. key在dirty中存在,則直接在dirty中更新value值
		e.storeLocked(&value)
	} else {
		// 5. key在read和dirty中都不存在,則走新增邏輯
		// read和dirty中數據相同,則從read中刷新dirty的數據(因為dirty為nil,有可能是初始化或dirty之前提升過了),並將amended標識為read和dirty不相同,因為后面即將走新增邏輯
		if !read.amended { 		
			m.dirtyLocked()
			m.read.Store(readOnly{m: read.m, amended: true})
		}
		m.dirty[key] = newEntry(value) // 新增邏輯,直接在dirty中加入kv鍵值對
	}
	m.mu.Unlock() // 不再操作dirty數據,解鎖啦
}


// tryStore 嘗試更新value 原子操作
func (e *entry) tryStore(i *interface{}) bool {
	for {
		p := atomic.LoadPointer(&e.p)
		if p == expunged { // 被刪除狀態,無法更新
			return false
		}
		if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
			return true
		}
	}
}


// unexpungeLocked 判斷是否指向expunged,如果指向expunged則修改為指向nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
	// 之所以需要將指向expunged的修改為指向nil ,是因為后續會將k/v加入dirty中,都已經加入dirty中,並且不是未刪除狀態,當然需要指向nil啦
	// 此value在read中暫時指向nil,但后續會更新value值,這樣read中和dirty中都是指向同一個value的 ( Store中第四步,更新value值)
	return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}


// storeLocked 更新指向的value值
func (e *entry) storeLocked(i *interface{}) {
	atomic.StorePointer(&e.p, unsafe.Pointer(i))
}


// dirtyLocked 刷新dirty數據邏輯,將read中未刪除的數據加入到dirty中
func (m *Map) dirtyLocked() {
	// 此函數僅在以下情況會執行: read和dirty相同時,比如初始化或dirty剛提升到read,dirty肯定是nil

	// dirty 非nil,則沒必要走刷新dirty數據邏輯
	if m.dirty != nil {
		return
	}

	read, _ := m.read.Load().(readOnly)
	m.dirty = make(map[interface{}]*entry, len(read.m)) // dirty 申請內存空間
	// 1.遍歷read,將read中未刪除元素加入dirty中(加入的其實不是真正的底層數據副本,而是指向底層數據的指針)
	for k, e := range read.m {
		if !e.tryExpungeLocked() { // 保證加入dirty中都是read中未刪除的元素,read中被刪除狀態的元素則沒必要加入dirty
			m.dirty[k] = e
		}
	}
}
// tryExpungeLocked 判斷元素是否為被刪除狀態
func (e *entry) tryExpungeLocked() (isExpunged bool) {
	// 進入此函數的指針,有三種指向: 指向正常value,指向nil,指向expunged,本函數的目的就是在判斷是否指向expunged之余,將指向nil的都改為指向expunged

	p := atomic.LoadPointer(&e.p) // 原子操作,載入指針

	// 將指向nil的指針,改為指向expunged
	for p == nil {
		if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { // 原子操作,比較和交換
			return true
		}
		p = atomic.LoadPointer(&e.p) // 原子操作,重新載入指針
	}
	// 有可能是正常元素,判斷是否指向expunged
	return p == expunged
}

大致總結一下上述流程:

  1. 在read中查找key,找到了則通過原子操作,嘗試更新value

  2. key在read中存在,但是被標記為已刪除,則kv加入dirty中,並更新value值

  3. key在read中不存在,但在dirty中存在,則直接在dirty中更新value

  4. key在read和dirty中都不存在,則直接在dirty中加入kv,需要注意的是,此時dirty可能為nil(因為之前可能沒有初始化或之前dirty提升過),需要將read中未刪除的元素加入dirty

新寫入的key會保存到dirty中,如果此時dirty為nil,就會先創建一個dirty,並將read中未刪除的數據拷貝到dirty

當dirty為nil時,read就代表map所有數據,當dirty不為nil的時候,dirty才代表map所有數據。

4. sync.map中刪除 k-v

// Delete 刪除元素
func (m *Map) Delete(key interface{}) {

	// 1.先在read中查找key
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]

	// 2.在read中沒有找到key,並且read和dirty中數據不相同(即dirty中有read中沒有的數據,因為插入數據都是直接插入到dirty中的,read還來不及根據dirty數據進行刷新)
	if !ok && read.amended {
		m.mu.Lock() // 操作dirty,鎖住先

		// 3.雙檢查機制,繼續在read中查找key
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]

		// 4. 在read中沒有找到key,並且read和dirty中數據不相同,則在dirty中刪除key
		if !ok && read.amended {
			delete(m.dirty, key)
		}
		m.mu.Unlock() // 解鎖,不再操作dirty
	}
	// 5. 通過key,找到了value,則刪除value
	if ok {
		e.delete()
	}
}
// delete 刪除value
func (e *entry) delete() (hadValue bool) {
	for {
		p := atomic.LoadPointer(&e.p)  // 原子操作方式加載指針
		if p == nil || p == expunged { // p 指向nil或已刪除元素,刪除失敗
			return false
		}
		// 將p指向nil
		// 為何不將p設置為expunged ? 因為p為expunged時,表示其已經不在dirty中了,這是由p的狀態機決定的!
		if atomic.CompareAndSwapPointer(&e.p, p, nil) {
			return true
		}
	}
}

大致總結一下刪除操作的流程:

  1. 在read中查key,找到了則直接刪除value(修改entry的指針p,改為指向nil,因為是指針,所以在read和dirty中都是可見的)。

  2. 在read中沒有找到key,但read數據和dirty數據有不同,則去dirty中直接刪除key(不管dirty中有無key,都是直接刪除,不會返回任何響應),最后也是entry的delete直接刪除value。

此函數的特點就是不會有任何的返回值,存在就刪除了,沒存在就不會刪,也刪不了,這些對函數外部的調用者都是不可見的

5. sync.map中遍歷 k-v

// Range 回調方式遍歷map
func (m *Map) Range(f func(key, value interface{}) bool) {

	read, _ := m.read.Load().(readOnly)

	// 1.dirty中有新數據,則提升dirty,然后再遍歷
	if read.amended {
		m.mu.Lock() //操作dirty,鎖住
		read, _ = m.read.Load().(readOnly)
		if read.amended { // 雙檢查機制,再次檢測dirty中是否有新數據
			read = readOnly{m: m.dirty} // 提升dirty為read,重置dirty和miss計數器
			m.read.Store(read)
			m.dirty = nil
			m.misses = 0
		}
		m.mu.Unlock()
	}

	// 到這就代表,read中的數據和dirty中數據是一致的,直接遍歷read即可

	// 2.回調的方式遍歷read
	for k, e := range read.m {
		v, ok := e.load()
		if !ok {
			continue
		}
		if !f(k, v) {
			break
		}
	}
}

注意事項:

  • 底層遍歷的其實是read,而如果dirty中有不同於read的新數據,則需要先提升dirty再進行遍歷,這樣數據才能一致

四.sycn.map的使用

package main

import (
	"fmt"
	"sync"
)

func main()  {
	var m sync.Map
	// 1. 寫入
	m.Store("qcrao", 18)
	m.Store("stefno", 20)

	// 2. 讀取
	age, _ := m.Load("qcrao")
	fmt.Println(age.(int))

	// 3. 遍歷
	m.Range(func(key, value interface{}) bool {
		name := key.(string)
		age := value.(int)
		fmt.Println(name, age)
		return true
	})

	// 4. 刪除
	m.Delete("qcrao")
	age, ok := m.Load("qcrao")
	fmt.Println(age, ok)

	// 5. 讀取或寫入
	m.LoadOrStore("stefno", 100)
	age, _ = m.Load("stefno")
	fmt.Println(age)
}
  • 第 1 步,寫入兩個 k-v 對;
  • 第 2 步,使用 Load 方法讀取其中的一個 key;
  • 第 3 步,遍歷所有的 k-v 對,並打印出來;
  • 第 4 步,刪除其中的一個 key,再讀這個 key,得到的就是 nil;
  • 第 5 步,使用 LoadOrStore,嘗試讀取或寫入 "Stefno",因為這個 key 已經存在,因此寫入不成功,並且讀出原值。

程序輸出:

18
stefno 20
qcrao 18
<nil> false
20

注意事項:

sycn.map 適用於讀多寫少的場景,對於讀很多的場景,會導致read map緩存失效,需要加鎖,導致競爭變多,而且未命中的read map次數過多,導致dirty map被提升為read map,這是一個O(N)操作,會進一步降低性能。

五.小結

  • sync.map 是並發安全的.
  • 通過讀寫分離,降低鎖時間來提高效率,適用於讀多寫少的場景。
  • sync.map底層其實是兩個map,一個read map,一個dirty map,read map 並發讀安全,所有讀操作優先read map,所有寫操作直接在dirty map中,read map和dirty map在需要時間會進行數據同步。

參考文章:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM