【GoLang】GoLang map 非線程安全 & 並發度寫優化


Catena (時序存儲引擎)中有一個函數的實現備受爭議,它從 map 中根據指定的 name 獲取一個 metricSource。每一次插入操作都會至少調用一次這個函數,現實場景中該函數調用更是頻繁,並且是跨多個協程的,因此我們必須要考慮同步。

該函數從 map[string]*metricSource 中根據指定的 name 獲取一個指向 metricSource 的指針,如果獲取不到則創建一個並返回。其中要注意的關鍵點是我們只會對這個 map 進行插入操作。

簡單實現如下:(為節省篇幅,省略了函數頭和返回,只貼重要部分)

var source *memorySource
var present bool

p.lock.Lock() // lock the mutex
defer p.lock.Unlock() // unlock the mutex at the end

if source, present = p.sources[name]; !present {
	// The source wasn't found, so we'll create it.
	source = &memorySource{
		name: name,
		metrics: map[string]*memoryMetric{},
	}

	// Insert the newly created *memorySource.
	p.sources[name] = source
}

經測試,該實現大約可以達到 1,400,000 插入/秒(通過協程並發調用,GOMAXPROCS 設置為 4)。看上去很快,但實際上它是慢於單個協程的,因為多個協程間存在鎖競爭。

我們簡化一下情況來說明這個問題,假設兩個協程分別要獲取“a”、“b”,並且“a”、“b”都已經存在於該 map 中。上述實現在運行時,一個協程獲取到鎖、拿指針、解鎖、繼續執行,此時另一個協程會被卡在獲取鎖。等待鎖釋放是非常耗時的,並且協程越多性能越差。

讓它變快的方法之一是移除鎖控制,並保證只有一個協程訪問這個 map。這個方法雖然簡單,但沒有伸縮性。下面我們看看另一種簡單的方法,並保證了線程安全和伸縮性。 

var source *memorySource
var present bool

if source, present = p.sources[name]; !present { // added this line
	// The source wasn't found, so we'll create it.

	p.lock.Lock() // lock the mutex
	defer p.lock.Unlock() // unlock at the end

	if source, present = p.sources[name]; !present {
		source = &memorySource{
			name: name,
			metrics: map[string]*memoryMetric{},
		}

		// Insert the newly created *memorySource.
		p.sources[name] = source
	}
	// if present is true, then another goroutine has already inserted
	// the element we want, and source is set to what we want.

} // added this line

// Note that if the source was present, we avoid the lock completely!

該實現可以達到 5,500,000 插入/秒,比第一個版本快 3.93 倍。有 4 個協程在跑測試,結果數值和預期是基本吻合的。

這個實現是 ok 的,因為我們沒有刪除、修改操作。在 CPU 緩存中的指針地址我們可以安全使用,不過要注意的是我們還是需要加鎖。如果不加,某協程在創建插入 source 時另一個協程可能已經正在插入,它們會處於競爭狀態。這個版本中我們只是在很少情況下加鎖,所以性能提高了很多。

John Potocny 建議移除 defer,因為會延誤解鎖時間(要在整個函數返回時才解鎖),下面給出一個“終極”版本:

var source *memorySource
var present bool

if source, present = p.sources[name]; !present {
	// The source wasn't found, so we'll create it.

	p.lock.Lock() // lock the mutex
	if source, present = p.sources[name]; !present {
		source = &memorySource{
			name: name,
			metrics: map[string]*memoryMetric{},
		}

		// Insert the newly created *memorySource.
		p.sources[name] = source
	}
	p.lock.Unlock() // unlock the mutex
}

// Note that if the source was present, we avoid the lock completely!

9,800,000 插入/秒!改了 4 行提升到 7 倍啊!!有木有!!!!


更新:(譯注:原作者循序漸進非常贊)

上面實現正確么?No!通過 Go Data Race Detector 我們可以很輕松發現竟態條件,我們不能保證 map 在同時讀寫時的完整性。

下面給出不存在竟態條件、線程安全,應該算是“正確”的版本了。使用了 RWMutex,讀操作不會被鎖,寫操作保持同步。

var source *memorySource
var present bool

p.lock.RLock()
if source, present = p.sources[name]; !present {
	// The source wasn't found, so we'll create it.
	p.lock.RUnlock()
	p.lock.Lock()
	if source, present = p.sources[name]; !present {
		source = &memorySource{
			name: name,
			metrics: map[string]*memoryMetric{},
		}

		// Insert the newly created *memorySource.
		p.sources[name] = source
	}
	p.lock.Unlock()
} else {
	p.lock.RUnlock()
}

經測試,該版本性能為其之前版本的 93.8%,在保證正確性的前提先能到達這樣已經很不錯了。也許我們可以認為它們之間根本沒有可比性,因為之前的版本是錯的。

 

參考資料:

Golang的鎖和線程安全的Map: http://www.java123.net/404333.html

[Golang]Map的一個絕妙特性: http://studygolang.com/articles/2494

如何證明 go map 不是並發安全的: https://segmentfault.com/q/1010000006259232

go語言映射map的線程協程安全問題: http://blog.csdn.net/htyu_0203_39/article/details/50979992

優化 Go 中的 map 並發存取: http://studygolang.com/articles/2775

 
擴展:


免責聲明!

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



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