《Go 語言併發之道》讀後感 - 第一章

sober-wang發表於2020-08-20

《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++

但是以上原子操作又可以拆分成三步:

  1. 檢索 i 的值
  2. 增加 i 的值
  3. 儲存 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 這個世界是不完整 。:)

更多原創文章乾貨分享,請關注公眾號
  • 《Go 語言併發之道》讀後感 - 第一章
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章