自旋鎖應該是Linux內核中使用最多的鎖了,其它鎖很多都依賴自旋鎖實現。我們今天先介紹自旋鎖,後續在介紹Linux內核的其它互斥機制。從字面意義上我們可以看出,自旋鎖處於自旋的狀態,什麼是自旋狀態呢?就是原地打轉,不停的循環。下面幾點是自旋鎖的特點:
自旋鎖(spin lock)是一種死等的鎖機制。在作業系統中,為了防止資源被兩個線程同時訪問,導致數據不一致,通常需要一種鎖機制。通常有有兩種實現方式:一個是死等,另外一個是掛起當前進程,調度其他進程執行。而自旋鎖則是一種死等的機制,當前的執行thread會不斷的重新嘗試直到獲取鎖進入臨界區。只允許一個thread進入。信號量(semaphore)可以允許多個thread同時進入,而自旋鎖一次只能有一個thread獲取鎖並進入臨界區,其他的thread都是在門口不斷的嘗試。執行時間短。由於自旋鎖死等這種特性,因此它使用在那些代碼不是非常複雜的臨界區(也就是切換線程成本相對於臨界區的訪問成本很高的場景)。如果臨界區執行時間太長,那麼不斷在臨界區門口「死等」的那些thread將會耗費大量的計算資源,顯然是不合適的。可以在中斷上下文執行。由於不睡眠,因此自旋鎖可以在中斷上下文中適用。基本接口介紹
了解了自旋鎖的基本特徵,接下來我們介紹一下自旋鎖的常用接口。對於基本的使用,自旋鎖的使用很簡單,主要涉及3部分內容:
定義一個自旋鎖對臨界區加鎖使用完後解鎖定義一個自旋鎖
定義自旋鎖就好像定義一個變量。下面函數用於定義一個自旋鎖。
自旋鎖加鎖
自旋鎖加鎖就是組織其它線程對相同臨界區的訪問,使用方法也非常簡單。函數原型如下:
自旋鎖解鎖
不多廢話了,下面是函數原型:
試探加鎖
由於自旋鎖在臨界區已經加鎖的情況下會導致其它想進入臨界區的線程處於忙等狀態,這樣會消耗CPU資源。有的時候我們不想這樣,內核又提供了另外一個接口,該接口會首先判斷是否可以進入,如果可以進入則獲取鎖資源,否則返回失敗。
應用示例
我們這裡只是一個非常簡單的示例,在該示例中有2個線程,分別進行對同一個變量的運算。如果沒有保護機制,數據將被計算混亂。通過自旋鎖,使得計算可以依次有序的進行,從而保證數據的正確性。
自旋鎖的實現
自旋鎖的基本原理和使用是比較簡單的,下面我們分析一下自旋鎖的實現。任何機制的出現肯定是為了解決某些問題,我們先看一下自旋鎖是為了解決什麼問題。
大家都知道,在CPU和內存之間包含一級緩存、二級緩存和三級緩存等緩存。原因很簡單,因為CPU訪問緩存的速度更快,將將經常訪問的數據加載到緩存中,可以減少內存訪問的頻率,從而提高計算的性能。CPU、緩存和內存的關係如下圖所示(簡圖,省略了好多內容):
那麼緩存跟我們的自旋鎖又有什麼關係呢?有了緩存之後,就可能出現緩存中數據和內存中數據不一致的情況。一方面是CPU無法直接更改內存中的值,更改操作需要至少讀和寫兩條指令,從而導致改寫操作的非原子性;另一方面是因為緩存是為了提升性能,因此CPU更新了緩存內容後原則上不會馬上更新到內存,否則就失去了緩存的意義。
如2所示,在沒有任何保護機制的情況下,假設在內存中有一個變量val本來值為2,兩個處理器同時對該值進行操作,左邊CPU從內存讀取該值,並進行+1運算後該值為3。而在右側CPU從內存讀取數據,並進行+2運算後結果為4。這樣,兩個CPU更新到內存的時機不同,則內存中的結果就會不同,也就是程序每次運行產生的結果可能是不一樣的。問題的關鍵是CPU同時對內存中的數據進行了讀和運算操作,而該操作本身不具備原子性,也就是分為讀和寫兩步,這樣導致結果非預期。這種情況顯然我們是不能接受的,這種情況下就需要自旋鎖出馬了。自旋鎖的目的就是將對同一個數據的並行運算串行化,也就是你改完後我再改,從而保證結果的一致性。
老版本的實現解析
為了容易理解,我們先看一下老版本(2.6.0)的Linux內核的自旋鎖是如何實現的。我們從自旋鎖的數據結構開始,對於X86體系結構具體定義如下。
typedef struct {volatile unsigned int lock; #ifdef CONFIG_DEBUG_SPINLOCK unsigned magic; #endif} spinlock_t;
忽略掉調試功能的代碼,其實就變的很簡單了,也就是只有一個volatile unsigned int 的變量。volatile關鍵字表示該變量的改變馬上更新到內存,不進行緩存。
在介紹之前我們可以猜測一下,通過這個變量進行標識自旋鎖是否已經加鎖,如果為1則表示已經加鎖,為0則表示沒有加鎖。在沒有加鎖的情況下,線程可以獲得該鎖,然後將變量置為1。其它線程由於發現該值為1,所以只能等待。而當線程解鎖的時候,將該變量設置為0,此時其它變量就可以進行加鎖了。或者將加鎖和未加鎖的標示反過來,也就是0表示加鎖,1表示未加鎖。這個都無所謂,只是一種狀態標識。
實際上Linux內核的實現就是上面我們描述的樣子。我們通過自旋鎖的幾個函數分別看一下其具體實現。
自旋鎖初始化
下面代碼是鎖的初始化代碼(初始化為1),可以看出其實就是將結構體的變量初始化為1。
自旋鎖加鎖
接下來我們看一下自旋鎖加鎖的具體實現。外面的do-while是Linux內核宏定義的慣用手法,這裡知道就可以了。可以看到這裡一共調用了3個函數,具體每個函數的作用在代碼中介紹。
下面代碼是試圖加鎖函數(_raw_spin_trylock)的代碼。這裡面是通過內嵌彙編的方式實現的。這段彙編的大概含義是對lock->lock與0值進行交換,並將lock->lock存儲到oldval中,如果oldval為正值,就返回1,
否則返回0。這裡面lock->lock就是存儲鎖數據的變量,前面我們介紹過,其初始值(未加鎖狀態)為1。因此,如果該鎖處於為加鎖狀態時,交換後oldval中的值為1,此時會加鎖成功,函數的返回值為真。如果lock->lock中的值為0,交換後oldval中的值為0,那此時加鎖就失敗,函數的返回值為假。
加鎖失敗的情況下,spin_lock函數會進入__preempt_spin_lock函數的邏輯。粗看代碼,這裡又有一個do-while循環,這個循環其實就是自旋鎖自旋的地方。可以看到while裡面試圖對鎖進行加鎖操作,如果加鎖成功會退出循環,而如果失敗則繼續循環。
總結一下,自旋鎖的實現其實就是通過原子彙編語言實現了對自旋鎖變量的賦值操作,並且在無法加鎖的情況下自旋等待。
自旋鎖解鎖
自旋鎖解鎖的流程要稍微簡單一些。下面將相關的幾部分代碼放到一起了。具體調用過程是spin_unlock->_raw_spin_unlock->spin_unlock_string。解鎖完成後需要開啟搶佔。
新版本的實現解析
有可能存在多個線程同時爭搶自旋鎖的情況,但老版本的實現無法保證前搶的一定能得到自旋鎖。因此新版本(2.6.25以後)的實現排隊功能,也就是先到先得。我們照例先從自旋鎖的數據結構開始,對於X86體系結構具體定義如下(這裡是最終定義,前面的嵌套調用關係這裡沒有介紹,大家自行看一下)。這裡__ticketpair_t和__ticket_t就是無符號整型數,大家可以自行看一下代碼。
這裡實際上還可以理解為一個整數兩部分(或者兩個整數)。有些人可能會好奇,通過整數就可以實現排隊自旋鎖了?是的,就是這麼簡單,當然還需要一些算法,程序本身就是數據結構和算法的集合嘛!先看一下這個結構體是怎麼用的。
其實排隊自旋鎖的原理很簡單,就是判斷head和tail兩個變量的值,如果相等則為未加鎖,否則說明已經處於加鎖狀態。自旋鎖初始化將head和tail都設置為0。當有線程加鎖的時候,首先判斷head和tail是否相等,相等就將tail加1,此時加鎖成功。如果兩者不相等則表示已經有其它線程加鎖,此時只能等待。
自旋鎖加鎖
這裡面使用了一個名為cmpxchg的函數,該函數完成的功能是:將old和ptr指向的內容比較,如果相等,則將new寫入到ptr中,返回old,如果不相等,則返回ptr指向的內容。這裡請自行閱讀代碼,本文就不貼代碼了。
嘗試加鎖實現
自旋鎖解鎖實現
自旋鎖解鎖實現要簡單的多,下面是X86的實現。僅僅是將head進行加1操作。需要注意的是必須保證該操作的原子性。這裡就不多廢話了。
能力增強
除了上面介紹的基本的加鎖和解鎖的接口外,Linux內核還實現增強的功能。比如可以在中斷環境中使用的自旋鎖和下半部使用的自旋鎖等等。下面是自旋鎖所涉及的接口列表。
我們以支持中斷的自旋鎖為例進行說明。其實支持中斷的自旋鎖內部僅僅增加了禁止本地中斷的函數調用。具體為什麼加這個,大家可以自行思考一下,原因是比較清楚的,本文不再贅述。
關於自旋鎖的內容還很多,比如讀寫自旋鎖,單核自旋鎖的特殊處理等等。由於篇幅問題,本文不再贅述。後續我們在另起文章進行闡述。