執行緒同步 7:自旋鎖

在前篇介紹了互斥鎖之後,本篇要來介紹一個與其非常雷同但又有點不同的鎖:自旋鎖(Spinlock)。

在前篇的範例情境中,我們的程式碼遭遇了一個比較複雜的情況, 於是引入了使用彈性強大的互斥子來解決執行緒之間的衝突保護問題。 然而互斥子方便歸方便,但是使用的代價卻有點兒巨大, 主要是因為當執行緒之間共同競爭的時候會導致進入休眠, 而執行緒的休眠與喚醒會產生相當可觀的時間延遲, 如果正好碰上頻繁且短暫的加鎖與解鎖的使用情況,那就將會帶遠遠超出真正需要的休眠延遲, 導致性能衰減過多。 而對與此種情況的最佳解決方案,可能就是本篇將要介紹的「自旋鎖」(Spinlock)。

如果上面的說明顯得過於抽象,那就讓我們重新回顧前篇的那個 數值累加的範例, 那就是一個典型的適合自旋鎖的使用場景。

for(int i = 0; i < n; ++i)
{
    mtx_lock(&data->mutex);
    data->value += x;
    mtx_unlock(&data->mutex);
}

在上面的數值累加核心內容裡,我們使用互斥子來保護可能會導致多緒衝突的累加程式碼敘述, 然而這卻帶來了一個可能不必要的過多等待的問題。 比方說如果此時有兩個執行緒正好一起執行了上鎖互斥子的呼叫, 當然我們知道只會有其中一條執行緒得以立刻通行而另一條執行緒得等待。 只不過看看臨界區段的內容,就是簡單的加法、賦值,頂多加上結構與指標的索引工作, 換算成 CPU 指令可能也就只有沒幾條命令,實際上可能只需要幾個 CPU 指令週期的時間就做完了, 然後執行緒就呼叫解鎖互斥子歸還通行牌,拂袖揚長而去。

就這樣,前一個執行緒飛快的做完了臨界區段的程式碼, 於是緊接著馬上輪到第二條執行緒進入臨界區段接著執行是不是? 然而實際上並不是這麼簡單,因為那第二條執行緒已經進入休眠等待的狀態了! 即便從當時搶通行牌搶輸了的時間開始計算,到贏家率先做完工作解鎖互斥子可能只花了 1 奈秒的時間, 即便只花了 1 奈秒互斥子就告知你可以醒過來繼續通行, 然而從休眠狀態要醒過來的執行緒還得在慢慢等待作業系統排程排到它, 等執行緒真正回復執行狀態並接著進入臨界區段的時候,可能好幾個毫秒已經過去了。 區區幾個毫秒也許並不會讓人感受到什麼變慢的感覺, 但是再加上這個範例剛剛好又是個非常頻繁進出臨界區段的應用場合,於是這個延遲就在不斷反覆當中被累積放大, 最終成為巨大的性能落差。 (在我的電腦上,使用原子變數的累加程式總共消耗的執行時間為 5.592 秒, 而使用互斥子的版本則為 31.544 秒。)

那麼如果我們改寫一下關於加鎖和解鎖的部份, 不使用原本的互斥子而改用我們自己實現的互斥子 mylock_t, 以及相關操作函式 mylock_lock()mylock_unlock() 來取代它:

for(int i = 0; i < n; ++i)
{
    mylock_lock(&data->mutex);
    data->value += x;
    mylock_unlock(&data->mutex);
}

其中我們自製版本的互斥子以及幾個關鍵的操作函式之實現內容如下:

#include <stdatomic.h>

typedef atomic_flag mylock_t;

void mylock_init(mylock_t *mutex)
{
    atomic_flag_clear(mutex);
}

void mylock_lock(mylock_t *mutex)
{
    while(atomic_flag_test_and_set(mutex))
    {
        // 沒事做,空耗著!
    }
}

void mylock_unlock(mylock_t *mutex)
{
    atomic_flag_clear(mutex);
}

就這樣實現了一個自製版本的互斥子。 其中最關鍵的內容其實就在於那個 mylock_lock() 函式, 其內容就是透過值交換的操作不斷反覆檢查一個原子變數的值是否為零, 如果為零則表示我是第一個拿到通行牌的幸運者,不為零則表示通行牌已被取走並且尚未歸還; 而解鎖,也就是歸還通行牌的操作就只是簡單的將變數重設為零。 乍看之下那個類似無限迴圈的程式碼會讓 CPU 不斷循環反覆做白工, 理論上會導致資源管理器上面的 CPU 使用率飆高,風扇狂轉,空耗 CPU 運算力; 然而因為臨界區段的程式碼相當精簡小巧以至於飛快就做完了, 因此實際上這個形似無限迴圈的等待其實可能只轉沒有幾圈就得到了通行牌。 最後的結果就是實際上空耗等待的時間相當短暫,而又避免掉了執行緒進入休眠再喚醒的過程, 從而使得它的響應速度非常快速即時。

因為這種鎖的重要實現核心在於那個看似空耗 CPU 性能的迴圈, 其如同芭蕾舞者在那高速旋轉卻實際沒有移動半步的運作特性, 於是這種鎖就被命名為「自旋鎖」(Spinlock)。 不過除了在某些老舊或比較不尋常的開發環境下可能會需要我們自己實現這種鎖之外, 現實中其實各大執行緒相關函式庫通常已經提供了這種自旋鎖可以讓我們直接使用(不過 C11 沒有), 因此本篇的範例並沒有使用上面那樣實現的自旋鎖, 而是呼叫了 pthread 程式庫所提供的自旋鎖(pthread_spinlock_t):

for(int i = 0; i < n; ++i)
{
    pthread_spin_lock(&data->lock);
    data->value += x;
    pthread_spin_unlock(&data->lock);
}

範例完整程式碼可從這裡下載, 其在我電腦上的執行結果如下:

Finished, value=800000000/800000000, spend=20546

對比不同保護方案所得到的結果, 採用原子變數的方案消耗時間為 5.592 秒, 採用互斥子的方案為 31.544 秒, 而採用自旋鎖的方案則為 20.546 秒。 從測試結果可見自旋鎖雖然相比原子變數也是相當大的劣勢, 然而與常用的互斥子相比卻是提升了相當可觀的效能。

Note
不過其實單單就這個範例來看,顯然最佳的解決方案是使用原子變數而非自旋鎖。 也就是說,在能夠使用少數原子變數來解決的問題上,例如單個變數的簡單操作計算來說, 使用原子變數通常會是最佳的作法; 而當需要保護的衝突操作較多較複雜而難以使用單純的原子變數來覆蓋的時候,則使用互斥子; 而當臨界區間程式碼相當簡單簡短且未涉及系統呼叫或等待, 但是又足夠複雜到無法使用原子變數來覆蓋的情況下, 則自旋鎖就可能是相當合適的採行方案。 依此原則,其實同樣在前篇的那個使用了鏈結串列的生產者與消費者程式, 才是在實務上比本篇範例更加適合應用自旋鎖的場合; 然而因為效能計算與比較上的方便,本篇還是採用了數值累加的程式碼來做自旋鎖的性能測試。

適合與不適合

自旋鎖看起來這麼美好,然而那是因為本篇所示例的應用場景恰好適合自旋鎖的緣故。 而自旋鎖其實在使用上相當考驗「因地制宜」的程式設計, 一旦使用在不合適的地方則可能導致性能不升反減,順便夾帶 CPU 功耗與溫度飆升的副作用! 因此了解自旋鎖的內部邏輯,並且妥善設計在適合的地方使用自旋鎖,就成為相當重要的程式設計功課。

自旋鎖最核心的運作邏輯就是那個空耗 CPU 效能的迴圈, 在合適的使用場景之下會因為其即時的響應速度而得到相當優秀的性能提升, 並且其空耗的劣勢也會因為簡潔精煉的臨界區段導致實際上空耗的時間同樣非常少,劣勢並不顯著, 而這也就是最適合使用自旋鎖來取代互斥子的最佳使用場景。

然而一旦臨界區段內的程式複雜冗長起來,甚至於可能呼叫了系統資源配置或進入休眠等待等情況, 則自旋鎖的使用就變成優勢不顯而劣勢凸出了! 如果臨界區段需要花費可觀的運算時間,甚至於可能會進入休眠等待狀態, 那麼人家簡單休眠個幾毫秒或幾十、幾百毫秒也許並不會消耗什麼系統資源; 然而你的自旋鎖也要在那邊空轉消耗上百毫秒的話,那影響可就太大了! 也許你會說頂多了不起就是 CPU 使用率飆升,風扇狂轉罷了,電腦扛得住的,至少自旋鎖的反應快啊! 然現實卻不然,先別說當臨界區段的時間跨度來到上百毫秒甚至更多的時候那曲曲幾個毫秒的時差算得上什麼事了, 就光因為自旋鎖空轉佔掉了 CPU 的計算力,可能就導致排擠了其它執行緒的執行機會, 最終反而變成溫度飆高、風扇狂轉,但程式整體卻可能反應更加遲鈍且效率更加低下的結果!

除此之外還有一個同樣不適合使用自旋鎖的環境,那就是單核 CPU 的運算環境下。 話說當代新買的電腦大概 CPU 沒有個 4 核心都會被人笑掉大牙了,根本也買不到那種單核 CPU, 然而其實出了 PC 範圍的話,在某些較為低端的設備上面其實還挺常見單核心 CPU 的。 回顧自旋鎖的原理,自旋鎖之所以能夠發揮反應迅速的能力,其實根本基礎在於多核心的平行運算。 就是因為存在真實同步進行的平行執行緒,才產生了可能自旋鎖才自旋幾圈就等到其它執行緒釋放鎖的機會。 然而當程式運作在單核心 CPU 環境下時,若非等到排程器切換使另一個執行緒得到執行的機會, 這個自旋鎖無論怎樣自旋空耗 CPU,都不可能等到鎖被釋放的。 於是自旋鎖只能夠一直空轉一直空轉,直到自己的時間片耗完並被排程器給切換出去為止。 因此若真的需要將程式放在單核心 CPU 環境下執行的話,還是老老實實使用互斥鎖吧!

總結

本篇介紹了一種用途極其類似於互斥子的自旋鎖, 在合適的場合下取代互斥子的使用時,也就是臨界區段簡潔且執行快速時, 可以得到相當可觀的性能提升效果; 然而當使用在並不適合的場合下,也就是臨界區段耗時較多或甚至需要進行系統資源配置或等待時, 那麼自旋鎖的使用將會導致 CPU 效能的無端消耗,並可能使程式效能更加劣化。 此外在非多核心或多 CPU 的執行平臺,也就是單 CPU 單核心的執行環境下, 自旋鎖的使用沒有意義,應該改回使用互斥子。

上一篇:「執行緒同步 6:互斥子」
下一篇:「執行緒同步 8:讀寫鎖」