深入理解Go 1.9 sync.Map

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

Go官方的faq已經提到內建的map不是線程(goroutine)安全的。在Go 1.6之前, 內建的map類型是部分goroutine安全的,並發的讀沒有問題,並發的寫可能有問題。自go 1.6之後, 並發地讀寫map會報錯,這在一些知名的開源庫中都存在這個問題,所以go 1.9之前的解決方案是額外綁定一個鎖,封裝成一個新的struct或者單獨使用鎖都可以。另外筆者在go 1.9之前通常是使用concurrent-map來解決這類問題,但是不是所有的第三方庫都以此來解決問題。

我們先來看看這個代碼範例:程式中一個goroutine一直讀,一個goroutine一直寫同一個索引值,即使讀寫的鍵不相同,而且map也沒有"擴容"等操作,代碼還是會報錯的,錯誤資訊是: fatal error: concurrent map read and map write。

package mainfunc main() {m := make(map[int]int)go func() {for {_ = m[1]}}()go func() {for {m[2] = 2}}()select {}}

問題的根源在Go的原始碼: hashmap_fast.go#L118,會看到讀的時候會檢查hashWriting標誌, 如果有這個標誌,就會報並發錯誤。

寫的時候會設定這個標誌: hashmap.go#L542

h.flags |= hashWriting

hashmap.go#L628設定完之後會取消這個標記。這樣並發讀寫的檢查有很多處, 比如寫的時候也會檢查是不是有並發的寫,刪除鍵的時候類似寫,遍曆的時候並發讀寫問題等。map的並發問題並不是那麼容易被發現, 你可以利用-race參數來檢查。

並發地使用map對象是我們日常開發中一個很常見的需求,特別是在一些大項目中。map總會儲存goroutine共用的資料。Go 1.9之前在Go官方blog的Go maps in action一文中,給出了一種簡便的解決方案。

首先,通過嵌入struct為map增加一個讀寫鎖

var counter = struct{    sync.RWMutex    m map[string]int}{m: make(map[string]int)}

讀寫資料時,可以很方便的加鎖

counter.RLock()n := counter.m["some_key"]counter.RUnlock()fmt.Println("some_key:", n)counter.Lock()counter.m["some_key"]++counter.Unlock()

當然,你也可以使用concurrent-map來解決問題

// Create a new map.map := cmap.New()// Sets item within map, sets "bar" under key "foo"map.Set("foo", "bar")// Retrieve item from map.if tmp, ok := map.Get("foo"); ok {bar := tmp.(string)}// Removes item under key "foo"map.Remove("foo")

兩者本質上都是使用sync.RWMutex來保障線程(goroutine)安全的。這種解決方案相當簡潔,並且利用讀寫鎖而不是Mutex可以進一步減少讀寫的時候因為鎖帶來的效能。但在map的資料非常大的情況下,一把鎖會導致大並發的用戶端共爭一把鎖,這時,在Go 1.9中sync.Map就非常實用。(除了以上這些之外,還有一個筆者想提到的庫,cmap也是一個相當好,安全且效能出色的第三方庫)

Go 1.9中sync.Map的實現有以下最佳化點:

  1. 空間換時間。 通過冗餘的兩個資料結構(read、dirty),實現加鎖對效能的影響。
  2. 使用唯讀資料(read),避免讀寫衝突。
  3. 動態調整,miss次數多了之後,將dirty資料提升為read。
  4. double-checking。
  5. 延遲刪除。 刪除一個索引值只是打標記,只有在提升dirty的時候才清理刪除的資料。
  6. 優先從read讀取、更新、刪除,因為對read的讀取不需要鎖。

sync.Map資料結構很簡單,包含四個欄位:readmudirtymisses

type Map struct {// 當涉及到dirty資料的操作的時候,需要使用此鎖mu Mutex// 一個唯讀資料結構,因為唯讀,所以不會有讀寫衝突。// 所以從這個資料中讀取總是安全的。// 實際上,實際也會更新這個資料的entries,如果entry是未刪除的(unexpunged), 並不需要加鎖。如果entry已經被刪除了,需要加鎖,以便更新dirty資料。read atomic.Value // readOnly// dirty資料包含當前的map包含的entries,它包含最新的entries(包括read中未刪除的資料,雖有冗餘,但是提升dirty欄位為read的時候非常快,不用一個一個的複製,而是直接將這個資料結構作為read欄位的一部分),有些資料還可能沒有移動到read欄位中。// 對於dirty的操作需要加鎖,因為對它的操作可能會有讀寫競爭。// 當dirty為空白的時候, 比如初始化或者剛提升完,下一次的寫操作會複製read欄位中未刪除的資料到這個資料中。dirty map[interface{}]*entry// 當從Map中讀取entry的時候,如果read中不包含這個entry,會嘗試從dirty中讀取,這個時候會將misses加一,// 當misses累積到 dirty的長度的時候, 就會將dirty提升為read,避免從dirty中miss太多次。因為操作dirty需要加鎖。misses int}

read的資料結構

type readOnly struct {m       map[interface{}]*entryamended bool // 如果Map.dirty有些資料不在其中的時候,這個值為true}

這裡的精髓是,使用了冗餘的資料結構readdirtydirty中會包含read中未刪除的entries,新增加的entries會加入到dirty中。amended指明Map.dirty中有readOnly.m未包含的資料,所以如果從Map.read找不到資料的話,還要進一步到Map.dirty中尋找。而對Map.read的修改是通過原子操作進行的。雖然readdirty有冗餘資料,但這些資料是通過指標指向同一個資料,所以儘管Map的value會很大,但是冗餘的空間佔用還是有限的。readOnly.mMap.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是一個正常的值

理解了sync.Map的資料結構,那麼我們先來看看sync.Map的Load方法實現

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {// 1.首先從m.read中得到唯讀readOnly,從它的map中尋找,不需要加鎖read, _ := m.read.Load().(readOnly)e, ok := read.m[key]// 2. 如果沒找到,並且m.dirty中有新資料,需要從m.dirty尋找,這個時候需要加鎖if !ok && read.amended {m.mu.Lock()// 雙檢查,避免加鎖的時候m.dirty提升為m.read,這個時候m.read可能被替換了。read, _ = m.read.Load().(readOnly)e, ok = read.m[key]// 如果m.read中還是不存在,並且m.dirty中有新資料if !ok && read.amended {// 從m.dirty尋找e, ok = m.dirty[key]// 不管m.dirty中存不存在,都將misses計數加一// missLocked()中滿足條件後就會提升m.dirtym.missLocked()}m.mu.Unlock()}if !ok {return nil, false}return e.load()}

Load載入方法,提供一個鍵key,尋找對應的值value,如果不存在,通過ok反映。這裡的精髓是從m.read中載入,不存在的情況下,並且m.dirty中有新資料,加鎖,然後從m.dirty中載入。另外一點是這裡使用了雙檢查的處理,因為在下面的兩個語句中,這兩行語句並不是一個原子操作。

if !ok && read.amended {m.mu.Lock()

雖然第一句執行的時候條件滿足,但是在加鎖之前,m.dirty可能被提升為m.read,所以加鎖後還得再檢查m.read,後續的方法中都使用了這個方法。如果我們查詢的索引值正好存在於m.read中,則無須加鎖,直接返回,理論上效能優異。即使不存在於m.read中,經過miss幾次之後,m.dirty會被提升為m.read,又會從m.read中尋找。所以對於更新/增加較少,載入存在的key很多的情境,效能基本和無鎖的map相差無幾。

經過miss幾次之後,m.dirty會被提升為m.read,那麼m.dirty又是如何被提升的呢?重點在missLocked方法中。

func (m *Map) missLocked() {m.misses++if m.misses < len(m.dirty) {return}m.read.Store(readOnly{m: m.dirty})m.dirty = nilm.misses = 0}

最後三行代碼就是提升m.dirty的,很簡單的將m.dirty作為readOnlym欄位,原子更新m.read。提升後m.dirtym.misses重設, 並且m.read.amended為false。

sync.Map的Store方法實現

func (m *Map) Store(key, value interface{}) {// 如果m.read存在這個鍵,並且這個entry沒有被標記刪除,嘗試直接儲存。// 因為m.dirty也指向這個entry,所以m.dirty也保持最新的entry。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() { //標記成未被刪除m.dirty[key] = e //m.dirty中不存在這個鍵,所以加入m.dirty}e.storeLocked(&value) //更新} else if e, ok := m.dirty[key]; ok { // m.dirty存在這個鍵,更新e.storeLocked(&value)} else { //新索引值if !read.amended { //m.dirty中沒有新的資料,往m.dirty中增加第一個新鍵m.dirtyLocked() //從m.read中複製未刪除的資料m.read.Store(readOnly{m: read.m, amended: true})}m.dirty[key] = newEntry(value) //將這個entry加入到m.dirty中}m.mu.Unlock()}func (m *Map) dirtyLocked() {if m.dirty != nil {return}read, _ := m.read.Load().(readOnly)m.dirty = make(map[interface{}]*entry, len(read.m))for k, e := range read.m {if !e.tryExpungeLocked() {m.dirty[k] = e}}}func (e *entry) tryExpungeLocked() (isExpunged bool) {p := atomic.LoadPointer(&e.p)for p == nil {// 將已經刪除標記為nil的資料標記為expungedif atomic.CompareAndSwapPointer(&e.p, nil, expunged) {return true}p = atomic.LoadPointer(&e.p)}return p == expunged}

Store方法是更新或者新增一個entry。以上操作都是先從操作m.read開始的,不滿足條件再加鎖,然後操作m.dirty。Store可能會在某種情況下(初始化或者m.dirty剛被提升後)從m.read中複製資料,如果這個時候m.read中資料量非常大,可能會影響效能。

sync.Map的Delete方法實現

func (m *Map) Delete(key interface{}) {read, _ := m.read.Load().(readOnly)e, ok := read.m[key]if !ok && read.amended {m.mu.Lock()read, _ = m.read.Load().(readOnly)e, ok = read.m[key]if !ok && read.amended {delete(m.dirty, key)}m.mu.Unlock()}if ok {e.delete()}}func (e *entry) delete() (hadValue bool) {for {p := atomic.LoadPointer(&e.p)// 已標記為刪除if p == nil || p == expunged {return false}// 原子操作,e.p標記為nilif atomic.CompareAndSwapPointer(&e.p, p, nil) {return true}}}

Delete方法刪除一個索引值。和Store方法一樣,刪除操作還是從m.read中開始, 如果這個entry不存在於m.read中,並且m.dirty中有新資料,則加鎖嘗試從m.dirty中刪除。注意,還是要雙檢查的。 從m.dirty中直接刪除即可,就當它沒存在過,但是如果是從m.read中刪除,並不會直接刪除,而是打標記而已。

sync.Map的Range方法實現

func (m *Map) Range(f func(key, value interface{}) bool) {read, _ := m.read.Load().(readOnly)// 如果m.dirty中有新資料,則提升m.dirty,然後在遍曆if read.amended {//提升m.dirtym.mu.Lock()read, _ = m.read.Load().(readOnly) //雙檢查if read.amended {read = readOnly{m: m.dirty}m.read.Store(read)m.dirty = nilm.misses = 0}m.mu.Unlock()}// 遍曆, for range是安全的for k, e := range read.m {v, ok := e.load()if !ok {continue}if !f(k, v) {break}}}

在Go語言中,for ... range map是內建的語言特性,所以沒有辦法使用for range遍曆sync.Map, 於是變通的有了Range方法,通過回調的方式遍曆。Range方法調用前可能會做一個m.dirty的提升,不過提升m.dirty不是一個耗時的操作。

sync.Map的LoadOrStore 方法實現

func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {read, _ := m.read.Load().(readOnly)if e, ok := read.m[key]; ok {actual, loaded, ok := e.tryLoadOrStore(value)if ok {return actual, loaded}}m.mu.Lock()read, _ = m.read.Load().(readOnly)if e, ok := read.m[key]; ok {if e.unexpungeLocked() {m.dirty[key] = e}actual, loaded, _ = e.tryLoadOrStore(value)} else if e, ok := m.dirty[key]; ok {actual, loaded, _ = e.tryLoadOrStore(value)m.missLocked()} else {if !read.amended {// 給dirty添加一個新key,// 標記唯讀為不完整m.dirtyLocked()m.read.Store(readOnly{m: read.m, amended: true})}m.dirty[key] = newEntry(value)actual, loaded = value, false}m.mu.Unlock()return actual, loaded}func (e *entry) tryLoadOrStore(i interface{}) (actual interface{}, loaded, ok bool) {p := atomic.LoadPointer(&e.p)if p == expunged {return nil, false, false}if p != nil {return *(*interface{})(p), true, true}ic := ifor {if atomic.CompareAndSwapPointer(&e.p, nil, unsafe.Pointer(&ic)) {return i, false, true}p = atomic.LoadPointer(&e.p)if p == expunged {return nil, false, false}if p != nil {return *(*interface{})(p), true, true}}}

LoadOrStore方法如果提供的key存在,則返回已存在的值(Load),否則儲存提供的索引值(Store)。同樣是從m.read開始,然後是m.dirty,最後還有雙檢查機制。

Go 1.9原始碼中提供了效能的測試: map_bench_test.go、map_reference_test.go,和以前的解決方案比較,效能會有不少的提升。

最後sync.Map沒有Len方法,並且目前沒有跡象要加上 (issue#20680),所以如果想得到當前Map中有效entries的數量,需要使用Range方法遍曆一次。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.