目录
1.进程通信的目的
2.进程间通信的方法
2.1管道
2.2System V IPC
2.2POSIX IPC
3.进程间通信实例
3.1匿名管道
3.2匿名管道小项目
3.3命名管道
3.4命名管道的实例
3.5system V共享内存
3.6System V共享内存代码实例
4.System V信号量
1.数据传输:一个进程需要将它的数据发送给另一个进程
2.资源共享:多个进程之间共享同样的资源
3.通知事件:一个进程需要向另一个或一组进程发送消息,通知它们发生了某件事情(例如子进程退出时要通知父进程)
4.进程控制:有些进程希望完全控制另一个进程的执行,此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时指导它的状态改变
通信的本质就是操作系统直接或间接给通信双方的进程提供"内存空间",要通信的进程必须看到一份公共的资源。这份公共资源是有操作系统的不同模块提供的(所以有多种通信的方法)。
管道分为匿名管道和命名管道,这些管道是基于文件系统的文件。管道文件是一个内存级文件,它有属于自己的内核缓冲区,它并不需要从磁盘中读取数据。
管道的特征有如下几点:
1.管道的生命周期跟随创建此管道的进程
2.管道可以让具有亲缘关系的进程相互通信(常用于父子通信)
3.管道是面向字节流的
4.半双工 -- 管道一般只用来做单向通信
5.互斥与同步机制 -- 对共享资源进行保护的方案
这种方案聚焦在本地通信,在现代这个万物互联的时代,这种方案是明显落后的。
1.System V 消息队列
2.System V 共享内存
3.System V 信号量
这种方案使得通信可以跨主机,是现在主流的通信方法。
1.消息队列
2.共享内存
3.信号量
3.互斥量
4.条件变量
5.读写锁
管道是一个文件,它是由进程打开的,也就说明当某个进程打开管道文件时,就与管道建立了读写关系。
如果在这个进程基础之上创建一个子进程,这个子进程就会拷贝父进程的东西(除了管道文件)。
此时建立的管道文件是双向通信的,不符合管道通信的特点,我们手动关闭一些文件,即可得到父子进程间的单向通信。
下面用一份很简单的代码来实现上述的通信过程:
#include
#include
#include
#include
#include
#include
#include // 实现父进程向管道写入 "i am father"
// 子进程从管道中读取父进程发送的数据
int main()
{int fds[2] = {0}; // 用来保存管道读端、写端的文件描述符int n = pipe(fds); // 创建管道文件的系统调用assert(n == 0); // pipe返回0证明创建管道成功// 管道文件创建创建成功后,会将读、写端的文件描述符写入fds// 其中,fds[0]表写端,fds[1]表读端pid_t id = fork(); // 创建子进程assert(id >= 0);if(id == 0) // 子进程{close(fds[1]); // 子进程关闭写端while(true){char buffer[1024] = {0};int s = read(fds[0],buffer,sizeof(buffer)-1); // 从管道读数据if(s > 0){buffer[s] = 0;std::cout << buffer << std::endl;}else if(s == 0){std::cout << "未从管道读到任何数据!" << std::endl;}}close(fds[0]);exit(0);}else // 父进程{close(fds[0]); // 父进程关闭读端while(true){char buffer[1024] = {0};snprintf(buffer,sizeof(buffer),"i am father");write(fds[1],buffer,strlen(buffer)); //向管道写入信息sleep(1); }}n = waitpid(id,nullptr,0); // 阻塞等待子进程退出assert(n == id);close(fds[1]);return 0;
}
以上代码能够实现一个最基本的通信,但我们需要注意一些细节:
1.程序中的pipe是一个系统调用,它接收一个指针类型的参数,其内部将会在内存开辟一个管道文件,并将读端、写端的文件描述符放入参数所指的空间。返回值为0则表示正常退出
2.用来存放管道读写端的fds数组,fds[0]表管道的读端文件描述符,fds[1]表管道的写端文件描述符
3.为了更加严格的使用管道做单向通信,父进程需要关闭读端、子进程需要关闭写端
4.如果管道的写端没有被关闭,且管道内没有任何数据,则读端将会阻塞,一直等待写端向管道发送数据(即程序会停留在read,不往下执行)
5.如果管道的写端关闭,read函数将会返回0(读到0个数据)
6.管道是一个固定大小的文件(其容量是有限的)
7.如果管道的读端被关闭,那么写端存在就没有任何意义。此时write便会收到来自操作系统的终止信号
8.如果读写端都没有被关闭,写端写数据的速度较快,而读端读数据的速度较慢,就会造成管道被写满,此时就会发生写端阻塞(即程序会停留在write,不往下执行)
9.如果读写端都没有被关闭,写端写数据较慢,而读端读数据较快,就会造成管道没有任何数据,此时就会发生读端阻塞(见4)
上面的程序只实现了数据的传送。现在我们应该实现事件通知和进程控制。我们的想法如下:
1.父进程创建N个子进程和N个管道
2.父进程向某个管道发送"控制命令"
3.子进程从对应的管道读取"控制命令"
4.子进程根据"控制命令"做出相应的动作
#include
#include
#include
#include
#include
#include
#include
#include
#include #define MAKE_RAND() srand((size_t)time(nullptr) ^ 15423 ^ 12)
///子进程执行的任务
void IO_mission()
{std::cout << getpid() << ":执行IO任务\n" << std::endl;sleep(1);
}void flush_mission()
{std::cout << getpid() << ":执行刷新任务\n" << std::endl;sleep(1);
}void download_mission()
{std::cout << getpid() << ":执行下载任务\n" << std::endl;sleep(1);
}typedef void(*func) ();void loadSubMission(std::vector& vec_mis)
{vec_mis.push_back(IO_mission);vec_mis.push_back(flush_mission);vec_mis.push_back(download_mission);
}///
struct Information
{Information(int writeFd,int subId):_writeFd(writeFd),_subId(subId){char buffer[1024] = {0};snprintf(buffer,sizeof(buffer),"process%d[pid(%d)-writeFd(%d)]",_size++,_subId,_writeFd);_name = buffer; }std::string _name;int _writeFd; // 管道写端文件描述符pid_t _subId; // 子进程的pidstatic int _size;
};
int Information::_size = 1;// 从管道读取信号
int readMisson(int fd)
{int sig = 0;int ret = read(fd,&sig,sizeof(int));if(ret > 0){return sig;}else {return -1;}
}// 创建管道和子进程
void createSubprocess(int subSize,std::vector& vec_info,std::vector& vec_mis)
{for(int i=0;i= 0);if(id == 0) // 子进程{close(fds[1]);while(true){int sig = readMisson(fds[0]);if(sig >= 0 && sig < vec_mis.size()){vec_mis[sig]();}else if(sig == -1){break;}}close(fds[0]);exit(0);}close(fds[0]);// 保存每个管道的读端,每个子进程pidvec_info.push_back(Information(fds[1],id));}
}// 向管道发送"控制信号"
void sendControlSig(std::vector& vec_info,std::vector& vec_mis)
{int subSize = vec_info.size(); // 子进程数量int misSize = vec_mis.size(); // 任务数量int cnt = 4;while(cnt--){// 随机选取管道、任务int subIndex = rand() % subSize;int misIndex = rand() % misSize;// 发送任务下标std::cout << misIndex << "号任务发送给" << vec_info[subIndex]._name << std:: endl;write(vec_info[subIndex]._writeFd,&misIndex,sizeof(int));sleep(1);}for(auto& e:vec_info){close(e._writeFd);// 本来在这里可以直接等待子进程退出// 但是这么写会存在bug,非常严重的bug//waitpid(e._subId,nullptr,0);}
}// 等待子进程退出
void waitSubExit(std::vector& vec_info)
{for(auto& e:vec_info){int ret = waitpid(e._subId,nullptr,0);assert(ret == e._subId);std::cout << e._name << "已被回收" << std::endl;}
}int main(int argc,char* argv[]) // 我们希望从命令行获取子进程数量
{MAKE_RAND();if(argc < 2){std::cout << "命令行参数不够!" << std::endl;return -1;}int subSize = *(argv[1]) - '0';std::vector vec_Information; // 存储管道、子进程信息的容器std::vector vec_mission; //存储子进程执行的任务的容器loadSubMission(vec_mission); // 将子进程要执行的任务加载到容器中createSubprocess(subSize,vec_Information,vec_mission);sendControlSig(vec_Information,vec_mission);waitSubExit(vec_Information);return 0;
}
我们来研究以下程序注释中描述的重大bug。假设我们创建4个子进程,并且子进程会拷贝一份父进程的文件描述符表,那么子进程的文件描述符表可以是这样的:
可以看到,除第一个子进程外,其余的子进程都拥有前面所有子进程所拥有的管道。所以当父进程关闭关闭第一个管道的写端时,第二个、第三个子进程依然占有第一个管道的写端,这就会导致read函数认为第一个管道的写端依然存在,继而发生写端阻塞。所以我们不能在代码中关闭完管道的写端后立马等待子进程退出。
解决方法一:将父进程关闭管道写端的过程和父进程回收子进程的过程各自封装成独立的函数。其用意在于:先批量化地将父进程地管道写端关闭,那么最后一个子进程的管道就没有写端,没有了写端操作系统就会发送一个终止信号,所以最后一个子进程退出,退出时,会将此子进程打开的所有文件关闭,也就是说,倒数第二个子进程的管道文件也没有了写端,依次类推。所以,先批量化的关闭父进程管道的写端后,每个子进程都处于僵尸状态,只需单独封装一个专门用来回收子进程的函数即可。
解决方法二:在子进程处理任务之前,就关掉所有管道的写端。这样做就能够实现一边关闭父进程管道的写端,也能立即回收管道对应的子进程。代码如下:
// 创建管道和子进程
void createSubprocess(int subSize,std::vector& vec_info,std::vector& vec_mis)
{std::vector deleteFd; //保存管道写端的文件描述符for(int i=0;i= 0);if(id == 0) // 子进程{// 处理任务之前,子进程关掉所有管道写端for(auto& e:deleteFd){close(e);}while(true){int sig = readMisson(fds[0]);if(sig >= 0 && sig < vec_mis.size()){vec_mis[sig]();}else if(sig == -1){break;}}close(fds[0]);exit(0);}close(fds[0]);// 保存每个管道的读端,每个子进程pidvec_info.push_back(Information(fds[1],id));}
}
命名管道与匿名管道一样,都是基于文件系统创建的。但是命名管道确实能够以文件的形式"躺"在磁盘上,是用户能看到的。又但是,根据匿名管道的特性,命名管道也不会向磁盘刷新任何数据,它也属于内存级文件。也就是说,管道通信的本质是通过内核的缓冲区。
命名管道与匿名管道的最大的区别便是,命名管道允许两个毫无关系的进程进行通信。还有便是,命名管道的创建、打开规则不一样:
1.匿名管道是通过系统调用pipe创建的,其参数是一种输出型参数。调用此函数的进程在函数调用完成后天然的打开了管道文件的读、写端。
2.命名管道需要通过系统调用mkfifo创建,其参数列表如图:
通过mkfifo系统调用,能够在磁盘上创建一个具有指定名称的命名管道。与其他文件一样,在同一目录下,文件的名称是唯一的。
3.命名管道需要被"删除"。这个删除不同于用户指令"rm",需要创建管道的进程使用unlink接口。
4.命名管道创建好之后,还不能立即使用。两个想要通信的进程必须通过系统调用open打开管道文件。想要向管道写数据的进程,调用write即可;想要读数据的进程,调用read即可。
5.当使用mkfifo创建好管道文件后,两个想要通信的进程,如果任意一方调用了open,任意一方没有调用open,此时调用了open的一方会发生"打开阻塞"。也就是说,两个进程想要通过命名管道通信,除了各自创建一次管道之外,还需要确保两个进程都通过open打开了命名管道。
创建一个名为server的进程和一个名为client的进程,server从管道读数据,client向管道写数据。因为两个想要通信的进程都需要单独创建一次管道文件,索性将创建管道的方法写成头文件:
// command.h
#pragma once// 编写打开管道文件的方式
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include // 默认为本目录下的named_pipe文件
#define PIPE_NAME "named_pipe"// 创建管道文件
bool createNamedPipe(const std::string& path)
{umask(0); //使得权限是我们想要的int n = mkfifo(path.c_str(),0600);// 只允许拥有者读写if(n < 0){// 返回值为-1则打开管道文件失败std::cout << "strerror:" << strerror(errno) << std::endl;return true;}else if(n == 0){return true;}
}// 关闭管道文件
bool closeNamedPipe(const std::string& path)
{int n = unlink(path.c_str()); // 关闭管道文件assert(n == 0);(void)n;
}
// server.cpp
#include "command.h"// server端做读端int main()
{bool r = createNamedPipe(PIPE_NAME);assert(r == true);(void)r;// 如果client端没有打开管道// 那么open将阻塞int rfd = open(PIPE_NAME,O_RDONLY);//只读方式打开if(rfd < 0){exit(1);}char buffer[1024] = {0};while(true){// 从管道中读取数据到bufferint n = read(rfd,buffer,sizeof(buffer)-1);if(n > 0)//read的返回值为读取到多少数据{buffer[n] = 0;std::cout << "client->server say #" << buffer << std::endl;}else if(n == 0)//如果数据为0,说明写端关闭{std::cout << "client quit, me too!" << std::endl;break;}}close(rfd);closeNamedPipe(PIPE_NAME);return 0;
}
// client.cpp
#include "command.h"// client做写端int main()
{// 如果server端没有打开管道文件// 那么open将阻塞int wfd = open(PIPE_NAME,O_WRONLY);//只写方式打开文件assert(wfd > 0);(void)wfd;char buffer[1024] = {0};while(true){// 从stdin中读取数据到buffer里面来// 大小-1更加安全(虽然fgets会自动补\0)std::cout << "client say #";fgets(buffer,sizeof(buffer)-1,stdin);if(strlen(buffer) > 0){// 事实上fgets一定会读到至少一个字符(\n)buffer[strlen(buffer)-1] = 0;//将读上来的回车吃掉}// 向管道中写入信息int n = write(wfd,buffer,strlen(buffer));assert(n > 0);(void)n;}close(wfd);return 0;
}
.PHONY:all
all:server clientserver:server.cppg++ -o $@ $^ -std=c++11 -gclient:client.cppg++ -o $@ $^ -std=c++11 -g.PHONY:clean
clean:rm -f server client
需要注意的是,同时打开两个进程之后,因为在设计代码的时候,client端是没有"删除"管道这一函数的,所以尽量保证先退出client端。client端退出后,server端检测到管道的写端关闭,就会终止通信,从而调用"删除"管道的函数。如果不按这个顺序去做,很有可能管道文件依旧"躺"在磁盘上。
同样的,cliet端并没有创建管道的函数,所以会直接open一个无效的管道文件。所以,尽量保证先打开server端,再打开client端。
尽量不要两个进程都创建同一个管道。
共享内存是进程间通信最快的通信方法。与管道不同的是,其声明周期跟随操作系统。原理如下:
1.通过系统调用在内存开辟一块共享内存
2.将此内存地址通过页表映射到不同进程的地址空间中
3.进程拿到虚拟地址直接读写通信
很显然,共享内存可以让任意进程通信。我们把共享内存地址映射到进程的地址空间的行为称为挂接;将此映射关系删除称为去关联。
内存的开辟是需要进程调用系统调用的,我们不能直接这么做(试想一下进程A开辟了内存,进程B怎么看到进程A开辟的内存?)。那么实现共享内存通信的具体步骤如下:
1.通信双方的进程首先要获取一个IPC资源的唯一标识(我们称为key),可以通过ftok这个系统调用完成:
通过指定的路径名以及自己设置的id(随便写)获取一个唯一的返回值。
2.创建共享内存。当某个进程获取到唯一标识之后,就可以使用shmget系统调用创建共享内存了:
注意第三个参数的意义:IPC_CREAT,如果内存当中不存在共享内存,那么就创建,如果存在,shmget就正常退出;IPC_EXCL,如果内存当中不存在共享内存,那么就创建,如果存在,shmget创建共享内存失败,返回-1,这个不能单独使用。IPC_CREAT通常用于非创建端的进程;IPC_EXCL用于创建端的进程,因为创建端的进程有必要保证创建的共享内存是新的。
shmget的返回值是标定创建好的内存的标识符。因为共享内存是操作系统创建的,所以也必须符合"先描述再组织"的思想。也就是说,假设第二个参数指定了4kb大小的,那么实际上创建的内存大小一定会比4kb大,因为操作系统需要对其管理,所以有必要多申请一块空间用于存放这块内存的属性(通过结构体的方式)。
调用完成后,操作系统会将唯一标识符(key)和shmget返回的标识符设置进描述共享内存的结构体当中。
还需要注意,物理内存是分块(分页)管理的,每页分为4kb。如果我们指定的大小不是4kb的整数倍,那么操作系统就会向上取整,但是我们的可用空间是我们指定的大小(例如申请20字节的大小,操作系统会提供4kb的内存,但我们只能使用20字节)。
最后需要注意,创建的共享内存是需要有权限的(与创建文件一样)。稍后在代码中演示。
3.通信双方的进程需要挂接。挂接通过系统调用shmat完成:
第二个参数设为空,第三个参数设为0。这个系统调用就如同C语言使用malloc一样。
4.通信完成之后先去关联。其系统调用为shmdt:
5. 最后,"删除"共享内存。其系统调用为shmctl:
将第二个参数设为IPC_RMID即可,第三个参数设为空。
为什么删除要打个引号呢?调用这个系统调用并不是真正删除。类似于文件,只有当文件硬链接数为0的时候才被删除。
真正删除共享内存的操作是:
ipcs -m ==>查看当前有多少共享内存
ipcrm -m shmid ==>删除指定标识符的共享内存
简化任务,这里让两个进程通信,两个进程公用一个头文件:
//command.h
#pragma once//先指定路径名和id,以便后续获取IPC资源唯一标识符#define PATHNAME "."//假设在当前路径
#define ID 0x66//随便给#include
#include
#include
#include
#include
#include //首先获取IPC资源唯一标识符
key_t getKey()
{key_t key = ftok(PATHNAME,ID);if(key < 0){std::cout << "ftok fail:" << strerror(errno) << std::endl;exit(1);}return key;
}//再创建共享内存
int createShm(key_t key)//需要有key指定
{//创建端只希望创建一个新的共享内存int shmid = shmget(key,4096,IPC_EXCL | IPC_CREAT | 0600);if(shmid < 0){std::cout << "shmid fail:" << strerror(errno) << std::endl;exit(1);}return shmid;
}//非创建端的只需要获取
int getShm(key_t key)
{int shmid = shmget(key,4096,IPC_CREAT);if(shmid < 0){std::cout << "shmid fail:" << strerror(errno) << std::endl;exit(1);}return shmid;
}//挂接
void* attachShm(int shmid)
{void* p = shmat(shmid,nullptr,0);//如果挂接失败怎么办?指针类型的p如何转成-1?//if((int)p == -1)//这种方法是错误的。因为Linux下的指针为8个字节if((long long)p == -1L){std::cout << "shmat fail:" << strerror(errno) << std::endl;exit(1);}return p;
}//去关联
void deAttachShm(const void* shmaddr)
{int ret = shmdt(shmaddr);if(ret < 0){std::cout << "shmcdt fail:" << strerror(errno) << std::endl;exit(1);}
}//"删除"共享内存
void delShm(int shmid)
{int ret = shmctl(shmid,IPC_RMID,nullptr);if(ret < 0){std::cout << "shmctl fail:" << strerror(errno) << std::endl;exit(1);}
}
//server.cpp#include "command.h"
#include //假设server端负责创建、删除内存;负责读
int main()
{key_t key = getKey();//获取IPC资源唯一标识符int shmid = createShm(key);//创建共享内存char* p = (char*)attachShm(shmid);//挂接//通信while(true){std::cout << "client -> server:" << p << std::endl;sleep(1);}deAttachShm(p);//去关联delShm(shmid);//删除return 0;
}
//client.cpp#include "command.h"
#include
//假设client端负责写
int main()
{key_t key = getKey();//获取IPC资源唯一标识符int shmid = getShm(key);//创建共享内存char* p = (char*)attachShm(shmid);//挂接//通信while(true){strcpy(p,"hello i am client!");sleep(3);}deAttachShm(p);//去关联return 0;
}
运行结果如下:
可以明显的发现共享内存并不像管道那样会发生阻塞行为。其原因在于:System V共享内存并没有提供保护机制。这就会导致client端并没有向共享内存写入任何数据,但是server端却一直在读。
这里只是简单描述一些概念,并没有实质编码。
信号量的本质就是一个公共资源的计数器,表示公共资源的多少。