👦個人主頁:Weraphael
?🏻作者簡介:目前正在學習c++和算法
??專欄:Linux
🐋 希望大家多多支持,咱一起進步!😁
如果文章有啥瑕疵,希望大佬指點一二
如果文章對你有幫助的話
歡迎 評論💬 點贊👍🏻 收藏 📂 加關注😍
目錄
- 前言
- 一、消息隊列 (了解)
- 1.1 原理
- 1.2 消息隊列的數據結構
- 1.3 系統調用接口
- 1.3.1 msgget - 創建消息隊列
- 1.3.2 msgctl - 釋放消息隊列
- 1.3.3 msgsnd - 發送數據塊
- 1.3.4 msgrcv - 接收數據塊
- 1.4 小結
- 二、信號量
- 2.1 前置概念:互斥、臨界資源等概念(重點)
- 2.2 理解信號量(重點)
- 2.3 總結一波
- 2.4 系統調用接口(了解)
- 2.4.1 semget - 創建信號量
- 2.4.2 semctl - 釋放
- 2.4.3 semop - 操作
- 2.5 信號量憑什么是進程間通信的一種?
- 2.6 信號量的數據結構
- 三、深入理解 System V 通信方式 (重點)
前言
在
System V
通信標準中,還有一種通信方式:消息隊列,以及一種實現互斥的工具:信號量;隨著時代的發展,這些陳舊的標準都已經較少使用了,但作為IPC
中的經典知識,我們可以對其做一個簡單了解。尤其是 信號量,可以通過它,為以后多線程學習中POSIX
信號量的學習做鋪墊
一、消息隊列 (了解)
1.1 原理
進程間通信的本質是:要讓雙方進程看到同一塊資源。那么對于System V
消息隊列,操作系統首先就要在內核中創建一個隊列(數據結構),再通過某種手段將兩個或多個進程看到同一個隊列后,即可通信。
- 進程
A
發送數據是以數據塊的形式發送到消息隊列中。 - 進程
B
同樣是以數據塊的形發送到消息隊列中。 - 注意:
System V
消息隊列允許多個進程雙向進行通信,而管道通常只能單向通信
- 但有一個問題:那消息隊列中存放著不同進程發送的數據塊,那如何判斷該數據塊是由哪個進程接收呢?
發送消息時,接收進程通常是根據消息類型來判斷消息的來源。
當然了,消息隊列跟共享內存一樣,是由操作系統創建的,其生命周期不隨進程,因此在使用結束后需要手動釋放,不然會導致內存泄漏!
1.2 消息隊列的數據結構
而我們知道,因為系統中不止一對進程在進行通信,可能會存在多個,那么操作系統就要在內核中開辟多個消息隊列,那么操作系統就必須對這些消息隊列進行管理,這又得搬出管理的六字真言:先描述,再組織。在Unix/Linux
中,描述消息隊列的信息通常通過struct msqid_ds
結構體來表示:
struct msqid_ds
struct msqid_ds
{// struct ipc_perm 結構包含了消息隊列的所有權和權限信息。struct ipc_perm msg_perm; // 最后一次向隊列中發送消息 (msgsnd) 的時間。 time_t msg_stime; // 最后一次從隊列中接收消息 (msgrcv) 的時間。 time_t msg_rtime; // 消息隊列屬性最后一次變更的時間。 time_t msg_ctime; // 隊列中當前的字節數 unsigned long __msg_cbytes; // 隊列中當前的消息數目。 msgqnum_t msg_qnum; // 隊列中允許存放的最大字節數。 msglen_t msg_qbytes; // 最后一次發送消息 (msgsnd) 的進程pid。 pid_t msg_lspid; // 最后一次接收消息 (msgrcv) 的進程pid。pid_t msg_lrpid;
};
struct ipc_perm
struct ipc_perm
{// __key用于標識 IPC 對象的鍵值,由用戶指定。key_t __key; // 擁有者的有效用戶ID (UID),即對象的當前所有者。uid_t uid; // 擁有者的有效組ID (GID),即對象的當前所屬組。gid_t gid; // 創建者的有效用戶ID (UID),即創建對象的用戶。 uid_t cuid; // 創建者的有效組ID (GID),即創建對象的用戶所屬的組。 gid_t cgid; // 對象的權限模式,定義了對象的訪問權限,通常以八進制表示。 unsigned short mode; // 序列號,用于處理 IPC 對象創建時的競爭條件。unsigned short __seq;
};
最后再通過諸如鏈表、順序表等數據結構將這些結構體對象管理起來。因此,往后我們對共享內存的管理,只需轉化為對某種數據結構的增刪查改。
1.3 系統調用接口
1.3.1 msgget - 創建消息隊列
msgget
用于創建一個新的System V
消息隊列或獲取一個已經存在的消息隊列。
函數原型如下:
#include <sys/types.h>#include <sys/ipc.h>
#include <sys/msg.h>int msgget(key_t key, int msgflg);
參數說明:
-
key
:消息隊列的鍵值。這個鍵值用于唯一標識一個消息隊列(內核層使用),多個進程可以通過相同的鍵值來訪問同一個消息隊列。通常,可以使用ftok
函數來生成一個鍵值。 -
msgflg
:這是一個標志參數,用于指定操作模式和權限。可以用操作符'|'
進行組合使用。它可以是以下幾個標志的組合:IPC_CREAT
:這個選項單獨使用的話,如果申請的消息隊列不存在,則創建一個新的消息隊列;如果存在,獲取已存在的消息隊列。IPC_EXCL
: 一般配合IPC_CREAT
一起使用(不單獨使用)。他主要是檢測共享內存是否存在,如果存在,則出錯返回;如果不存在就創建。確保申請的消息隊列一定是新的。- 權限標志:以與文件權限類似的方式指定消息隊列的訪問權限(例如
0666
表示所有用戶可讀寫)。 - 但在獲取已存在的消息隊列時,可以設置為
0
-
返回值:
- 成功時返回消息隊列標識符
msqid
。(操作系統內部分配的,提供給用戶層使用,類似于文件描述符fd
) - 失敗時返回 -1,并設置
errno
以指示錯誤原因。
- 成功時返回消息隊列標識符
看到這里,有沒有發現以上接口和創建共享內存段shmget
函數非常的像啊,至于key
和消息隊列標識符的區別這里就不再詳細介紹了,更多細節請參考:點擊跳轉
接下來我們簡單使用msgget
函數創建消息隊列,并使用 ipcs -q
指令查看系統維護的消息隊列的信息
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>using namespace std;const char *pathname = "/home/wj";
int proj_id = 'A';int main()
{// 使用ftok函數生成鍵值key_t key = ftok(pathname, proj_id);printf("key is 0x%x\n", key);// 創建消息隊列int msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);printf("msqid is %d\n", msqid);return 0;
}
【程序結果】
由于我們還沒使用消息隊列進行通信,所以已使用字節used-bytes
和消息數messages
都是0
1.3.2 msgctl - 釋放消息隊列
如上我們可以看見,當進程結束后,我們還是能看到消息隊列在系統的相關信息。所以我們應該手動將其釋放,避免內存泄漏!
釋放的方法和共享內存一樣有兩種方法:
- 方法一:使用以下指令
ipcrm -q <msqid>
- 方法二:使用系統調用接口
msgctl
函數是用于控制消息隊列的函數之一,它允許程序員執行多種操作,如獲取消息隊列的屬性、設置消息隊列的屬性、刪除消息隊列等。
具體的函數原型如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgctl(int msqid, int cmd, struct msqid_ds *buf);
參數說明:
-
msqid
:消息隊列的標識符。即msgget
函數的返回值。 -
cmd
:要執行的操作命令,可以是以下幾種之一:IPC_STAT
:獲取消息隊列的狀態信息,并將其存儲在struct msqid_ds *buf
中。IPC_SET
:設置消息隊列的狀態,從struct msqid_ds *buf
中讀取新的狀態信息。IPC_RMID
:從系統中刪除消息隊列。
-
buf
:一個指向struct msqid_ds
結構的指針,用于存儲或傳遞消息隊列的狀態信息。如果是刪除消息隊列,此參數可以設置為nullptr
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>using namespace std;const char *pathname = "/home/wj";
int proj_id = 'A';int main()
{// 使用ftok函數生成鍵值key_t key = ftok(pathname, proj_id);printf("key is 0x%x\n", key);// 創建消息隊列int msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);printf("msqid is %d\n", msqid);// 進程結束前釋放消息隊列msgctl(msqid, IPC_RMID, nullptr);return 0;
}
【程序結果】
1.3.3 msgsnd - 發送數據塊
共享內存會比消息隊列多兩步:掛接到各自進程的進程地址空間、取消掛接。而對于消息隊列,當我們創建好資源后,就可以直接發送數據了。
msgsnd
函數用于向消息隊列中發送消息,其函數原型如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
參數說明:
msqid
:消息隊列的標識符,由msgget
函數返回。msgp
:指向要發送的消息內容的指針,通常是用戶定義的結構體指針。就是我們在原理部分說的數據塊結構體。其結構如下:
struct msgbuf
{long mtype; /* message type, must be > 0 */char mtext[1]; /* message data */
};
mtype
就是傳說中數據塊類型,據發送方而設定;而mtex
是一個比較特殊的東西:柔性數組,其中存儲待發送的 信息,因為是 柔性數組,所以可以根據 信息 的大小靈活調整數組的大小。對于柔性數組,大家可以參考這篇文章:點擊跳轉
msgsz
:消息的大小,以字節為單位。這個大小必須是消息隊列的最大消息大小(msg_qbytes)的一個有效值,否則會導致msgsnd
失敗。msgflg
:表示發送數據塊的方式,一般默認為0
- 返回值:成功返回
0
,失敗返回-1
1.3.4 msgrcv - 接收數據塊
msgrcv
函數用于從消息隊列中接收消息。
其函數原型如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
參數說明:
msqid
:是消息隊列的標識符,由msgget
函數返回。msgp
:是一個指向接收消息的緩沖區的指針,通常是一個用戶定義的結構體指針。msgsz
:是接收緩沖區的大小,即可以接收的最大消息大小(字節數)。如果實際接收到的消息大小大于msgsz
,則消息可能會被截斷,這取決于msgflg
是否設置了MSG_NOERROR
。msgtyp
:是消息類型,即從消息隊列中選擇接收的消息類型。如果msgtyp
大于0
,則只接收msgtyp
類型的消息;如果msgtyp
等于0
,則接收隊列中的第一個消息;如果msgtyp
小于0
,則接收隊列中小于或等于msgtyp
絕對值的最高優先級的消息。msgflg
:表示接收數據塊的方式,一般默認為0
- 返回值:成功返回接收到的消息的大小(字節數);失敗返回
-1
,并設置errno
來指示錯誤的具體原因。
同樣的,接收的數據結構如下所示,也包含了類型和柔性數組
struct msgbuf
{long mtype; /* message type, must be > 0 */char mtext[1]; /* message data */
};
1.4 小結
消息隊列 的大部分接口都與 共享內存 近似,所以掌握 共享內存 后,即可快速上手 消息隊列。但是如你所見,System V
版的消息隊列 使用起來比較麻煩,并且過于陳舊,現在已經較少使用了,所以我們不必對其進行深究,知道個大概就行了 ~
二、信號量
2.1 前置概念:互斥、臨界資源等概念(重點)
進程A
發送消息,進程B
接收消息,在整個通信的過程中可能會出現錯亂問題。比方A
向B
發送100Byte
的任務信息,但是A
可能才寫到50Byte
,B
進程就開始讀走了,導致B
進程任務信息不完整。我們稱之 數據不一致問題。因此,就衍生出以下幾個概念:
- 首先可以通過加鎖的方式(多線程部分講解) 來保證 互斥。互斥本質就是:任何時刻,只允許一個執行流訪問共享資源(保護共享資源免受并發訪問的影響),
- 而這種只允許一個執行流訪問(執行訪問代碼)的資源稱做臨界資源。這個臨界資源一般是內存空間。(比方說管道就是一種臨界資源)
- 我們訪問臨界資源的代碼稱做 臨界區(類比代碼區)
注意:在管道通信中不存在這些問題,因為管道有原子性和同步互斥,而共享內存是沒有任何的保護機制的。
那么現在就可以解釋一個現象:為什么多個進程(或者線程)向顯示器打印各自的信息有時候會錯亂。原因很簡單,在Linux
中,顯示器是文件,當多個進程向同一個文件打印,前提是這些進程需要看到同一份資源,所以顯示器文件在多進程中就是一個共享資源,而在打印的過程中并沒有添加保護機制,因此會看到數據不一致,錯亂問題。如果不想有這些情況,你就需要將顯示器文件變成臨界資源,如加鎖等。
2.2 理解信號量(重點)
信號量(有的教材叫信號燈)的本質是就是計數器。這個計數器用來記錄可用資源的數量。
下面將一個故事來加深理解:假設一個放映廳有100
個位置,對應也會售賣100
張票(不考慮其他情況)。當我們買票去看電影,但是還沒去觀看(甚至不看),那個位置在電影的時間段永遠是我們自己的。因此,買票的本質是對資源的預定機制!而每賣一張票,剩余的票數(計數器)就要減一,對應的放映廳里面的資源就要少一個。當票數的計數器減到0
之后,表示資源已經被申請完畢了。
臨界資源(如同放映廳)可以被劃分很多小塊的資源(如同放映廳里的位置),那么我們可以允許多個執行流(如同觀眾)來訪問這份臨界資源,但是最怕多個執行流會訪問同一個小塊的資源,一旦出現,就會發生混亂現象。因此,最好的方法就是引入一個計數器cnt
,當cnt > 0 && cnt - 1
,說明執行流申請資源成功(本質是對資源的預定機制),就有訪問資源的權限。當cnt <= 0
表示資源被申請完了,當再有執行流申請,一定會失敗,除了有執行流釋放(退票)。
所以每一個執行流若是要訪問共享資源中的一小部分的時候,不是直接訪問,而是先申請計數器資源。如同看電影需要先買票 ~
故事還沒完,如果電影院的放映廳只有一個座位,我們只需要值為1
的計數器,但如果有10
個人想要這一個位置,那么必定要先申請計數器資源,但不變的是只有一個人能看電影,不就是只有一個執行流在訪問一份臨界資源,這不就是互斥訪問嗎?
在并發編程中,一個只能取兩個狀態(通常是0
和1
)的計數器被稱為二元信號量。二元信號量通常被用來實現互斥訪問(本質就是一個鎖),即保證在任何時刻只有一個進程(或線程)能夠訪問臨界資源。在電影院座位的故事中,計數器的兩個狀態可以分別表示座位的空閑(1
)和已占用(0
)狀態。
這又有一個新的問題:要訪問臨界資源,先要申請計數器資源。而信號量本質是計數器,那么信號量不就是共享資源嗎?
而計數器(信號量)作為保護方,要保護臨界資源只允許一個執行流訪問。但俗話說得好,要保護別人的安全,首先得先保證自己的安全。而對一個整數--
其實并不安全,這里簡單說說為什么不是安全的,等到線程部分再詳談。
--
操作在C
語言上是一條語句;但是這條語句在匯編語言上就是多條匯編語句,一般是三條。首先計數器的值要從內存中放到CPU
的寄存器中,然后再CPU
進行--
操作,最后再將計算結果協會計數器變量的內存位置。而執行流在運行的時候,可以隨時被切換,如果沒有適當的同步措施(如互斥鎖),多個執行流同時訪問計數器可能會導致競態條件。競態條件會破壞計數器的預期行為,使其不能正確地反映實際資源的狀態。
即然--
都不安全,那談何保護別人?
因此,申請信號量,本質是對計數器--
,在信號量中專門封裝了一個操作(方法),我們將這種操作稱為P
操作。如果減一后的計數器值小于零(即信號量的計數器值變為負數),那么執行流就會被阻塞,直到信號量的計數器變為正數,表示有可用資源;釋放資源的同時也要釋放信號量,本質是對計數器進行++
操作,也叫做V
操作。
需要注意的是,P
操作和V
操作通常需要具有 原子性。其意思是一件事情要么不做,要做就做完,是兩態的。沒有“正在做”這樣的概念。也就是說,原子性確保了多個執行流在執行--
操作時,不會被其他執行流中斷或干擾,而且操作要么完全執行成功,要么完全不執行,沒有正在執行的說法。
2.3 總結一波
-
信號量本質是計數器,
PV
操作具有原子性。 -
執行流申請資源,必須先申請信號量(計數器)資源,得到信號量之后,才能訪問臨界資源!
-
信號量值
1
,0
兩態的。二元信號量,就是互斥功能。 -
申請計數器(信號量)的本質是對臨界資源的預定機制!
2.4 系統調用接口(了解)
信號量的系統調用挺“惡心”的,大家了解就行~
2.4.1 semget - 創建信號量
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semget(key_t key, int nsems, int semflg);
組成部分 | 含義 | |
---|---|---|
返回值 int | 創建成功返回信號量集的 semid ,失敗返回 -1 | |
參數1 key_t key | 創建信號量集時的唯一 key 值,通過函數 ftok 計算獲取 | |
參數2 int nsems | 待創建的信號量個數,這也正是 集 的來源 | |
參數3 int semflg | 位圖,可以設置消息隊列的創建方式及創建權限 |
除了參數2,其他基本與另外倆兄弟一模一樣,實際傳遞時,一般傳 1
,表示只創建一個 信號量。
2.4.2 semctl - 釋放
老生常談的兩種釋放方式:指令釋放、函數釋放
- 指令釋放:直接通過指令
ipcrm -s <semid>
釋放信號量集。你還可以使用ipcs -s
來查看系統中信號量的相關信息。 - 通過函數釋放。
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semctl(int semid, int semnum, int cmd, ...);
組成部分 | 含義 | |
---|---|---|
返回值 int | 成功返回 0 ,失敗返回 -1 | |
參數1 int semid | 待控制的信號量集 id | |
參數2 int semnum | 表示對信號量集中的第 semnum 個信號量作操作 | |
參數4 ... | 可變參數列表,不止可以獲取信號量的數據結構,還可以獲取其他信息 |
2.4.3 semop - 操作
信號量的操縱比較ex,也比較麻煩,所以僅作了解即可
使用 semop 函數對 信號量 進行諸如 +1、-1 的基本操作。
#include <sys/types.h>#include <sys/ipc.h>#include <sys/sem.h>int semop(int semid, struct sembuf *sops, unsigned nsops);
組成部分 | 含義 |
---|---|
返回值 int | 成功返回 0 ,失敗返回 -1 |
參數1 int semid | 待操作的信號量集 id |
參數2 struct sembuf *sops | 一個比較特殊的參數,需要自己設計結構體 |
參數3 unsigned nsops | 可以簡單理解為信號量編號 |
重點在于參數2,這是一個結構體,具體成員如下:
unsigned short sem_num; /* semaphore number */
short sem_op; /* semaphore operation */
short sem_flg; /* operation flags */
其中包含信號量編號、操作等信息,需要我們自己設計出一個結構體,然后傳給semop
函數使用。
可以簡單理解為:sem_op
就是要進行的操作,如果將 sem_op
設為 -1
,表示信號量 -1
(申請),同理 +1
表示信號量 +1
(歸還)
sem_flg
是設置動作,一般設為默認即可
當然這些函數我們不必深入去研究,知道個大概就行了
2.5 信號量憑什么是進程間通信的一種?
講了這么多信號量的知識,我們并沒有發現信號量能傳數據進行通信,而是作為一個計數器。
這里就要解釋一下了,通信并不僅僅在于數據的傳遞,也在于雙方互相協同。
補充什么是協同:雙方或多方在通信或合作過程中,通過相互配合、相互支持、相互理解和相互作用,共同達成某種目標。
雖然協同不是以傳輸數據為目的,但是它是以事件通知為目的,它的本質也是在傳遞信息,只是沒那么容易感知到而已。
因此,協同本質也是通信,信號量首先要被所有的通信進程看到。
2.6 信號量的數據結構
在Linux
中,可以通過man semctl
進行查看
struct semid_ds
struct semid_ds
{struct ipc_perm sem_perm; /* Ownership and permissions */time_t sem_otime; /* Last semop time */time_t sem_ctime; /* Last change time */unsigned long sem_nsems; /* No. of semaphores in set */
};
struct ipc_perm
struct ipc_perm
{key_t __key; /* Key supplied to semget(2) */uid_t uid; /* Effective UID of owner */gid_t gid; /* Effective GID of owner */uid_t cuid; /* Effective UID of creator */gid_t cgid; /* Effective GID of creator */unsigned short mode; /* Permissions */unsigned short __seq; /* Sequence number */
};
顯然,無論是 共享內存、消息隊列、信號量,它們的ipc_perm
結構體中的內容都是一模一樣的,結構上的統一可以帶來管理上的便利,具體原因可以接著往下看。
三、深入理解 System V 通信方式 (重點)
接下來我們再來詳細說說IPC
資源在內核中是怎么管理的。
如上我們發現:操作系統描述IPC
對象(共享內存、消息隊列、信號量)的數據結構的第一個字段的第一個成員都是struct ipc_perm
類型成員變量。
這樣設計的好處就是,在操作系統內可以定義一個struct ipc_perm
類型的數組(或鏈表等數據結構)來管理所有的IPC
對象,此時每當我們申請一個IPC
資源,就在該數組當中開辟一個這樣的結構。
這是因為IPC
對象的增、刪、查、改操作與struct ipc_perm
結構體相關,struct ipc_perm
包含了IPC
對象的權限信息。這些權限信息對于操作系統來說是非常重要的,它決定了哪些進程可以訪問、操作這些IPC
對象。因此,往后我們對IPC
對象的增、刪、查和改操作,就轉化為對數組的增、刪、查和改操作。而數組下標,就是IPC
對象的標識符。(類似于文件描述符fd
)
就比方說通過共享內存段標識符在數組中找到其struct ipc_perm
對象,而當我們需要訪問其struct shmid_ds
成員變量時,只需將struct ipc_perm*
強制轉化為struct shmid_ds*
即可訪問。
而操作系統為什么能知道要轉化哪個IPC
對象?可以這么理解:
- 在用戶角度,操作(增、刪、查、改)
IPC
對象時會使用struct ipc_perm
結構體來描述對象的權限和所有者信息。這是給開發者和應用程序使用的接口,用來傳遞創建和訪問IPC
對象的參數。 - 但從內核角度出發,真正管理
IPC
對象的是kern_ipc_perm
結構體(或類似的結構體)。內核會在創建IPC
對象時使用特定的系統調用(如msgget
、shmget
、semget
)來分配和初始化相應的kern_ipc_perm
結構體。這些結構體通常包含一個類型標志位字段,用于標識這個IPC
對象的類型。
那這不就是多態的思想嗎?struct ipc_perm
充當基類,其他的IPC
對象數據結構充當子類,它們繼承了 struct ipc_perm
的屬性,并且增加了特定于每種 IPC 對象類型的信息和操作。指針指向誰就調用誰。
至此,進程間通信的知識點就到此結束啦~