課程內容#
IPC—— 進程間通信
—— 共享內存 ——#
- 父親生孩子之前約定好共享的數據位置
- 相關接口:shm*
shmget#
分配 System V 類型的共享內存段 [段式內存]
[PS] System V 的通信方式,還在沿用;其啟動方式已經被摈棄
- man shmget
- 原型
- 返回值類型:int
- 形參類型:key_t,size_t,int
- ipc→進程間通信
- 描述
- 傳入的 key 和返回的 id 是聯繫在一起的
- 新的共享內存段的大小,對應整數頁大小 [上取整]
- 創建共享內存需要有標誌:IPC_CREAT
- 如果沒有 IPC_CREAT,會檢查用戶對 key 對應段的訪問權限
- 注意:還需要通過至少 9 個 bit 明確權限,如 0600,前面的 0 不能少
- 返回值
- 成功,返回一個有效的內存標識符 [id];錯誤,返回 - 1 並設置 errno
- 通過 key 拿到 id 後,如何找到對應的地址呢?shmat 👇
shmat、shmdt#
共享內存操作 [attach,附著;detach,分離]
- man shmat
- 原型
- 注意 shmat 的返回值為:void *
- 描述
- shmat
- 將 id 指定的共享內存段附著到調用進程自己的地址空間
- 每一個進程都認為自己用的是一塊獨立的連續內存 [虛擬內存技術]
- shmaddr 明確附著地址:
- NULL:系統自動附著 [常用]
- 否則,附著自動下取整的地址 [shmflg 中定義了 SHM_RND]或者手動保證頁對齊的地址
- 成功的調用會更新共享內存的構造體 shmid_ds
- shm_nattach:附著數量。共享內存對應的真實物理空間可能被多個進程附著
- shmdt
- 是 shmat 的相反操作
- 操作的必須是當前被附著的地址
- shmat
- 返回值
- 成功,shmat 返回地址,shmdt 返回 0
- 錯誤,返回 - 1,並設置 errno
- id 在整個系統中是唯一的,同一個 id 一定對應同一塊物理內存
- 【注意】相同的 id 通過 shmat 返回的地址不同,這是因為在不同的進程中表現為獨立的地址空間 [虛擬內存的概念]
- 話說回來,shmget 通過 key 拿到 id,shmat 通過 id 拿到內存地址,但【key】又是如何得到的呢?👇
ftok#
將一個路徑名和一個項目標識符轉換為 System V IPC 的 key
- man ftok
- 原型
- 需要一個路徑名和一個 int 類型變量完成轉換
- 描述
- 文件需要存在,並可訪問
- proj_id 有至少 8 個有效位,不能是 0
- 相同的文件名和 proj_id 對應相同的返回值
- 所以固定的傳入參數,返回的 key 是固定的
- 返回值
- 成功,返回 key;失敗,返回 - 1,並設置 errno
shmctl#
共享內存的控制
- man shmctl
- 原型
- cmd:命令,int 類型。可預測是一些大寫的宏定義
- 描述
- 可以看到 shmid_ds 結構體的細節信息
- 一些命令
- IPC_STAT:拷貝 shmid_ds 結構體信息到 buf 中 [需有讀權限]
- IPC_SET:修改 shmid_id 結構體
- IPC_RMID:標記段將被銷毀
- 只有當共享內存段沒有附著時,段才會被真正的銷毀
- 不需要 buf 變量,設為 NULL 即可
- [PS] 必須檢查是否真的被銷毀,否則可能會有殘留 [可以通過返回值]
- IPC_INFO [Linux 特有,為了兼容性,盡量避免使用]
- 返回值
- 一般地:0,成功 [除了 INFO、STAT];-1,出錯
—— 線程相關 ——#
來自線程庫 [pthread]
互斥量、條件變量
[PS] 多線程、高並發,使用共享內存時都需要考慮互斥
pthread_mutex_*#
【互斥量】操作 mutex
- man pthread_mutex_init 等
- lock:使用 lock 時,會判斷是否已加鎖,若加了鎖,則會掛起直到解鎖 [可能發生阻塞的地方]
- init:動態初始化方式,需要用到屬性變量
- 屬性接口:pthread_mutexattr_*
- init:初始化
- 初始化函數中,attr 是一個傳出參數,返回值為 0
- setpshared:設置進程間共享
- pshared 變量為 int 類型,實際上就是一個標誌位
- 0:進程間私有;1:進程間共享 → 分別對應宏 PTHREAD_PROCESS_PRIVATE、PTHREAD_PROCESS_SHARED
- init:初始化
- 互斥量的基本操作:創建屬性變量 👉 初始化互斥量 👉 加 / 解鎖操作,詳見代碼演示 —— 共享內存 [互斥鎖]
pthread_cond_*#
【條件變量】控制條件
- man pthread_cond_init 等
- 結構很像 mutex
- 條件變量是一種同步設備,允許線程掛起釋放處理器資源,直到滿足某種條件
- 基本操作:發送條件信號,等待條件信號
- ⭐必須關聯一個互斥量 mutex,來避免競爭條件:可能在一個線程準備好等待條件之前,另一個線程就已經發出信號了,從而導致消息遺漏
- init:初始化可以使用屬性變量 [但實際上在 Linux 線程實現中,已忽略屬性變量]
- signal:只會重啟正在等待的線程中的1個;如果沒有等待中的線程,就啥事沒有
- broadcast:則會重啟所有等待中的線程
- wait
- ① 解鎖 mutex 並等待條件變量激活 [真正的原子操作]
- ② 在 wait 返回前 [線程重啟之前 / 等到了信號],會重新將 mutex 上鎖
- [PS]
- 在調用 wait 的時候,互斥量必須是上鎖狀態
- 等待掛起的時候,不會消耗 CPU
- ❗ 圖中黃框所在段
- 保證在 wait 解鎖 mutex-> 準備等待期間 [原子操作],其他線程不會發信號
- ❓ 那這個時候這個 mutex 已經解鎖了,所以這個原子操作可能還需要用到一個類似互斥量的家伙
- ❓ 感覺上 wait 可以先準備好等待 -> 再解鎖 mutex,這是正常邏輯,而上面解鎖了,又進行一個原子操作,那先解鎖有什麼講究呢
代碼演示#
共享內存 [未加鎖]#
5 個進程進行 1~10000 的累加和演示
- ❗ 這裡是子孩子直接用從父進程那繼承的共享內存地址 share_memory [虛擬的地址]
- 可見,不同進程中的相同虛擬地址都是指向同一片共享內存的 [物理內存]
- 如果子孩子通過繼承來的 shmat 和 id 獲得共享地址,會對應子進程中新的虛擬地址,但是同樣指向同一片共享內存,原繼承的共享地址 share_memory 同樣可用
- ❗ 如何在使用完共享內存後實現真正的銷毀
- 使用 shmdt 分離所有進程中附著的共享內存 [經試驗,這一步沒有也可以,因為進程結束會自動分離]
- 再使用 shmctl 配合 IPC_RMID 銷毀共享內存段 [移除 shmid]
- [PS]
- 每個進程會算一個附著,當附著為 0 時,shmctl 才會真正銷毀內存段
- 也可以去掉 shmget 中的 IPC_EXCL 標誌,不檢測是否已存在,但不是根本方法
- 如果不進行上述的 shmctl 操作
- 第一次執行沒問題,代表成功創建了共享內存段
- 但第二次執行顯示文件已存在
- 說明上次執行後,共享內存並沒有自動銷毀,第二次執行還是使用相同的 key 創建共享內存 [每次相同的 key 對應的 shmid 不相同]
- [探索過程]
- ipcs:展示 IPC 相關的資源信息
- 可以看到未銷毀的共享內存,還有消息隊列、信號量
- 以及它們的 key、id、權限 perms
- 也可以看到它們的 nattch 都為 0,說明已經沒有附著了
- ipcrm:刪除 IPC 資源
- 參數的使用通過 --help 查看
- 可通過 key 或者 id 移除資源
- 手動刪除資源再運行程序:
ipcs | grep [owner] | awk '{print $2}' | xargs ipcrm -m && ./a.out
- ipcs:展示 IPC 相關的資源信息
- ❗ 出錯如下錯誤的本質原因 [個人理解]
- 1~10000 的正確累加和,應為 50005000
- 多個進程同時操作共享內存 [或者] 一個進程的讀寫操作未完全完成,另一個進程開始新的操作了。如:
- 一個進程剛對 now++,寫入內存,此時另一個進程拿到新的 now 再 ++,再用加了 2 次的 now 和舊 sum 相加
- 也就是 now++ 和 sum 累加兩個操作不是原子操作了
- 但一般 CPU 速度太快了,所以很大概率進行的就是原子操作 [now++ 和 sum 累加寫入內存],很小概率出錯
- 多核比單核更容易出現計算錯誤,因為一個處理器只能同時運行一個進程
- [PS]
- 設置權限時 0600 的第一個 0 表示使用 8 進制
- usleep (100):讓進程休眠 100ms,讓一個進程計算一次就阻塞,讓其它進程繼續,單純為了體現分工
- 頭文件根據 man 手冊自行添加
共享內存 [互斥量]#
在內存中更高效的鎖,類似文件鎖 [可前往例子] 的思想
- 創建互斥量:創建屬性變量👉初始化互斥量
- 互斥量在進程間使用的兩個條件
- ① 互斥量變量放在共享內存中,共享給每個進程,從而控制每個進程
- ② 在父進程中創建互斥量時,創建的屬性變量設置進程共享 [默認只在線程間起作用]
- 但現在一些內核可能不需要做此操作也可以,不過為了兼容性,建議設置一下 ❗
- [PS]
- 計算完累加和後,也要記得解鎖
- 系統中最慢的操作是 IO 操作,所以在 printf 前解鎖,讓互斥量更早開放,會更高效
- fflush 可以手動刷新緩衝區,避免輸出混亂
共享內存 [條件變量]#
每個進程輪流算 100 次
- cond 條件變量的初始化,類似 mutex 互斥量
- Linux 線程的實現,其實不需要 attr 屬性變量;mutex 同
- 對於單核機器,每次在發送條件信號之前,需要 usleep 一會,保證子進程準備好 wait,等待信號。否則,
- 對於父進程,其優先執行,發送信號,此時還沒輪到子進程運行,就會錯過信號
- 對於子進程之間,也類似,發信號前,先讓其他子進程先運行到 wait 狀態 [實際上 usleep 也不能保證]
- ❗ 注意
- wait 前要有加鎖的 mutex
- 每次操作完記得解鎖 + 發信號操作 [100 次計算 / 10000 的累加完成]
- 發信號操作的順序有兩種:
- ① 加鎖 —— 發信號 —— 解鎖
- 缺:等待線程由於收到信號從內核中被喚醒 [→用戶態],但是因為沒有可加鎖的 mutex,很遺憾又 [從用戶態] 回到內核空間,直到有可加鎖的 mutex
- 兩次上下文切換 [內核態和用戶態之間] 損耗性能。
- 優:
- 保證了線程的優先級 [❓]
- 並且在 Linux 線程實現中,有 cond_wait 隊列和 mutex_lock 隊列,所以不會返回到用戶態,從而不會有性能損耗
- 缺:等待線程由於收到信號從內核中被喚醒 [→用戶態],但是因為沒有可加鎖的 mutex,很遺憾又 [從用戶態] 回到內核空間,直到有可加鎖的 mutex
- ② 加鎖 —— 解鎖 —— 發信號 [本文採用]
- 優:保證 cond_wait 隊列中的線程有可加鎖的 mutex 使用
- 缺:可能搶到 mutex 的低優先級的線程優先執行
- 參考:條件變量 pthread_cond_signal、pthread_cond_wait——CSDN
- ① 加鎖 —— 發信號 —— 解鎖
- 實現效果
- 每個進程輪流計算 100 次
- [PS] 即使是 usleep 也不能完全避免消息遺漏,可以使用共享內存中的一個變量記錄信號的發出;此外,還可能有虛假喚醒的情況發生
- 綜合解決方法,參考虛假喚醒 && 消息遺漏——CSDN
if (!count) { // ->消息遺漏 [還沒有在wait狀態的]
pthread_mutex_lock(&lock);
while (condition_is_false) { // ->虛假喚醒 [同時喚醒多個]
pthread_cond_wait(&cond, &lock);
}
//...
pthread_mutex_unlock(&lock);
}
簡易聊天室#
- chat.h
- 用戶名 + 消息 + 使用共享內存的標配
- 1.server.c
- 邏輯與前面的代碼演示基本類似;注意後面的清空操作,主要為了數據安全
- 2.client.c
- 關注第 41 行的 while 循環的作用:防止客戶端搶鎖,導致服務端收不到 signal
- 還存在隱患:可能某客戶端搶不到鎖,發生阻塞
- [注意] gcc 編譯 2.client.c 時,一定要加 - lpthread;否則不會報錯,但運行時,服務端收不到信號
- 效果演示
- 左為服務端,右為兩個客戶端
附加知識點#
- 有親緣關係的進程:父子之間、兄弟之間
思考點#
- 進程之間搶著算 VS. 每個進程各算一百次,哪種方式更高效?
- 後者更高效,往極端想,一個進程計算全部的更高效
- 這涉及到 CPU 的吞吐量問題,這種累加屬於 CPU 密集型,而不是 IO 密集型
- 可參考 4 高級進程管理 ——處理器約束型和 IO 約束型進程
Tips#
- 查看 IPC 相關的資源信息命令:ipcs
- 對於用戶線程,一個進程中的一個線程崩潰,整個進程裡的所有線程都會崩潰
- 課前電影推薦:《與狼共舞》 1990
- 體驗不同文化,有很多安靜、美好的場景
- https://pan.baidu.com/s/1hqxmTAYyd3iCOyGdCTPmYw
- 提取碼:yqed