爲何說併發場景不要亂用sync.map

「本文已參與好文召集令活動,點擊查看:後端、大前端雙賽道投稿,2萬元獎池等你挑戰!前端

map 自己併發不安全的

咱們都知道go的map是併發不安全的,當幾個goruotine同時對一個map進行讀寫操做時,就會出現併發寫問題fatal error: concurrent map writes後端

carbon-7.png

  1. 在程序一開始咱們初始化一個map
  2. 子goroutine對m[a]賦值
  3. 主goroutine對m[a]賦值

理論上只要在多核cpu下,若是子goroutine和主gouroutine同時在運行,就會出現問題。咱們不妨用go自帶的-race 來檢測下,能夠運行 go run -race main.go安全

carbon-8.png 經過檢測,咱們能夠發現,存在data race,即數據競爭問題。有人說這簡單,加鎖解決,加鎖當然能夠解決,可是你懂的,鎖的開銷問題。
撇開數據競爭的問題,咱們能夠經過看個例子來了解下鎖的開銷:markdown

carbon-9.png

  1. BenchmarkAddMapWithUnLock 是測試無鎖的
  2. BenchmarkAddMapWithLock 是測試有鎖的

經過go test -bench .來跑測試,得出的結果以下:併發

carbon (22).png 能夠發現無鎖的平均耗時約6.6 ms,帶鎖的平均耗時約7.0 ms,雖然說相差無幾,但也反應加鎖的開銷。在一些複雜的案例中,可能會更明顯。源碼分析

sync.map

有人說,既然鎖開銷大,那麼就用go內置的方法sync.map,它能夠解決併發問題。sync.map確實能夠解決併發map問題,可是它在讀多寫少的狀況下,比較適合,能夠保證併發安全,同時又不須要鎖的開銷,在寫多讀少的狀況下反而可能會更差,主要是由於它的設計,咱們從源碼分析看看:post

結構

carbon (23).png

  1. mutex鎖,當涉及到髒數據(dirty)操做時候,須要使用這個鎖
  2. read,讀不須要加鎖,就是從read中讀的,read是atomic.Value類型,具體結構以下:

carbon (25).png read的數據存在readOnly.m中,也是個map,value是個entry的指針,entry是個結構體具體類型以下:性能

carbon (26).png 裏面就一個p,當咱們設置一個key的value時,能夠理解爲p就是指向這個value的指針(p就是value的地址)。
readOnly.amended = true的時候,表示read的數據不是最新的,dirty裏面包含一些read沒有的新key。
3. Map的dirty也是map類型,從命名來看它是髒的,能夠理解某些場景新加kv的時候,會先加到dirty中,它比read要新。
4. Map的misses,當從read中沒讀到數據,且amended=true的時候,會嘗試從dirty中讀取,而且misses會加1,當misssed數量大於等於dirty的長度的時候,就會把dirty賦給read,同時重置missed和dirty。測試

舉個例子

sync.map的核心思想就是空間換時間。
假設如今有個畫展對外展現(read)n幅畫,一羣人來看,你們在這個畫展上想看什麼就看什麼,不用等待、不用排隊。這時上了副新畫,可是因爲畫展示在在工做時間,不能直接掛上去,並且新畫可能還要保養什麼,暫時不放在畫展(read)上,因而就先放在備份的倉庫中(dirty),若是真有人要看這幅新畫,那麼只能領他到倉庫中(dirty)中去看,假設這時來了個新畫,此時倉庫中有n+1副畫了,這時有人來問:有沒有這幅新畫呀,經理說:有,你和我到倉庫中去看下。這時又有人來問:有沒有這幅新畫呀,經理說:有,你和我到倉庫中去看下。當問有沒有這幅新畫的次數達到了n+1的時候,這時畫展的老闆發現這幅新畫要看的人還很多。因而對經理說:你去看下,等下沒人看畫展(read)的時候,把畫展(read)的畫所有下掉,把倉庫(dirty)裏面的畫所有換上。當經理所有換結束後,此時畫展(read)上已是最全最新的畫了。
sync.map的原理大概就相似上面的例子,在少許人對新畫(新的k、v)感興趣的時候,就帶他去倉庫(dirty)看,此時由於經理只有一個,因此每次只能帶一我的(加鎖),效率低,其餘的畫,在畫展(read)上,隨便看,效率高。atom

Store (新增或者更新一個kv)

carbon (27).png

  1. 當key存在read的時候,那麼此時就是更新value,嘗試去直接更新value,更新成功了就返回,不須要加鎖。這裏面有個tryStore:

carbon (29).png tryStore裏面有判斷p == expunged就返回false。p有三種類型:nil(read中的key被delete的時候其實軟刪除,只是把p設置成nil)、expunged(被刪除的key(p==nil)會在read copy 到 dirty的時候再被設置成expunged)、其餘正常的value的地址,這裏若是是expunged就不選擇更新value。

  1. 加鎖,接下來都是線程安全的。
  2. 加鎖的過程可能本來不存在的key,加完鎖有了,因此要再check下,若是read中存在,且原本被dirty刪除了,那麼在dirty中還原下key,最後設置value。
  3. 若是read中沒有key,可是dirty中有,那麼直接修改value
  4. 若是read和dirty中都沒有這個key,且dirty爲nil的時候,嘗試把read中未刪除的copy到dirty中去,(read中刪除不是真的刪除,會把entry.p設置爲nil,簡單理解就是把key的value的地址設置爲nil),這些都是在dirtyLocked中完成的:

carbon (30).png 而後在dirty中設置新的k、v。(這裏能夠發現新的k、v都是先加在dirty的map中的,read是沒有的)。
6. 如今dirty是比較乾淨的數據了(已經清空了nil或expunged的key),設置amended=true(說明此時dirty不爲空,且dirty中有新數據)
7. 解鎖
總結:

  1. 能夠發現對於更新,read和dirty由於value是指針,底層是一個value,這樣都會被更新
  2. 對於新增的,會先加在dirty中,read中並不會新增
  3. 對於新增是要加鎖的,因此假設存在一種極端的case:一直加新key,那麼每次都是要加鎖的,況且中間還有if else的分支判斷。總體確定是比常規map加鎖性能要差的。

Load(獲取一個kv)

carbon (28).png

  1. 當read中不存在這個key,且amenbed=true的時候(經過上面的store,說明此時dirty有新數據),加鎖(dirty不是線程安全的)
  2. 由於加鎖的過程,可能read發生變化,因此再次check下
  3. 去dirty中獲取數據
  4. 經過misslock,無論有沒有,先對misses +1,若是miss次數>=len(dirty),那麼就把dirty copy給read,這樣read的數據就是最新的了
  5. 重製dirty和misses。

carbon (31).png 6. 若是沒有對應的key,就返回nil,有的話,就返回對應的value

總結:

  1. 若是read中有key,就不用加鎖,直接返回,效率高,讀多的場景友好
  2. 若是dirty有key的話,經過記錄miss次數來反轉read,忍受一段miss的帶來的lock時間,對於新key最終仍是讀read。

Delete(刪除一個k)

carbon (32).png

  1. 當read不存在這個key,且dirty有新數據的時候,加鎖
  2. 由於加鎖的過程,可能read發生變化,因此再次check下
  3. dirty中有新數據的時候,直接刪除dirty中的k
  4. 若是read有,那麼就軟刪除,設置p爲nil

carbon (33).png 總結:當刪除的key在read中,能夠經過軟刪除來標記,這樣自己read對應的map不會由於頻繁刪除而觸發等量擴容,關於map的擴容規則能夠參考map原理

回到題目

經過分析了sync.map咱們發現,在讀多寫少的狀況下,仍是比較優秀的,相比常規map加鎖那種確定是更好的,可是寫多讀少的狀況下,並不適合,由於仍是涉及到頻繁的加鎖、read和dirty交換等開銷,搞很差還比常規的map加鎖性能更差。咱們仍是經過一個極端的例子來看:

carbon (34).png

  1. BenchmarkAddMapWithUnLock 是測試無鎖的
  2. BenchmarkAddMapWithLock 是測試有鎖的
  3. BenchmarkAddMapWithSyncMap 是測試sync.map

3個方法都是對一個map加10w條數據。

經過go test -bench .來跑測試,得出的結果以下:

carbon (35).png 能夠看出sync.map的耗時是其餘的兩個的5倍左右。sync.map是個好東西,可是場景用錯,反而拔苗助長。

image.png

相關文章
相關標籤/搜索