十九、線程同步

一、線程同步的目的

我們在進程部分講解了同步,互斥,臨界區等相關概念,這些概念在線程中也是一樣的。進程各自擁有自己的地址空間,進程間通訊方式如果爲共享內存,那麼就需要對進程進行同步控制。
多個線程會共享進程之間很多資源,存在數據共享性大,線程協作性高等特點,如果不對多線程進行同步控制,如果多線程共同讀取共享數據,不會對共享數據進行修改等改變操作,那還不會出現什麼問題,但是一旦多個線程對共享數據進行寫,修改操作,就會讓程序的結果變得不可控,產生不一致結果,這並不是我們想看到的,故爲了讓每次的程序結果一致,同步,必須採取一定的線程同步機制讓線程同步。

二、線程同步方式

線程同步方式和進程的很像,都是對臨界資源進行控制,處理線程互斥,同步這兩種關係。主要有四種方式:互斥量,讀寫鎖,信號量,條件變量

(一)互斥量(互斥鎖)

1. 基本概念

互斥量(也稱爲互斥鎖)類似於進程通信方式的二元信號量,互斥鎖是通過簡單加鎖的方法來控制對臨界資源的訪問,實現任意時間只有一個進程訪問臨界資源。互斥鎖只有兩種狀態,線程在進入臨界區之前,加鎖操作。線程在退出臨界區之後,解鎖操作。鎖有兩種狀態 加鎖,解鎖

【1. 使用步驟:】

  • 線程在訪問臨界資源之前,對臨界區代碼進行加鎖操作,如果鎖是加鎖狀態的,則線程執行加鎖操作將被阻塞,直到鎖解鎖可解除解鎖。即表示這塊臨界區只允許一個線程進行訪問。
  • 進行臨界區代碼執行,即對臨界資源進行操作的代碼運行。
  • 退出臨界區之後,進行解鎖操作

可以概括爲:
在這裏插入圖片描述
在這種方式下,每次只有一個線程可以訪問臨界資源。

【2. 互斥鎖的特點:】

  • 原子性: 即加鎖、解鎖操作是一個原子操作,在一個線程執行過程中不可能被其他線程打斷。
  • 唯一性: 一旦一個線程執行了加鎖操作,在其解鎖之前,沒有其他線程可以成功再次加鎖。
  • 非繁忙等待: 如果一個線程已經鎖定了一個互斥量,第二個線程又試圖去鎖定這個互斥量,則第二個線程將被阻塞掛起,不佔用任何CPU資源,直到第一個線程解除對這個互斥量的鎖定爲止,第二個線程則被喚醒並繼續執行,同時鎖定這個互斥量。
  • 一般用於解決競爭/互斥關係,即多個線程對於資源的爭奪。
  • 加鎖,解鎖在代碼中必須成對出現,即一個線程加鎖,解鎖;不能一個線程加鎖,一個線程解鎖,會容易出現死鎖。
  • 一把鎖只能用於對一個資源的互斥訪問,不能實現多個資源的多線程互斥問題。

【3. 互斥鎖函數操作:】

  • 定義互斥鎖: 一般將鎖定義到全局,方便多個線程可以對其操作。

    pthread_mutex_t  mutex;//定義名爲mutex的互斥鎖
    
  • 初始化互斥鎖:
    對定義的互斥鎖進行初始化,函數原型如下:

    # include<pthread.h>
    int pthread_mutex_init(pthread_mutex_t* mutex,const pthread_mutexattr_t* attr)
                  //成功返回0,失敗返回錯誤編號
    

    參數:
    (1)第一個參數:將定義的互斥鎖地址傳入即可。
    (2)第二個參數:一般將attr設置爲NULL,表示用默認的屬性初始化互斥量。

    初始化的鎖處於解鎖狀態

  • 加鎖:
    對互斥鎖進行加鎖獲取臨界資源的方式主要兩種:直接去加鎖,嘗試加鎖。

    • 直接加鎖:
    # include<pthread.h>
    int pthread_mutex_lock(pthread_mutex_t* mutex)
                  //成功返回0,失敗返回錯誤編號
    

    參數爲互斥鎖地址,如果要加鎖的互斥量已被上鎖,則調用者一直阻塞,直到互斥鎖解鎖後再上鎖

    • 嘗試加鎖:
    # include<pthread.h>
    int pthread_mutex_trylock(pthread_mutex_t* mutex)
                  //成功返回0,失敗返回錯誤編號
    

    參數爲互斥鎖地址,調用此函數,如果互斥鎖爲加鎖,則上鎖,返回0;若互斥鎖已加鎖,則函數加鎖失敗,返回EBUSY,線程不會阻塞。

  • 解鎖:
    一般和加鎖函數成對出現,函數原型爲:

    # include<pthread.h>
    int pthread_mutex_unlock(pthread_mutex_t* mutex)
                    //成功返回0,失敗返回錯誤編號
    

    參數爲要解鎖的互斥鎖地址。

  • 銷燬互斥鎖:
    使用完互斥鎖後,必須對其進行銷燬,釋放資源,函數運行爲:

    # include<pthread.h>
    int pthread_mutex_destroy(pthread_mutex_t* mutex)
                  //成功返回0,失敗返回錯誤編號
    

2. 實例

實現: 兩個線程,主線程負責接收用戶輸入,函數線程負責將用戶輸入打印到終端頁面。

思考:

  • 數據傳遞:線程之間需要共享數據,所以不能定義爲局部的,必須定義爲全局,堆都可以,我們使用全局數組buff來存儲數據。
  • 線程關係:主線程沒有接收到數據時,函數線程不能打印;函數線程打印時,主線程不能讀取;主線程寫入時,函數線程不能打印。
    總結出來就是:buff是臨界資源,主線程和函數線程對其進行訪問,所以在處理buff前進行加鎖操作,處理完成後解鎖。

處理:

  • 加鎖→主線程寫入數據,寫完→解鎖;
  • 加鎖→函數線程讀取數據,讀完→解鎖

那麼代碼爲:

# include<unistd.h>
# include<stdio.h>
# include<string.h>
# include<time.h>
# include<assert.h>
# include<pthread.h>

pthread_mutex_t mutex;//定義互斥鎖
char buff[128]={0};



void* fun(void* arg)
{
    while(1)
    {
        pthread_mutex_lock(&mutex);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        printf("fun:%s\n",buff);
        memset(buff,0,128);
        
        int n=rand()%3+1;
        sleep(n);

        pthread_mutex_unlock(&mutex);
        n=rand()%3+1;
        sleep(1);
    }
}
int main()
{
    srand((unsigned int)(time(NULL)*time(NULL)));
    pthread_mutex_init(&mutex,NULL);//初始化的鎖是打開狀態
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);//創建線程
    assert(res==0);

    while(1)
    {
        pthread_mutex_lock(&mutex);
        printf("input:");
        fgets(buff,127,stdin);
        pthread_mutex_unlock(&mutex);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        int n=rand()%3+1;
        sleep(1);
    }
    pthread_join(id,NULL);
    pthread_mutex_destroy(&mutex);
}

在這裏插入圖片描述
可以看到主線程獲取寫入一個,函數線程輸出一個。主線程先運行,創建函數線程後並不代表讓出CPU,直到sleep時纔會讓出CPU,這時纔會出現:函數線程和線程併發運行,搶佔CPU資源。

(二)讀寫鎖

1. 基本概念

讀寫鎖是進階的互斥鎖,它允許更高的並行性。互斥鎖有兩種狀態:加鎖,解鎖;讀寫鎖有三種狀態:

  • 讀模式下加鎖狀態
  • 寫模式下加鎖狀態
  • 不加鎖狀態。

一次只有一個線程可以佔有寫模式的讀寫鎖,但是多個線程可以同時佔有讀模式的讀寫鎖。 故讀寫鎖也叫做共享-獨佔鎖,當讀寫鎖以讀模式鎖住時,它是以共享模式鎖住的,當它以寫模式鎖住時,它是以獨佔模式鎖住的。

【1. 讀寫鎖的特點:】

  • 多個讀者可以同時讀,即當讀寫鎖是讀加鎖狀態時,所有試圖以讀模式對它進行加鎖的線程都可以得到訪問權,但如果線程希望以寫模式對鎖進行加鎖,它必須阻塞直到所有的線程釋放讀鎖,故不存在讀-寫的可能。
  • 只允許一個寫者寫,不允許寫-寫,寫-讀。即當讀寫鎖是寫加鎖狀態時,在這個鎖被解鎖之前,所有試圖對這個鎖加鎖(讀鎖或寫鎖)的線程都會被阻塞
  • 寫者優於讀者。當讀寫鎖處於讀模式鎖住狀態時,如果有另外的線程試圖以寫模式加鎖,讀寫鎖通常會阻塞隨後的讀模式鎖請求,也就是說:一旦有寫者,則後續讀者必須等待,喚醒時優先考慮寫者。
  • 讀寫鎖適合對數據結構讀的次數遠大於寫的情況下使用。

【2. 讀寫鎖函數操作:】

  1. 定義讀寫鎖
    讀寫鎖一般也定義在全局:

    pthread_rwlock_t rwlock;//定義了名爲rwlock的讀寫鎖
    
  2. 初始化
    對定義的讀寫鎖進行初始化,函數原型如下:

    # include<pthread.h>
    int pthread_rwlock_init(pthread_rwlock_t* mutex,const pthread_rwlockattr_t* attr)
                  //成功返回0,失敗返回錯誤編號
    

    參數:
    (1)第一個參數:將定義的讀寫鎖地址傳入即可。
    (2)第二個參數:一般將attr設置爲NULL,表示用默認的屬性初始化讀寫鎖。

  3. 加鎖
    有兩種加鎖的方式:加讀鎖,加寫鎖。

    • 加讀鎖:
    # include<pthread.h>
    int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);//可能會出現阻塞
    int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock);//不會阻塞,錯誤編號爲EBUSY
                  //成功返回0,失敗返回錯誤編號
    

    參數爲讀寫鎖地址,在讀模式下鎖定讀寫鎖,可以允許多個線程進行加讀鎖讀取數據。如果需要對讀鎖的數量進行限制,那麼需要檢查其返回值。

    • 加寫鎖:
    # include<pthread.h>
    int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);//可能出現阻塞
    int pthread_rwlock_trywrlock(pthread_rwlock_t* rwlock)//不會阻塞,錯誤編號爲EBUSY
                  //成功返回0,失敗返回錯誤編號
    

    參數爲讀寫鎖地址,在寫模式下鎖定讀寫鎖,只允許一個線程加鎖,其他加鎖進行阻塞。

  4. 解鎖

    # include<pthread.h>
    int pthread_rwlock_unlock(pthread_rwlock_t* rwlock)
                     //成功返回0,失敗返回錯誤編號
    
  5. 銷燬鎖

    # include<pthread.h>
    int pthread_rwlock_destroy(pthread_rwlock_t* rwlock)
                     //成功返回0,失敗返回錯誤編號
    

2. 實例

實現: 主線程對buff寫入數據,創建的2個函數線程在主線程寫入後對buff進行打印。
思路: 主線程寫入時加寫鎖,兩個函數線程讀取時加讀鎖,這樣兩個函數線程可以同時讀取,就有:

  • 主線程→寫鎖→將數據寫入buff→解鎖
  • 函數線程fun→讀鎖→輸出buff數據→解讀鎖。
  • 函數線程fun1→讀鎖→輸出buff數據→解讀鎖

注意:不要對buff進行memset清0,否則另一個函數線程讀不到了。
那麼代碼如下:

# include<unistd.h>
# include<stdio.h>
# include<string.h>
# include<time.h>
# include<assert.h>
# include<pthread.h>

pthread_rwlock_t rwlock;//創建讀寫鎖
char buff[128]={0};



void* fun(void* arg)//讀數據
{
    while(1)
    {
        pthread_rwlock_rdlock(&rwlock);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        printf("fun:%s\n",buff);
        //memset(buff,0,128);
        
        int n=rand()%3+1;
        sleep(n);

        pthread_rwlock_unlock(&rwlock);
        n=rand()%3+1;
        sleep(1);
    }
}
void* fun1(void* arg)//讀數據
{
    while(1)
    {
        pthread_rwlock_rdlock(&rwlock);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        printf("fun1:%s\n",buff);
        //memset(buff,0,128);
        
        int n=rand()%3+1;
        sleep(n);

        pthread_rwlock_unlock(&rwlock);
        n=rand()%3+1;
        sleep(1);
    }
}
int main()
{
    srand((unsigned int)(time(NULL)*time(NULL)));
    pthread_rwlock_init(&rwlock,NULL);//初始化
    pthread_t id[2];
    int res=pthread_create(&id[0],NULL,fun,NULL);//創建線程
    int r=pthread_create(&id[1],NULL,fun1,NULL);
    assert(res==0);

    while(1)//寫數據
    {
        pthread_rwlock_wrlock(&rwlock);
        printf("input:");
        fgets(buff,127,stdin);
        pthread_rwlock_unlock(&rwlock);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        int n=rand()%3+1;
        sleep(1);
    }
    
    pthread_join(id[0],NULL);
    pthread_join(id[1],NULL);
    pthread_rwlock_destroy(&rwlock);
}

在這裏插入圖片描述
可以看到fun1,fun加了讀鎖,可以同時讀取數據,主線程加了寫鎖,只能一個線程寫數據。

(三)信號量

1. 基本概念

在進程間通訊時我們講過信號量,它可以保護臨界資源,實現進程同步。線程的信號量和進程的信號量類似,但是注意:

  • 進程對信號量P、V等操作需要我們用函數封裝,線程則是提供了一整套的方法,不需要我們去封裝P、V等操作,故線程信號量操作比進程信號量操作簡單
  • 線程信號量對於全局共享,但進程每個都有自己的地址空間,不能實現全局信號量進行進程間同步,故進程的信號量可以在線程中使用,但線程的不可以在進程間使用

線程的信號量也是一個計數器,記錄臨界資源的個數,取值爲正整數,用信號量達到線程間的同步。

【1. 信號量的特點:】

  • 信號量一般用於解決線程同步問題,即協調線程對於臨界資源的關係,就像一個紅綠燈一樣,但不代表不能解決互斥問題。
  • 當信號量的初始值爲1時,表示只有一個臨界資源,可以看成互斥鎖
  • 信號量一般由一個線程釋放,另一個線程獲取,保證線程同步,這一點和鎖有很大的區別。

【2. 信號量函數操作:】

  1. 定義信號量
    定義爲全局,多個線程可以共享

    # include<semaphore.h>
    sem_t sem1;//定義一個信號量
    
  2. 初始化、銷燬信號量

    # include<semaphore.h>
    int sem_init(sem_t* sem,int shared,int val);//初始化
    int sem_destroy(sem_t* sem);//銷燬
                       //成功返回0,出錯返回錯誤編號
    

    初始化函數的參數
    (1)sem:信號量指針。
    (2)shared:是否在進程間共享標識,0爲不共享,1爲共享。Linux不支持,所以爲0。
    (3)val:信號量初始值,爲正整數。

  3. P操作
    獲取資源,信號量值減一,函數原型爲:

    # include <semaphore.h>
    int sem_wait(sem_t* sem);
             //成功返回0,出錯返回錯誤編號
    

    如果信號量爲0,那麼P操作會阻塞,只有信號量被釋放,阻塞纔會解除

  4. V操作
    釋放資源,信號量值加一,函數原型爲:

    # include <semaphore.h>
    int sem_post(sem_t* sem);
              //成功返回0,出錯返回錯誤編號
    

    V操作永遠不會阻塞。

2. 實例

題目和互斥鎖一樣,我們嘗試用信號量來解決,經過上面的分析,我們不難發現這是一個:兩個線程對於buff臨界資源的協作關係,即主線程寫了,函數線程讀,函數線程讀了,主線程寫;並不是競爭關係。
那麼我們就設定兩個信號量分別來控制主線程和函數線程,讓它們達到一種協作關係:sem1控制函數線程,開始運行時函數線程阻塞等待主線程寫數據,所以初值爲0;sem2控制主線程,運行程序就開始對buff寫數據,所以初值爲1。那麼兩個線程的信號量操作如下:

  • P(sem2),此時sem2=0 → 主線程運行向buff中寫數據 → 寫完數據,V(sem1),釋放信號,此時sem1=1,sem2=0,可以讀,不可以寫。 即兩個線程併發運行,只有函數線程可以運行讀取數據,因爲可以進行P操作,主線程不能運行,在P(sem2)操作時就會阻塞。
  • P(sem1),此時sem1=0 → 函數線程運行讀取數據 → 讀取完畢,V(sem2),釋放信號,此時sem1=0,sem2=1,可以寫,不可以讀。 即兩個線程併發運行,只有主線程可以運行寫入數據,因爲可以進行P操作,函數線程不能運行,在P(sem1)操作時就會阻塞。

代碼如下:

# include<unistd.h>
# include<stdio.h>
# include<string.h>
# include<time.h>
# include<assert.h>
# include<pthread.h>
# include<semaphore.h>
sem_t sem1;
sem_t sem2;
char buff[128]={0};



void* fun(void* arg)
{
    while(1)
    {
        sem_wait(&sem1);//P
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        printf("fun:%s\n",buff);
        memset(buff,0,128);
        
        int n=rand()%3+1;
        sleep(n);

        sem_post(&sem2);//V
        n=rand()%3+1;
        sleep(1);
    }
}
int main()
{
    srand((unsigned int)(time(NULL)*time(NULL)));
    sem_init(&sem1,0,0);
    sem_init(&sem2,0,1);
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);

    while(1)
    {
        sem_wait(&sem2);//P
        printf("input:");
        fgets(buff,127,stdin);
        sem_post(&sem1);//V
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        int n=rand()%3+1;
        sleep(1);
    }
    pthread_join(id,NULL);
    sem_destroy(&sem1);
    sem_destroy(&sem2);    
}

在這裏插入圖片描述
可以看到實現了用信號量達到了線程間同步。

(四)條件變量

1. 基本概念

條件變量提供了一種線程間的通知機制,給多個線程提供了一個回合的場所,條件變量自動阻塞一個進程,直到相應的某種特殊情況發生爲止,會喚醒等待這個條件的線程。這種方式提高了進程同步的合理性和高效性

條件本身是由互斥鎖保護的,和互斥鎖配合使用,線程在改變條件狀態之前必須鎖住互斥鎖,如果一個條件,則一個線程自動阻塞,並釋放等待狀態改變的互斥鎖。如果條件符合線程等待的條件,則給狀態加鎖喚起線程,之後解鎖

可以理解爲:一個線程修改條件,另一個線程等待條件,一旦等到自己需要的條件,就去運行。

【1. 條件變量的特點:】

  • 需要和互斥鎖一起使用,纔可以起到線程間同步的作用。
  • 允許線程以無競爭方式等待特定的條件發生,即條件改變這個信號會發送到所有等待這個條件發生的線程,不是說一個線程接收到這個消息其他線程就接收不到了。

【2. 條件變量函數操作:】

  1. 定義條件變量
    定義爲全局,多個線程可以共享

    # include<pthread.h>
    pthread_cond_t cond ;//定義一個條件變量
    
  2. 初始化、銷燬條件變量

    # include<pthread.h>
    int pthread_cond_init(pthread_cond_t* cond,pthread_condattr_t* attr)//初始化
     int pthread_cond_destroy(pthread_cond_t* cond)//銷燬
                       //成功返回0,出錯返回錯誤編號
    

    attr設置爲NULL。

  3. 等待條件爲真函數
    函數原型爲:

    # include<pthread.h>
    int pthread_cond_wait(pthread_cond_t*   cond,pthread_mutex_t* mutex);
           //成功返回0,失敗返回錯誤編號
    

    傳遞給此函數的互斥量對條件進行保護,調用者把鎖住的互斥量傳給函數,函數調用線程放到等待條件的線程列表上,然後對互斥量解鎖,這兩步是原子操作。等待函數返回時,互斥量再次被鎖住

    所以使用此函數的代碼爲:

    pthread_mutex_lock(&mutex);
    pthread_cond_wait(&cond,&mutex);//自動阻塞,直到條件發生
    pthread_mutex_unlock(&mutex);
    

    可以用一張圖來描述一下:
    在這裏插入圖片描述

  4. 條件成立喚醒線程

    # include<pthread.h>
    int pthread_cond_signal(pthread_cond_t* cond);//喚醒等待該條件的某個線程
    int pthread_cond_broadcast(pthread_cond_t* cond);//喚醒等待該條件的所有進程
        //成功返回0,失敗返回錯誤編號
    

    使用時也應該先加鎖,再喚醒,再解鎖。

2. 實例

實現: 主線程給buff中寫入數據,其他兩個函數線程等待buff中有數據時纔可以進行數據打印,一旦輸入end結束符,則線程結束。
分析:

  • 創建線程,通過傳參,創建出兩個線程。
  • 函數線程先pthread_cond_wait()阻塞,等待buff數據寫入後,主線程喚醒。
  • 主線程向buff中寫入數據後,根據寫入的數據進行線程喚醒,如果不是結束符,則喚起任意一個等待的線程輸出數據,如果是結束符end,則喚起所有等待的線程,所有線程break結束了線程。

那麼就可以寫出代碼:

# include<unistd.h>
# include<stdio.h>
# include<string.h>
# include<time.h>
# include<assert.h>
# include<pthread.h>

pthread_cond_t cond;//條件變量
pthread_mutex_t mutex;//互斥鎖
char buff[128]={0};



void* fun(void* arg)
{
    char* s=(char*)arg;
    while(1)
    {
        pthread_mutex_lock(&mutex);//加鎖
        pthread_cond_wait(&cond,&mutex);//阻塞,等待有條件發生
        pthread_mutex_unlock(&mutex);//解鎖
        printf("%s:%s",s,buff);//
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
    }
}
int main()
{
    pthread_mutex_init(&mutex,NULL);
    pthread_cond_init(&cond,NULL);

    pthread_t id[2];
    int res=pthread_create(&id[0],NULL,fun,"thread1");//通過傳參數thread1創建thread1線程
    assert(res==0);
    res=pthread_create(&id[1],NULL,fun,"thread2");
    assert(res==0);

    while(1)
    {
        printf("input:");
        fgets(buff,127,stdin);
        if(strncmp(buff,"end",3)==0)//喚醒在條件變量cond上等待的所有線程
        {
            pthread_mutex_lock(&mutex);
            pthread_cond_broadcast(&cond);
            pthread_mutex_unlock(&mutex);
            break;
        }
        else//喚醒滿足條件的一個線程
        {
            pthread_mutex_lock(&mutex);
            pthread_cond_signal(&cond);
            pthread_mutex_unlock(&mutex);
        }
        sleep(1);
    }
    pthread_join(id[0],NULL);
    pthread_join(id[1],NULL);

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
}

在這裏插入圖片描述
可以看到如果輸入數據,會喚起任意一個線程,進行數據的輸出;如果輸入end,會喚起所有線程。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章