作為一個終身學習者,輸入和輸出是必不可少的。輸入多了之后,會發現很多中文文章很難讀,可能還有很多錯漏之處。不客氣地說,輸入的是垃圾,輸出的只能是垃圾。
曹大經常說需要多看英文資料,包括各種新出的英文書、文章等等,這從他的書單也可以看出來。我自己的情況是:英文資料讀的不多,英文技術書則基本就沒完整地讀過一本。之前在寫文章的過程中,還是看了一些英文文章,收獲很大。
這次嘗試讀一讀英文技術書。但是直接讀的話,經常讀完和沒讀一樣,沒有什么感覺。於是我嘗試一種邊讀書邊記讀書筆記的方式,過程中讀到有趣的、有用的、以前不知道的地方就記下來,和大家分享。
這是一本 2017 年 7 月份出版的書,到今天已經過去三年了,Go 的版本也從當時的 Go 1.8,升級到了最新的 Go 1.15,變化巨大。
下面是我記的筆記:
-
並發程序經常出錯的一個原因是人們認為自己所寫代碼的執行順序是按書寫的順序來執行的,但在並發場景下,這顯然是有問題的。
-
Atomicity,原子性。談論原子性,必須要有一個 context。因為在一個 context 下是原子性的,但在另一個 context 下,就可能不是原子性的了。具體的 context 可能是:進程、操作系統、機器、集群……假想個例子,在一維空間中的 X 軸上,從坐標 1 到坐標 3 必須要經過坐標 2,這在一維空間中是絕對正確的。但作為活在三維空間里的人,我有很多種辦法不經過 X 軸上的坐標 2 而到達坐標 3。僅管我的軌跡映射到 X 軸上還是會“經過”坐標 2,這也更像一個“降維打擊”的例子。
-
形成死鎖的四個條件:Mutual Exclusion(並發實體任意時刻獨占資源)、Wait For Condition(並發實體同時持有資源並都在等待其他資源)、No Preemption(資源只能被持有它的實體釋放)、Circular Wait(循環等待,a 等 b,b 等 c,c 等 a……)。
-
活鎖是飢餓的一種,任何需要分享的資源都有可能發生飢餓,如 CPU、內存、文件句柄、數據庫連接等。
-
並發(Concurrency)說的是代碼,並行(Parallelism)說的是正在運行的程序。我們無法寫出並行的代碼,只能寫並發的代碼,並且期望它能並行執行。想象一下,我們寫的代碼在單核 CPU 上運行,還能並行地起來嗎?
-
考察並發的代碼是否是在並行執行,我們得看在哪一個抽象的層級上看:並發原語、程序的運行時、操作系統、操作系統所在的平台(容器、虛擬機……)、CPUs、機器、集群……
-
和前面說的 Atomicity 一樣,談論 Parallelism 時,也要有一個 context。它決定是否將能將兩個操作看成並行。例如,我們運行 2 個操作,每個操作花費 1 秒。如果 context 是 5 秒鍾,那可以說這兩個操作是在並行執行;但如果 context 是 1 秒鍾,那我們認為,這兩個操作是串行地在執行。注意,context 並不等同於時間,線程、進程、操作系統等都可以看成 context。
-
給並發或者說並行定義什么樣的 context 和並發程序是否正確運行有很大關系。例如,context 是兩台電腦,我們分別在兩台電腦上運行兩個計算器程序,那理論上這兩個計算器程序就是並行的,且不會相互影響。
-
在上面的例子里,context 是兩台電腦,operations 是兩個進程。很明顯,我在我的電腦上運行任何程序,都不會影響你的電腦。但是在同一台機器上,一個進程還能保證不影響另一個進程嗎?回答是不一定,比如讀寫同一個文件……
-
大部分程序的並發抽象層級是線程。Go 在抽象層級上又增加了一個 goroutine。按理說,層級層次越高,並發安全性越難保證。但實際上 goroutine 讓事情變得更容易,因為它並不是在線程的抽象層級之上又加了一層,而是取代了線程。
-
Go channel 的設計思想來源於 Hoare 於 1978 年發表在 ACM 上的一篇關於 CSP(Communicating Sequential Processes)的論文。Go 是第一門吸收了 CSP 精華並且將其發揚光大的語言。
-
大多數語言使用線程+並發同步訪問控制作為並發模型,而 Go 的並發模型由 goroutine 和 channel 組成。線程類似於 goroutine,而並發同步訪問控制則類似於 mutex。
-
Go 並發的理念是:簡單,盡量使用 channel,盡情使用 goroutine。
-
在 linux 上,簡單測試線程切換成本:
# 在 CPU0 上執行,在兩個內核線程間發送、接收消息
taskset -c 0 perf bench sched pipe -T
因為是單核,所以在兩個線程間發送、接收消息,需要進行上下文切換。在我的乞丐版阿里雲主機上得到結果:
# Running 'sched/pipe' benchmark:
# Executed 1000000 pipe operations between two threads
Total time: 69.171 [sec]
69.171280 usecs/op
14456 ops/sec
計算出大致的線程切換成本:69.171280/2 = 34.58564 us。
-
使用 sync.WaitGroup 時要注意,sync.Add 要在新起 goroutine 語句的外層調用,否則執行到 sync.Wait 時,可能新起的 goroutine 還沒調度到,sync.Add 自然沒執行,最終導致邏輯出錯。
-
mutex 是 mutual exclusion 的簡寫,翻譯一下:互相排斥。
-
sync.cond 有兩個比較有意思的方法:sync.Cond.Signal 和 sync.Cond.Broadcast。前者會喚醒等待時間最長的 goroutine,后者會喚醒所有等待的 goroutine。另外,要注意 sync.Cond.Wait 方法內部,隱藏了一些副作用,會先解鎖:
c.L.Unlock()
,然后再加鎖:c.L.Lock()
。 -
查詢 Go 源碼使用了多少次 sync.Once:
grep -ir sync.Once $(go env GOROOT)/src | wc -l
-
channel 是粘合 goroutine 的膠水,select 則是粘合 channel 的膠水。
-
關於 runtime.GOMAXPROCS(n) 函數的一個可能的使用場景:代碼中可能存在 data race 的情況,增加 n 值可以讓 data race 更快地發生,從而可以更快地調試錯誤。
-
為了避免 goroutine 泄露,請注意:生成子 goroutine 的父 goroutine 需要負責停止子 gotoutine,即誰創建誰銷毀。
-
可以將一個“無序、耗時長”的 stage 轉成 fan-out。fan-in 是多轉一,fan-out 則是一轉多。
-
設計系統的時候,應該一開始就考慮 timeout 和 cancel。
-
分布式系統需要支持 timeout 的幾個理由:
-
飽和
系統飽和時,最后到達的請求需要直接超時返回,否則可能引發雪崩; -
數據過期
數據其實有一定的時間窗口,過了窗口,就是無效數據了。例如前端一個請求過來,假設用戶可以容忍 2s,那這個窗口就是 2s,分布式系統需要支持 2s 的超時設置,超過 2s 后數據無效; -
防止死鎖
當然,觸發 timeout,有可能使死鎖變成活鎖。系統設計的目標應該是在不觸發 timeout 的情況下不發生死鎖。
- 與上一條對應的,分布式系統應該支持 cancel 操作的幾個理由:
-
超時
超時需要取消; -
用戶干預
當有用戶驅動的並發操作時,用戶可取消他發起的操作; -
父節點取消
就像 context 一樣,父 context 取消了,子 context 也要跟着取消; -
重復的請求
為了得到更快的響應,同時向幾個系統發起請求,當得到了最快的系統響應后,取消其他系統的請求。
-
可以將多個 ratelimiter 組合在一起,提供更有表達力的 ratelimiter。例如我可以限制每秒 1 個請求,同時每分鍾限制 10 個請求。具體見第五章 Rate Limiting 小節。
-
Go 使用 fork-join 模型。fork 即 go func(){}(), 而 join 則一般是指 sync.WaitGroup 或 channels。
-
在一個函數里(位於某個 goroutine)不斷地執行 go func(){}() 語句時,會不斷地產生相應的 goroutine,並被添加到當前 goroutine 所在的 P 上的 LRQ 中,LRQ 可以看作是一個雙端隊列,越靠近隊列尾的 goroutine 和當前 goroutine 的空間局部性越緊密,越需要優先執行。基於這點考慮,新產生的 goroutine 並不是直接放到 LRQ,而是會先放到 P 的 runnext 字段,執行完當前 goroutine 或當前 goroutine 被 park 后,首先執行的就是這個 runnext。如果之后又有新創建的 goroutine,它又會把當前掛在 runnext 上的 goroutine 頂到 LRQ 中。P 執行的時候從隊列頭的 goroutine 開始執行,而當 steal-working 發生時,也總是先從 LRQ 的頭部偷,其實就是 FIFO。
最后,全書讀起來還是挺順暢的,所需要的知識也並沒有超出我現有的認知,筆記也並不多,總算是完整地讀完了第一本全英文的書吧,期待后面讀更多。