談Go語言中併發Map的使用

最近開發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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章