本文給做鎖應用模型的軟體設計師介紹一下怎麼為鎖的使用建模(寫設計)。請注意,本文討論的是「使用」,不是鎖本身的設計。

需要討論這個問題,是因為很多缺乏設計經驗的工程師很容易認為鎖的設計是一個編碼問題,不知道要抽象它的什麼模型,就算寫設計文檔也就是寫成代碼。這種行為,一旦遇到非常複雜的鎖設計,很容易就暈菜,在最終的代碼中引入很多Race Condition問題。

鎖的本質是排隊。當你有多個執行執行線程的時候,本來這些線程是並行執行,互相河水不犯井水的,但你這些線程要修改一組數據,這組數據互相有關係,那麼一個沒有修改完,其他人就只能等著,這就是排隊。

這裡說的「線程」,是個泛化的概念。它可以理解為進程、pthread,kthread,中斷向量,Linux的bottom half,Posix的signal鉤子,都可以是這裡的線程。

針對不同的線程的排隊,你會需要不同的鎖,比如讀寫鎖,spinlock,mutex,condition等,包括部分不稱為鎖的東西,比如RCU,關中斷,關bottom half等。只要它們能把線程停下來排隊,就是鎖。針對不同的線程選擇合適的鎖,這是一個獨立的問題,不在本文的討論範圍內。筆者認為,這種選擇,大部分時候也不值得在設計文檔中做出選擇,它僅僅是個編碼問題。但每種鎖的特性和行為特徵,那是必須很清楚的,這裡不談不表示他們不重要,只是說他們屬於編碼維度,不屬於設計建模維度。如果你是在Linux內核編程,最好都看看Linux的Unreliable Guide To Locking。就算不是,這個文檔也很值得看,因為它基本上把鎖的各種形態都做了介紹了。

理解鎖的本質是排隊,對我們的建模非常重要,因為單個線程的本質其實就是排隊。既然都是排隊,那麼只要我們能把線程減少到只有一個,那麼鎖就是不需要的。我看過不止一次了,有人辛辛苦苦做了很複雜的鎖設計,最後發現大部分執行都是串列的,既然如此,你當初何必開那麼多線程

所以,鎖應用設計的本質是線程族的設計。你要做鎖的設計,首先把你有哪些線程挖出來。看你是否需要這種並行。當然,這有時是被迫的,比如你做了一個庫,別人可以用多個線程上下文來調用你,你又有多個非堆棧變數需要使用,這種情況,你的線程族就是所有可以調用你的線程。如果對方還可以從中斷向量中調用你,那你的線程族中就又包括中斷向量了。把這些上下文先找出來,這是鎖應用設計的第一步。

順便說一句,我個人是比較討厭在公共的演算法庫中加鎖的,因為鎖和線程庫相關,一旦你選定了鎖,就選定了線程庫,降低了公共演算法庫的自由度。這毫無意義。我要封裝,我自己會加封裝層,犯不著你自作聰明。

第二步是找出引起排隊的數據結構。先按最粗的粒度,把他們看做是一體的再說,比如你管理一組用戶,那麼就先考慮用一把鎖,把任何訪問這組數據的行為都鎖起來再說。然後看看是否影響你的線程發揮性能了(當然包括你未來的變數),沒有就到此為止,別過度設計了。

然後我們就可以看優化了。還是拿前面這個例子來說事,你有一組用戶,每個用戶的數據是一個必須排隊的數據結構,整個用戶池是另一個必須排隊的數據結構。如果多個線程會操作在不同的用戶上面,那麼我們可以獨立使用兩把鎖,一個鎖用戶,一個鎖用戶池。這樣大部分時候,我們的線程相互作用在不同的用戶上面,他們沒有排隊問題。

但有兩把(或者更多)鎖是非常危險的事情,簡單學過自己操作系統原理都知道這種死鎖模型(無非就是圓桌拿筷子那一套)。我這裡不談這個東西的技術原理。我要談的是:這個東西沒有銀彈,一旦你開始進入這個模式了,你要設計這裡的原則,保證你編碼的時候不會掉入這個陷阱。

比如你有3個操作,op1作用在用戶上,op2把用戶加入用戶池,op3把用戶刪除出用戶池。op1你只鎖用戶鎖;op2你只鎖用戶池鎖;op3你先鎖這個用戶鎖,然後鎖用戶池,讓用戶離開用戶池後,釋放用戶池鎖,斷開這個用戶在op1上的訪問通道,然後放開用戶鎖。

看起來這個設計自恰了——其實沒有——你打算什麼時候釋放這個用戶鎖?可能op1已經進來了,還在鎖上等著呢,你根本斷不開它。

所以,很可能你需要更複雜的設計,比如一種方法是在用戶池上加一個引用計數,只要op1的通道打開了,拒絕掉所有的op3。這是Linux大部分公共框架用的方法。

但這種方法只能用於介面通道全部受控的情況,如果每個用戶可以被signal或者中斷這種線程所左右,你這個關閉就不一定能做到了,這時你得為每個用戶設計一個可見的隊列,把釋放操作放在這個隊列中(例如評論中有人提到的使用引用計數),讓op1已經沒有操作作用在這個隊列上,你才能釋放它。——是不是煩死了?——我前面說過了,沒事最好就不要弄什麼多線程,弄了多線程最好也不要弄什麼多個鎖,這就是個馬蜂窩。

但如果你沒得選,你就只能做這個設計。鎖應用設計就是需要設計一個保證在線程任何組合的情況下都能自恰的邏輯。它通常包含:

  1. 定義你面對什麼線程環境
  2. 定義你所要保護的需要原子處理的數據
  3. 定義包括用的鎖,以及鎖的使用原則。這個原則最難的,通常就是死鎖規避和由此引起的資源釋放陷阱

但你不需要定義具體的每個執行流程,那個可以留給編碼。


推薦閱讀:
相关文章