線程同步的幾種方式


1、前言

幾年的編程生涯中,線程的使用可以說是非常常見的,從工作第一年把GUI和后台工作放在同一個線程中導致界面卡死(想想以前還裝專業地給生產的同事寫SOP,讓他們在操作的時候別點擊界面,真可笑),到現在能隨隨便便就能封裝一個簡易的線程池,這中間這么些年卻從來沒有系統地整理過線程的一些重要的知識點,今天翻了翻舊書,想起來,順便整理一下線程間同步的幾種方式。

關於線程的一些基礎知識和使用方式,就不多說了,直接上正文。

以下內容,細節和代碼處參考 后台開發核心技術與應用實踐 一書

2、線程同步

我們都知道,在不同的線程針對同一個資源的時候,如果只是單純的讀取,不需要加以同步處理,而如果修改,不進行處理的話,會導致資源的沖突。

因為在並發的情況下,指令執行的先后順序由內核決定。同一個線程的內部,指令按照先后順序執行,但不同線程之間的指令很難說清楚哪一個會先執行。如果運行的結果依賴於不同線程執行的先后的話,那么就會造成競爭條件,在這樣的狀況下,計算機的結果很難預知,所以應該盡量避免競爭條件的形成。最常見的解決競爭條件的方法是將原先分離的兩個指令構成不可分割的一個原子操作,而其他任務不能插入到原子操作中。

對於多線程的程序來說,同步指的是在一定的時間內只允許某一個線程訪問某個資源,具體可以使用一下四種方式實現:

  1. 互斥鎖(mutex)
  2. 條件變量(condition)
  3. 讀寫鎖(reader-writer lock)
  4. 信號量(semphore)

以下代碼使用 pthread庫 示例,不同的庫有不同的實現和使用方式,該篇只做演示,不做詳細討論

以下面代碼示例,這是一個會造成資源競爭的代碼,運行會出現不可預知的錯誤

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int gTotalTickerNum = 20;

void *sellTicket(void *arg) {
  for (int i = 0; i < 20; i++) {
    if (gTotalTickerNum > 0) {
      sleep(1);
      printf("sell the %dth ticket\n", 20 - gTotalTickerNum + 1);
      gTotalTickerNum--;
    }
  }
  return nullptr;
}

int main() {
  pthread_t pthread[4];
  for (int index = 0; index < 4; index++) {
    int res = pthread_create(&pthread[index], nullptr, &sellTicket, nullptr);
    if (res) {
      printf("pthread create error, res=%d\n", res);
      return res;
    }
  }
  sleep(20);
  void *retval;
  for (int index = 0; index < 4; index++) {
    int res = pthread_join(pthread[index], &retval);
    if (res) {
      printf("tif=%d join error, res = %d\n", index, res);
      return res;
    }
    printf("retval=%d\n", retval);
  }
  return 0;
}

2.1、互斥鎖

互斥鎖 是最常見的線程同步方式,它是一種特殊的變量,它有 lockunlock 兩種狀態,一旦獲取,就會上鎖,且只能由該線程解鎖,期間,其他線程無法獲取

以上代碼使用互斥鎖可以修改如下:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int gTotalTickerNum = 20;

void *sellTicket(void *arg) {
  for (int i = 0; i < 20; i++) {
    pthread_mutex_lock(&mutex);
    if (gTotalTickerNum > 0) {
      sleep(1);
      printf("sell the %dth ticket\n", 20 - gTotalTickerNum + 1);
      gTotalTickerNum--;
    }
    pthread_mutex_unlock(&mutex);
  }
  return nullptr;
}

如上,在使用同一個資源前加鎖,使用后解鎖,即可實現線程同步,需要注意的是,如果加鎖后不解鎖,會造成死鎖

優點:

  1. 使用簡單;

缺點:

  1. 重復鎖定和解鎖,每次都會檢查共享數據結構,浪費時間和資源;
  2. 繁忙查詢的效率非常低;

2.2、條件變量

針對互斥鎖浪費資源且效率低的缺點,可以使用條件變量。

條件變量的方法是,當線程在等待某些滿足條件時使線程進入睡眠狀態,一旦條件滿足,就喚醒,這樣不會占用寶貴的互斥對象鎖,實現高效

條件變量允許線程阻塞並等待另一個線程發送信號,一般和互斥鎖一起使用。

條件變量被用來阻塞一個線程,當條件不滿足時,線程會解開互斥鎖,並等待條件發生變化。一旦其他線程改變了條件變量,將通知相應的阻塞線程,這些線程重新鎖定互斥鎖,然后執行后續代碼,最后再解開互斥鎖。

代碼如下:

#include <iostream>
#include <unistd.h>

using namespace std;

pthread_cond_t qready = PTHREAD_COND_INITIALIZER;//初始構造條件變量
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;//初始構造鎖

int x = 10, y = 20;

void *func1(void *arg) {
  cout << "func1" << endl;
  pthread_mutex_lock(&qlock);
  while (x < y) {
    pthread_cond_wait(&qready, &qlock);
  }
  pthread_mutex_unlock(&qlock);
  cout << "func1 end" << endl;
}

void *func2(void *arg) {
  cout << "func2" << endl;
  pthread_mutex_lock(&qlock);
  x = 20, y = 10;
  cout << "x & y changed" << endl;
  pthread_mutex_unlock(&qlock);
  if (x > y) {
    pthread_cond_signal(&qready);
  }
  cout << "func2 end" << endl;
}

int main() {
  pthread_t tid1, tid2;
  int res = pthread_create(&tid1, nullptr, func1, nullptr);
  if (res) {
    cout << "pthread 1 create error" << endl;
    return res;
  }
  sleep(2);
  res = pthread_create(&tid2, nullptr, func2, nullptr);
  if (res) {
    cout << "pthread 2 create error" << endl;
    return res;
  }
  sleep(10);
  return 0;
}

如上,條件變量需要和互斥鎖一起使用

代碼中,如果沒有條件變量,則需要等 func1() 執行完成之后,fun2() 才能獲取互斥鎖,繼續執行。

而使用了條件變量之后,在 fun1 中的 pthread_cond_wait(&qready, &qlock); 代碼,會先解開互斥鎖,讓其他線程能夠得到這個互斥鎖。在 fun2 中完成條件后執行代碼 pthread_cond_signal(&qready); 后激活等待的線程,此時若有多個線程,則會按照入隊的順序激活一個,當然也可以使用 pthread_cond_broadcast() 方法激活所有等待線程

pthread_cond_signal 函數的作用是發送一個信號給另外一個正在處於阻塞等待狀態的線程,使其脫離阻塞狀態,繼續執行

pthread_cond_signal 不會有 驚群現象,它最多給一個線程發送信號。當有多個線程阻塞時,會根據優先級高低和入隊順序決定取消阻塞線程

2.3、讀寫鎖

讀寫鎖 也稱之為 共享-獨占鎖,一般用在讀和寫的次數有很大不同的場合。即對某些資源的訪問會出現兩種情況,一種是訪問的排他性,需要獨占,稱之為寫操作;還有就是訪問可以共享,稱之為讀操作。

讀寫所 相比於不管三七二十一,通通獨占的模式,有着很大的適用性和並行性。其有以下幾種狀態:

  1. 讀寫鎖處於寫鎖定的狀態,則在解鎖之前,所有試圖加鎖的線程都會阻塞;
  2. 讀寫鎖處於讀鎖定的狀態,則所有試圖以讀模式加鎖的線程都可得到訪問權,但是以寫模式加鎖的線程則會阻塞;
  3. 讀寫鎖處於讀模式的鎖(未加鎖)狀態時,有另外的線程試圖以寫模式加鎖,則讀寫鎖會阻塞讀模式加鎖的請求,這樣避免了讀模式鎖長期占用,導致的寫模式鎖長期阻塞的情況;

適用場景:

讀寫鎖最適用於對數據結構的毒操作次數多於寫操作次數的場合。

處理這種問題一般有兩種常見的策略:

  1. 強讀者同步

    總是給讀者更高的優先權,只要沒有寫操作,讀者就可以獲取訪問權,比如圖書館查詢系統采用強讀者同步策略;

  2. 強寫者同步

    寫者有更高的優先級,讀者只能等到寫者結束之后才能執行,比如航班訂票系統,要求看到最新的信息記錄,會使用強寫者同步策略;

2.4、信號量

信號量 和互斥鎖的區別在於:互斥鎖只允許一個線程進入臨界區,信號量允許多個線程同時進入臨界區

可以這樣理解,互斥鎖使用對同一個資源的互斥的方式達到線程同步的目的,信號量可以同步多個資源以達到線程同步

代碼示例:

以下代碼模擬某個營業廳兩個窗口處理業務的場景,有10個客戶進入營業廳,當發現窗口已滿,則等待,當有可用的窗口時,就接受服務

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <semaphore.h>

#define CUSTOMER_NUM 10

sem_t sem;

void *get_server(void *thread_id) {
    int cusotmer_id = *((int *) thread_id);
    if (sem_wait(&sem) == 0) {
        printf("customer %d receive server ...\n", cusotmer_id);
        sem_post(&sem);
    }
}

int main() {
    sem_init(&sem, 0, 2);
    pthread_t customers[CUSTOMER_NUM];
    int customer_id[CUSTOMER_NUM];//用戶id

    for (int index = 0; index < CUSTOMER_NUM; index++) {
        customer_id[index] = index;
        printf("customer %d arrived\n", customer_id[index]);
        int res = pthread_create(&customers[index], nullptr, get_server, &customer_id[index]);
        if (res) {
            printf("create thread error\n");
            return res;
        }
    }
    for (int index = 0; index < CUSTOMER_NUM; index++) {
        pthread_join(customers[index], nullptr);
    }
    sem_destroy(&sem);

    return 0;
}

執行結果

customer 0 arrived
customer 1 arrived
customer 2 arrived
customer 1 receive server ...
customer 2 receive server ...
customer 3 arrived
customer 4 arrived
customer 0 receive server ...
customer 3 receive server ...
customer 4 receive server ...
customer 5 arrived
customer 6 arrived
customer 5 receive server ...
customer 6 receive server ...
customer 7 arrived
customer 8 arrived
customer 9 arrived
customer 8 receive server ...
customer 7 receive server ...
customer 9 receive server ...

if (sem_wait(&sem) == 0) 表示當前信號量大於0,即有空閑的窗口,可以為該顧客服務,並將信號量-1,服務完成 sem_post 方法把信號量+1,以便繼續服務

void *get_server(void *thread_id) {
  int cusotmer_id = *((int *) thread_id);
  if (sem_wait(&sem) == 0) {
    usleep(100);
    printf("customer %d receive server ...\n", cusotmer_id);
    sem_post(&sem);
  }
}

3、總結

幾種線程同步的方式各有利弊,實際開發中需要根據場景選擇不同的方式使用開發


免責聲明!

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



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