深度阅读之《Concurrency in Go》
很多朋友可能都知道,年前我從滴滴跑路,來了字節跳動,并且很快就寫了篇傳播甚廣的弱智找 bug 文章:《“���”引發的線上事故》,之后就再也沒動靜了……
當然最主要的原因還是這邊工作的節奏比較快,而且還是大小周,工作之外,基本沒有寫作的精力了。當然,根本的原因還是水平太低,因為像左神就高產似那啥,所以還是得多從自己身上找找原因。
雖然工作比較累,但不得不說字節工作環境還是不錯的,除了免費的三餐、聽不完的分享、各路大神之外,還有非常好用的飛書……所以,這里很自然地就出現了內推廣告:
校招:
字節跳動校招內推碼: U6FPT3Y?
https://job.toutiao.com/s/JkKrnq4
社招:
https://job.toutiao.com/s/JkKRuj4
這里還有一個直達我們團隊的校招后端職位(公眾號后臺回復“校招”獲得更多職位),也歡迎直接投這個:
字節跳動校招內推碼: U6FPT3Y?
https://job.toutiao.com/s/JkKMayq
之前在朋友圈也推過一次,投的同學還是挺多的。除了技術,其他如運營、產品也可以通過這個渠道投遞。好處是可以加我微信(raoquancheng1991),我幫你查看&&催進度,必要的時候我可以幫你完善下簡歷。歡迎大家投遞,并且分享給需要的朋友,在此先謝過了~順便說一句,我看不到你投遞的簡歷內容,只能看到一條投遞到某個崗位的記錄。
說回正事,回到文章以及《Concurrency in Go》這本書。作為一個終身學習者,輸入和輸出是必不可少的。輸入多了之后,會發現很多中文文章很難讀,可能還有很多錯漏之處。不客氣地說,輸入的是垃圾,輸出的只能是垃圾。
曹大經常說需要多看英文資料[1],包括各種新出的英文書、文章等等,這從他的書單[2]也可以看出來。我自己的情況是:英文資料讀的不多,英文技術書則基本就沒完整地讀過一本。之前在寫文章的過程中,還是看了一些英文文章,收獲很大。
這次嘗試讀一讀英文技術書。但是直接讀的話,經常讀完和沒讀一樣,沒有什么感覺。于是我嘗試一種邊讀書邊記讀書筆記的方式,過程中讀到有趣的、有用的、以前不知道的地方就記下來,和大家分享。
這是一本 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 上,簡單測試線程切換成本:
因為是單核,所以在兩個線程間發送、接收消息,需要進行上下文切換。在我的乞丐版阿里云主機上得到結果:
# Running 'sched/pipe' benchmark: # Executed 1000000 pipe operations between two threadsTotal time: 69.171 [sec]69.171280 usecs/op14456 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:
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。
最后,全書讀起來還是挺順暢的,所需要的知識也并沒有超出我現有的認知,筆記也并不多,總算是完整地讀完了第一本全英文的書吧,期待后面讀更多。
參考資料
[1]
英文資料: https://xargin.com/how-to-learn/
[2]書單: https://xargin.com/readings/
總結
以上是生活随笔為你收集整理的深度阅读之《Concurrency in Go》的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: fasthttp 快在哪里
- 下一篇: 在 Go 语言中 Patch 非导出函数