Linux中的各種鎖及其基本原理


Linux中的各種鎖及其基本原理

1.概述

通過本文將了解到如下內容:

  • Linux系統的並行性特征
  • 互斥和同步機制
  • Linux中常用鎖的基本特性
  • 互斥鎖和條件變量

2.Linux的並行性特征

Linux作為典型的多用戶、多任務、搶占式內核調度的操作系統,為了提高並行處理能力,無論在內核層面還是在用戶層面都需要特殊的機制來確保任務的正確性和系統的穩定運行,就如同一個國家需要各種法律條款來約束每個公民的行為,才能有條不紊地運轉。

在內核層面涉及到各種軟硬件中斷、進線程睡眠、搶占式內核調度、多處理器SMP架構等,因此內核在完成自己工作的時候一直在處理這些資源搶占的沖突問題。

在用戶層面的進程,雖然Linux作為虛地址模式操作系統,為每個進程開辟了獨立的虛擬地址空間,偽獨占式擁有資源,但是仍然存在很多場景不得不產生多個進程共享資源的問題,來完成進程間的通信,但是在Go語言中進程間的通信使用消息來完成,處理地更優雅一些。

在線程層面,線程作為進程的一部分,進程內的多個線程只擁有自己的獨立堆棧等少量結構,大部分的資源還是過線程共享,因此多線程的資源占用沖突比進程更加明顯,所以多線程編程的線程安全問題是個重難點。綜上可知,無論在kernel還是user space都必須有一些機制來確保對於資源共享問題的解決,然后這個機制就是接下來要說的:同步和互斥。

3.同步和互斥機制

  • 基本概念

同步和互斥的概念有時候很容易混淆,可以簡單地認為同步是更加宏觀角度的一種說法,互斥是沖突解決的細節方法。所謂同步就是調度者讓任務按照約定的合理的順序進行,但是當任務之間出現資源競爭,也就是競態沖突時,使用互斥的規則強制約束允許數量的任務占用資源,從而解決各個競爭狀態,實現任務的合理運行。

同步和互斥密不可分,有資料說互斥是一種特殊的同步,對此我不太理解,不過實際中想明白細節就行,文字游戲沒有意義。

簡單來說:

  • 同步與互斥機制是用於控制多個任務對某些特定資源的訪問策略
  • 同步是控制多個任務按照一定的規則或順序訪問某些共享資源
  • 互斥是控制某些共享資源在任意時刻只能允許規定數量的任務訪問
  • 角色分類

整個協調流程涉及的角色本質上只有三類:

  • 不可獨占的共享資源
  • 多個使用者
  • 調度者

調度者需要為多個運行任務制定訪問使用規則來實現穩定運行,這個調度者可以是內核、可以是應用程序,具體場景具體分析。

  • 重要術語

要很好地理解同步和互斥,就必須得搞清楚幾個重要術語:

  • 競爭冒險(race hazard)或競態條件(race condition)

最早聽說這個術語是在模電數電的課程上,門電路出現競態條件造成錯誤的結果,在計算機里面就是多個使用者同時操作共享的變量造成結果的不確定。

  • 臨界區

臨界區域critical section是指多使用者可能同時共同操作的那部分代碼,比如自加自減操作,多個線程處理時就需要對自加自減進行保護,這段代碼就是臨界區域。

4.Linux中常用的鎖

在說鎖之前還需要知道幾個東西:信號量和條件變量。這兩個東西和鎖有一定的聯系和區別,在不同的場合單獨使用或者配合實現來說實現安全的並發,至於網上很多說互斥鎖是一種信號量的特例,對於這種特例理解不了也罷。信號量和互斥鎖的場景不一樣,信號量主要是資源數量的管理(池化),實際用的頻率遠不如互斥鎖,文字游戲着實無趣,實用主義至上,掌握高頻工具的特點正確使用即可,大可不必過於學術派。在使用鎖時需要明確幾個問題:

  • 鎖的所有權問題 誰加鎖 誰解鎖 解鈴還須系鈴人
  • 鎖的作用就是對臨界區資源的讀寫操作的安全限制
  • 鎖是否可以被多個使用者占用(互不影響的使用者對資源的占用)
  • 占用資源的加鎖者的釋放問題 (鎖持有的超時問題)
  • 等待資源的待加鎖者的等待問題(如何通知到其他等着資源的使用者)
  • 多個臨界區資源鎖的循環問題(死鎖場景)

帶着問題明確想要達到的目的,我們同樣可以根據自己的需求設計鎖,Linux現有的鎖如果從上面幾個問題的角度去理解,就非常容易了。

  • 自旋鎖spinlock

自旋鎖的主要特征是使用者在想要獲得臨界區執行權限時,如果臨界區已經被加鎖,那么自旋鎖並不會阻塞睡眠,等待系統來主動喚醒,而是原地忙輪詢資源是否被釋放加鎖,自旋就是自我旋轉,這個名字還是很形象的。自旋鎖有它的優點就是避免了系統的喚醒,自己來執行輪詢,如果在臨界區的資源代碼非常短且是原子的,那么使用起來是非常方便的,避免了各種上下文切換,開銷非常小,因此在內核的一些數據結構中自旋鎖被廣泛的使用。

  • 互斥鎖mutex

使用者使用互斥鎖時在訪問共享資源之前對進行加鎖操作,在訪問完成之后進行解鎖操作,誰加鎖誰釋放,其他使用者沒有釋放權限。 加鎖后,任何其他試圖再次加鎖的線程會被阻塞,直到當前進程解鎖。 區別於自旋鎖,互斥鎖無法獲取鎖時將阻塞睡眠,需要系統來喚醒,可以看出來自旋轉自己原地旋轉來確定鎖被釋放了,互斥鎖由系統來喚醒,但是現實並不是那么美好的,因為很多業務邏輯系統是不知道的,仍然需要業務線程執行while來輪詢是否可以重新加鎖。考慮這種情況:解鎖時有多個線程阻塞,那么所有該鎖上的線程都被編程就緒狀態, 第一個變為就緒狀態的線程又執行加鎖操作,那么其他的線程又會進入等待,對其他線程而言就是虛假喚醒。 在這種方式下,只有一個線程能夠訪問被互斥鎖保護的資源。

  • 讀寫鎖rwlock

讀寫鎖也叫共享互斥鎖:讀模式共享和寫模式互斥,本質上這種非常合理,因為在數據沒有被寫的前提下,多個使用者讀取時完全不需要加鎖的。讀寫鎖有讀加鎖狀態、寫加鎖狀態和不加鎖狀態三種狀態,當讀寫鎖在寫加鎖模式下,任何試圖對這個鎖進行加鎖的線程都會被阻塞,直到寫進程對其解鎖。

讀優先的讀寫鎖:讀寫鎖rwlock默認的也是讀優先,也就是:當讀寫鎖在讀加鎖模式先,任何線程都可以對其進行讀加鎖操作,但是所有試圖進行寫加鎖操作的線程都會被阻塞,直到所有的讀線程都解鎖,因此讀寫鎖很適合讀次數遠遠大於寫的情況。這種情況需要考慮寫飢餓問題,也就是大量的讀一直輪不到寫,因此需要設置公平的讀寫策略。在一次面試中曾經問到實現一個寫優先級的讀寫鎖,感興趣的可以想想如何實現。

  • RCU鎖

RCU鎖是讀寫鎖的擴展版本,簡單來說就是支持多讀多寫同時加鎖,多讀沒什么好說的,但是對於多寫同時加鎖,還是存在一些技術挑戰的。RCU鎖翻譯為Read Copy Update Lock,讀-拷貝-更新 鎖。Copy拷貝:寫者在訪問臨界區時,寫者將先拷貝一個臨界區副本,然后對副本進行修改;Update更新:RCU機制將在在適當時機使用一個回調函數把指向原來臨界區的指針重新指向新的被修改的臨界區,鎖機制中的垃圾收集器負責回調函數的調用。更新時機:沒有CPU再去操作這段被RCU保護的臨界區后,這段臨界區即可回收了,此時回調函數即被調用。從實現邏輯來看,RCU鎖在多個寫者之間的同步開銷還是比較大的,涉及到多份數據拷貝,回調函數等,因此這種鎖機制的使用范圍比較窄,適用於讀多寫少的情況,如網絡路由表的查詢更新、設備狀態表更新等,在業務開發中使用不是很多。

  • 可重入鎖和不可重入鎖
  • 遞歸鎖recursive mutex 可重入鎖(reentrant mutex)
  • 非遞歸鎖non-recursive mutex 不可重入鎖(non-reentrant mutex)

Windows下的Mutex和Critical Section是可遞歸的。Linux下的pthread_mutex_t鎖默認是非遞歸的。在Linux中可以顯式設置PTHREAD_MUTEX_RECURSIVE屬性,將pthread_mutex_t設為遞歸鎖避免這種場景。 同一個線程可以多次獲取同一個遞歸鎖,不會產生死鎖。而如果一個線程多次獲取同一個非遞歸鎖,則會產生死鎖。

如下代碼對於非遞歸鎖的死鎖示例:

MutexLock mutex;      
void testa()  
{  
    mutex.lock();  
    do_sth();
    mutex.unlock();  
}     
void testb()  
{  
  mutex.lock();   
  testa();  
  mutex.unlock();   
}

代碼中testb使用了mutex並且調用testa,但是testa中也調用了相同的mutext,這種場景下如果mutex是非遞歸的就會出現死鎖。

  • 條件變量condition variables

條件變量是用來等待線程而不是上鎖的,通常和互斥鎖一起使用。互斥鎖的一個明顯的特點就是某些業務場景中無法借助系統來喚醒,仍然需要業務代碼使用while來判斷,這樣效率本質上比較低。而條件變量通過允許線程阻塞和等待另一個線程發送信號來彌補互斥鎖的不足,所以互斥鎖和條件變量通常一起使用,來讓條件變量異步喚醒阻塞的線程。

條件變量和互斥鎖的典型使用就是生產者和消費者模型,這個模型非常經典,也在面試中經常被問到,示例代碼:

#include <stdio.h>
#include <pthread.h>
#define MAX 5

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t notfull = PTHREAD_COND_INITIALIZER;  //是否隊滿
pthread_cond_t notempty = PTHREAD_COND_INITIALIZER; //是否隊空
int top = 0;
int bottom = 0;

void* produce(void* arg)
{
    int i;
    for ( i = 0; i < MAX*2; i++)
    {
        pthread_mutex_lock(&mutex);
        while ((top+1)%MAX == bottom)
        {
            printf("full! producer is waiting\n");
            //等待隊不滿
            pthread_cond_wait(notfull, &mutex);
        }
        top = (top+1) % MAX;
        //發出隊非空的消息
        pthread_cond_signal(notempty);
        pthread_mutex_unlock(&mutex);
    }
    return (void*)1;
}
void* consume(void* arg)
{
    int i;
    for ( i = 0; i < MAX*2; i++)
    {
        pthread_mutex_lock(&mutex);
        while ( top%MAX == bottom)
        {
            printf("empty! consumer is waiting\n");
            //等待隊不空
            pthread_cond_wait(notempty, &mutex);
        }
        bottom = (bottom+1) % MAX;
        //發出隊不滿的消息
        pthread_cond_signal(notfull);
        pthread_mutex_unlock(&mutex);
    }
    return (void*)2;
}
int main(int argc, char *argv[])
{
    pthread_t thid1;
    pthread_t thid2;
    pthread_t thid3;
    pthread_t thid4;

    int ret1;
    int ret2;
    int ret3;
    int ret4;

    pthread_create(&thid1, NULL, produce, NULL);
    pthread_create(&thid2, NULL, consume, NULL);
    pthread_create(&thid3, NULL, produce, NULL);
    pthread_create(&thid4, NULL, consume, NULL);

    pthread_join(thid1, (void**)&ret1);
    pthread_join(thid2, (void**)&ret2);
    pthread_join(thid3, (void**)&ret3);
    pthread_join(thid4, (void**)&ret4);
    return 0;
}

其中pthread_cond_wait的使用是個需要注意的地方:pthread_cond_wait()是先將互斥鎖解開,並陷入阻塞,直到pthread_signal()發出信號后pthread_cond_wait()再加上鎖,然后退出。

5.參考資料

線程同步:遞歸鎖、非遞歸鎖 | 學步園www.xuebuyuan.com

互斥鎖、死鎖和遞歸鎖 - Bigberg - 博客園www.cnblogs.com

可遞歸鎖與非遞歸鎖-hfm_honey-ChinaUnix博客blog.chinaunix.net

https://blog.csdn.net/qq_15437629/article/details/79116590blog.csdn.net



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM