🌎進程間通信
文章目錄:
進程間通信
????進程間通信簡介
??????進程間通信目的
??????初識進程間通信
??????進程間通信的分類
????匿名管道通信
??????認識管道
??????匿名管道
??????匿名管道測試
??????管道的四種情況
??????管道的五種特性
??????管道的讀寫規則
????命名管道
??????命名管道通信
??????命名管道打開規則
????System V 共享內存
??????工作原理
??????共享內存接口
????????shmget接口
????????ftok接口
??????共享內存編碼模擬
????????編碼初步構建
????????刪除共享內存
????????共享內存各個屬性
????????共享內存正式代碼
????System V 消息隊列
????System V 信號量
??????信號量相關概念鋪墊
??????信號量
??????信號量相關接口
????System V 共享內存、消息隊列、信號量的共性
🚀進程間通信簡介
??進程間通信目的
- ?數據傳輸:一個進程需要將它的數據發送給另一個進程
- ?資源共享:多個進程之間共享同樣的資源。
- ?通知事件:一個進程需要向另一個或一組進程發送消息,通知它(它們)發生了某種事件(如進程終止時要通知父進程)。
- ?進程控制:有些進程希望完全控制另一個進程的執行(如Debug進程),此時控制進程希望能夠攔截另一個進程的所有陷入和異常,并能夠及時知道它的狀態改變。
??通過之前的學習,我們知道進程之間具有獨立性,為了保持這個特性,所以進程之間不存在數據直接傳遞的情況。在許多場景下,需要進程之間相互配合,所以需要進程間通信。
??初識進程間通信
??進程間通信最樸素的說法是,一個進程把數據交給另一個進程即可。而想要進程之間進行通信,必須保證每個進程的獨立性。所以,在進程之間就需要一個交換數據的空間,并且該 空間(內存)不能由通信雙方任何一個提供。
??由此可知,進程間通信的本質是 先讓不同的進程看到同一份資源(通常為操作系統提供)。而具體的做法如下幾種。
??進程間通信的分類
??操作系統提供的“空間” 有不同樣式,就決定了有不同的通信方式,分為以下幾種:
管道通信:
- ?匿名管道pipe
- ?命名管道
System V IPC:
- ?System V 消息隊列
- ?System V 共享內存
- ?System V 信號量
POSIX IPC:
- ?消息隊列
- ?共享內存
- ?信號量
- ?互斥量
- ?條件變量
- ?讀寫鎖
🚀匿名管道通信
??認識管道
??管道是Unix中最古老的進程間通信方式,我們把一個進程連接到另外一個數據流稱為一個 “管道”。比如我們層學過的管道符號:‘|’。
??在詳細談論管道的概念之前,先來回顧一下文件描述符與緩沖區:文件描述符表的前三位分別指向標注輸入、標準輸出、標準錯誤。進程自己創建的文件則從3號下標為初始點位。
??如今我們使用open()接口分別以 ‘r’ 和 ‘w’ 的方式打開同一個文件,雖然是同一個文件,但是 操作系統會分配兩個文件描述符分別指向同一個文件。
??每個文件都有自己的緩沖區,每個文件在讀寫之前,都需要把數據從磁盤先加載到內存當中,再有內核加載到緩沖區中,而log.txt文件只有一份,所以,兩個文件指向同一個緩沖區。
??接著,父進程進行fork創建子進程,我們知道,子進程創建時會對父進程頁表、文件描述符表等數據進行 淺拷貝,而他們指向的內存空間還是同一個。
??有人會問,這跟進程間通信有什么關系,別忘了進程間通信的本質是 讓不同進程看到同一份資源!而上述這種方式就做到了雙方看到同一份資源,所以 管道 就是:基于文件的,讓不同進程看到同一份資源方式 就是管道。
??管道在設計時,為了讓管道更簡單,所以管道被設計為只能單向通信!所以我們可以把兩個進程一個負責讀數據,一個負責寫數據,也就是設置讀寫端。假設父進程為reader,子進程為writer:
??而為什么我們兩個文件,一個為讀端一個為寫端這樣設計,因為當父進程fork出子進程的時候,同時把文件描述符表也拷貝下來,這樣父子進程的兩個文件描述符都分別是讀端和寫端,這時候只需要父子進程禁用掉不同的一個端就可以構建管道通信了!
??匿名管道
??操作系統不讓用戶直接操作管道文件,因為用戶可能會造成權限問題、文件覆蓋數據泄露等問題。所以給我們提供了一個用于管道通信的接口:
int pipe(int pipefd[2]);
- pipefd[2]:輸出型參數,文件描述符數組,其中pipefd[0]表示讀端, pipefd[1]表示寫端。
- 返回值:成功返回0,失敗返回錯誤代碼。
??pipe接口不需要向磁盤中刷新,且磁盤中并不存在的文件。通過調用pipe接口系統會 生成一個內存級的文件。這種文件沒有文件名,所以也叫匿名文件、而這種使用方式則被稱為 匿名管道!
??那么匿名管道如何讓不同進程看到同一份資源呢?原理就是有父進程創建子進程,子進程繼承父進程的相關屬性信息。通過相同的文件描述符表從而將兩個進程聯系起來。
- 匿名管道特點:只能與有血緣關系的進程來進行進程間通信。常常用于父子進程。
??為了更加深刻理解匿名管道通信,我們站在文件描述符的角度來理解管道通信。因為管道通信需要有血緣關系的進程之間通信,所以無法避免的我們需要使用fork創建子進程來通信:
1.父進程創建管道文件
2.父進程fork出子進程
3.父進程關閉pipefd[0],子進程關閉pipefd[1]
??匿名管道測試
??管道究竟該怎么使用,我們不妨編寫一段代碼熟悉一下,在編寫之前,先確定幾個事項,父子進程讀寫問題,這里我以父進程為w端,子進程為r端(相反也行)。
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/wait.h>
#include<sys/types.h>void writer(int wfd)//寫端調用
{const char* str = "hello father, I am child";char buffer[128];int cnt = 0;pid_t pid = getpid();while(1){snprintf(buffer, sizeof(buffer), "messge:%s, pid: %d, count: %d\n", str, pid, cnt);//向buffer內寫入strwrite(wfd, buffer, sizeof(buffer));//通過系統調用對管道文件進行寫入cnt++;sleep(1);}
}void reader(int rfd)//讀端調用
{char buffer[1024];while(1){ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);//系統文件與C語言沒關系所以不算 '\0'(void)n;//返回值用不到,避免警告,制造的假應用場景printf("father get a message: %s", buffer);}
}int main()
{// 創建管道int pipefd[2];int n = pipe(pipefd);if(n < 0) return 1;printf("pipefd[0]: %d, pipefd[1]: %d\n", pipefd[0]/*reader*/, pipefd[1]/*writer*/);// fork子進程pid_t id = fork();if(id == 0){// child w端close(pipefd[0]);writer(pipefd[1]);exit(0);}//father r端close(pipefd[1]);reader(pipefd[0]);//通過系統調用 對管道文件讀取wait(NULL);return 0;
}
??整體的代碼結構還是比較簡單易懂的,我們通過循環腳本來監視代碼,觀察是否按預期運行:
??管道的四種情況
??管道作為最古老的一種進程間通信方式,其優點與弊端也早就被程序員們挖掘出來了,我們來看看管道通信有哪些特性吧。
情況一:
??還是上述匿名管道測試代碼,子進程一直在寫,父進程一直在讀子進程寫的數據,現在我們讓子進程等待五秒之后再對管道文件進行寫入:
??那么問題就來了,在子進程休眠的這五秒期間,父進程在干嗎?實際上,在子進程休眠的這5秒,父進程在等待子進程休眠結束,直到子進程再次寫入數據時,父進程才會讀取。
??所以我們的 結論 就是:管道內部沒有數據的時候,并且其中的寫端不關閉自己的文件描述符時,讀端就要進行阻塞等待,直到管道文件有數據。
情況二:
??第二中情況,當寫端一直在對管道文件進行寫入,而讀端卻不再對管道文件(一直執行sleep)進行讀取,我們修改寫端接口如下:
void writer(int wfd)
{const char* str = "hello father, I am child";char buffer[128];int cnt = 0;pid_t pid = getpid();while(1){// snprintf(buffer, sizeof(buffer), "messge:%s, pid: %d, count: %d\n", str, pid, cnt);//向buffer內寫入str// write(wfd, buffer, sizeof(buffer));//通過系統調用對管道文件進行寫入char* ch = "X";write(wfd, ch, 1);cnt++;printf("cnt: %d\n", cnt);}
}
??如果我們編譯運行程序我們會發現,寫端對管道文件一直寫入一個字符,但是到了第65536個字符時卻卡在這里了。
??其實這個時候 寫端在阻塞,這是因為我們寫入的對象,也就是 管道文件 被寫滿了!從計數器我們可以看出一個管道文件的大小為 65536 個字節(ubuntu20.04)!也就是 64KB 大小!
??注意:管道文件的大小依據平臺的不同也各不相同。
??所以我們得到的 結論 是:當管道內部被寫滿,且讀端不關閉自己的文件描述符,寫端寫滿之后,就要進行阻塞等待!
情況三:
??當寫端對管道文件緩沖區進行了有限次的寫入,并且把寫端的文件描述符關閉,而讀端我們保持正常讀取內容,讀端多的僅僅把讀端的返回值打印出來。
??我們發現當10讀取執行完成之后,就一直在執行讀取操作,而我們讀取使用的 read 接口的返回值卻從0變為了1。我們接著用監視窗口來監視一下:
??當寫端寫了10個數據之后將文件描述符關閉,那么讀端進程就會變為僵尸狀態。由此我們可以得出,read接口返回值的含義 是,當寫端停止寫入并關閉了文件描述符,read的返回值為0,正常讀取的返回值 >0。
所以我們可以這樣修改讀端的代碼:
void reader(int rfd)
{char buffer[1024];while (1){ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);if (n > 0)printf("father get a message: %s, ret: %ld\n", buffer, n);else if (n == 0){printf("read pipe done, read file done!\n");break;}elsebreak;}
}
所以我們就能得出 結論:
??對于讀端而言,當讀端不再寫入,并且關閉了pipe,那么讀端將會把管道內的內容讀完,最后就會讀到返回值為0,表示讀取結束,類似于讀到了文件的結尾。
情況四:
??我們把情況三最后的代碼變換一下,讀端讀取改為有次數限制,并且讀取一定次數之后關閉讀的文件描述符,而寫端無限制對管道文件寫入,那么我們會看到什么現象呢?
??而我們發現似乎也沒什么不對啊?讀取完之后不就直接退出了嗎?你應該仔細想想,我們僅僅是關閉了讀的文件描述符,但是沒有關閉寫的文件描述符啊。
??這就是最后一個 結論:當讀端不再進行讀取操作,并且關閉自己的文件描述符fd,而寫端依舊在寫。那么OS就會通過信號(SIGPIPE)的方式直接終止寫端的進程。
??如何證明讀端是被13號信號殺死的?我們采用的是父進程讀子進程寫的方式,也就是說將來子進程被殺死而父進程則可以通過wait的方式來獲取子進程退出時的異常!
int status = 0;
pid_t rid = waitpid(id, &status, 0);if(rid == id)
{printf("exit code : %d, exit signal : %d\n", WEXITSTATUS(status), status & 0x7F);
}
??管道的五種特性
??根據管道的4種特殊情況,也就間接的創造了管道的5個特性,分別來認識管道的5種特性。
第一、二種:
??根據情況一和情況二,兩者結合來看,當管道文件有數據時讀端就讀,有空間寫端就進行寫入。而當管道緩沖區沒有空間時,寫端停止寫入,當管道沒有數據時,讀端就不讀了。
??換句話說,父子進程(w 和 r)之間是具有明顯的執行順序的。父子進程之間會協調他們之間的步調。這樣我們的第一個特性也就出來了:
-
?特性一:父子進程(讀寫端)自帶同步機制。
-
?特性二:管道是以具有血緣關系的進程通信的,常見于父子關系。
第三種:
??我們讓寫端一直向管道內寫,而讀端控制在特定時間內進行讀取。也就是讓寫端一直寫,讀端間斷讀。
??我們可以發現,寫端在寫滿了之后就等待讀端讀取,當讀取一部分之后寫端就又會 從剛才停止的地方繼續對管道內進行寫入!
??雖然寫端寫滿了,但是為何讀端一次性會讀取那么多的數據呢?其實這個情況現在并不好解釋,以后在學習網絡時會有詳細解讀,這里我們只需要知道:
- ?特性三:管道是面向字節流的。
第四種:
??普通文件退出時,操作系統會自動釋放掉這個文件,而我們管道文件也是文件,所以我們第四種特性就是:
- ?特性四:父子進程退出,管道將會自動釋放,這也就說明文件的聲明周期是跟隨進程的。
第五種:
??其實最后一種我們潛移默化的已經知道了,從我們寫的第一份管道代碼起,管道的通信都是一個進程讀一個進程寫,所以我們的最后一種特性就是:
- ?特性五:管道只能單向通信,并且管道通信是一種半雙工的特殊情況。
全雙工:數據可以在兩個方向上同時傳輸,允許通信雙方同時發送和接收數據。比如網絡中 tcp 協議就是采用 全雙工通信方式。
半雙工:數據只可以在兩個方向的其中一個方向上傳輸,但是不能兩個方向都傳輸。比如我們日常對話就是半雙工模式。
??管道的讀寫規則
當沒有數據可讀時:
- ?O_NONBLOCK disable:read調用阻塞,即進程暫停執行,一直等到有數據來到為止。
- ?O_NONBLOCK enable:read調用返回-1,errno值為EAGAIN。
當管道滿的時候:
- ?O_NONBLOCK disable: write調用阻塞,直到有進程讀走數據
- ?O_NONBLOCK enable:調用返回-1,errno值為EAGAIN
- 如果所有管道寫端對應的文件描述符被關閉,則read返回0
- 如果所有管道讀端對應的文件描述符被關閉,則write操作會產生信號SIGPIPE,進而可能導致write進程退出。
- 當要寫入的數據量不大于 PIPE_BUF 時,linux將保證寫入的 原子性。
- 當要寫入的數據量大于 PIPE_BUF 時,linux將不再保證寫入的 原子性(原子性將在線程篇作詳細解釋)。
🚀命名管道
??命名管道通信
??命名管道與匿名管道有什么區別,其實在名字上就可以看出來。命名管道的管道文件是有名字的,而不同的是,命名管道可以讓不同的進程之間可以通信,讓不同的進程看到同一份資源。
??這里不同的進程不僅僅指有血緣關系的進程,沒有血緣關系的進程依舊適用。要讓兩個進程之間進行通信,那么就必定需要讓兩個進程看到同一份資源!
??而要打開管道文件,那么每個進程就必定要有對應的struct file結構體對象,但是OS不會讓一個文件存在兩個屬性和兩個重復的緩沖區,所以實際上 兩個file的inode是同一個文件的inode,而它們的緩沖區也指向同一個緩沖區!
??但是這樣的話,怎么能保證兩個不同的進程打開的是同一個文件呢?在平常我們是通過 文件路徑 + 文件名 來找到文件的。而命名管道文件也是如此!
我們使用如下命令創建命名管道文件:
mkfifo pipe_name #創建命名管道文件
??FIFO表示先進先出,而管道其實就是一種隊列,它的字節流就是先進先出。管道文件在創建完成之后,我們在Shell中可以發現:
??管道文件創建出來之后,OS甚至會在文件名后面加上 ‘|’ 來表示這是一個管道文件,并且在文件權限那里我們能夠看到開頭為 ‘p’,也表示pipe文件。
??那么如何使用代碼創建管道文件呢?我們來認識一個接口:
int mkfifo(const char*pathname, mode_t mode);
- ?pathname參數:需要生成管道文件的路徑信息。
- ?mode參數:生成管道文件的權限位,受權限掩碼的影響。
- ?返回值:成功創建管道返回0,創建失敗返回-1,并且設置錯誤碼。
??基于此,我們來寫一個不同進程之間使用命名管道的簡單通信:
Comm.hpp:
#ifndef __COM_HPP__
#define __COM_HPP__#include <iostream>
#include <sys/types.h>
#include <cerrno>
#include <cstring>
#include <sys/stat.h>
#include <unistd.h>
#include <string>#define Mode 0666 // 設置權限位// 把管道通信封裝為一個類
class Fifo
{
public:Fifo(const std::string& path):_path(path)// 構造函數創建管道文件{umask(0);// 消除權限掩碼的影響int n = mkfifo(_path.c_str(), Mode);// 調用接口創建管道文件if(n == 0)// 根據返回值做判斷{std::cout << "mkfifo sucess" << std::endl;}else{// 創建失敗則打印出錯誤信息并且導出錯誤碼std::cerr << "mkfifo failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;}}~Fifo(){}
private:std::string _path;
};#endif
pipe_client.cpp:
#include "Comm.hpp"int main()
{std::cout << "hello client" << std::endl;return 0;
}
pipe_server:
#include "Comm.hpp"int main()
{// 創建文件Fifo fifo("./fifo");sleep(1);return 0;
}
makefile:
.PHONY:all #依次生成多個可執行程序,將all的依賴方法置空即可
all:pipe_client pipe_server pipe_server:PipeServer.ccg++ -o $@ $^ -std=c++11
pipe_client:PipeClient.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f pipe_client pipe_server
??我們執行了兩次可執行程序,第二次就報錯了,報錯信息也打印出來了,報錯原因是文件已經存在。如果我們想在代碼里讓創建的管道析構,那么可以調用下面接口:
int unlink(const char* pathname);
- ?pathname:需要刪除的文件名+文件路徑。
- ?返回值:與mkfifo返回值含義相同。
~Fifo()
{sleep(10);// 等待10s 再析構int n = unlink(_path.c_str());// 刪除管道文件if(n == 0){std::cout << "remove fifo file " << _path << " sucess" << std::endl;}else{std::cerr << "remove failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;}
}
??這樣文件就可以刪除了,至此,我們就初步搭建好管道文件了,接下來就可以寫通信的代碼了。
??這里我以 客戶端為寫端(writer),服務器端為讀端(reader),并且由服務端創建好管道文件,那么代碼編寫如下:
pipe_client:
#include "Comm.hpp"int main()
{int wfd = open(PATH, O_WRONLY);// 客戶端為writer,以只寫的方式打開文件if(wfd < 0)// 當wfd<0時打印錯誤信息{std::cerr << "open failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;return 1;}std::string inbuffer;while(true){std::cout << "Please enter your message# ";std::getline(std::cin, inbuffer);// 從標準輸入里獲取信息到inbuffer里// 消息為quit則退出if(inbuffer == "quit") break;// 發消息ssize_t n = write(wfd, inbuffer.c_str(), inbuffer.size());// 對inbuffer數組進行寫入操作if(n < 0)// 當n < 0 時,我們需要將對應的報錯信息打印出來{std::cerr << "write failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;break;}}close(wfd);// 執行完畢,關閉文件描述符return 0;
}
pipe_server:
#include "Comm.hpp"int main()
{Fifo fifo(PATH);// 創建管道文件int rfd = open(PATH, O_RDONLY); // 服務端為讀端以只讀的方式打開文件if(rfd < 0)// 文件打開失敗,打印錯誤信息以及錯誤碼{std::cerr << "open failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;return 1;}char buffer[1024];while(true)// 一直對客戶端進行讀取{ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);if(n > 0){buffer[n] = 0;std::cout << "client say : " << buffer << std::endl;}else if(n == 0){std::cout << "client quit, me too!" << std::endl;break;}else// 讀取文件失敗時,打印錯誤信息{std::cerr << "read failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;break;}}close(rfd);// 關閉文件fdreturn 0;
}
??完整的源代碼戳這里:命名管道通信。
??這里還有一個點需要注意,當僅僅運行服務器端時會卡在那里,這是因為 調用open接口的時候就會阻塞等待,直到寫端對管道文件進行寫入時 open 才會返回。
??命名管道打開規則
如果當前打開操作是為讀(reader)而打開FIFO時:
- ?O_NONBLOCK disable:阻塞直到有相應進程為寫而打開該FIFO
- ?O_NONBLOCK enable:立刻返回成功
如果當前打開操作是為寫(writer)而打開FIFO時:
- ?O_NONBLOCK disable:阻塞直到有相應進程為讀而打開該FIFO
- ?O_NONBLOCK enable:立刻返回失敗,錯誤碼為ENXIO
🚀System V 共享內存
??工作原理
??首先我們要明白,共享內存是為了讓進程之間進行通信,所以共享內存一定也遵守著 讓不同進程看到同一份資源 的原則,而共享內存可以讓毫不相干的進程之間進行通信。
??當兩個進程之間使用共享內存進行通信的時。首先,操作系統在內存中開辟一段物理空間作為 共享內存,然后在通過頁表建立映射關系,將共享內存映射到進程地址空間的共享區。最后將 地址空間共享區映射位置的起始地址返回給用戶。
??于是用戶就可以拿到虛擬地址,在經由頁表映射到共享內存的起始地址。而不論是mm_struct(進程地址空間)還是頁表,都屬于內核數據結構,所以構建映射以及返回虛擬地址等操作都是由操作系統來完成的。
??當兩個進程都對同一塊共享內存建立了映射關系,那么它們就可以 通過共享內存塊來看到同一份資源,于是就滿足進程間通信的條件。以上就是共享內存的工作原理。
??共享內存接口
🚩shmget接口
??多說無益,碼上見真章,在實現System V 共享內存的代碼之前,我們需要先認識一個接口 shmget 用來 申請共享內存:
int shmget(key_t key, size_t size, int shmflag);
參數及返回值含義:
參數/返回值 | 含義 |
---|---|
key | 共享內存段的標識符,與進程id類似 |
size | 共享內存大小 |
shmflag | 由九個權限標志構成,它們的用法和創建文件時使用的mode模式標志是一樣的 |
返回值 | 成功返回一個非負整數,即該共享內存段的標識符,失敗返回 -1,同時錯誤碼被設置。 |
??參數key和參數shmflag需要單獨來解釋一下。首先,我們要明白,共享內存進程間通信并不僅僅局限于一對進程,未來我們可以在 內存中創建多個共享內存 從而支持多對進程都可以進行通信。
??所以說 共享內存再內存中可以存在很多個,那么 多個共享內存是一定要被操作系統管理的。操作系統如何管理共享內存?先描述,再組織!
??將每一個共享內存的屬性抽離,用結構體將屬性組織,于是對共享內存屬性的管理就變為了對共享內存結構體的管理。而有那么多的進程,操作系統怎么知道那兩個進程是在使用同一個共享內存的呢?
??所以,OS為了識別不同進程進行通信的共享內存,于是也給共享內存添加了一個 標識符:key,其與進程的標識符類似,不同的共享內存key值具有唯一性。
??shmflag 參數是 用來指定創建共享內存的的權限,其存在多個參數,這些參數都是由宏構成,而我們最常用的不過一下兩個參數:
- ?IPC_CREAT選項:如果共享內存不存在,則創建。如果共享內存已經存在,則獲取這個共享內存。
- ?IPC_EXCL選項:此選項不能單獨使用,無意義。
- ?IPC_CREAT | IPC_EXCL:如果共享內存不存在,則創建共享內存。如果已經存在,則報錯。
??而我們使用這兩個選項盡量兩個選項一起使用,也就是第三種形式,這樣的好處就是,只要我們共享內存創建成功了,就一定是最新創建的!
🚩ftok接口
??可是為什么共享內存標識符需要我們手動的去設置呢?為何不能像進程那樣分配一個標識符呢?其實,如果讓操作系統來給我們傳key這個參數是做不到的,如果操作系統能將同一個key值傳遞給兩個不同的進程 那還需要共享內存來做通信嗎?
??基于此,所以我們需要手動傳參key值,但是key值我們傳什么呢?其實key這個參數有專門的接口提供給用戶使用:
key_t ftok(const char* pathname, int proj_id);
- ?pathname:路徑名。
- ?proj_id:傳入任意一個整數。
??ftok的返回值就是key的類型,而ftok接口其實是一個算法,由我們傳入的文件名和一個整數進行算法,返回一個數字,這個數字就是key值。至于這個值是多少并不重要,只要能夠標識唯一性即可。我們進程想要找到對應的共享內存,拿上這個key值就可以找到對應的共享內存了。
??共享內存編碼模擬
🚩編碼初步構建
??要想進行共享內存方式的進程間通信,首先需要獲取共享內存,并且需要兩個測試進程來獲取共享內存,Comm.hpp用來編寫接口供客戶端和服務端直接來調用。
Comm.hpp:
#pragma once #include <iostream>
#include <cerrno>
#include <sys/shm.h>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <cstdlib>
#include <cstring>const char* pathname = "/home/xzy/work/name_pipe/shm_ipc";// 創建路徑
const int proj_id = 0x100;// 任意整數
const int defaultsize = 4096; // 字節為單位std::string ToHex(key_t k)//轉換16進制
{char buffer[1024];snprintf(buffer, sizeof(buffer), "%x", k);return buffer;
}key_t GetShmKeyOrDie()// 獲取共享內存key值
{key_t key = ftok(pathname, proj_id);if(key < 0){std::cerr << "ftok error, errno: " << errno << ", errno string: " << strerror(errno) << std::endl;exit(1);}return key;
}int CreateShmOrDie(key_t key, int size, int flag)// 共享內存創建方式,用于二級調用
{int shmid = shmget(key, size, flag);if(shmid < 0){std::cerr << "shmget error, errno: " << errno << ", error string: " << strerror(errno) << std::endl;exit(2);}return shmid;
}int CreateShm(key_t key, int size)// 僅創建共享內存
{return CreateShmOrDie(key, size, IPC_CREAT | IPC_EXCL);
}int GetShm(key_t key, int size)// 僅獲取共享內存(可能會創建)
{return CreateShmOrDie(key, size, IPC_CREAT);
}
ShmClient.cpp:
#include "Comm.hpp"int main()
{key_t key = GetShmKeyOrDie();// 獲取key值std::cout << "key: " << ToHex(key) << std::endl;int shmid = GetShm(key, defaultsize);// 獲取共享內存key值,客戶端并不需要創建std::cout << "shmid: " << shmid << std::endl;return 0;
}
ShmServer.cpp:
#include "Comm.hpp"int main()
{key_t key = GetShmKeyOrDie();std::cout << "key: " << ToHex(key) << std::endl;// 將數字轉換為16進制更美觀int shmid = CreateShm(key, defaultsize);// 創建共享內存std::cout << "shmid: " << shmid << std::endl;return 0;
}
Makefile:
.PHONY:all
all:shm_client shm_servershm_server:ShmServer.ccg++ -o $@ $^ -std=c++11
shm_client:ShmClient.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -f shm_client shm_server
??上述共享內存的代碼還是很簡單的,但是我們再來看下面這個現象:
??為什么我們再次運行服務端想要創建一個共享內存卻不行呢?而報錯信息顯示的是文件已經存在,說到底是共享內存已經存在。
🚩刪除共享內存
??一個文件,當我們對一個文件進行操作時,一個進程打開一個文件,進程退出的時候這個被打開的文件就會被系統自動釋放掉,也就是說 文件的生命周期隨進程。
??而我們在上述代碼運行了共享內存,運行的兩個進程(客戶端、服務端)都已經退出了,當我們想再次創建共享內存時就被告知共享內存已存在。其實,當我們 創建了共享內存,如果 沒有主動釋放它,則一直存在。 也就是說,共享內存的生命周期隨內核。除非重啟系統。
??雖然系統不能幫助我們自動釋放共享內存,但是系統給我們提供了刪除共享內存的命令,而在刪除共享內存之前,我們需要先查看系統中的共享內存:
ipcs -m #查看系統中指定用戶創建的共享內存
??刪除共享內存,在Linux中也有相對的指令,只不過刪除共享內存是通過shmid來刪除的并不是通過key值來刪除的,原因我們稍后會提:
ipcrm -m shmid #刪除指定的共享內存
??刪除共享內存并不僅僅只有指令級操作,也有代碼級操作,我們同樣可以調用刪除接口shmctl:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- ?shmid:由shmget返回的共享內存標識碼。
- ?cmd:將要采取的動作(三個可取值)。
- ?buf:指向一個保存著共享內存的模式狀態和訪問權限的數據結構。
- ?返回值:成功返回0,失敗返回-1。
共享內存在內核中的數據結構:
struct shmid_ds {struct ipc_perm shm_perm; /* operation perms */int shm_segsz; /* size of segment (bytes) */__kernel_time_t shm_atime; /* last attach time */__kernel_time_t shm_dtime; /* last detach time */__kernel_time_t shm_ctime; /* last change time */__kernel_ipc_pid_t shm_cpid; /* pid of creator */__kernel_ipc_pid_t shm_lpid; /* pid of last operator */unsigned short shm_nattch; /* no. of current attaches */unsigned short shm_unused; /* compatibility */void *shm_unused2; /* ditto - used by DIPC */void *shm_unused3; /* unused */
};// 可通過shmid_ds結構體對象調用ipc_perm
struct ipc_perm {key_t __key; /* Key supplied to shmget(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 + SHM_DEST andSHM_LOCKED flags */unsigned short __seq; /* Sequence number */
};
cmd參數的三個動作:
??當然cmd參數的選項并不只有這三項,但是最常用的就是這三個選項,我們也可以看看man手冊里對cmd這個參數的介紹:
??還記得我們使用指令刪除共享內存嗎,為什么我們指定key來刪除呢?其實 不論是指令級還是代碼級別,最后對共享內存進行控制,使用的都是shmid,而key值站在內核的角度是 僅僅 用來區分shm的唯一性。而key值和shmid之間的關系就類似于打開文件的struct file* 和 文件fd。
🚩共享內存各個屬性
??我們可以使用ipcs -m來查看共享內存,但是我們在查看時,會發現共享內存有一些我們并不認識的選項:
- ?key:共享內存段的鍵值,它是一個標識符,進程通過key值來訪問共享內存段,key值常常使用ftok接口生成。
- ?shmid:共享內存段的標識符,系統分配給共享內存的唯一標識。
- ?owner:指定共享內存創建的用戶名。
- ?perms:共享內存段的權限位(8進制),在創建共享內存時,shmflag參數可以添加共享內存權限。
- ?bytes:共享內存段大小,字節為單位,在Ubuntu20.04下最小單位為4096字節,也就是4kb。
- ?nattch:共享內存進程使用數量,表示有多少個進程正在使用該共享內存。
- ?status:共享內存段的狀態。
??為什么字節數和我上面給出的并不一致呢?不是說好以4kb為單位的嗎?其實雖然在這里寫的是4097但是內核會給我們開辟8kb的空間,并且我們僅僅使用4097字節。而剩下的字節就會被浪費掉,所以我們盡量將字節數寫為4kb的整數倍。
🚩 共享內存正式代碼
??在寫代碼之前還需要認識兩個接口shmat(shm attach):
int shmat(int shmid, const void *shmaddr, int shmflg);
- ?功能:將共享內存段連接到進程地址空間。
- ?shmid:共享內存標識符。
- ?shmaddr:指定連接的地址,即用戶指定將shm掛接到哪里。
- ?shmflag:其兩個可能取值是 SHM_RND 和 SHM_RDONLY。
- ?返回值:成功返回一個指針(地址空間的虛擬地址),指向共享內存的首地址;失敗返回-1,并且設置錯誤碼。
shmaddr說明:
shmaddr為NULL,核心自動選擇一個地址
shmaddr不為NULL且shmflg無SHM_RND標記,則以shmaddr為連接地址。
shmaddr不為NULL且shmflg設置了SHM_RND標記,則連接的地址會自動向下調整為SHMLBA的整數倍。公式:shmaddr - (shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示連接操作用來只讀共享內存
??以及另外一個接口shmdt(shm detach):
int shmdt(const void *shmaddr);
- ?功能:將共享內存段與當前進程脫離(聯系切斷)
- ?shmaddr: 由shmat所返回的指針(虛擬地址)
- ?返回值:成功返回0;失敗返回-1,并設置錯誤碼
注意:將共享內存段與當前進程脫離不等于刪除共享內存段。
??共享內存同樣分為三個文件,客戶端、服務器端、頭文件。頭文件提供客戶端和服務器端所需要的接口。
Comm.hpp:
#pragma once #include <iostream>
#include <cerrno>
#include <sys/shm.h>
#include <unistd.h>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <cstdlib>
#include <cstring>const char* pathname = "/home/xzy/work/shm_ipc";// 創建路徑
const int proj_id = 0x100;// 任意整數
const int defaultsize = 4096; // 字節為單位std::string ToHex(key_t k)//轉換16進制
{char buffer[1024];snprintf(buffer, sizeof(buffer), "%x", k);return buffer;
}key_t GetShmKeyOrDie()// 獲取共享內存key值
{key_t key = ftok(pathname, proj_id);if(key < 0){std::cerr << "ftok error, errno: " << errno << ", errno string: " << strerror(errno) << std::endl;exit(1);}return key;
}int CreateShmOrDie(key_t key, int size, int flag)// 共享內存創建方式,用于二級調用
{int shmid = shmget(key, size, flag);if(shmid < 0){std::cerr << "shmget error, errno: " << errno << ", error string: " << strerror(errno) << std::endl;exit(2);}return shmid;
}int CreateShm(key_t key, int size)// 僅創建共享內存
{return CreateShmOrDie(key, size, IPC_CREAT | IPC_EXCL | 0666);// 權限設置為0666
}int GetShm(key_t key, int size)// 僅獲取共享內存(可能會創建)
{return CreateShmOrDie(key, size, IPC_CREAT);
}void DeleteShm(int shmid)// 刪除共享內存
{int n = shmctl(shmid, IPC_RMID, nullptr);if(n < 0){std::cerr << "shmctl error" << std::endl;}else// 成功刪除{std::cout << "shmctl delete shm sucess, shmid: " << shmid << std::endl;}
}void ShmDebug(int shmid)
{struct shmid_ds shmds;int n = shmctl(shmid, IPC_STAT, &shmds);if(n < 0){std::cerr << "shmctl error" << std::endl;return;}//Debug 日志std::cout << "shmds.shm_segsz: " << shmds.shm_segsz << std::endl;std::cout << "shmds.shm_nattch: " << shmds.shm_nattch << std::endl;std::cout << "shmds.shm_ctime: " << shmds.shm_ctime << std::endl;std::cout << "shmds.shm_perm.__key" << ToHex(shmds.shm_perm.__key) << std::endl;
}void* ShmAttach(int shmid)
{void* addr = shmat(shmid, nullptr, 0);// 連接進程 返回虛擬地址if((long long)addr == -1)// 連接失敗打印錯誤信息{std::cerr << "shmat error" << std::endl;return nullptr;}return addr;
}void ShmDetach(void *addr)// 解除關聯
{int n = shmdt(addr);if(n < 0){std::cerr << "shmdt error" << std::endl;return;}
}
ShmServer:
#include "Comm.hpp"int CreateShm()
{// 獲取keykey_t key = GetShmKeyOrDie();std::cout << "key: " << ToHex(key) << std::endl;// 將數字轉換為16進制更美觀// 創建共享內存int shmid = CreateShm(key, defaultsize);std::cout << "shmid: " << shmid << std::endl;return shmid;
}int main()
{// 創建共享內存int shmid = CreateShm();// 掛接共享內存char* addr = (char*)ShmAttach(shmid);std::cout << "Attach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;// Server 通信for(;;){std::cout << "shm content: " << addr << std::endl;sleep(1);}ShmDetach(addr);std::cout << "Detach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;sleep(5);// 刪除共享內促DeleteShm(shmid);return 0;
}
ShmClient:
#include "Comm.hpp"int main()
{key_t key = GetShmKeyOrDie();// 獲取key值std::cout << "key: " << ToHex(key) << std::endl;int shmid = GetShm(key, defaultsize);// 獲取共享內存std::cout << "shmid: " << shmid << std::endl;// 將客戶端掛接到共享內存char* addr = (char*)ShmAttach(shmid);std::cout << "Attach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;memset(addr, 0, defaultsize);// 通信開始for(char ch = 'A'; ch <= 'Z'; ++ch){addr[ch-'A'] = ch;sleep(1);}// 將與共享內存的掛接取消ShmDetach(addr);std::cout << "Detach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;sleep(5);return 0;
}
??首先,在運行之前將監控腳本打起來,一直檢測是否連接成功,然后運行服務器端(讀端),再運行客戶端(寫端):
??我們可以看到,當我們僅僅運行服務器端的時候,服務器端一直在進行讀取,并沒有進行寫入,這個現象就很奇怪,我們前面在運行管道文件的時候,當管道內沒有數據時,讀端是會阻塞等待的,會與寫端做一個協同。
??其實,這就是共享內存的一個 缺點:共享內存不提供進程間通信協同的任何機制,導致數據不一致!但是它也有自己的 優點:共享內存是所有進程間通信最快的!
??為什么說共享內存是進程間通信最快的一種通信方式呢?其實,如果你仔細品共享內存和用戶之間是如何傳遞信息的就可以知道為什么共享內存會這么快了:
??共享內存是在內存中開辟的,而我們前面說過,共享內存會將數據從內存中加載到進程地址空間的共享區中,這個過程只需要拷貝一次,而用戶則會通過頁表獲取加載進共享區的共享內存的起始地址,整個過程并不需要過多的拷貝!
??而管道在運行時,寫端會先將數據從用戶端拷貝(寫入)到內核的管道文件中,而讀端讀取數據時,需要將數據從管道文件在拷貝到本地,這樣拷貝次數增多,開銷成本就變大,自然比不過共享內存了。
??為了保證數據的一致性,只能由我們用戶自己來實現,我們可以 使用 信號量 的方式來實現共享內存,但是我們還沒有接觸到。還有一種方式就是 使用管道來同步我們的共享內存,因為 管道自帶同步機制!
??而恰好我們前面也學習了管道文件,我們可以復用上面寫的命名管道,并且添加一些同步機制,讓共享內存可以同步起來:
#pragma once#include <iostream>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
#include <assert.h>
#include <cerrno>
#include <string>#define Path "fifo"
#define Mode 0666// 創建管道文件
class Fifo
{
public:Fifo(const std::string &path = Path) :_path(path){umask(0);int n = mkfifo(_path.c_str(), Mode);if(n <= 0){std::cerr << "mkfifo failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;}std::cout << "mkfifo sucess, fifo pipe be created..." << std::endl;}~Fifo(){int n = unlink(_path.c_str());if(n < 0){std::cerr << "unlink fifo failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;}std::cout << "unlink fifo sucess..." << std::endl;}private:std::string _path;
};// 同步機制
class Sync
{
public:Sync() :_wfd(-1), _rfd(-1){}void OpenRead()// 以讀的方式打開文件{_rfd = open(Path, O_RDONLY);if(_rfd < 0){std::cerr << "open read failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;exit(1);}}void OpenWrite()// 以寫的方式打開文件{_wfd = open(Path, O_WRONLY);if(_wfd < 0){std::cerr << "open write failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;exit(1);}}bool Wait()// 等待{bool ret = true;uint32_t c;ssize_t n = read(_rfd, &c, sizeof(uint32_t));// 根據管道文件的特性,讀端在沒有寫端寫入之前會一直處于等待狀態if(n == sizeof(uint32_t)){std::cout << "wakeup the process" << std::endl;return ret;}else if(n <= 0){std::cerr << "Wait failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;ret = false;}return ret;}void wakeup()// 喚醒{uint32_t c;ssize_t n = write(_wfd, &c, sizeof(uint32_t));// 同樣,根據管道的特性,當寫端對管道文件進行寫入的時候,我們的讀端才能解除等待狀態,開始對管道文件內容進行讀取if(n <= 0){std::cerr << "wakeup failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;}std::cout << "wakeup server..." << std::endl;}~Sync(){}
private:int _wfd;// 寫端fdint _rfd;// 讀端fd
};
??將讀寫端設置完成之后,我們就可以在客戶端和服務器端對其進行調用了:
??這樣再次運行其客戶端和服務器端,效果如下:
??對于共享內存內存以管道的方式實現同步的完整源代碼點擊以下鏈接:共享內存通信(管道實現同步機制)
🚀System V 消息隊列
?? 隨著時代的進步,System V 的本地通信逐漸被淘汰,除了共享內存現在依舊存有不少應用場景,其他類似消息隊列這種技術已經被逐漸淘汰,我們在這里只需要簡單了解即可。
??消息隊列屬于內核數據結構,用戶層不可對其隨意修改,只能通過系統提供的接口對消息隊列的內容進行寫入和讀取。
??用戶層的 每個進程都可以是讀寫端,每個既可以向消息隊列中寫入數據,也可以從消息隊列中讀取數據。
?系統中的消息隊列那么多,我怎么知道你給我發送數據是在哪一個塊上呢?我怎么能保證自己不會讀取到自己在消息隊列中寫的信息呢?
??其實,消息隊列的內核數據結構就說明了一些,因為和共享內存都屬于System V類型的通信,所以他們的內核數據結構會有很強的相似性:
??通過消息隊列的數據結構我們可以看到,消息隊列也有 ipc_perm 這個結構體,其也有自己的key值,而這個 key值就是消息隊列的唯一標識符。
??和共享內存一樣,消息隊列有自己的獲取、發送、以及銷毀接口:
獲取消息隊列:
msgctl,cmd參數與共享內存相同:
發送數據到消息隊列:
??查看系統中的消息隊列也很簡單使用 ipcs -q
即可查詢系統中的消息隊列的情況了:
🚀System V 信號量
??信號量相關概念鋪墊
??前面我們介紹了共享內存,我們直到共享內存不具有同步機制,所以后面我們使用管道來為共享內存構建的進程間通信讀寫端做同步工作。如果我們沒有對共享內存使用管道做一個同步機制,那么可能會出現下面這樣的問題:
??我們使用管道,讓兩個進程分別處于讀寫端,如果不加任何同步,我們可以讓不同的進程同時訪問同一塊內存資源,如果兩個進程對該資源為只讀,那么就不會有任何影響。但是如果 不同進程對同一塊內存資源進行修改,這樣就會造成 數據不一致的問題。
??對于公共資源進行保護,是一個多執行流場景下,一個比較常見和重要的話題。而 對公共資源進行保護有兩種方法: 同步 和 互斥。
- ?同步:訪問公共資源安全的前提下,具有一定的順序性。
- ?互斥:訪問公共資源的時候,在任何時刻只有一方對公共資源進行訪問。
??資源在操作系統并發編程中是很重要的概念,而有些公共資源又被稱為臨界資源:
- ?臨界資源:被保護起來的,任何時刻只允許一個執行流訪問的公共資源。
??共享內存、管道,都是被多個進程看到同一份資源,而這份公共資源就屬于一種臨界資源。常見還有打印機、文件等。
- ?臨界區:訪問臨界資源的代碼叫做臨界區。
??同一個程序中,臨界區是需要進行同步的部分,確保同一時間只有一個 進程/線程 可以進入臨界區訪問臨界資源。比如在共享內存中,Client端調用Wakeup就屬于臨街區,而其他未訪問到臨界資源的代碼就是 非臨界區。
??由此可以看出,保護公共資源的本質:程序員保護臨界區。
- ?原子性:操作對象的時候,不會中中斷。只有兩種狀態,要么完全執行,要么完全不執行。
??信號量
??信號量(Semaphore) 是用于 進程/線程 間的 同步機制。信號量可以控制多個進程對共享資源的訪問。
??通俗來說,我們日常在預定火車票,在火車真正開來之前,這個票會一直給你留著,也就是說資源不一定是我持有才是我的,我預定了,那么這個資源在將來也是我的。而我多少資源,我就賣多少票,并保證每一份資源都不會被并發訪問。
??那么我們可以把整個火車看作一份資源,一份資源只能有一個人搶票,這個人哪里都可以坐,但是這樣效率很低。而我們把火車切割為無數個小資源,這樣每個小資源都可以對應一個人搶票把所有座位的票賣出去,這樣資源利用率就會比前者高。
??操作系統也是如此,對臨界資源的分配有自己的規則,而這種規則就叫做 信號量。
- ?信號量:本質是一個計數器,描述臨界資源數量的計數器。
??也就是說,我們進程之間的通信可以采用信號量的方式來時間對資源的同步訪問,如何訪問呢?實際上,如果我們使用信號量的方式來獲取資源,進程就需要先申請信號量,信號量申請成功,就一定會有該 進程/線程 的資源(和預定和車票類似)。
??申請完成信號量,等待資源的分配。接著找到對應訪問資源進行訪問,訪問完成最后一步釋放信號量。就比如阿熊坐火車到站了,出站的那一刻火車票就算是失效了,不然難不成這趟火車的這個座位一直是阿熊的專座?顯然不合常理。釋放完的信號量后面就可以再次被別人申請了。
??而信號量的使用非常簡單,其實就是一個計數器,開始有一個可分配數值。遇到 進程/線程 申請信號量則計數器 -1,遇到信號量被釋放則計數器 +1,如果信號量 <= 0 則之后的 進程/線程 則需要進行等待。
??話雖如此,但是我們使用一個整數作為信號量,對其進行增加刪除來對資源計數,這樣的方式對于多進程的場景真的可行嗎?
??實際上,這種場景是沒辦法使用一個整數來當做計數器的,就拿父子進程來說,我們都知道,子進程被fork出來之后,任何一個進程對自己的數據進行增刪改的時候,就會發生寫時拷貝,其中一個進程保留原始數據,另外一個進程保留改動后的數據,這樣就造成了數據不一致的問題。
??而今天我們想要使用一個整數作為信號量不也是如此嗎?如何才能保證進程之間數據一致性的問題呢?所以解決方法一定是,讓不同的進程看到同一份計數器資源!
??綜上所述,我們可以得出,信號量也是一種進程間通信!因為它 保證了不同進程看到同一份資源!而這就是進程間通信的前提。
??只不過我們并不是通過信號量來傳遞消息,而是 使用信號量來實現不同進程之間的協同操作!
其實為什么使用整數不能作為信號量還有一個原因:
??假設信號量計數器為一個變量 int count; 那么對于 count++、count- -,這樣的操作也是不能使用整數的一個原因,因為其不能保證原子性!
??在這里,我寫了一份簡單的代碼,對于第一條語句,對count進行賦值操作,在匯編層面只有一條語句,第一句就是原子性的。
??但是第二句和第三局就不同了,因為都是后置++,- -,而這樣的操作轉換成匯編層面實際上是由六條匯編語句來完成的,所以操作上并非是原子性的。這樣就可能會導致,有一方執行流正在做++,但是另一方執行流在++期間還沒進行++時已經做了- -了,這樣就會產生數據不一致的問題。
上面的部分會詳細在線程篇講述。
??程序員既然要實現多進程并發的場景,所有的進程需要訪問臨界資源,在申請 Sem(信號量) 和釋放 Sem 的時候,都必須要保證 申請(++) 和 釋放(- -) 操作是原子的!而對信號量++和- - 的操作我們就叫做PV操作:
信號量PV操作:
- ?P操作(wait操作):將信號量的值減一,信號量的值大于0時,進程繼續執行,信號量小于等于0時,進入阻塞狀態進入等待隊列,等待信號量的值再次大于0。
- ?V操作(signal操作):將信號量的值加一,當信號量的值小于等于0時,則會喚醒一個阻塞中的進程,移除阻塞隊列并 開始/繼續 執行。
??信號量的P操作用于請求資源,資源無可分配時進程則被阻塞。V操作用于釋放資源,喚醒阻塞的進程。但是今天,如果我們信號量的初始值是1呢?也就是說開始就只有一份資源的情況下,會有什么不同嗎?其實如果 信號量只有1的話,一定是互斥的,我們稱其為 二元信號量:
- ?二元信號量(Binary Semaphore):也被稱為 互斥量(Mutex),也是一種控制對共享資源訪問的同步機制。二元信號量的取值只有0和1。主要用于實現互斥訪問,防止 多線程 同時訪問臨界資源,從而導致數據不一致的問題。
??但是在這里我們并不對二元信號量做深入了解,因為其也是在線程篇很重要,所以在線程篇我們會詳細談論。
??信號量相關接口
??一個臨界資源可以申請一個信號量,而在多數并發場景中,臨界資源不止一個,所以 申請信號量資源定然一次性申請多個信號量,這與信號量是幾 定要做區分。
??理論知識我們說的也差不多了,那么我們在程序中如何申請信號量呢?如何對信號量進行操作呢?我們一般使用 semget 接口:
int semget(key_t key, int nsem, int semflg);
- ?key參數:指定信號量集的鍵值,該鍵值用于標識唯一的信號量集,同樣,使用ftok函數生成key值。
- ?nsems參數:表示指定信號量集信號量的數量,如果需要獲取信號量集,該參數設置為0,如果要創建信號量集需要設置對應的參數。
- ?semflg參數:與共享內存的flag標志位相同,有IPC_CREAT、IPC_EXEC等選項,以及權限位。
- ?返回值:成功返回信號量集的一個標識符,失敗返回-1,并設置錯誤碼。
??概念中我們不止一次的提到了信號量集,其實就可以把信號量集看作為一個數組,數組里可以有多個信號量。而刪除信號量接口 semctl:
int semctl(int semid, int semnum, int cmd, ...);
- ?semid參數:信號量集的標識符。
- ?semnum參數:信號量集中信號量編號從0開始,類似數組下標。
- ?cmd參數:與共享內存cmd些許選項一致,使用 IPC_RMID 選項可刪除共享內存。
- ?第四個參數:信號量集的屬性,可傳入semid_ds的結構體,與共享內存和管道類似。
- ?返回值:與cmd選項相關,大部分選項成功則返回0,失敗返回-1,并設置錯誤碼。
??而我們能創建和刪除信號量之后,我們還需要對信號量進行增刪控制,也就是需要 對信號量進行 PV操作,我們可以使用 semop 接口:
int semop(int semid, struct sembuf* sops, size_t nsops);
- ?semid參數:與前面兩個接口一致。
- ?sops參數:表示指向 struct sembuf 數組指針。其為操作數組,每個數組元素定義了對信號量的一個操作。
struct sembuf {unsigned short sem_num; // 信號量集中的信號量編號,指定信號量集中的哪個信號量進行操作(從 0 開始計數)short sem_op;// 操作類型指定要執行的操作類型。常見的操作類型包括:
/*正數:將信號量的值增加sem_op 的值。
負數:將信號量的值減少 -sem_op 的值。
如果減少后的值小于 0,則調用進程將被阻塞,直到信號量的值為非負數。
0:等待信號量的值變為 0*/short sem_flg;// 操作標志
/*sem_flg:操作標志,可以是以下值的組合:
IPC_NOWAIT:如果操作不能立即完成,則 semop 調用會立即返回錯誤,而不是阻塞。
SEM_UNDO:操作會被記錄下來,以便在進程終止時自動撤銷。*/
};
-
?nsops參數:操作數組中的操作數目,表示 sops 數組中包含的 struct sembuf 結構體數量。
-
?返回值:0表示返回成功,-1為失敗,并設置錯誤碼。
??在系統中查看信號量使用 ipcs -s
即可查看系統中信號量情況:
🚀System V 共享內存、消息隊列、信號量的共性
??我們學完了共享內存、消息隊列以及信號量就不難發現他們有非常多的相似之處,首先是在系統中分別查看他們三個的狀態用到的命令都是 ipcs
并且他們的程序調用接口都有cmd參數,并且都可調用 xxxid_ds 結構體 和 ipc_perm 結構體。也就是說他們三個是操作系統特意設計的!
??而它們都是可以對進程之間進行通信的方法,而操作系統注定要對 IPC(Inter-Process Communication,進程間通信) 資源做管理!如何管理?先描述,再組織!
??接下來我們就看一看進程間通信在 內核中 的表示形式:
??實際上,在操作系統中,共享內存、消息隊列、信號量被視為同一種資源,可以被看成一個整體,而我們內核中的共享內存、消息隊列、信號量都存在一個內核結構體:kern_ipc_perm 。而實際在內核當中,所有管理IPC資源的結構體,第一個成員都一樣,他們三個都 是由其進行強制類型轉換所得到的 三個不同類型的 ipc_perm(sem_perm、shm_perm、q_perm)。
??而 kern_ipc_perm 是 ipc_id_ary 結構體中的一個指針數組,指針數組的每一個元素都是指針,每個指針指向你所創建的 共享內存/消息隊列/信號量 的 ipc_perm(sem_perm/shm_perm/q_perm)結構體 ,而我們學過C語言的都知道,結構體中數組指針的地址,是該數組指針指向數組首元素的地址。所以,我們就可以拿到不同類型 ipc_perm 的地址,那么就可以 通過 起始地址+偏移量 的方式訪問內核數據結構成員!
??那么從此以后,操作系統對IPC資源的管理就轉化為了對數組的增刪查改!但是問題來了,我們IPC有多種方式進行通信,而且IPC不同它們的 ipc_perm 的類型就不同,那么操作系統如何轉換 kern_ipc_perm* 指針數組的每一個元素讓其與IPC的類型對應呢?
??很簡單,我們使用強制類型轉換,將對應IPC 類型的 ipc_perm 強制類型轉換即可:
// 例子,以下全是假設
kern_id_perm* ipc[n];(sem_array*)ipc[0]->sem_base[0].semval--;// 強制類型轉換為信號量ipc_perm,再基于此對信號量數目做--
(msg_queue*)ipc[1]->q_time;// 強轉為消息隊列的ipc_perm,訪問其成員
(shmid_kernel)ipc[2]->id;// 強轉為共享內存...
??現在我們知道了如何對不同類型IPC的ipc_perm進行類型轉換,但是有個更重要的問題,我們怎么確定你是誰呢?怎么知道你是IPC的哪個類型呢?不知道哪個類型我們也沒辦法做強制類型轉換啊??
??其實這個問題也非常簡單,內核中的IPC類型無非就 共享內存、信號量、消息隊列 這三個類型,而我們寫三個接口,每個接口的作用就是強轉為它們三個的類型,一一進行匹配,成功則返回強轉后的結果,失敗則返回nullptr,接著繼續強轉試錯,終是可以找到對應的類型的。
??可是計算機怎么知道你需要強轉為什么類型呢?不用擔心,在kern_ipc_perm中有一個叫做mode的屬性成員,其記錄著你需要轉換結構體的類型,所以我們就可以通過上述方式對不同IPC類型進行識別并強轉了,例如:
#define IPC_TYPE_SHM 0x1
#define IPC_TYPE_SEM (0x1 << 1)
#define IPC_TYPE_MSG (0x1 << 2)shmid_kernel* (kern_ipc_perm *p)
{if(p->mode & IPC_TYPE_SHM)return (shmid_kernel*)p;// 是則強轉elsereturn nullptr;
}
...
??如果你學習過像java、C++、python、rust…具有面向對象的高級語言,那么你一定對上面那張圖有疑問:這張圖怎么這么像我學過的 多態 呢??但是它是C語言啊,并沒有多態啊?沒錯,這就是 使用 C語言實現的多態。
??每個結構體的第一個成員就是基類指針,而基類就可以通過指針對子類進行訪問,所以就間接形成了我們今天的多態,但是注意,操作系統是要比C++、Java、Python這些具有面向對象特性語言要出來的早!所以多態其實就是在我們日常的工程開發當中總結出來的規律。
??以上就是全部內容啦,文章創作不易,如果對您有幫助的話,還望給作者一個小小的三連吧~~