CSAPP 併發程式設計讀書筆記

Zijian/TENG發表於2021-12-20

CSAPP 併發程式設計筆記

併發和並行

  • 併發:Concurrency,只要時間上重疊就算併發,可以是單處理器交替處理
  • 並行:Parallel,屬於併發的一種特殊情況(真子集),多核/多 CPU 同時處理

構造併發程式的方法

現代作業系統提供了 3 種基本的構造併發程式的方法:

  • 程式:每個邏輯控制流都是一個程式,由核心排程和維護。
  • I/O 多路複用 :在一個程式上下文中顯式地排程他們自己的邏輯流。邏輯流被模型化為狀態機
  • 執行緒:執行在單一程式上下文中的邏輯流,由核心進行排程。可以看作上面兩種方式的混合體(核心排程,但共享同一虛擬地址空間)。

12.1 基於程式的併發程式設計

示例程式碼

void sigchld_handler(int sig)
{
    while(waitpid(-1, 0, WNOHANG) > 0) 
        ;
}

int main()
{
    signal(SIGCHLD, sigchld_handler); // 回收僵死子程式資源
    listenfd = open_listenfd();
    while (1) {
      connfd = accept(...);
      if (fork() == 0) { // 子程式
        close(listenfd); // 關閉父程式 fd,不關閉問題不大,子程式結束時自動關閉
        process(connfd);
        close(connfd);
        exit(0);
      }
      close(connfd); // 父程式關閉 fd。重要!否則永遠不會釋放 connfd 連線描述符,導致記憶體洩漏!
    }
}

父、子程式中的已連線描述符都指向同一個檔案表表項,所以父程式關閉 connfd 至關重要。否則,永遠不會釋放已連線描述符 4 connfd 的檔案表條目,引起記憶體洩漏。因為 socket 檔案表表項中的引用計數,直到父子程式 connfd 都關閉了,到客戶端的連線才會終止。

程式併發優缺點

共享檔案表,但不共享地址空間(是優點,也是缺點)。不方便共享資料,只能通過顯式 IPC。程式控制和 IPC 開銷大。

12.2 基於 I/O 多路複用的併發程式設計

背景知識

通過 select 函式,等待一組描述符 ready。

#include <sys/select.h>
int select(int n, fd_set *fdset, NULL, NULL, NULL); // 返回 ready fd 的個數,出錯返回 -1

FD_ZERO(fd_set *fdset);
FD_CLR(int fd, fd_set *fdset);
FD_SET(int fd, fd_set *fdset);
FD_ISSET(int fd, fd_set *fdset);

select 阻塞,直到至少一個 fd ready(即讀取一個位元組不阻塞)

示例程式碼

注意:select 有副作用,會修改入參 fdset 的內容!

fd_set read_set;
FD_ZERO(&read_set);
FD_SET(STDIN_FILENO, &read_set);
FD_SET(listenfd, &read_set);

while(1) {
  fd_set ready_set = read_set; // 因為 select 會修改入參 read_set 的內容,每次都重新從 read_set 拷貝!
  select(listenfd+1, &ready_set, NULL, NULL, NULL);
  if(FD_ISSET(STDIN_FILENO, &ready_set)
     // ...
  if(FD_ISSET(listenfd, &ready_set)
     // ...
}

I/O 多路複用優缺點

  • 單一程式上下文,共享資料容易。

  • 事件驅動,不需要上下文切換,高效,有明顯的效能優勢。

  • 編碼複雜

  • 不能充分利用多核處理器

因為明顯的效能優勢,現代高效能伺服器如 Node.js、nginx 和 Tornado 都是基於 I/O 多路複用的事件驅動

12.3 基於執行緒的併發程式設計

背景知識

# include <pthread.h>
typedef void *(func)(void *);

// 建立執行緒
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
// 返回撥用者執行緒 ID
pthread_t pthread_self();
// 顯示終止執行緒(threadFunc 返回即隱式終止),如果主執行緒呼叫,則等待所有其他對等執行緒終止,再終止主執行緒和整個程式
void pthread_exit(void *thread_return);
// 對等執行緒以當前執行緒 ID 作為引數,呼叫 pthread_cancel 來終止當前執行緒
int pthread_cancel(pthread_t tid); // 成功返回 0
// 回收已終止執行緒的資源
int pthread_join(pthread_t tid, void **thread_return); // 阻塞直到 tid 終止,成功返回 0
// 分離執行緒
int pthread_detach(pthread_t tid); // 成功返回 0
// 初始化執行緒 (可以用來實現單例模式)
pthread_once_t once = PTHREAD_ONCE_INIT; // 必須是全域性或者靜態變數,固定初始化為 PTHREAD_ONCE_INIT(主要用其地址)
int pthread_once(pthread_once_t *once_control, void(*init_routine)(void));

執行緒由核心自動排程,每個執行緒有自己的執行緒上下文

執行緒上下文:執行緒 ID(TID)、棧、棧指標、程式計數器、通用目的暫存器和條件碼。

示例程式碼

while (1) {
  pConnfd = new int();
  *pConnfd = accept(...);
  pthread_create(&tid, NULL, threadFunc, pConnfd);
}

void* threadFunc(void* p)
{
  int connfd = *p;
  pthread_detach(pthread_self());
  free(p);
}

為了避免 race condition,connfd 必須在堆中建立,線上程中釋放,而不能直接把 connfd 的地址傳給 threadFunc!

12.4 多執行緒共享變數

執行緒記憶體模型

  • 每個執行緒有獨立的執行緒上下文,共享程式上下文其餘部分,包括整個使用者虛擬地址空間:只讀文字(程式碼.text)、讀/寫資料(.bss & .data)、堆、共享庫程式碼和資料。
  • 執行緒棧不對其他執行緒設防。

將變數對映到記憶體

  • 全域性變數:定義在函式之外。僅一個例項@虛擬記憶體讀/寫區
  • 本地自動變數:定義在函式內,且沒有 static。@虛擬記憶體執行緒棧
  • 本地靜態變數:定義在函式內,並有 static。僅一個例項@虛擬記憶體讀/寫區

C++11 thread_local 存在哪裡?

12.5 訊號量

背景知識

  • cnt++ 可以細分 3 個子步驟:載入 L、更新 U、儲存 S。這三個動作必須一次性完成,不可中斷。
  • 進度圖不適用於多處理器。
  • P(s):若 s 非零,則 s 減 1,立即返回。若 s 為零,掛起執行緒,直到 s 變為非零,然後將 s 減 1 返回。
  • V(s):將 s 加 1。如果有執行緒阻塞,則喚醒這些執行緒中的某一個。
  • P、V 的加一減一的操作都是原子操作,即 L、U、S 的過程沒有中斷。
  • 如果有多個執行緒再等待喚醒,V(s) 只能隨機喚醒一個執行緒,不能指定喚醒哪個執行緒。
#include <semaphore.h>

// 成功返回 0,出錯返回 -1
int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *sem);  // P(s) 如果 s 為 0,掛起,直到 s 變為非零。V 操作可以重啟這個執行緒。
int sem_post(sem_t *sem);  // V(s)

生產者-消費者

int buf[N]; // 理解成 queue<int>
sem_t mutex, slots, items;

void init(int n)
{
    sem_init(&mutex, 0, 1);
    sem_init(&slots, 0, n);
    sem_init(&items, 0, 0);
}

void producer(T item)
{
    sem_wait(&slots)
    sem_wait(&mutex);
    // insert item into buf
    sem_post(&mutex);
    sem_post(&items);
}

T consumer()
{
    T item;
    sem_wait(&items);
    sem_wait(&mutex);
    // pop item from buf
    sem_post(&mutex);
    sem_post(&slots);
    return item;
}

讀者-寫者

讀者、寫者平等地爭奪 w,一旦讀者獲取了 w,將一直佔有 w,直到最後一個讀者離開,釋放 w。

如果讀者不斷到達,寫者可能無限等待,導致飢餓

以下是一個讀者優先的例子。(弱優先順序,當最後一個讀者釋放 w,下一個獲取 w 的不一定是等待 w 的讀者,也有可能是等待 w 的寫者!)

int readcnt = 0;
sem_t mutex; // 保護 readcnt
sem_t w;     // 讀者或寫者搶佔 w

void init()
{
    sem_init(&mutex, 0, 1);
    sem_init(&w, 0, 1);
}

void reader()
{
    while(1)
    {
        sem_wait(&mutex);
        readcnt++;
        if(readcnt == 1)
            sem_wait(&w); // 如果這是第一個讀者,搶佔 w
        sem_post(&mutex);
      
        // Critical section
        // Reading...
      
        sem_wait(&mutex);
        readcnt--;
        if(readcnt == 0)
            sem_post(&w); // 如果這是最後一個讀者,釋放 w
        sem_post(&mutex);
    }
}

void writer()
{
    while(1)
    {
        sem_wait(&w);
            
        // Critical section
        // Writing...
        
        sem_post(&w);
    }
}

綜合:基於預執行緒化的併發伺服器

很好的例子,結合上述多種方式的優點,建議親自寫一遍。程式碼參考 CSAPP,不再贅述。

12.6 使用執行緒提高並行性

通用技術:向對等執行緒傳遞一個小整數,作為唯一的執行緒 ID。每個對等執行緒根據執行緒 ID 來決定它應該計算序列的哪一部分。

通常每個核上執行一個執行緒,在一個核上執行執行多個執行緒會有額外的上下文切換開銷。

多執行緒求和的例子:

執行緒數 1 2 4 8 16
sum_mutex 68.00 432.00 719.00 552.00 599.00
sum_global 7.26 3.64 1.91 1.85 1.84
sum_local 1.06 0.54 0.28 0.29 0.30
// 加鎖操作全域性變數
void* sum_mutex(void* vargp)
{
    // 根據 vargp 確定計算範圍
    for(i=start; i<end; i++) {
        sem_wait(&mutex);
        gsum += i;
        sem_post(&mutex);
    }
    return NULL;
}

// 每個執行緒獨立位置存放結果,無需 mutex,直接累加到全域性陣列。主執行緒等待所有子執行緒完成
void* sum_global(void* vargp)
{
    long threadId = *((long*) vargp);
    // 根據 vargp 確定計算範圍
    for(i=start; i<end; i++)
        gsum[threadId] += i;
    return NULL;
}

// 先用區域性變數累加結果,減少不必要的記憶體引用,最後一次性賦給全域性陣列
void* sum_local(void* vargp)
{
    // 根據 vargp 確定計算範圍
    int local_sum = 0;
    for(i=start; i<end; i++) {
        local_sum += i;
    }
    gsum[threadId] = local_sum;
    return NULL;
}

12.7 其他併發問題

執行緒安全

執行緒安全(thread-safe):多個併發執行緒反覆呼叫,結果正確

可重入(reentrant):執行緒安全的真子集,不需要同步操作,比不可重入的執行緒安全的函式更高效。

四類不相交的執行緒不安全函式:

不安全類 說明 例子 變為執行緒安全的方法
1 不保護共享變數的函式 - 同步操作保護共享變數;缺點:慢
2 保持跨越多個呼叫的狀態的函式 偽隨機數生成器:當前呼叫結果依賴前次呼叫的中間結果 唯一方式是重寫。不再依賴 static 資料,而是依靠呼叫者在引數中傳遞狀態
3 返回指向靜態變數的指標的函式 ctime、gethostbyname:將結果儲存在 static 變數中,然後返回這個變數的指標 a) 重寫:呼叫者傳遞存放結果的變數地址; b) 如果難以修改,則建立包裝函式,進行加鎖-複製。
4 呼叫執行緒不安全函式的函式 f 呼叫執行緒不安全函式 g 如果 g 是第 2 類,只能重寫 g;如果 g 是 1、3 類,可以加鎖(拷貝)

Linux 中執行緒不安全函式

大多數 Linux 函式都是執行緒安全的,包括定義在 C 庫中的函式(例如 malloc、free、realloc、printf、scanf)

執行緒不安全函式 執行緒不安全類 Linux 執行緒安全版本
rand 2 rand_r
strtok(已棄用) 2 strtok_r
asctime 3 asctime_r
ctime 3 ctime_r
gethostbyaddr(已棄用,推薦 getaddrinfo) 3 gethostbyaddr_r
gethostbyname(已棄用,推薦 getnameinfo) 3 gethostbyname_r
net_ntoa(已棄用,推薦 inet_ntop) 3
localtime 3 localtime_r

死鎖

  • 分析工具:進度圖
  • 一個死鎖的例子:執行緒 A 持有 mutex1,等待 mutex2;執行緒 B 持有 mutex2,等待 mutex1
  • 避免死鎖的最簡單方式——互斥鎖加鎖順序規則:給定所有互斥操作的一個全序,如果每個執行緒都是以一種順序獲得互斥鎖並以相反的順序釋放,那麼這個程式就是無死鎖的。

注:現代 C++ 可以一次獲得多個鎖,從根源上避免了死鎖。

12.8 小結

  • 併發:時間上重疊的邏輯流
  • 三種併發機制:程式、I/O 多路複用和執行緒
    • 程式由核心排程,獨立虛擬地址空間,只能顯式 IPC 共享資料
    • 事件驅動程式有自己的併發邏輯流(模型化為狀態機),用 I/O 多路複用來顯式排程這些流
    • 執行緒:核心自動排程,單一程式上下文
  • 訊號量解決共享資料的併發訪問問題,提供互斥訪問,也支援生產者-消費者、讀者-寫者
  • 被執行緒呼叫的函式必須執行緒安全,有四類執行緒不安全的函式
  • 可重入函式比不可重入函式更高效,因為不需要任何同步操作
  • 小心競爭和死鎖

Reference

相關文章