完全理解併發
越是往後端深入到 Web 開發和分散式系統,就越會覺得把「併發」理解清楚是多麼重要,而每天的日常工作中很多時候都需要處理與併發有關的話題,大到整個系統架構層面的併發考量,小到某一段程式碼的併發控制。
本來不想把這篇文章加入到「完全理解」系列,因為覺得「併發」涉及到的東西實在是很多,而我想憑藉一篇部落格企圖「完全理解」那是不現實的。但雖然不能做到「完全理解」,但我還是會嘗試盡力提綱挈領的把「併發」相關的話題都理清楚,把脈絡勾勒出來,讓它儘可能對得起「完全」吧。
併發與並行
在開始正式的話題之前,我們先來釐清兩個概念——「併發」與「並行」。
Erlang 之父 Joe Armstrong 曾經用一張非常簡單易懂的圖解釋了「併發」與「並行」的區別:
「併發」是同學們排成兩隊,然而卻只有一個咖啡機在工作,所以兩個佇列排在前面的同學交替使用咖啡機;「並行」則是兩臺咖啡機分別服務兩個獨立的同學佇列,它們同時進行,互不干擾。
這樣的例子簡單易懂,但依然還需要更進一步說明的是,「併發」與「並行」其實並不是同一個維度上非黑即白的兩個對立的概念。「併發」更多的強調的是有沒有這樣的能力或特徵,它是從事物的性質和對外表現上來說的,它不在乎你內部是如何實現「併發」的,相對而言是在更高層次上的概括,而「並行」則規定了它們在物理上一定是同時進行的,相對而言更嚴格。
具體從程式設計的角度來說,「並行」甚至可以是實現「併發」的一種手段,比如用下文所要講的「多執行緒」或「多程序」的方式來賦能某一段程式的「併發」能力:為了使程式具備「併發」的能力,採用「多執行緒」或「多程序」在應用層面上可以「並行」執行的具體實現手段來為其賦能。
計算機作業系統發展史
要追根溯源的來理解「併發」,我們首先應該瞭解一下計算機作業系統的發展歷史。因為回顧整個計算機作業系統的發展歷史,我們就會發現,幾乎所有的關鍵歷史節點,都是因為它使得計算機系統擁有了更強的併發處理能力而變得重要的。
在計算機誕生之初,是沒有作業系統這種東西存在的,當時人們只能先把打孔紙帶通過輸入機將程式傳入計算機,再啟動執行。在程式執行完畢之後,印表機把計算結果輸出,在現場監督程式執行的程式設計師再取走紙帶。在這個時期,同一個計算機系統在同一時間只能處理唯一確定的一件事情。
後來人們發明了批處理系統。批處理系統相當於計算機上的一個任務監督程式,在它的控制和排程下,計算機能夠自動的、成批的處理一個或多個使用者的任務。在批處理系統的幫助下,各個任務之間可以自動銜接,減少了需要人工建立任務和手工操作的時間,提高了計算機的利用效率。
再後來,隨著 CPU 效能的不斷提升,出現了分時系統和實時系統。分時系統是把 CPU 的執行時間分成很短的時間片,按時間片輪流把 CPU 分配給各聯機任務使用。若某個任務在分配給它的時間片內不能完成其計算,則該任務暫時中斷,把 CPU 讓給另一任務使用,等待下一輪排程時再繼續其執行。由於計算機速度很快,各個任務之間輪轉得也很快,給每個使用者的感覺是他獨佔了一臺計算機。而每個使用者可以通過自己的終端向系統發出各種操作控制命令,在充分的人機互動情況下,完成作業的執行。為了解決分時系統不能及時響應使用者指令的情況,又出現了能夠在嚴格的時間範圍內完成事件處理、及時響應隨機外部事件的實時系統。
IBM 於 1964 年伴隨著大型機 System/360 推出了通用作業系統 OS/360,這個通用的作業系統使得不同型號的計算機裝置也可以在同樣的作業系統控制下使用同樣的外部裝置(如印表機)和更上層的軟體,並且這些裝置之間可以相互連線,共同工作。通用作業系統使得不同型號的計算機裝置之間能夠真正組成網路來處理複雜的任務。
再往後就是我們相對更加熟悉的 Unix/Linux/Windows/OS X 這些現代作業系統誕生、迭代、版本更替的歷史,目前這些作業系統的併發能力已經遠遠超出了當時的 OS/360,而現代計算機網路的加持更是使得理論上的計算機併發處理能力幾乎已經沒有了上限。
從整個計算機作業系統的歷史我們可以清晰的看到歷史上的電腦科學家們是如何一步一步來提升計算機處理任務的效率的。從一開始需要手工的開啟、監督、結束單一任務,到使用批處理系統來自動化的監督任務流水線,再到使用分時系統來讓 CPU 在多個任務之間不停的輪轉,然後用後文要詳述的多 CPU 使用多程序和多執行緒的方式來進一步提升多工的執行效率,最後我們使用通用計算機系統來組成龐大的計算叢集來處理複雜的各種各樣需要隨機響應的任務,併發能力一次比一次有了質的提升。
如果說 「摩爾定律」 使得 CPU 的效能可以指數級的增長來從單機速度上提升任務完成的效率,那麼不斷的提升併發能力則是更高屋建瓴的考慮如何不間斷不浪費的來「壓榨」CPU 的高效能,這種思考問題的維度比一門心思的考慮提升單 CPU 的速度更具有現實意義,對於解決現有問題來說是更加高明的選擇。尤其是在「摩爾定律」行將失效的今天,採用分散式的方式提高整個系統的併發處理能力幾乎成了唯一的選擇。
多程序與多執行緒
讓我們先回到單機時代,來了解目前主流的併發模型——多程序與多執行緒。現代作業系統早已進入多 CPU 時代,自然會支援多程序和多執行緒。「程序」就是作業系統中一個具有獨立功能的程式,作業系統管理所有程序的執行並且以程序為單位分配儲存空間。一個程序還可以擁有多個併發的執行流程,這些併發的執行流程是可以獲得 CPU 排程和分派的基本執行單元,也就是執行緒。
程序是計算機資源的擁有者,建立、切換和銷燬都有較大的時空開銷,而一個程序內的所有執行緒共享這個程序的資源,更輕量級,對其的相關操作也開銷更小。需要注意的是,對於單核 CPU 系統而言,並行其實是不存在的,任何時刻 CPU 其實只能被一個執行緒所獲取,執行緒之間共享了 CPU 的執行時間。由於切換的速度很快,對外表現為併發執行的樣子。
多程序和多執行緒是如今高階程式語言中實現併發的常規模型,比如 C++、Java、Python。同時為了解決程式中多個程序和執行緒對資源的搶佔問題,還引入了「鎖」的概念。在這個併發模型中,需要開發人員利用「鎖」來處理資源搶佔的問題,也就是不讓某一個資源同時被多於一個程序(執行緒)所處理而帶來不可預期的後果。
既然有多程序和多執行緒,那麼「鎖」自然也有「程序鎖」和「執行緒鎖」。我們知道兩個程序其實是相互獨立的,各自擁有作業系統分配的獨立資源,而「程序鎖」是為了防止兩個程序對他們所佔用的資源以外的共享資源同時訪問,一般可以使用作業系統級別的訊號量來實現。相對應的,「執行緒鎖」則是保證同一段程式碼在同一時間只有一個執行緒在執行,一般各語言本身或類庫會提供實現方式。
分散式併發鎖
「分散式鎖」跟「程序鎖」和「執行緒鎖」很像,不過它更多的是使用在計算叢集的場景中。在本質上,程序鎖、執行緒鎖和分散式鎖的作用都是一樣的,只是作用的顆粒度不一樣。執行緒鎖作用於單一程序的範圍,程序鎖作用於單一作業系統的範圍,而分散式鎖則可以作用於網路結構中。在分散式叢集當中,我們使用分散式鎖來保證不同執行緒對程式碼和資源是獨佔的。
如何實現一個完美的分散式鎖呢?我們先來分析一下實現一個好的分散式鎖應該滿足什麼需求:首先這個加鎖操作應該是原子性的,否則這個鎖是有可能被「擊穿」的;其次鎖一般還需要有過期時間,使得某一次執行異常沒有移除鎖的情況下也能自然過期然後重試。
以大多數人都熟悉的 Redis 為例,我們可以使用 Redis 的 set 指令,把 key 作為鎖的標誌。尤其注意的是,這個操作原子性包含了檢視鎖存不存在、加鎖和設定過期時間三步操作,它們合在一起應該具有原子性(至少前兩步)。也就是說,如果我們需要先使用 get 指令檢視鎖存不存在再決定是否加鎖,這個鎖已經不是一把好「鎖」了。好在 Redis 2.6.12 以上的 set 指令 支援了同時加引數設定過期時間和判斷 key 是否存在,比如使用 SET lock_key lock EX 5000 NX
,NX 保證了鎖不存在時才上鎖,而 Redis 指令本身具有原子性,這樣就實現了一把看起來還不錯的鎖。在任務執行完畢後我們還需要用 del 指令主動把鎖刪掉以釋放資源。
上述例子只是一個簡單的場景,這個鎖其實還並不是完美的。思考這樣的問題:如果某一次操作執行緒 A 執行的特別慢,超過了過期時間,這個時候鎖已經自動過期失效了,這樣就有可能兩個執行緒同時在執行了。如何避免這樣的問題這裡不再詳述,感興趣的同學可以參考末尾 References 裡面的內容。總之,實現一把完美的分散式鎖可能並沒有想象中那麼簡單。
非同步
對於高階程式語言而言,多程序和多執行緒的併發模型更多的還是與作業系統底層對於併發的實現是保持一致的。也就是說,在它們的抽象層級上,實現併發的方式基本只是復刻了作業系統底層的併發模型。而 JavaScript 卻不一樣。
JavaScript 在誕生時就被定位為在網頁前端執行的指令碼,為了保證執行緒安全,而且主執行緒也不會被 I/O 等待所阻塞而失去響應,JavaScript 在設計階段就採用了「非同步事件模型」。這個模型並不是 JavaScript 獨有的,它只是借用了這個古老的模型來解決它自身的問題。
在實際情況中,非同步事件模型也是採用多執行緒的方式來實施的。但是對於開發人員而言,你永遠只需要跟主執行緒打交道,而所有的這些互動都是所謂「非同步」的,也就是說,你呼叫的任何一個 API 都在執行成功後主動告知你執行結果,這樣你就可以不必被任何 I/O 所堵塞(詳細的關於非同步、同步、阻塞和非阻塞的辨析可以看這裡: 《完全理解同步/非同步與阻塞/非阻塞》 )。既然不會被阻塞,那麼你可以以很快的速度呼叫很多個你需要呼叫的 I/O,這些 API 在自己執行完畢後會主動返回結果,你可以拿著結果來繼續做後續的事情,而其他時刻你完全是「自由」的。
在這個併發模型中,你不再需要處理各種「鎖」的問題,因為真正和你互動的只有主執行緒。可是作為開發者,你可能需要考慮如何處理程式碼中的各種非同步流程。因為在非同步的世界裡,程式碼不再是簡單的按照書寫順序來順序執行的,如何在工程中清晰合理的組織這些流程是在這個併發模型下需要考慮的問題。
各種各樣的併發模型
這個世界上除了多程序/多執行緒的併發模型和非同步事件併發模型,還有很多其他的併發模型,比如 Erlang 的 Actor 併發模型和 Golang 的 CSP 併發模型。在 Erlang 的併發世界裡,有很多比核心執行緒還要輕量級的物件,它們之間通過各種 Message 來進行資料共享。這些物件非常輕,可以同時成千上萬的被創建出來實現併發,而物件之間都通過發訊息來進行資料交換,根本不需要「鎖」。
在一個沒有「鎖」的世界裡,併發的效率是可以大大的提升的。
總結
每一個併發模型都有其存在的意義和價值,不過在具體的業務場景下,採用不同的併發模型的好壞卻是客觀的。
真理是存在的,可以被不斷逼近卻永遠無法被任何事物所完美詮釋。在技術的領域裡,沒有永恆的真理,但是真理永遠值得被追求。