《Go 語言並發之道》讀后感 - 第一章
前言
人生路漫漫,總有一本書幫助你在某條道路上打通任督二脈,《Go 語言並發之道》就是我作為一個 Gopher 道路上的一本打通任督二脈的書。說說我和它的偶遇,在一次 B 站雲原生社區一次分享會上,眾多大佬同推薦,並決定一起去讀《Kubernetes 源碼刨析》一書。我聽到后心潮澎湃,沖到當當准備下單買下一本《Kubernetes 源碼刨析》,但是發現竟然要郵費,那么我湊個單吧,在眾多推薦中突然看見《Go 語言並發之道》,它作為樹葉映襯着花朵次日便到了我樓下的快遞櫃。萬萬沒想到,我對樹葉的喜愛,遠超花朵。
性能瓶頸
在 1965 年,戈丁·摩爾寫了一篇三頁的論文,成就了后期人們耳熟能詳的摩爾定律。看到 Intel 如同牙膏一樣的擠單核的頻率,我們就可想物理性能極限的天花板或許已經到來了,所以開始推出多核 CPU ,多核多線程,以 Intel i9 為例已經是 8核16線程。
再以物理空間的舉例,還記得5年前,我在一家傳統行業龍頭公司做桌面運維,在師父的指引下幾乎將分公司所有的筆記本電腦拆解一同,炎炎夏日清理積灰。我就發現鑲嵌在主板上的 CPU 是一個方方正正的放款。然而現在的 CPU 已經變成一個長方形躺在我們電腦主板上了。從形狀的變化也可以看出 CPU 性能已經達到極限。
並發之苦
眾所周知,並發代碼是很難正確構建的。它通常需要完成幾個迭代才能讓它按預期的方式工作,即使這樣,在某些時間點(更高的磁盤利用率,更多的用戶登錄到系統等)到達之前,Bug 在代碼中存在數年的事情也不少見,以至於以前未發現的 Bug 在后面先露出來。
在書中提到了以下幾種問題,在完成並發代碼時常常遇見:
競爭條件
當兩個或多個操作必須按正確的順序執行,而程序並未保證這個順序,就會發生競爭。例如:多個線程,進程同時修改一塊內存空間,需要想辦法確保修改的線后順序,或正確性。
// 一個例子
var data int
go func(){
data++
}()
if data == 0{
fmt.Printf("The values is %v \n",data)
}
上面的代碼有三種輸出結果:
- 不打印任何東西
- 打印 “The values is 0"
- 打印 ”The values is 1"
你會發現上面的代碼執行順序亂了,這個需要親自做實驗,多執行幾遍。你可以用一個 for{}
試一下。
為了解決以上的問題,我們可以讓程序在執行過程中暫停幾秒,試着等待看程序是否會恢復正常。
// 一個例子
var data int
go func(){
data++
}()
// 暫停 3s
time.Sleep(3 * time.Second)
if data == 0{
fmt.Printf("The values is %v \n",data)
}
但是在實際生產,生活中我們程序所需要的執行時間是不固定的,有可能當前網速快,請求就變快;有肯能服務器磁盤有壞道,寫盤卡住很長時間;較大的 JSON 數據在序列化與反序列化上花費了過多時間。當這個時候你怎么確定 time.Sleep()
時間呢?
原子性
當某些東西被認為是原子的,或具有原子性的時候,這就以為者它運行的環境中,它是不可分割的或不可中斷的。
第一件非常重要的事情就是 “上下文”。你的程序,操作系統,硬件,都存在上下文。操作的原子性可以根據當前定義的范圍而改變。
書中舉了一個非常有趣的例子,我們大家應該都玩過游戲,游戲的外掛就是修改了游戲程序的內存中的上下文從而加強了你的角色。這對於游戲開發者來說,他們的程序沒有問題,健康良好的運行,但是外掛修改了游戲程序在操作系統環境中的上下文。
不可分割(indivisible)和不可中斷(uninterruptible)這些術語在你鎖定義的上下文中,原子的東西將被完整運行,例如:
i++
但是以上原子操作又可以拆分成三步:
- 檢索 i 的值
- 增加 i 的值
- 存儲 i 的值
我的理解,針對於我們所寫代碼的操作,和想要出現的結果,需要原子性。但是再對一個函數細分,它可能就不是原子性的。
內存訪問同步
假設有這樣一個數據競爭:兩個並發進程視圖訪問相同的內存區域,他們訪問內存的方式不是原子的。就會出現競爭。這里需要提出一個新的名詞,叫臨界區(critical section)。舉個例子:
var data int
go func(){ data++ }()
if data == 0{
fmt.Println(data)
}else{
fmt.Println(data)
}
例子中有三個臨界區:
- goroutine 正在使數據變量遞增
- if 語句,它檢查數據的值是否為 0
- fmt.Println() 語句,在檢索並打印變量的值
為了保證內存訪問操作的正確性,我們通常的方式是通過 sync
包在臨界區加鎖,好了現在我們知道加鎖可以保證內存訪問同步。那么問題來了:
- 我的臨界區是否是頻繁進入和退出?
- 我的臨界區應該有多大?
死鎖,活鎖和飢餓
死鎖:
死鎖是所有的並發進程彼此等待的。在這種情況下,沒有外界干預,程序將無法恢復。死鎖例子,我這里就偷個懶不寫了,相信剛接觸 channel 的小伙伴一定被 deadlock
困擾了很久,在塵封的記憶中找出那段代碼回一下吧。
出現死鎖有幾個必要條件。1971年,Edgar Coffman 的論文給出指導意見,Coffman 條件如下:
-相互排他,並發進程同時擁有資源的獨占權。
- 等待條件,並發進程必須同時擁有一個資源,並等待額外的資源
- 沒有搶占,並發進程擁有的資源只能被該進程釋放,即可滿足這個條件
- 循環等待,並發進程P1 必須等待一系列其他的並發進程 P2,這些並發進程同時也等待 P1 ,這樣便滿足了這個最終條件。
活鎖:
活鎖是正在主動執行並發操作的程序,但是這些操作無法向前推進程序的狀態。我的理解就是各退一步,然后再退,這就是活鎖。
書中用兩個人從走廊的兩頭通過走廊是互退一步舉例,我們生活中還有類似例子。例如:你騎自行車按照交通規則靠右行駛,迎面來一個二桿子沒有遵守交通規則,這樣的錯車徑歷誰都經歷過,很有可能就撞一起了。
飢餓:
飢餓是在任何情況下,並發進程都無法獲得執行工作所需的所有資源。舉個例子:《海賊王》近期路飛被凱多囚禁了,去工地板磚,但是他是一個貪婪的工人,把所有的磚都搬完了,獲得了大量的飯票,其他工人沒有飯票就得餓肚子。當然路飛還是會分享食物給其他工友,但是計算機中的程序可不會這么智能。
在日常的開發過程中,我們需要找到一個平衡點,同步訪問內存是昂貴的,所以將我們的鎖擴展到臨界區之外是有利的。另一方面,這樣做我們就得冒着餓死其他並發進程的風險。
還有來自外部的飢餓,例如:CPU,內存,文件句柄,數據庫鏈接等,任何必須共享的資源都是有可能產生飢餓的原因。
確定並發安全
最后,我們來談談開發並發代碼的最困難的地方,即所有其他問的根源——人。每一行代碼后面至少有一個人。
注釋,首次別這么嚴重的強調了一次,特別是在並發代碼中,作者希望每一個負責並發的團隊,或人,把每一個並發函數,接口(類),注釋清楚。
- 誰負責並發?
- 如何利用並發原語解決這個問題?
- 誰負責同步?
如果沒有足夠的注釋,調用方,復查代碼的人可能需要非常多的時間才能夠正確的使用已完成的並發代碼,當這些人遇見這種情況,他可能選擇重構。反復造輪子,你就會發現 TMD 重復代碼怎么這么多!
面對復雜的簡單性
這也許是我選擇 Go 語言作為我的主語言的原因,作為一個從 Python 到 Go 的運維開發工程師,寫 Go 代碼的時候無數次回想起 Python 操作列表,字典的便捷,而且在寫代碼的時候是如此優雅,就想我們在說話寫文章一樣,然而開心是有代價的。寫時簡單,部署難,是我對 Python 程序的總結。Go 的代碼看起來雖然丑,寫起來也覺得丑,但是寫時難,部署易,這對於運維來說,so happy!
並發方面,Python 線程池,進程池,需要各導入不同的包才可使用,協程不在官方庫內,此時苦瓜臉。然而 Go 從原語級別解決這個問題,啟動 goroutine 只需 go 即可,多個協程間的通信,我們創建 channel 即可
沒有用過其他的語言,比如:Java,C++, Rust 等,我也不好做比較。
再次聲明,我並沒有詆毀 Python ,作為一個運維,沒有 Python 這個世界是不完整 。:)