Go 面試題:string 是線程安全的嗎?
大家好,我是煎魚。
之前在某知名平臺看到大家在交流 Go 崗位相關(guān)的面試題,其中有一道引起了大家的一些討論,勾起被八股文的深深回憶。
面試題如下:
圖片
如標題所示,原題是:Go 中的 string 賦值是線程安全的嗎?
我們可以一起先想想答案,看看中不中。
線程安全是什么
線程安全是指在多線程環(huán)境下,程序的執(zhí)行能夠正確地處理多個線程并發(fā)訪問共享數(shù)據(jù)的情況,保證程序的正確性和可靠性。
圖片
能被稱之為:線程安全,需要在多個線程同時訪問共享數(shù)據(jù)時,滿足如下幾個條件:
- 不會出現(xiàn)數(shù)據(jù)競爭(data race):多個線程同時對同一數(shù)據(jù)進行讀寫操作,導致數(shù)據(jù)不一致或未定義的行為。
- 不會出現(xiàn)死鎖(deadlock):多個線程互相等待對方釋放資源而無法繼續(xù)執(zhí)行的情況。
- 不會出現(xiàn)饑餓(starvation):某個線程因為資源分配不公而無法得到執(zhí)行的情況。
string 線程安全
需要有一個基礎(chǔ)了解,對于 string 類型,運行時表現(xiàn)對照是 StringHeader 結(jié)構(gòu)體。
如下:
type StringHeader struct {
Data uintptr
Len int
}
- Data:存放指針,其指向具體的存儲數(shù)據(jù)的內(nèi)存區(qū)域。
- Len:字符串的長度。
在了解前置知識后,接下來進入到實踐環(huán)境??纯丛?Go 里 string 類型的變量,做并發(fā)賦值到底是否線程安全。
案例一:并發(fā)訪問
我們先看第一個案例,多個 goroutine 中并發(fā)訪問同一個 string 變量的場景。如下代碼:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
str := "腦子進煎魚了"
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(str)
}()
}
wg.Wait()
}
輸出結(jié)果:
腦子進煎魚了
腦子進煎魚了
腦子進煎魚了
腦子進煎魚了
腦子進煎魚了
在上面的例子中,我們定義了一個 string 變量 str,然后啟動了 5 個 goroutine,每個 goroutine 都會輸出 str 的值。由于 str 是不可變類型,因此在多個 goroutine 中并發(fā)訪問它是安全的。
可能有同學疑惑不可變類型是什么?
不可變類型,指的是一種不能被修改的數(shù)據(jù)類型,也稱為值類型(value type)。不可變類型在創(chuàng)建后其值不能被改變,任何對它的修改操作都會返回一個新的值,而不會改變原有的值。
案例二:并發(fā)寫入
第一個案例看起來沒什么問題。我們再看第二個案例,針對多個 goroutine 并發(fā)寫入的場景來進行驗證。
如下代碼:
func main() {
var wg sync.WaitGroup
str := "腦子進煎魚了"
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
str += "!" // 修改 str 變量
fmt.Println(str)
}()
}
wg.Wait()
}
輸出結(jié)果:
腦子進煎魚了!
腦子進煎魚了?。?腦子進煎魚了?。?!
腦子進煎魚了?。。?!
腦子進煎魚了?。。。?!
看起來沒什么問題,還是正常的拼接結(jié)果,輸出的順序也完全沒有問題的樣子。(大霧)
我們再多運行幾次。再看看輸出結(jié)果:
// demo1
腦子進煎魚了!
腦子進煎魚了!!
腦子進煎魚了?。?!
腦子進煎魚了!??!
腦子進煎魚了?。。?
// demo2
腦子進煎魚了!
腦子進煎魚了?。?!
腦子進煎魚了!!
腦子進煎魚了?。。。?!
腦子進煎魚了!?。?!
在上面的例子中,我們在每個 goroutine 中向 str 變量中添加了一個感嘆號。由于多個 goroutine 同時修改了 str 變量,因此可能會出現(xiàn)數(shù)據(jù)競爭的情況。
我們會發(fā)現(xiàn)程序輸出結(jié)果會出現(xiàn)亂序或不一致的情況,可以確認 string 類型變量在多個 goroutine 中是不安全的。
要警惕這種場景,在實際業(yè)務代碼中,常有人前人留 BUG,后人因此翻車。主打一個熬夜查和修 BUG,分分鐘還得洗臟數(shù)據(jù)。
string 實現(xiàn)線程安全
使用互斥鎖
要實現(xiàn) string 類型變量的線程安全,第一種方式:使用互斥鎖(Mutex)來保護共享變量,確保同一時間只有一個 goroutine 可以訪問它。下面是一個改造后的例子。
如下代碼:
func main() {
var wg sync.WaitGroup
var mu sync.Mutex // 定義一個互斥鎖
str := "煎魚"
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // 加鎖
str += "!"
fmt.Println(str)
mu.Unlock() // 解鎖
}()
}
wg.Wait()
}
輸出結(jié)果:
煎魚!
煎魚?。?煎魚?。。?煎魚?。。。?煎魚?。。。。?/code>
在上面的例子中,我們使用了 sync 包中的 Mutex 類型來定義一個互斥鎖 mu。在每個 goroutine 中,我們先使用 mu.Lock() 方法來加鎖,確保同一時間只有一個 goroutine 可以訪問 str 變量。
再修改 str 變量的值并輸出,最后使用 mu.Unlock() 方法來解鎖,讓其他 goroutine 可以繼續(xù)訪問 str 變量。
需要注意,互斥鎖會帶來一些性能上的開銷,兩全難齊美。
使用 atomic 包
第二種方案是使用 atomic 包來實現(xiàn)原子操作,如下代碼:
func main() {
var wg sync.WaitGroup
var str atomic.Value // 定義一個原子變量
str.Store("hello, world")
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
oldStr := str.Load().(string) // 讀取原子變量的值
newStr := oldStr + "!"
str.Store(newStr) // 寫入原子變量的值
fmt.Println(newStr)
}()
}
wg.Wait()
}
這樣子也可以保證 string 類型變量的原子操作。但在現(xiàn)實場景下,仍然無法解決多 goroutine 導致的競態(tài)條件(race condition)。
也就是存在多個 goroutine 并發(fā)取到的變量值都是一樣的,得到的結(jié)果還是不固定的,最終還是要用 Mutex 或者 RWMutex 鎖來做共享變量保護。
這兩者沒有絕對的好壞,但需要分清楚你的使用場景,決定用鎖還是 atomic,又或是其他邏輯上的調(diào)整。
總結(jié)
在前面我們有把 StringHeader 結(jié)構(gòu)體讓大家看看,其實很明顯是不支持線程安全的。平白無故每個類型都去支持線程安全的話,會增加很多開銷。
絕大多數(shù)的情況下,你可以默認任何數(shù)據(jù)類型的變量賦值都不是線程安全的,除非他加了鎖(Mutex)或 atomic(原子操作)。而在 string、slice、map 的并發(fā)寫導致出錯的場景,更是每隔一段時間就能在線上看到一兩次。
每次做并發(fā)操作時,都建議想清楚,這個場景的到底需不需要保護共享變量,做好原子操作等。