一.管道(未命名管道)
管道是一种IPC方式,它具有两点局限性,第一点是由于管道在最初是一种半双工的进程间通信方式,虽然有的系统允许通过管道进行全双工通信,但是为了可移植性不应做这种假设。第二点是管道只能在具有公共祖先的两个进程之间通信(那用户创建的进程不都有公共祖先init进程?所以该公共祖先还应该创建了管道,这样子进程才能继承引用该管道的文件描述符)。
1.管道的创建pipe
管道使用函数int pipe(int fd[2])进行创建,该函数会设置两个描述符fd[0]和fd[1]分别作为管道的读端和写端。一般使用管道的方式是,父进程先调用pipe函数创建一个管道,之后调用fork创建子进程,此时子进程会继承父进程的文件描述符,这样父子进程便可以引用同一管道(注意fork并不会复制管道,复制的是文件描述符),之后便可以使用该管道进行通信。为了可移植性,默认遵循管道为半双工,父子进程应该一个关闭读端(close(fd[0])),一个关闭写端(close(fd[1])),这依据通信的方向而定。代码示例如下:
#include <string.h>
int main()
{
int pfd[2];
if(pipe(pfd) < 0) {
std::cout<<"error:"<<errno<<std::endl;
exit(1);
}
int ret = fork();
if(ret < 0){
std::cout<<"fork error:"<<errno<<std::endl;
exit(2);
}
else if(ret > 0) {
close(pfd[1]);
char buf[100];
int n = read(pfd[0],buf,sizeof buf); // 父进程从管道中读出数据
buf[n] = '\0';
std::cout<<"parent recv:"<<buf<<std::endl;
}
else {
close(pfd[0]);
std::string sendStr = "hello world";
write(pfd[1], sendStr.c_str(), sendStr.length()); // 子进程发送数据
std::cout<<"child send"<<std::endl;
}
return 0;
}
2.管道并非只能进行父子进程通信
总能在网上看见说管道只能实现父子进程的通信,其实不然,在上一小节已经说过了,fork进程会复制父进程的文件描述符,那么该父进程创建的子进程之间依然可以通信,准确的说只要是复制了该管道的文件描述的后代进程间都可以进行通信,当然若在该描述符上未设置执行时关闭,那么将该描述符作为exec的参数,exec启动的新进程也可使用该管道。下面举一个同一父进程的两个子进程间通信的例子。
int main()
{
int pfd[2];
if(pipe(pfd) < 0) {
std::cout<<"error:"<<errno<<std::endl;
exit(1);
}
for(int i = 0; i < 2; i++) {
if(fork() == 0){ // 由于想节省一点页面空间,此处未对fork出错进行处理
if(i == 0) {
close(pfd[1]); // 第一个子进程将等待从管道读取数据
char buf[100];
int n = read(pfd[0],buf,sizeof buf);
buf[n] = '\0';
std::cout<<"first child recv:"<<buf<<std::endl;
}
else {
close(pfd[0]);
std::string sendStr = "hello world";
write(pfd[1], sendStr.c_str(), sendStr.length()); // 第二个子进程将通过该管道发送数据
std::cout<<"second child send"<<std::endl;
}
return 0; // 两个子进程操作完毕后将直接退出
}
}
getchar();
return 0;
}
3.管道一端被关闭
当管道的一端被关闭后,若接着进行读写操作则分为以下四种情况进行处理:
- 若读一个写关闭的管道,那么当数据被读完后,read将返回0,表示文件结束(这点和TCP套接字很像)。若是写端未关闭,管道无数据时调用read应该陷入等待,若是非阻塞的应该返回-1,并将error置为EAGAIN(这是根据TCP所做的猜测)。
- 若读一个读关闭的管道,read 返回-1, errno设置为EBADF
- 若写一个读端已被关闭的管道,则产生信号EPIPE。若忽略该信号或捕获信号并从信号处理函数返回,则write返回-1,errno设为EPIPE
- 若写一个写关闭的管道,write返回-1, errno设置为EBADF
4.多个进程写同一个管道
常量PIPE_BUF规定了内核管道缓冲的大小。 在写管道(或FIFO时),如果对管道调用write,且要求写的字节数小于PIPE_BUF,则此操作不会于其它进程对同一管道的操作交叉进行,否则所写的数据可能会与其它进程所写的数据相互交叉。【猜测】:这是由于对管道的写操作应该是加锁的。
二.协同进程
当一个进程即产生另一个进程的输入,也读取从那一个进程产生的输出,那么我们称这个进程为协同进程。比如一个进程即将对方的输出链接到标准输入,又将自己的输出作为标准输出链接到对方,如下图所示:
【注】:这一块未作过多了解
三.FIFO(命名管道)
FIFO又称为命名管道,从前面对未命名管道的说明中我们可以看到,之所以不能在任意两个进程间通过未命名管道进行通信,是因为无法通过一个"众所周知"的方式让各个进程或得引用该管道的文件描述符。而命名管道通过类似于创建文件的方式创建一个有名字的命名管道,这样任何进程只要知道该管道的名字(路径+管道名)则可以打开该管道,从而获取引用该管道的文件描述符,这样这些进程便可以通过该命名管道进行通信。
1.创建命名管道文件
可以使用mkfifo(const char* path, mote_t mode)或mkfilfoat(int fd, const char *path, mote_t mode)函数创建命名管道文件,就像是创建文件那样,比如我们使用命令mkfifo testFIFO命令会创建如下管道:
2.命名管道的使用
使用1中方式创建管道后,需要先使用open函数来打开它,之后可以使用write,read函数进行通信。一般若以只读方式打开这个FIFO,则该进程会阻塞到有某个进程已写方式打开该管道。但若我们在以读open时设置了非阻塞标志(O_NONBLOCK),如果无进程以写方式打开,则会立刻返回-1。但若是以写open打开且设置了非阻塞标志(O_NONBLOCK),那么若无进程以读方式打开该文件,那么会立刻返回-1,并将errno设置为ENXIO。
1)多进程原子写
与管道一样,如果每次调用write写FIFO的字节数小于PIPE_BUF,那么可以保证各进程的写数据不会混杂。
2)一端关闭时进行读写操作
其情况与未命名管道相同,此处不在赘述。
3)使用举例
APUE中举了一个在同一主机上多个客户进程与服务器进程进行通信的例子。服务器先设置一个众所周知的命名管道,之后客户端可以通过这个管道向服务器传递消息。但是服务器向各个客户端发送消息时便不能使用这个管道了(即使假设该管道是全双工的也不可以),因为客户端无法区分哪条消息是发送给自己的,为解决这个问题,采用了利用客户进程ID为每个客户端建立一个独立的命名管道。由于当客户端数量变为0时,会导致对管道写端的引用计数变为0,从而关闭写端,这样服务器在读取管道时read会返回0,为了避免这个问题,服务器在创建众所周知的命名管道时采用读写的方式打开,这样可以保证总有一个文件描述符引用命名管道的写端,也就不会被关闭。
但使用管道存在一些问题,比如读端的进程崩溃了,但写进程并不能判断读进程是否崩溃终止,这就使得这个FIFO会遗留在文件系统中。此外,写端的进程必须捕获SIGPIPE信号,因为当写端进程向FIFO写入数据时,可能读端的进程已经终止,此时FIFO的读端会关闭,那么此时会向一个读端关闭的管道写数据,从而收到SIGPIPE信号。
四.XSI IPC的标识符,键以及权限结构
1.XSI IPC
有3种IPC被称作XSI IPC:消息队列,信号量以及共享内存。它们的创建与使用函数非常相似(比如都是用键进行创建并返回一个标识符,之后通过该标识符使用IPC),它们也有着一些相似的特性。
2.标识符与键
每个XSI IPC都有两个名称,分别称为内部名(标识符)和外部名(key_t,一个长整型),其中外部门有称为键,用于创建一个新队列或引用一个现有队列(我们可以把键key_t看作是一个外部索引,操作系统使用其去查找到这个IPC对象,或是将其作为新创建IPC对象的唯一标识),这个键由内核变换为标识符,从而获得其内部名(标识符)。而内部名用于操作该IPC对象,比如要向该IPC对象中读写数据都需要内部名也就是标识符作为参数。
【注】:XSI IPC标识符并非文件描述符,当创建一个新的XSI IPC时,该标识符会连续加1,直至达到一个整型数的最大值再变为0。这与文件描述符的最小未用机制不同。
1)使用key_t创建或打开XSI IPC (int msgget(key_t key, int flag), semget, shmget)
- 当使用一个键值key打开一个IPC时:key必须等于队列创建时指明的key的值,且flag的IPC_CREATE位不能被置位。
- 当创建一个XSI IPC时:key值可以设置为IPC_PRIVATE,这个特殊的键值总是用于创建新的IPC,也可以使用一指定的键值,但是该键值需保证还未与一个IPC对象相关联,否则会出错返回。此时flag的IPC_CREAT位必须置位,且若希望保证没有引用同一标识符的现有IPC结构还应将IPC_EXCL位置位,此时如果IPC结构已存在则会出错返回EEXIST。
【举例】:第六部分举了一个进程创建共享内存并写入数据,另一个进程读取共享内存的例子
2)多个进程如何引用同一个XSI IPC对象
通过上述介绍可知,要想使用XSI IPC对象需要知道其内部名(标识符),而该标识符是通过外部名(键)返回的,而只有创建IPC对象的进程知道其外部名,为此想要让其它进程可以使用该IPC对象,有以下几种方法:
- 要么IPC的创建者将其内部名或外部名放在一个文件中或其它某处(一般放标识符,放键实在多次一举);
- 要么使用一个大家都事先协商好的键值去创建一个IPC,这样大家都可以用该键值获得标识符;
- 要么使用key_t ftok(const char* path, int id)函数将一个路径名与项目ID的组合转化为一个key_t,因此各个进程只要使用相同的组合便可以获取到相同的键值,这其实是前一种发法的变种。
【注】:使用不同的组合但是项目ID参数相同调用ftok方法可能产生相同的key_t值,因为其产生key_t值的算法是通过以下几个数进行计算的:按指定路径名path取得文件(也就是说path必须引用一个现有文件)的stat结构的st_dev(设备节点)与st_ino(索引节点)字段以及项目ID。但是由于索引节点编号与键值通常存放在长整型中,所以创建时可能会丢失信息(为什么会丢??难道不能都用长整型进行计算,反正最后返回的也是长整型。可能是因为与项目IDint id进行了某种组合运算)。
3.权限结构
像文件拥有对应的权限一样(参考博文《文件访问》),每个XSI IPC对象都有一个与之关联的ipc_perm结构用于描述操作权限与所有者。ipc_perm结构如下所示:
struct ipc_perm { // 内核中有一个与之对应的结构 struct kern_ipc_perm
// 暂不太命令这些ID从何而来,待看过更深入的描述后进行补充
uid_t uid; // 所有者的有效用户ID
gid_t gid; // 所有者的有效组ID
uid_t cuid; // 创建者的有效用户ID
gid_t cgid; // 创建者的有效组ID
// 只有用户权限等于uid或cuid,或是超级用户才可以匹配权限模式字中的相应权限
mode_t mode; // 权限模式字, 用户权限,组权限,以及其它权限
};
IPC的权限类型也类似于文件的权限,但是IPC不存在执行权限,只有读,写权限,对于信号量叫读,更改(其实就是写)权限。IPC权限与相应值如下表所示:
权限 | 位 |
用户读 用户写(更改) | 0400 0200 |
组读 组写(更改) | 0040 0020 |
其他读 其他写(更改) | 0004 0002 |
4.XSI IPC的优缺点
1)缺点
- 无论是文件对象(struct file{})还是索引节点(struct inode{})都有一个引用计数器(一个atomic_t类型的计数器)当引用计数为0时会进行相应的销毁工作。但是IPC没有引用计数器,如果一个进程出创建了一个消息队列,并且在该队列中放入了一些数据后终止,那么消息队列及其内容不会被删除,它们会一直被留在系统中,直到某个进程读取消息或删除消息队列。[问题]:若最后一个进程退出时消息队列无剩余数据会推出吗?【测试办法】:创建一个消息队列,并记录它的内部名称,并终止这个进程,随后再打开一个进程去去访问该消息队列,观察其是否返回错误。(待测)
【注】:对于未命名管道当最后一个引用的进程终止时,未命名管道就被完全的删除了。而对于FIFO,虽然最后一个引用的进程结束后,FIFO仍会留在系统中,但FIFO中的数据会被删除。
- 这些IPC结构在文件中没有名字,因此不能用普通的函数去设置它们的文件属性,而需要使用为它们而单独制定的函数,比如不能使用ls命令去查看IPC对象,也不能使用rm来删除它,而要使用命令ipcs和ipcrm命令。
- 因为这些IPC不使用文件描述符,因此不可以使用I/O复用(select和poll),从而不便于在一个线程中使用多个XSI IPC对象
2)优点
- 消息队列可以使用非先进先出的次序处理(在消息队列的收发数据中进行说明)。
不同IPC的比较如下图所示:
【注】:流控制指的是没如果系统资源(缓冲区)不足,或者接收进程不能再接收更多的消息,则发送进程陷入睡眠,当条件满足时再自动唤醒发送进程。
【注】:由于消息队列在通信之前需要通过某种技术先获知队列标识符,因此并不认为消息队列是无连接的
五.消息队列
1.概述
消息队列虽称为队列,但是它是消息的连接表,而且并不用遵循队列先进先出的顺序,而是可以根据消息类型取出相应消息(消息类型是在放入数据时由用户指定的,会在之后介绍消息队列收发数据时进行说明),消息队列时全双工的。每一个消息队列都有一个msqid_ds结构与其关联(该结构的信息其实取自消息队列在内核中的结构体struct msg_queue{},在说明《深入理解Linux内核》一书时会进行说明),该结构记录了消息队列的权限,队列中的消息数,队列中的字节数等等,如下所示:
struct msqid_ds {
struct ipc_perm msg_perm; // 该消息队列的执行权限及所有者
msgqnum_t msg_qnum; // 队列中的消息数,注意是消息的条数,而非字节数
msglen_t msg_qbytes; // 队列中的最大字节数
//【注】:内核中还为每个消息队列记录了队列中的字节数
pid_t msg_lspid; // 最后调用msgsnd()的进程ID
pid_t msg_lrpid; // 最后调用msgrcv()的进程ID
time_t msg_stime; // 最后调用msgsnd()的时间
time_t msg_rtime; // 最后调用msgrcv()的时间
time_t msg_ctime; // 最后修改的时间
... //不同实现可能还有其它字段
};
2.消息队列的创建与引用int msgget(key_t key, int flag)
我们可以使用msgget函数通过键值创建(键值需未关联或为IPC_PRIVATE)或引用(键值必须与某一个现有消息队列的键值相等)一个消息队列,若成功该函数会返回一个非负标识符,供调用其它函数使用,否则返回-1。
若是新创建的消息队列,那么其msqid_ds中的相应值会被初始化(即内核会初始化一个struct msg_queue{}),权限值msg_perm中的mode会被设置为flag中指定的权限值,这些权限使用4.3中表格内的值,msg_qbytes被设置为系统限制值,msg_ctime被设置为当前时间,其它值为0。
3.对消息队列的控制函数int msgctl(int msqid, int cmd, struct msqid_ds *buf)
该函数可以根据命令cmd的不同对消息队列进行不同的操作,比如获取消息队列的状态(即msqid_ds结构),设置消息队列的权限,删除消息队列。
但是需注意以下几点:1.只有以下两种用户可以修改消息队列的权限:一种是有效用户ID等于msg_perm.cuid或msg_perm.uid,第二种是超级用户。2.只有超级用户队列中的最大字节数(msg_qbytes)。3.当上述两种用户之一删除(只有它们有此权限)消息队列时,会立即生效(删除立即生效是由于没有引用计数,这因该也算一个缺点),若还有进程在该消息队列上进行操作,则会返回EIDRM。
4.消息队列中数据的收发
1)向消息队列写数据 int msgsnd(int msqid, const void* ptr, size_t bytes, int flags)
该函数可用于向标识符msqid所指定的消息队列写如数据,而ptr指向的就是一块地址空间,该空间由两部分组成:一个long型的变量用于标识该消息的类型(我们在前面说过消息队列可以根据消息类型取数据,而无需先进先出,所指的消息类型便是此处的值),而该变量之后紧跟着的便是用于存储数据的缓冲区,比如以下结构:struct MyMsg{long msgType, char data[512]}。bytes指定了缓存中数据的字节数。而参数flags可以指定为IPC_NOWAIT,即类似于文件I/O的非阻塞I/O标志。当该函数成功返回时,msqid_ds结构中的相应字段也会进行更新。
2)从消息队列中读数据 int msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag)
同样的,ptr仍指向一块上述的缓冲,nbytes为缓冲区数据缓冲的长度,若返回的数据大于nbytes则根据是否在flag中设置MSG_NOERROR而做不同的处理,若设置了则截断数据,被截部分会丢弃,且不通知进程。若未设置则返回-1,errno设置未E2BIG,但消息仍留在消息队列中。
同样的,flag也可设置为非阻塞IPC_NOWAIT。
而根据指定的type值的不同,又3种从消息队列种取数据的策略:
- type == 0: 返回队列中的第一条消息。
- type > 0 : 返回队列中消息类型为type的第一个消息
- type < 0 : 返回队列中消息类型小于等于type绝对值的值最小的一条消息
利用上述特点可以将消息类型根据消息优先级的不同设置为相应的优先权值,也可以在多客户单服务器中,使用type记录客户的进程ID。
【注】:APUE中在对比了消息队列与其它IPC后,发现消息队列原先速度较高的优点已不复存在,综合4.4中所述的消息队列的缺点得出的结论是:在新的应用程序中不...应...该...在...使...用...它...们....~-~...。
六.共享存储(共享内存)
1.概述
共享内存允许两个或多个进程共享一个存储区,因为数据不需要在进程间传递数据,因此这是最快的一种IPC。但是使用共享内存时应该注意多个进程之间的同步。我们可以使用信号量,互斥锁和文件锁来是实现进程间的同步。
【猜测】:操作系统应该是在地址空间中取出了一段空间作为共享内存,在将该段空间的首地址与进程中的地址进行了存储映射一样,只是共享内存与文件无关,关于共享内存具体如何实现的,再看过《深入理解Linux内核》后再进行说明。
内核为每个共享内存段维护了一个数据结构, 至少包含以下字段:
struct shmid_ds {
struct ipc_perm shm_perm; // 存储了相应权限及所有者
size_t shm_segsz; // 内存区字节数
pid_t shm_lpid; // 最后访问的进程ID
pid_t shm_cpid; // 创建者的进程ID
unsigned long shm_nattch; // 当前附加的内存区数
time_t shm_atime; // 最后访问的时间
time_t shm_dtime; // 最后分离的时间
time_t shm_ctime; // 最后修改的时间
};
2.共享内存区的创建于获取 int shmget(key_t key, size_t size, int flag)
其使用方式与消息通道基本相同,此处不再赘述,只是需要说明的是当创建一个共享内存时必须指定共享内存的大小size,若是获取一个已创建的共享内存,那么size应该为0。一个创建的新段其内容为0。
3.对共享内存的控制操作 int shmctl(int shmid, int cmd, struct shmid_ds *buf)
当cmd为IPC_STAT或IPC_SET时与消息通道相同此处不再赘述,此处只说明几个不同的命令:
- IPC_RMID:从系统中删除共享存储段,与消息队列不同的是,由于共享内存维护着一个链接计数,因此删除操作不会立即生效,只是将该段标记为删除,之后其它进程也不可再用shmat连接该共享内存,而真正的删除操作要等到最后一个进程与该共享内存分离后。【注】:经测试发现若不调用该函数,即使进程退出共享内存也不会被删除,具体测试再之后给出
4.连接到共享存储段void *shmat(int shmid, const void* addr, int flag)
建立从进程地址空间内某地址addr到共享内存的映射,一般将addr指定为0,以便由系统选择地址。若flag指定了SHM_RDONLY则以只读方式连接此段,否则以读写方式连接。
5.断开到共享内存的连接int shmdt(const void* addr)
该函数与指定IPC_RMID的shmctl不同,其并不将共享内存标记为删除,仍可使用shmat连接到该共享内存。
6.共享内存使用举例
1)方式1
先运行以下代码,其会创建一个共享内存,方便起见直接输出其标识符,以便放在第二个程序中
#include <sys/shm.h>
#include <sys/ipc.h>
#include <unistd.h>
#include <fcntl.h>
#include <iostream>
int main()
{
int flag = 0;
flag |= (IPC_CREAT & IPC_EXCL);
int shmId = shmget(IPC_PRIVATE, 100, flag | 0400 | 0200);
if(shmId == -1) {
std::cout<<"shmget err:"<<errno<<std::endl;
exit(0);
}
std::cout<<"share memory ID:"<<shmId<<std::endl;
void* addr = shmat(shmId, 0,0);
if(addr == (void*)-1){
std::cout<<"shmat err:"<<errno<<std::endl;
exit(0);
}
std::cout<<"shm addr: "<<addr<<std::endl;
int* ptr = reinterpret_cast<int*>(addr);
*ptr = 5;
std::cout<<"write "<<*ptr << " complete"<<std::endl;
getchar();
shmctl(shmId, IPC_RMID, nullptr);
return 0;
}
运行结果如下:
接着运行第二个程序:
#include <sys/shm.h>
#include <sys/ipc.h>
#include <unistd.h>
#include <fcntl.h>
#include <iostream>
int main()
{
int shmId = 35651599;
void* addr = shmat(shmId, 0,0);
if(addr == (void*)-1){
std::cout<<"shmat err:"<<errno<<std::endl;
exit(0);
}
std::cout<<"shm addr: "<<addr<<std::endl;
int* ptr = reinterpret_cast<int*>(addr);
std::cout<<"read share memory:"<<*ptr<<std::endl;
shmctl(shmId, IPC_RMID, 0);
return 0;
}
运行结果如下:
【注】:可以看到两个程序返回的地址是不同的,因此shmat函数比非直接将共享内存的地址返回,而是进行了某种映射。其实是映射到了进程地址空间的某一块,即堆与栈之间
2)方式2(使用/dev/zero文件)
共享内存与多个进程同时存储映射到一个文件十分相似,只是存储映射是与文件相关联的,而共享内存则并无这种映射。我们可以mmap间进程的地址空间映射到/dev/zero文件,其效果等同于使用一段匿名的内存。 /dev/zero 是一个特殊的文件,当你读它的时候,它会提供无限的空字符(NULL, 0x00)。
使用方法:暂未试,可参APUE p463
七.信号量