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 多路複用來顯式排程這些流
- 執行緒:核心自動排程,單一程式上下文
- 訊號量解決共享資料的併發訪問問題,提供互斥訪問,也支援生產者-消費者、讀者-寫者
- 被執行緒呼叫的函式必須執行緒安全,有四類執行緒不安全的函式
- 可重入函式比不可重入函式更高效,因為不需要任何同步操作
- 小心競爭和死鎖