鎖使用設計
本文給做鎖應用模型的軟體設計師介紹一下怎麼為鎖的使用建模(寫設計)。請注意,本文討論的是“使用”,不是鎖本身的設計。需要討論這個問題,是因為很多缺乏設計經驗的工程師很容易認為鎖的設計是一個編碼問題,不知道要抽象它的什麼問題,就算寫設計文件也就是寫成程式碼。這種行為,一旦遇到非常複雜的鎖設計,很容易就暈菜,在最終的程式碼中引入很多Race Condition問題。
鎖的本質是排隊。當你有多個執行執行執行緒的時候,本來這些執行緒是並行執行,互相河水不犯井水的,但你這些執行緒要修改一組資料,這組資料互相有關係,那麼一個沒有修改完,其他人就只能等著,這就是排隊。
這裡說的“執行緒”,是個泛化的概念。它可以理解為程序、pthread,kthread,中斷向量,Linux的bottom half,Posix的signal鉤子,都可以是執行緒。
針對不同的執行緒的排隊,你會需要不同的鎖,比如讀寫鎖,spinlock,mutex,condition等,包括部分不稱為鎖的東西,比如RCU,關中斷等。只要它們能把執行緒停下來排隊,就是鎖。針對不同的執行緒選擇合適的鎖,這是一個獨立的問題,不在本文的討論範圍內。筆者認為,這種選擇,大部分時候也不值得在設計文件中做出選擇,它僅僅是個編碼問題。但每種鎖的特性和行為特徵,那是必須很清楚的,這裡不談不表示他們不重要。如果你是在Linux核心程式設計,最好都看看Linux的Unreliable Guide To Locking。就算不是,這個文件也很值得看,因為它基本上把鎖的各種形態都做了介紹了。
理解鎖的本質是排隊,對我們非常重要,因為單個執行緒的本質其實就是排隊。既然都是排隊,那麼只要我們能把執行緒減少到只有一個,那麼鎖就是不需要的。我看過不止一次了,有人辛辛苦苦做了鎖的設計,最後發現大部分執行都是序列的,既然如此,你當初何必開多執行緒?
所以,鎖應用設計的本質是執行緒族的設計。你要做鎖的設計,首先把你有哪些執行緒挖出來。看你是否需要這種並行。當然,這有時是被迫的,比如你做了一個庫,別人可以用多個執行緒上下文來呼叫你,你又有多個非堆疊變數需要使用,這種情況,你的執行緒族就是所有可以呼叫你的執行緒。如果對方還可以從中斷向量中呼叫你,那你的執行緒族中就又包括中斷向量了。把這些上下文先找出來,這是鎖應用設計的第一步。
順便說一句,我個人是比較討厭在公共的演算法庫中加鎖的,因為鎖和執行緒庫相關,一旦你選定了鎖,就選定了執行緒庫,降低了公共演算法庫的自由度。這毫無意義。我要封裝,我自己會加封裝層,犯不著你自作聰明。
第二步是找出引起排隊的資料結構。先按最粗的粒度,把他們看做是一體的再說,比如你管理一組使用者,那麼就先考慮用一把鎖,把任何訪問這組資料的行為都鎖起來再說。然後看看是否影響你的執行緒發揮性能了(當然包括你未來的變數),沒有就到此為止,別過度設計了。
然後我們就可以看優化了。還是拿前面這個例子來說事,你有一組使用者,每個使用者的資料是一個必須排隊的資料結構,整個使用者池是另一個必須排隊的資料結構。如果多個執行緒會操作在不同的使用者上面,那麼我們可以獨立使用兩把鎖,一個鎖使用者,一個鎖使用者池。這樣大部分時候,我們的執行緒相互作用在不同的使用者上面,他們沒有排隊問題。
但有兩把鎖是非常危險的事情,簡單學過自己作業系統原理都知道這種死鎖模型。我這裡不談這個東西的技術原理。我要談的是:這個東西沒有銀彈,一旦你開始進入這個模式了,你要設計這裡的原則,保證你編碼的時候不會掉入這個陷阱。
比如你有3個操作,op1作用在使用者上,op2把使用者加入使用者池,op3把使用者刪除出使用者池。op1你只鎖使用者鎖;op2你只鎖使用者池鎖;op3你先鎖這個使用者鎖,然後鎖使用者池,讓使用者離開使用者池後,釋放使用者池鎖,斷開這個使用者在op1上的訪問通道,然後放開使用者鎖。
看起來這個設計自恰了——其實沒有——你打算什麼時候釋放這個使用者鎖?可能op1已經進來了,還在鎖上等著呢,你根本斷不開它。
所以,很可能你需要更復雜的設計,比如一種方法是在使用者池上加一個引用計數,只要op1的通道打開了,拒絕掉所有的op3。這是Linux大部分公共框架用的方法。
但這種方法只能用於介面通道全部受控的情況,如果每個使用者可以被signal或者中斷這種執行緒所左右,你這個關閉就不一定能做到了,這時你得為每個使用者設計一個可見的佇列,把釋放操作放在這個佇列中,讓op1已經沒有操作作用在這個佇列上,你才能釋放它。——是不是煩死了?——我前面說過了,沒事最好就不要弄什麼多執行緒,弄了多執行緒最好也不要弄什麼多個鎖,這就是個馬蜂窩。
但如果你沒得選,你就只能做這個設計。鎖應用設計就是需要設計一個保證線上程組合的情況下都能自恰的邏輯。它通常包含:
- 定義你面對什麼執行緒環境
- 定義你所要保護的需要原子處理的資料
- 定義包括用的鎖,以及鎖的使用原則。這個原則最難的,通常就是死鎖規避和由此引起的資源釋放陷阱
但你不需要定義具體的每個執行流程,那個可以留給編碼。