最近開發Go語言總是遇到哈希表的使用,在高併發下如何保證讀寫的安全性尤爲重要,假如不瞭解的情況下,使用原生map的話,性能倒是很高,但在多個goroutine操作下就會遇到併發讀寫的錯誤出現。爲了併發安全,修改讀寫訪問,每次都寫都加入讀寫鎖,又會導致性能的大幅度下降,安全和性能實在是難以同時兼得。
這裏我們梳理下Go當前訪問Map的幾種方式,並給出實際的測試實例和性能表現。
1. 標準庫map結構
map是go語言標準庫中的哈希表實現,不是併發安全的,也就是在多個goroutine同時訪問的時候存在數據併發訪問衝突,導致程序異常。適合單一的goroutine中使用,性能也是所有所有哈希表實現中最好的,
同時在多個goroutine讀取或者使用for循環遍歷相同的map也是併發安全的,只要不涉及更新即可高效的訪問map結構。
設計的測試實例如下面所示:
const (
MapSize = 10000
LoopCounter = 1000
)
func BenchmarkMap(b *testing.B) {
// 初始化數據結構
m := map[string]int{}
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m[keys[i]] = i
}
// 重置測試計時器
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m[keys[i]]; ok {
}
}
}
}
func BenchmarkMapWrite(b *testing.B) {
m :=...
...
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
m[keys[i]] = i
}
}
}
func BenchmarkMapReadAndWrite(b *testing.B) {
m :=...
...
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if d, ok := m[keys[i]]; ok == true {
m[keys[i]] = d + 1
}
}
}
}
實際執行的情況如下面所示, 可以看到實際讀取和寫入的時間差不多,我們這裏以讀寫1000次爲一個單元,因此單一讀取的操作的時間是25ns左右,也就是每秒可以執行4000萬次的操作,寫入操作爲30ns左右,每秒執行3000多萬次。測試中讀寫操作包含了1000次讀取和1000次寫入操作。
BenchmarkMapRead-4 50000 25804 ns/op 0 B/op 0 allocs/op
BenchmarkMapWrite-4 50000 31337 ns/op 0 B/op 0 allocs/op
BenchmarkMapReadAndWrite-4 30000 47668 ns/op 0 B/op 0 allocs/op
假如我們測試併發的讀取和寫入操作,可以通過啓動多個goroutine來測試, 下面的環境下我們啓動8個goroutine同時去讀取和寫入這個map對象,實際執行情況肯定是會直接報錯退出。
func BenchmarkMapReadAndWriteWithMutilGoroutine(b *testing.B) {
m :=...
...
wg := sync.WaitGroup{}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if d, ok := m[keys[i]]; ok == true {
m[keys[i]] = d + 1
}
}
}
}()
}
wg.Wait()
}
// 執行出錯的原生map的併發讀寫
// fatal error: concurrent map read and map write
2.自定義加鎖的map結構
我們可以簡單的封裝下map結構使其實現安全的併發訪問,並實現一些基本的讀取,寫入和刪除接口操作。使用一個全局的讀寫鎖,當數據讀取和寫入的時候分別執行鎖操作,保證不會出現讀寫衝突的問題。缺點就是,當我們的map數據寫操作較多的情況,會導致效率較低。一個寫操作將導致全局map的後續讀取和寫入都需要等待,直到釋放該鎖。
實現和測試的代碼如下:
type CustomIntMap struct {
sync.RWMutex
internal map[string]int
}
func NewCustomIntMap() *CustomIntMap {
return &CustomIntMap{
internal: make(map[string]int),
}
}
func (rm *CustomIntMap) Load(key string) (value int, ok bool) {
rm.RLock()
result, ok := rm.internal[key]
rm.RUnlock()
return result, ok
}
func (rm *CustomIntMap) Delete(key string) {
rm.Lock()
delete(rm.internal, key)
rm.Unlock()
}
func (rm *CustomIntMap) Store(key string, value int) {
rm.Lock()
rm.internal[key] = value
rm.Unlock()
}
上面的讀寫鎖,用於不同的接口使用,當我們僅僅讀取的時候,僅需要執行讀取鎖,不影響其他goroutine的讀取執行,性能損耗不大,全局鎖則會阻止其他goroutine讀寫的執行
func BenchmarkCustomMapRead(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
}
}
}
}
func BenchmarkCustomMapWrite(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
m.Store(keys[i], i+1)
}
}
}
func BenchmarkCustomMapReadAndWrite(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}
執行測試的結果如下所示, 可以看到相對於使用第三方庫,這種方式明顯可以改善程序的運行效率,相對於concurrent-map的性能提升約1/3左右
BenchmarkCustomMapRead-4 30000 53241 ns/op 0 B/op 0 allocs/op
BenchmarkCustomMapWrite-4 20000 70543 ns/op 0 B/op 0 allocs/op
BenchmarkCustomMapReadAndWrite-4 20000 96799 ns/op 0 B/op 0 allocs/op
執行多個goroutine的時候,讀取和寫入操作的時間進行測試。但是對於多個goroutine下的讀寫操作,自定義加鎖map的性能卻不如concurrent-map的效率。
func BenchmarkCustomMapReadAndWriteWithMutilGoroutine(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
wg := sync.WaitGroup{}
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}()
}
wg.Wait()
}
執行發現自定義的安全鎖map在多個goroutine下的讀取操作和
BenchmarkCustomMapReadAndWriteWithMutilGoroutine-4 5000 338891 ns/op 0 B/op 0 allocs/op
PASS
3. 第三方庫concurrent-map
1.9版本之前,在官方還未推出自己的sync.Map併發哈希表結構之前,concurrent-map被很多人使用作爲可選的併發安全的讀寫map庫。與直接加鎖相比,concurrent-map通過使用分片的方式將,存儲空間分爲幾個片段,每個片段包含一部分數據,這樣我們在寫的時候不需要講整個的結構都進行加鎖,阻塞新的寫入或者讀取操作。
問題在於每次讀取和寫入都需要額外的計算分片信息。同時併發寫相同分片的key會導致效率更低。同時每次讀寫內部還需要進行類型的轉換,因爲concurrent-map存儲的類型爲interface結構類型。
提供基本的讀取和設置以及刪除操作。這裏我們使用同樣的方式對於讀寫性能進行測試, 這裏只貼出讀寫的測試代碼:
func BenchmarkConcurrentMapReadWrite(b *testing.B) {
m := cmap.New()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Set(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Get(keys[i]); ok {
m.Set(keys[i], i+1)
}
}
}
測試可以看出來,實際執行的讀寫操作時間性能相對於原生的map基本上將近3x的性能下降。 Concurrent-map的實現上面也是採用了鎖的方式,作爲第三方開源產品,很多情況下爲了兼容不同的存取實現,內部會有一些額外的操作比如類型轉換,而這些都可能導致程序性能的下降。
BenchmarkConcurrentMapRead-4 20000 63583 ns/op 0 B/op 0 allocs/op
BenchmarkConcurrentMapWrite-4 10000 121971 ns/op 8000 B/op 1000 allocs/op
BenchmarkConcurrentMapReadWrite-4 10000 160574 ns/op 8000 B/op 1000 allocs/op
針對併發操作的測試如下面所示,啓動兩個goroutine來同時執行讀寫操作,
func BenchmarkConcurrentMapReadAndWriteWithMutilGoroutine(b *testing.B) {
m := cmap.New()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Set(keys[i], i)
}
b.ResetTimer()
wg := sync.WaitGroup{}
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Get(keys[i]); ok {
m.Set(keys[i], i+1)
}
}
}
}()
}
wg.Wait()
}
測試結果如下面所示, 兩個goroutine,執行讀寫操作實際執行結果每個內部的操作讀寫約爲280ns/2 = 140ns基本上在多個goroutine上與上面的基本一致。
BenchmarkConcurrentMapReadAndWriteWithMutilGoroutine-4 5000 280993 ns/op 16000 B/op 2000 allocs/op
4. sync.Map
sync.Map結構是在1.9版本後加入到標準庫中,提供給用戶使用的併發安全鎖機制,實現上通過兩個map結構,其中一個只讀的map,另外一個通過RWLocker鎖保護的讀寫map組成。Go開發人員在github上提到過sync.Map的實現和解決的問題,提到sync.Map主要用於解決服務器上由於CPU核心過多導致的緩存爭奪(多CPU更新同一個緩存變量的情況下,導致效率降低)的問題,這個問題可能導致對於一個O(1)操作,在多個核心(N核)同時操作的時候,可能導致變爲O(N)的操作。
適合僅新增key的情況,且讀取數據的比例遠大於更新數據的總量。
同時也提到了類似於concurrent-map中多個分片鎖的實現,對於併發讀相同分片下可能導致對於分片鎖資源的爭奪問題。另外分片結構和鎖策略也可能導致程序運行的其他問題。
我們使用通用的測試方法測試, 如下所示爲程序的讀寫測試,和併發讀寫操作測試代碼。
func BenchmarkSyncMapReadAndWrite(b *testing.B) {
var m sync.Map
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}
func BenchmarkSyncMapReadAndWriteWithMutilGoroutine(b *testing.B) {
var m sync.Map
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
wg := sync.WaitGroup{}
for i := 0; i < MaxGoRoutineNumber; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}()
}
wg.Wait()
}
測試的結果如下面所示:
BenchmarkSyncMapRead-4 30000 61689 ns/op 0 B/op 0 allocs/op
BenchmarkSyncMapWrite-4 10000 247273 ns/op 40000 B/op 3000 allocs/op
BenchmarkSyncMapReadAndWrite-4 5000 271561 ns/op 40000 B/op 3000 allocs/op
BenchmarkSyncMapReadAndWriteWithMutliGoroutine-4 5000 331194 ns/op 80000 B/op 6000 allocs/op
PASS
總結
通過對比各種不同的實現,可以看出:
-
讀寫方面原生map性能最好,其他的加鎖的版本和sync.Map均會有2-3倍左右的性能下降
- 根據測試的性能讀取排序大概是:map>自定義加鎖map>concurrent-map>sync.Map, 總體上可以性能損耗相對map有2-3倍的下降
- 根據測試的性能寫入排序大概是:map >自定義加鎖map>concurrent-map>sync.Map,其中sync.Map的寫操作下降嚴重約5-6倍
- 同時讀寫操作下,基本上和上面的順序一致,自定義的鎖約原生map的兩倍左右,concurrent-map2-3倍,sync.Map基本上達到了4倍的性能下降。
-
併發方面:對於相同的操作下,併發讀寫(各一半)操作則出現自定義加鎖map變慢的情況
- 2個goroutine下,concurrent-map > 自定義加鎖map>sync.Map
- 4個goroutine下,concurrent-map >自定義加鎖map> sync.Map
- 8個goroutine下,concurrent-map >自定義加鎖map> sync.Map
- 16個goroutine下,concurrent-map >自定義加鎖map> sync.Map
- 32個goroutine下,concurrent-map > sync.Map >自定義加鎖map
- 64個goroutine下,concurrent-map > sync.Map >自定義加鎖map
-
併發方面:對於相同的操作下,併發讀操作則出現自定義加鎖map變慢的情況
- 2個goroutine下,sync.Map > concurrent-map >自定義加鎖map
- 4個goroutine下,sync.Map > concurrent-map >自定義加鎖map
- 8個goroutine下,sync.Map > concurrent-map >自定義加鎖map
- 16個goroutine下,sync.Map > concurrent-map >自定義加鎖map
- 32個goroutine下,sync.Map > concurrent-map >自定義加鎖map
- 64個goroutine下, sync.Map > concurrent-map >自定義加鎖map
可以看出,當我們不需要併發操作的時候,直接使用map更快(顯而易見),但是對於併發操作的時候,則要根據實際的業務需求進行判斷,如果讀取操作更多,且key比較穩定的話,在多個CPU核心的條件下,而對於讀寫各一半的情況下concurrent-map則會更好一些。
最後附上此次測試的系統信息
MacOSX: 10.14.6 Darwin Kernel Version 18.7.0
CPU: 2.5 GHz Intel Core i5
Memory: 16 GB 1600 MHz DDR3