之前囫圇吞棗地靠著有道詞典把《Mastering GO》看了一遍,什么筆記都沒記,回頭一想好像什么也沒記住,英語水平差也不太可能去二刷,現(xiàn)在看《Concurrency in GO》不能犯之前的錯(cuò)誤,需要記點(diǎn)什么,不是翻譯書籍,一方面是轉(zhuǎn)化成自己的語言,其次是時(shí)間長了可以來復(fù)習(xí),還可以給想學(xué)習(xí)的小伙伴一起看看,共同進(jìn)步。
第一章主要介紹了并發(fā)的發(fā)展歷史和并發(fā)編程的難點(diǎn),最后介紹 GO 語言在這些困難之處做出的努力,自帶并發(fā)和低延遲的垃圾回收機(jī)制、內(nèi)存管理機(jī)制、豐富的同步原語和運(yùn)行時(shí)系統(tǒng)可以兼顧性能和可維護(hù)性的同時(shí),更容易寫出正確的并發(fā)代碼,降低程序員的心智負(fù)擔(dān),make your life easier。
摩爾定律,網(wǎng)絡(luò)規(guī)模和我們所處的困境
介紹了計(jì)算機(jī)科學(xué)發(fā)展的一些歷史和并發(fā)編程出現(xiàn)的必然性(多核處理器的誕生)。
被稱為計(jì)算機(jī)第一定律的摩爾定律是指IC上可容納的晶體管數(shù)目,約每隔18個(gè)月便會(huì)增加一倍,性能也將提升一倍。廣泛應(yīng)用于眾多領(lǐng)域,形容指數(shù)級的增長。
為什么并發(fā)編程很難?
并發(fā)代碼很難編寫,一般需要多次迭代才能按照預(yù)期的方式工作,bug 隱藏很深,甚至幾年前的代碼在某些場景下(磁盤使用率高、大量的用戶登入到系統(tǒng)等)也會(huì)出現(xiàn)問題。大部分并發(fā)編程的問題可以歸納總結(jié)為下面幾種。
數(shù)據(jù)競爭(data race)
多個(gè)并發(fā)線程(至少一個(gè)寫操作)同時(shí)嘗試訪問同一塊內(nèi)存區(qū)域,并且操作的方式不是原子性的,讀線程正在讀取內(nèi)存,而寫線程還沒有完成寫入操作,這時(shí)會(huì)讀取到不完整的數(shù)據(jù)。
數(shù)據(jù)競爭的原因
我們知道,一枚 CPU 核心同一時(shí)刻只能執(zhí)行一條機(jī)器指令,指令因其不可分割性被稱為原子操作,即:不能拆分成更小的操作。
注:希臘單詞 atom (?τομο?; atomos),表示不可切分。
不可分割性讓原子操作天然具備線程安全:當(dāng)一個(gè)線程寫入共享數(shù)據(jù)操作具有原子性時(shí),其他線程在寫入操作完成之前無法讀取數(shù)據(jù);反之,當(dāng)一個(gè)線程讀取共享數(shù)據(jù)操作具有原子性時(shí),一定能夠讀到某個(gè)時(shí)刻的完整數(shù)據(jù),不會(huì)發(fā)生數(shù)據(jù)競爭。
壞消息是,程序中絕大多數(shù)的操作并不是原子操作,即使如 x = 1 這樣簡單的賦值語句,在硬件上也可能由多條機(jī)器指令組成,賦值語句本身并不是線程安全的。
舉個(gè)例子,
package main
import (
"fmt"
"sync"
)
func main() {
var s int32
var wg sync.WaitGroup
for i := 0; i <= 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s++
}()
}
wg.Wait()
fmt.Println(s) // 9462,每次執(zhí)行結(jié)果都不一樣
}
數(shù)據(jù)競爭的解決辦法
解決辦法有多種,互斥鎖和原子操作等等,代碼如下:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var s int32
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt32(&s, 1) // 原子操作
}()
}
wg.Wait()
fmt.Println(s) // always 10000
}
競態(tài)條件(race condition)
多個(gè)線程按照不可預(yù)知的順序執(zhí)行操作,而實(shí)際的要求應(yīng)該按照指定順序執(zhí)行,程序的運(yùn)行結(jié)果產(chǎn)生(預(yù)料之外)的變化。
競態(tài)條件的原因
就像操作系統(tǒng)能完全控掌控線程管理,按照調(diào)度算法啟動(dòng)、暫停、終止線程,而程序員無法控制線程執(zhí)行時(shí)間或執(zhí)行順序。程序員也無法控制 goroutines 的執(zhí)行順序,甚至無法掌控 goroutines 啟動(dòng)需要多長時(shí)間。
舉個(gè)例子:
1 var data int
2 go func() {
3 data++
4 }()
5 if data == 0 {
6 fmt.Printf("the value is %v.\n", data)
7 }
執(zhí)行以上示例代碼會(huì)有三種不同的可能,程序執(zhí)行的結(jié)果完全無法預(yù)期。
- 什么都不輸出,代表第 3 行在第 5 行之前執(zhí)行
- the value is 0,代表第 5 行在第 3 行之前執(zhí)行,并且第 6 行也在第 3 行之前執(zhí)行
- the value is 1,代表第 5 行在第 3 行之前執(zhí)行,但第 6 行在第 3 行之后執(zhí)行
這類問題經(jīng)常發(fā)生在當(dāng)一個(gè)線程執(zhí)行 "check-then-act"(檢查如果 value 等于 X 然后執(zhí)行一些邏輯),然而另外一個(gè)線程在 “check” 和 “act” 之間對 X 進(jìn)行一些操作的時(shí)候。比如:
if (x == 5) // The "Check"
{
y = x * 2; // The "Act"
// If another thread changed x in between "if (x == 5)" and "y = x * 2" above,
// y will not be equal to 10.
}
這時(shí)候 y 的結(jié)果完全無法預(yù)料。
競態(tài)條件的解決辦法
為了防止競態(tài)條件的產(chǎn)生,可以將這一系列操作作為一個(gè)critical section(臨界區(qū))。
// 臨界區(qū)加鎖
if (x == 5)
{
y = x * 2; // Now, nothing can change x until the lock is released.
// Therefore y = 10
}
// 臨界區(qū)釋放鎖
Go 語言提供了檢測競態(tài)的工具,go run -race main.go 或者 go build -race main.go
原子性
當(dāng)討論原子性的時(shí)候,意味著在當(dāng)前上下文,該操作是不可分割或不可中斷的。
首先一個(gè)很重要的概念“上下文”。有些操作在一個(gè)上下文中是原子性的,不代表在其他上下文中也是。比如一個(gè)操作在你的進(jìn)程上下文中是原子性的,在操作系統(tǒng)的上下文中就可能不是。操作系統(tǒng)上下文中的原子操作在機(jī)器上下文中就可能不是。總之,一個(gè)操作的原子性與否,會(huì)隨著你當(dāng)前定義的上下文或作用域改變而改變。考慮原子性時(shí),通常要做的第一件事就是明確定義上下文或作用域,該操作才有原子性可言。
接下來看看“不可分割”或“不可中斷”,這些意味著在你定義的上下文中,原子性的事物將整體發(fā)生,而在該上下文中不會(huì)同時(shí)發(fā)生任何事情。
看個(gè)簡單的例子:
i ++
看上去像原子操作,但是實(shí)際上它包含多個(gè)操作:
- 獲取 i 的值
- i 的值加 1
- 保存 i 的值
每個(gè)單獨(dú)的操作是原子性,但是組合在一起也許就不是了,這取決于你定義的上下文。如果你的上下文是一個(gè)沒有多進(jìn)程的程序,i ++ 就是原子的。如果你的上下文是一個(gè)沒有將 i 變量暴露給其他 goroutines 的 goroutine(局部變量),i ++ 也是原子的。
內(nèi)存同步訪問
確保同一時(shí)刻僅有一枚線程在使用資源。將代碼的特定部分作上標(biāo)記,這樣多個(gè)并發(fā)線程就不會(huì)同時(shí)執(zhí)行這段代碼,也不會(huì)讓共享數(shù)據(jù)變得混亂。這個(gè)特定的部分就是critical section(臨界區(qū))的概念。
將前面的例子做一點(diǎn)點(diǎn)修改:
var data int
go func() { data++}()
if data == 0 {
fmt.Println("the value is 0.")
} else {
fmt.Printf("the value is %v.\n", data)
}
以上代碼存在 3 個(gè) 臨界區(qū):
- data 變量加 1 的 goroutine
- if 語句,檢查 data 的值是否等于 0
- fmt.Printf 語句,獲取 data 的值用于輸出
Go 語言存在很多方式保護(hù)程序中的臨界區(qū),下面列舉一個(gè)比較常規(guī)的互斥鎖方式。
var memoryAccess sync.Mutex // 1
var value int
go func() {
memoryAccess.Lock() // 2
value++
memoryAccess.Unlock() // 3
}()
memoryAccess.Lock() // 4
if value == 0 {
fmt.Printf("the value is %v.\n", value)
} else {
fmt.Printf("the value is %v.\n", value)
}
memoryAccess.Unlock() // 5
- 聲明一個(gè)互斥鎖變量
- 為 goroutine 中的臨界區(qū)加鎖,直到釋放鎖之前,goroutine 將獨(dú)占訪問 value 變量的內(nèi)存
- 釋放 goroutine 中的臨界區(qū)鎖,代表 goroutine 已完成對應(yīng)內(nèi)存的操作
- 為 if 語句的臨界區(qū)加鎖,確保 if 語句 和 fmt.Printf 語句 可以獨(dú)占訪問 value 變量的內(nèi)存,沒有其他線程可以修改它,程序按照預(yù)期執(zhí)行。
- 釋放 if 語句的臨界區(qū)的鎖,代表操作完成
通過給臨界區(qū)加鎖釋放鎖實(shí)現(xiàn)了對數(shù)據(jù)的獨(dú)占訪問,但是這需要高度依賴開發(fā)者加鎖和釋放鎖,即使在一些大型開源項(xiàng)目中只加鎖不釋放鎖的 bug 也普遍存在,使用的時(shí)候需要倍加注意。
內(nèi)存同步訪問也會(huì)帶來性能問題,激烈的鎖競爭會(huì)影響程序性能,在確保程序正常的前提下,鎖的粒度(臨界區(qū))越小越好。
注意一下上面的方式雖然解決了數(shù)據(jù)競爭(data race),但是沒有解決競態(tài)條件(race condition),因?yàn)槎鄠€(gè)線程的執(zhí)行順序還是不確定的。
除了上述的問題之外,并發(fā)編程還有很多問題需要考慮,如死鎖、活鎖、饑餓等,處理不好也會(huì)導(dǎo)致程序出現(xiàn)問題。
死鎖
死鎖程序是一種所有并發(fā)進(jìn)程都在等待的程序。 在這種狀態(tài)下,程序在沒有外部干預(yù)的情況下永遠(yuǎn)不會(huì)恢復(fù)。
慣例舉例:
type value struct {
mu sync.Mutex
value int
}
var wg sync.WaitGroup
printSum := func(v1, v2 *value) {
defer wg.Done()
v1.mu.Lock() // 1
defer v1.mu.Unlock() // 2
time.Sleep(2 * time.Second) // 3
v2.mu.Lock()
defer v2.mu.Unlock()
fmt.Printf("sum=%v\n", v1.value+v2.value)
}
var a, b value
wg.Add(2)
go printSum(&a, &b) // goroutine X
go printSum(&b, &a) // goroutine Y
wg.Wait()
- 加鎖進(jìn)入臨界區(qū)
- 使用 defer 語句在 return 之前執(zhí)行釋放鎖退出臨界區(qū)
- 使用 sleep 模擬一個(gè)需要執(zhí)行一段時(shí)間的任務(wù)(觸發(fā)死鎖)
上述代碼的執(zhí)行結(jié)果:
fatal error: all goroutines are asleep - deadlock!
原因分析如下:
框代表函數(shù),水平線對這些函數(shù)的調(diào)用,垂直線代表函數(shù)的生命周期。
mian 函數(shù)調(diào)用 go printSum(&a, &b) 開啟 goroutine X,立馬又調(diào)用 go printSum(&b, &a) 開啟 goroutine Y,X 中 給 a 加鎖,執(zhí)行 sleep 之后嘗試給 b 加鎖,a 鎖未釋放,Y 中先給 b 加鎖,執(zhí)行 sleep 之后嘗試給 a 加鎖,b 鎖也未釋放,這就造成 X, Y 都持有對方需要的資源,并嘗試獲取對方的資源,永久地等待下去。
科夫曼條件
1971年,埃德加科夫曼在一篇論文中列舉了這些條件。這些條件現(xiàn)在稱為科夫曼條件,是幫助檢測,防止和糾正死鎖的技術(shù)基礎(chǔ)。
A deadlock situation on a resource can arise if and only if all of the following conditions hold simultaneously in a system:
Mutual exclusion: At least one resource must be held in a non-shareable mode. Otherwise, the processes would not be prevented from using the resource when necessary. Only one process can use the resource at any given instant of time.
Hold and wait or resource holding: a process is currently holding at least one resource and requesting additional resources which are being held by other processes.
No preemption: a resource can be released only voluntarily by the process holding it.
Circular wait: each process must be waiting for a resource which is being held by another process, which in turn is waiting for the first process to release the resource. In general, there is a set of waiting processes, P = {P1, P2, …, PN}, such that P1 is waiting for a resource held by P2, P2 is waiting for a resource held by P3 and so on until PN is waiting for a resource held by P1.
These four conditions are known as the Coffman conditions from their first description in a 1971 article by Edward G. Coffman, Jr.
- 互斥條件:至少一個(gè)資源必須以不可共享的模式持有。 否則,在必要時(shí)不會(huì)阻止進(jìn)程使用資源。 在任何給定時(shí)刻,只有一個(gè)進(jìn)程可以使用該資源。
- 保持和等待條件:一個(gè)進(jìn)程當(dāng)前至少持有一個(gè)資源,并請求其他進(jìn)程持有的額外資源。
- 無搶占條件:無法強(qiáng)制從進(jìn)程中獲取資源。資源只能由持有它的進(jìn)程自愿釋放。
- 循環(huán)等待條件:一個(gè)進(jìn)程正在等待第二個(gè)進(jìn)程持有的資源而第二個(gè)進(jìn)程正在等待第三個(gè)進(jìn)程的情況……等等,最后一個(gè)進(jìn)程正在等待第一個(gè)進(jìn)程。從而形成一個(gè)循環(huán)等待。
同時(shí)滿足上面 4 個(gè)條件就會(huì)觸發(fā)死鎖。分析一下上面的例子是如何滿足柯夫曼條件的:
- printSum 函數(shù)確實(shí)需要對 a 和 b 的獨(dú)占權(quán)限,因此它滿足此條件。
- goroutine X 持有資源 a 并且嘗試請求資源 b
- goroutines 都無法被搶占
- goroutine X 等待 goroutine Y 持有的資源 b,goroutine Y 等待 goroutine X 持有的資源 a,形成了循環(huán)等待。
死鎖預(yù)防
我們已經(jīng)了解到,如果所有四個(gè)科夫曼條件都成立,則會(huì)發(fā)生死鎖,因此阻止其中的一個(gè)或多個(gè)可以防止死鎖。
- 刪除互斥:所有資源必須是可共享的,這意味著一次可以有多個(gè)進(jìn)程獲取資源。這種方法幾乎是不可能的。
- 刪除保持和等待條件:可以通過要求進(jìn)程在啟動(dòng)之前(或在開始一組特定操作之前)請求它們將需要的所有資源來防止?jié)M足保持和等待條件。 另一種方法是要求進(jìn)程只有在沒有資源時(shí)才請求資源; 首先,他們必須先釋放所有當(dāng)前持有的資源,然后才能從頭開始請求他們需要的所有資源。也不太實(shí)際。
- 搶占資源: 搶占“鎖定”資源通常意味著回滾,應(yīng)該避免,因?yàn)樗拈_銷非常大。 允許搶占的算法包括無鎖和無等待算法以及樂觀并發(fā)控制。 如果一個(gè)進(jìn)程持有一些資源并請求一些不能立即分配給它的其他資源,則可以通過釋放該進(jìn)程當(dāng)前持有的所有資源來消除這種情況。
- 避免循環(huán)等待條件:如果資源在層次結(jié)構(gòu)中維護(hù),并且進(jìn)程可以按優(yōu)先級遞增的順序保存資源,則可以避免這種情況。這避免了循環(huán)等待。另一種方法是強(qiáng)制每個(gè)進(jìn)程規(guī)則使用一個(gè)資源,進(jìn)程可以在釋放當(dāng)前所擁有的資源后請求資源。這避免了循環(huán)等待。
活鎖
活鎖類似于死鎖,不同之處在于活鎖中涉及的進(jìn)程的狀態(tài)不斷地相互改變,沒有進(jìn)展。
概念很抽象,想象一個(gè)這樣的場景,你需要越過一個(gè)人穿過走廊,他移動(dòng)到一邊讓你通過,但你也這樣做了。你移動(dòng)到另一邊,不巧他也這樣做了,這種情況永久持續(xù)下去就是活鎖。
好像還是很難想象跟編程有什么關(guān)系,死鎖是加不上就死等,活鎖是加不上就放開已獲得的資源重試。這里應(yīng)用一個(gè)知乎上的高贊回答。并發(fā)問題中活鎖(live lock)到底指的是什么?
饑餓
饑餓是并發(fā)進(jìn)程無法獲得執(zhí)行工作所需的所有資源的任何情況。 活鎖是饑餓的一種極端場景,所有的并發(fā)進(jìn)程都是平等的,都沒有完成自己的工作。更廣泛地說,饑餓通常意味著有一個(gè)或多個(gè)貪婪的并發(fā)進(jìn)程不公平地阻止一個(gè)或多個(gè)并發(fā)進(jìn)程盡可能有效地完成工作,甚至根本無法完成工作(餓死)。 解決饑餓問題通常使用“公平”策略,比如 GMP 模型中為了防止全局隊(duì)列的饑餓,每 61 次調(diào)度就會(huì)優(yōu)先從全局隊(duì)列中獲取 G。還有 sync 包中 Mutex 的實(shí)現(xiàn)也考慮了饑餓模式,當(dāng)一個(gè) G 嘗試獲取鎖的時(shí)間超過 1 ms 就會(huì)轉(zhuǎn)成饑餓模式,在鎖競爭的時(shí)候會(huì)直接分配給饑餓的 G 去運(yùn)行。請記住,饑餓還可以產(chǎn)生于CPU,內(nèi)存,文件句柄和數(shù)據(jù)庫連接等,任何必須共享的資源都可能產(chǎn)生饑餓。
水平有限,有不對的地方歡迎提出修改意見。
參考資料
了解「多線程」技術(shù)
DBMS 中的死鎖
死鎖 維基百科
《Concurrency in GO》作者: Katherine Cox-Buday