进程通信IPC
作者:下家山
一:管道popen
我们在学习进程的时候,看到一种在两个进程间发送消息的非常简单的方法:使用信号量。我们创建通知事件,通过它引起响应,但传送的信息只限于一个信号值。
在本章,我们将介绍管道,通过它进程之间可以交换更有用的数据。
1.1 什么是管道
当一个进程链接数据流到另一个进程时,我们使用管道,通常是把一个进程输出通过管道连接到另一个进程的输入。
最简单的管道,shell命令“|”;
Shell所做的工作实际上是对标准输入和标准输出流进行了重新连接,是数据流从键盘输入通过两个命令最终输出到屏幕上。
1.2 进程管道
可能最简单的在两个程序之间传递数据的方法就是使用popen和pclose函数了。他们的原型如下:
#include <stdio.h>
FILE *popen(const char *command, const char *open_mode);
Int pclose(FILE *stream_to_close);
- Popen函数
Popen函数运行一个程序将另一个程序作为新进程来启动,并可以传递数据给它或者通过它接收数据。
Command参数是要运行的程序名和相应的参数。
Open_mode参数必须是“r”或者“w”。
1.2.1 管道读实例代码
1.2.2 管道读执行结果
1.2.3 管道读实例解析
如果open_mode是“r”,被调用程序的输出就可以被调用程序使用,调用程序利用popen函数返回的FILE*文件流指针,就可以通过常用的stdio库函数(如fread)来读取被调用程序的输出。
【BUFSIZ是stdio.h中用#define语句定义的一个宏】
1.2.4 管道写实例代码
1.2.5 管道写实例执行结果
1.2.6 管道写实例解析
如果open_mode 是“w”,调用程序就可以用fwrite调用向被调用程序发送数据,而被调用程序可以在自己的标准输入上读取这些数据。被调用的程序通常不会意识到自己正在从另一个进程读取数据,它只是在标准输入流上读取数据,然后做出相应的操作。
二:管道pipe
我们通过上面的实例看到,popen是通过启动一个shell来解释管道所打开的命令的。这个代价是有点大的。
Unix底层函数里面还提供了一个更强大的建立管道的函数pipe。它在两个程序之间通过管道传递数据,不需要启动shell。
2.1 pipe函数原型
#include <unistd.h>
Int pipe (int file_descriptor[2]);
参数:
一个拥有两个整形成员的数组【表示文件描述符】;
返回值:
如果这个函数调用成功,将会在数组中填上两个新的值【文件描述符】,然后返回0,如果失败返回-1并设置errno来表明失败原因。失败原因有下面三种:
EMFILE:进程使用的文件描述符过多
ENFILE:系统的文件表已满
EFAULT:文件描述符无效
函数功能:
Pipe调用成功后,在数组file_descriptor[2]中填入了两个新值【文件描述符】,这两个新值【文件描述符】以一种特殊的方式连接起来。
写到file_descriptor[1]的所有数据都可以从file_descriptor[0]读出来。
数据基于先进先出的原则FIFO。【注意与栈的区别】
【注意,pipe类似于open,所以,读写函数对应要用write,read,不能用fread和fwrite】
2.2 pipe实例(在单一进程间通信)
2.3 执行结果
2.4 实例解析
2.5 思考问题
1:为什么read函数读20个字符,但是执行结果只读出11个字符?
2:sizeof和strlen的区别
2.6 pipe实例【父子进程间】
2.7 执行结果
2.8 实例分析
2.9 思考问题
去掉代码中的wait函数,会有什么现象?
还有其他办法吗?
三:不完整管道的读写操作【选学】
在继续学习之前,我们再来仔细研究一下打开的文件描述符。至此,我们一直采取的是让读进程读取一些数据后直接退出的方式,并假设Linux会把清理文件当做是在进程结束时应该做的工作的一部分。
但大多数从标准输入读取数据的程序采用的却是与我们到目前为止见到的例子非常不同的另外一种做法。
通常,他们并不知道有多少数据需要读取,所以往往采取循环的方法:
读取数据——>处理数据——>读取更多的数据,直到没有数据可读为止;
3.1 当没有数据可读
当没有数据可读时,Read调用通常会阻塞,即它将暂停进程来等待直到有数据为止。如果管道的另一端已被关闭,也就是说没有进程打开这个管道,并向它写数据了,这时read调用就会阻塞。
但这样的阻塞不是很有用,因此对一个已经关闭写数据的管道做read调用将返回0而不是阻塞。
这就使读进程能够像检测文件结束一样,对管道进行检测。
3.2 对写端关闭的管道进行读操作代码
3.3 执行结果
3.4 实例解析
3.5 写一个读端已被关闭的管道
如果写一个读端已被关闭的管道,则产生信号SIGPIPE,如果忽略该信号或捕捉该信号并从处理程序返回,则write返回-1,同时errno设置为EPIPE。
3.6 写读端关闭管道例程
3.7 执行结果
3.8 实例解析
pipe |
| 现象 |
| 如果去读一个写端已经关闭的管道 | Read返回值=0; 不会阻塞 |
| 如果去读一个写端没有关闭的管道(写端没有写) | Read函数会阻塞 |
| 如果去写一个读端关闭的管道【写端要安排捕获SIGPIPE函数】 | Write返回-1,并且产生SIGPIPE信号 |
3.9思考问题
只有把父子进程中的针对管道的写文件描述符都关闭了,管道才会被认为是关闭,此时读管道的读写动作才会失败。
写程序验证。
四:popen和pipe的主要区别
4.1
Popen函数类似于fopen属于标准库函数,所以它的头文件是<stdio.h>;
Pipe属于底层函数,类似于open,所以它的头文件是<unistd.h>。
4.2
Popen在本进程中启动shell进程执行相关命令【复杂开销大】
Pipe可用户父子进程间通信【轻便】
五:命名管道FIFO
既然FIFO是命名管道,那么可以猜想popen和pipe是无名管道了【事实也如此】。
到目前为止,我们还只能在相关的程序之间传递数据,即这些数据是由一个共同的祖先进程启动的。【相关程序,指有亲缘关系,父子,兄弟】
但,如果我们想在不相关的进程之间交换数据,popen和pipe很难做到。
我们可以用FIFO文件来完成这项工作,它通常也被称为命名管道。
命名管道是一种特殊类型的文件,它在文件系统中以文件名的形式存在,但它的行为却和我们已经见过的没有名字的管道(popen,pipe)类似。
5.1 命令行创建管道
5.2 程序中创建管道
在程序中创建管道,我们需要用到如下函数:
#include <sys/types.h>
#include <sys/stat.h>
Int mkfifo(const char *filename, mode_t mode);
5.3 实例代码
5.4 执行结果
5.5 实例解析
5.6 fifo进程间通信
我们想通过mkfifo建立一个管道文件my_fifo,然后写一个程序向这个管道写入数据【write】,再写一个文件向这个管道读数据,我们先看现象。
5.7 fifo通信代码
写部分代码
读部分代码
5.8 执行结果
我们先执行写进程
加入了统计时间的命令time,我们看到程序不动,被阻塞了!
另起一个窗口,执行读进程,并加入统计时间命令
我们看到,当读进程运行的时候,写进程结束了
5.9 实例分析
写进程被读进程阻塞的原因,是因为写进程是以O_WRONLY方式打开的管道,以这种方式打开管道将会阻塞,直到有一个进程以读方式打开同一个管道才会解除阻塞。
5.10 思考:
在上面实例中,如果我们先运行读进程,会有什么现象呢?
5.11 以非阻塞形式打开管道【选学】
我们对读进程的代码进行一点改动,如下:
int fd = open(argv[1], O_RDONLY | O_NONBLOCK);
然后,先运行读进程,再运行写进程,结果如下
没有被阻塞,但是什么都没有打印出来。
那我们先运行写进程,再运行修改过的读进程:
阻塞在这里,因为没有读进程运行。
运行读进程后,写进程阻塞被解除!
问题分析
当我们在读进程中以
int fd = open(argv[1], O_RDONLY | O_NONBLOCK);
这种方式打开管道时,open函数不会受到阻塞。原因,在于我们加入了O_NONBLOCK属性。
O_NONBLOCK是非阻塞的意思,因此,当打开一个管道的时候,不会因为没有写进程而阻塞,会立即返回!
写进程以非阻塞方式打开情况
并且,先运行写进程:
5.12 使用open打开FIFO文件的几种情况
Open函数 | 打开FIFO文件的方式 | 说明 |
O_RDONLY | 进程如果是这种方式打开管道,进程将会阻塞,除非有一个进程以写方式打开同一个管道文件 | |
O_RDONLY | O_NONBLOCK | 即使没有其他进程以写方式打开FIFO,这个open函数调用也将成功的立即返回,read返回0. | |
O_WRONLY | Open调用将阻塞,直到有一个进程以读方式打开同一个管道文件 | |
O_WRONLY | O_NONBLOCK | 这个函数将立即返回,但如果没有进程以读方式打开FIFO文件,open调用将返回一个错误-1: No such device or address并且,FIFO也不会被打开。Write返回-1, |
管道:
无名管道【完整管道】
父 子 情况
/不写【且没有关闭】 R 阻塞
W /不读【且没有关闭】 不会阻塞,可以写成功
无名管道【不完整管道】
父 子 情况
不写【关闭写】 R 读出字节数为0
W 关闭R, 产生SIGPIPE,写动作返回-1
有名管道
进程A 进程B
W 不读 写会阻塞【默认打开】
不写 R 读会阻塞【默认打开】
W 不读 写出错【NON_BLOCK打开】
No such device or address
不写 R 读返回0【NON_BLOCK打开】
六:信号量
6.1 实例代码
6.2 执行结果
很多时候输出是凌乱的,这里4个’O’字符,就输出到下面来了。这是正常现象,只要’’O’和’X’是成对出现,就是正常的。
6.3 实例解析
6.4 什么是信号量
信号量:
用于管理对资源的访问。
资源:
硬件资源,如蓝牙,wifi,串口,内存;
软件资源,中断,代码,堆栈。
信号量:
是一种特殊的变量,它只取正整数值,并且程序对其访问都是原子操作。
原子操作:
就是在对一个变量进行修改的时候,要经过读取——>修改——>回写三个步骤,原子操作就是这个过程中不会有其他指令(甚至一个中断)发生。
信号量的PV操作:
信号量这个特殊的操作,只允许对它进行等待(wait)和发送信号(signal)这两种操作,在linux中这两种操作有专门术语:
P(SV):用于等待;
V(SV):用于发送信号:【SV表示信号量变量】
【这两个字母来自于荷兰语passeren和vrijgeven】
P(sv):如果SV的值大于零,就给它减去1,如果它的值等于0,就挂起该进程的执行 |
V(sv):如果有其他进程因等待sv而挂起,就让它恢复运行,如果没有进程因等待sv而被挂起,就给它加1 |
6.5 信号量相关函数
#include <sys/sem.h>
Int semctl(int sem_id, int sem_num, int command, ...);
Int semget(key_t key, int num_sems, int sem_flags);
Int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
6.5.1 semget函数:
Semget函数的作用是创建一个新信号量或取得一个已有信号量的ID。
Int semget(key_t key, int num_sems, int sem_flags);
参数:
第一个参数key是整数值,不相关的进程可以通过它访问同一个信号量。Key值常用ftok函数来得到。参考【ftok函数.doc】
第二个参数指定需要的信号量数目。它几乎总是取值为1;
第三个参数是一组标志,它与open函数的打开模式标志很相似,其作用类似于文件的访问权限。我们常用加入IPC_CREAT标志,来创建一个新的信号量。如果信号量已经创建,semget函数或忽略这个参数。
返回值:
Semget函数在成功时返回一个正数,代表新创建信号量的标识符,其他信号量函数通过这个标识符来使用该信号量。调用失败返回-1。
6.5.2 semop函数
Semop函数用于改变信号量的值,它的定义如下:
Int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
参数:
第一个参数sem_id是由semget返回的信号量标识符。
第二个参数sem_ops是指向一个结构的指针:
Struct sembuf {
Short sem_num;
Short sem_op;
Short sem_flg;
}
第一个成员sem_num是信号量编号,除非你需要使用一组信号量,否则该成员一直为0.
第二个成员sem_op是信号量在一次操作中需要改变的数值,通常只会用到两个值,一个是-1,也就是P操作,它等待信号量变为可用;一个是+1,也就是V操作,它发送信号,表示信号量现在可用。
第三个成员sem_flg通常被设置成SEM_UNDO。它使操作系统跟踪当前进程对这个信号量的修改情况,如果这个进程在没有释放该信号量的情况下终止,操作系统将自动释放该进程所持有的信号量。
第三个参数,num_sem_ops,表示信号量的个数。一般为1.
6.5.3 semctl函数
Semctl函数用来初始化信号量的值,或者删除信号量。
Int semctl(int sem_id, int sem_num, int command, ...);
参数:
第一个参数sem_id是由semget返回的信号量标识符。
第二个参数sem_num参数是信号量编号,当需要用到成组的信号量时,才需要设置这个参数,所以单个信号量这个参数都为0,表示这是第一个也是唯一一个信号量。
第三个参数command,是要采取的动作,动作一般是两个:
SETVAL:设置信号量的值为一个已知的值。【这个值在第四个参数中提供】
IPC_RMID:删除信号量标识符;
第四个参数,sem_union,它是一个union semun结构:
Union semun {
Int val;
Struct semid_ds *buf;
Unsigned short *array;
}
这个结构体必须自己定义。而真正有意义的是val值。【要理解,请结合上面的代码】
七:共享内存
共享内存是3个IPC【inter-process communiction】机制中的第二个。
它允许两个不相关的进程访问同一个逻辑内存。
但大多数共享内存的具体实现,都把由不同进程之间共享的内存安排为同一段物理内存。
7.1逻辑内存和物理内存的区别:
看图说话
7.2 什么是共享内存:
共享内存是由IPC为进程创建的一个特殊的地址范围,它将出现在该进程【创建共享内存的进程】的地址空间中。
其他进程可以将同一段共享内存【已经创建的共享内存】链接到它们自己的进程空间中。 所有进程【已经把共享内存连接到它们自己进程空间的进程】都可以访问共享内存中的地址,就好象它们是由malloc分配的一样。
如果某个进程向共享内存写入了数据,所做的改动将立刻被可以访问同一段共享内存的任何其他进程看到。
共享内存是IPC中最快的一种通信方式,但是它没有同步机制。因此,我们需要借助其他的机制来同步对共享内存的访问。、
7.3 实现共享内存的函数
#include <sys/shm.h>
Void *shmat( int shm_id, const void *shm_addr, int shmflg );
Int shmctl(int shm_id, int cmd, struct shmid_ds *buf);
Int shmdt(const void *shm_addr);
Int shmget(key_t key, size_t size, int shmflg);
7.4 shmget函数
我们用shmget函数来创建共享内存:
Int shmget(key_t key, size_t size, int shmflg);
参数:
第一个参数:
key,与信号量一样,程序需要提供一个参数key,它有效的为共享内存段命名。
第二个参数:
Size,以字节为单位指定需要共享的内存容量。
第三个参数:
Shmflg,与建立信号量函数semget的第三个参数一样。【参考6.5节】
返回值:
Shmget函数如果创建共享内存成功,返回一个非负整数,即共享内存标识符,该标识符将用于后续的共享内存函数。如果失败,返回-1。
7.5 shmat函数
第一次创建共享内存段时,它不能被任何进程访问。要想启用对该共享内存的访问,必须将其连接到一个进程的地址空间中。这项工作由shmat来完成。
Void *shmat( int shm_id, const void *shm_addr, int shmflg );
参数:
第一个参数:
Shm_id是由shmget返回的共享内存的标识符。
第二个参数:
Shm_addr指定的是共享内存连接到当前进程中的地址位置。它通常是一个空指针,表示让系统来选择共享内存出现的地址。
第三个参数:
Shmflg是一组标志。它的两个可能的取值是SHM_RND(这个标志与shm_addr联合使用,用来控制共享内存连接的地址)和SHM_RDONLY(它使得连接的内存只读)。我们很少需要控制共享内存连接的地址,通常都是让系统来选择一个地址,否则就会使应用程序对硬件的依赖过高。
返回值:
如果shmat函数调用成功,它返回一个指向共享内存第一个字节的指针;如果失败,返回-1.
7.6 共享内存的访问权限【选学】
共享内存的读写权限由它的属主(共享内存的创建者),它的访问权限(shmget函数的shmflg参数)和当前进程的属主(父进程)决定。
共享内存的访问权限,类似于文件的访问规则。
这个规则的一个例外是:当shmflg & SHM_RDONLY为true时的情况。此时,即使该共享内存的访问权限允许写操作,它都不能被写人。【这里的shmflg是shmget函数中的第三个参数】
7.7 shmdt函数
Int shmdt(const void *shm_addr);
Shmdt函数的作用是将共享内存从当前进程中分离,只要连接到共享内存的进程不用的时候都要通过这个函数分离。
参数:
是shmat函数返回的地址指针。
返回值:
成功时返回0,失败返回-1.
【共享内存分离并不代表被删除,只是使得该共享内存对当前进程不再可用】
7.8 shmctl
Int shmctl(int shm_id, int cmd, struct shmid_ds *buf);
与复杂的信号量控制函数相比,共享内存的控制函数要稍微简单一些。
Shmid_ds结构至少包含以下成员:
Struct shmid_ds
{
Uid_t shm_perm.uid;
Uid_t shm_perm.gid;
Mode_t shm_perm.mode;
}
参数:
第一个参数shm_id是shmget返回的共享内存的标识符;
第二个参数command是要采取的动作,它可以取3个值:
命令 | 说明 |
IPC_STAT | 把shmid_ds结构中的数据设置为共享内存的当前关联值 |
IPC_SET | 如何进程有足够的权限,就把共享内存的当前关联值设置为 shmid_ds结构中给出的值 |
IPC_RMID | 删除共享内存段 |
第三个参数buf是一个指针,它指向包含共享内存模式和访问权限的结构。
返回值:
成功返回0,失败返回-1.
【真正的理解结合代码示例】
7.9 实例代码
Shm_w.c:创建共享内存,负责把写到共享内存里面的数据显示出来【消费者】
Shm_r.c 连接到(shm_w创建的)共享内存,负责往共享内存里面写数据【生产者】
Shm_com.h ,消费者生产者共用头文件【共享内存】
7.10 执行结果
7.11 实例解析
共享内存:
消费者:
生产者:
八:共享内存的缺点
在共享内存实例中【第七章】,我们用的是自己提供的,非常简陋的同步标志written_by_you,它包括一个非常缺乏效率的忙等待【不停的循环】。这可以使得我们的示例非常简单,但在实际编程中,我们应该用信号量或通过消息机制来同步。
九:消息队列
消息队列提供了一种从一个进程向另一个进程发送数据块的方法。
【数据块有最大长度的限制】
9.1 消息队列函数
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
int msgget(key_t key, int msgflg);
int msgrcv(int msqid, void *msq_ptr, size_t msg_sz, long ing msgtype, int msgflg);
Int msgsnd(int msqid, const void *msg_ptr, size_t msg_sz, int msgflg);
9.2 msgget函数
我们用msgget来创建和访问一个消息队列:
int msgget(key_t key, int msgflg);
参数:
第一个参数与其他IPC机制一样,程序必须提供一个键值来命名某个特定的消息队列。
第二个参数msgflg,与信号量,共享内存原理一样。
返回值:
成功返回一个正整数,即队列标识符,失败返回-1。
9.3 msgsnd函数
Msgsnd函数用来把消息添加到消息队列中:
Int msgsnd(int msqid, const void *msg_ptr, size_t msg_sz, int msgflg);
参数:
第一个参数是msgget函数返回的消息队列标识符;
第二个参数是要添加消息的结构,这个结构体是由程序员来定义的,函数原型给的void*,所以调用这个函数的时候要进行强制转换。
我们常这样定义这个消息结构体:
Struct my_message {
Long int messsage_type;
/*你向传输的数据放在下面*/
}
【long int在64位机系统中是8字节】
这个结构体受到两方面的约束:
首先,它的长度必须小于系统规定的上限:
其次,它必须以一个长整型成员变量开始。
第三个参数:
Msg_sz,是由msg_ptr所指向的消息的长度。这个长度包括my_message中message_type成员的长度。
第四个参数:
Msgflg控制在当前消息队列满或队列消息到达系统范围的限制时将要发生的事情。
如果msgflg中设置了IPC_NOWAIT标志,函数将立刻返回,不发送消息并且返回值为-1.如果msgflg中的IPC_NOWAIT标志被清除,则发送进程将挂起以等待队列中腾出可用空间。
返回值:
成功返回0,失败返回-1。
9.4 msgrcv函数
Msgrcv函数从一个消息队列中获取消息:
int msgrcv(int msqid, void *msq_ptr, size_t msg_sz, long ing msgtype, int msgflg);
参数:
第一个参数msqid是由msgget返回的消息队列的标识符;
第二个参数msq_ptr指向一个准备接收消息的指针,同msgsnd函数的第二个参数;
第三个参数同msgsnd函数的第三个参数;
第四个参数msgtype是一个长整数,它可以实现一种简单形式的接收优先级。实际应用很简单,一般按照消息发送的顺序来接收他们,设置为0.
第五个参数,msgflg用于控制当队列中没有相应类型的消息可以接收时将发生的事情。如果msgflg中设置了IPC_NOWAIT标志,函数将立刻返回,不发送消息并且返回值为-1.如果msgflg中的IPC_NOWAIT标志被清除,进程将会挂起以等待一条相应类型的消息到达。
注意:接收方第四个参数设置为-1,失败
返回值:
成功返回放到接收缓冲区中的字节数,消息被复制到有msg_ptr所指向的用户分配的缓冲区中,然后删除消息队列中的对应消息。失败返回-1.
9.5 msgctl函数:
最后一个函数,它的作用与共享内存中的控制函数非常相似:
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
Msqid_ds结构至少包含以下成员:
Struct msqid_ds{
Uid_t msg_perm.uid;
Uid_t msg_perm.gid;
Mode_t msg_perm.mode;
}
参数:
第一个参数msqid是由msgget返回的消息队列标识符。
第二个参数command是将要采取的动作,它可以取3个值:
命令 | 说明 |
IPC_STAT | 把msqid_ds结构中的数据设置为消息队列的当前关联值 |
IPC_SET | 如何进程有足够的权限,就把消息队列的当前关联值设置为 msqid_ds结构中给出的值 |
IPC_RMID | 删除消息队列 |
返回值:
成功时返回0,失败返回-1.如果删除消息队列时,某个进程正在msgsnd或msgrcv函数中等待,这两个函数将失败。
9.6 实例代码
Msq_rcv.c接收程序【接收者】,创建消息队列,负责接收消息,但只有接收者接收完最后一个消息后可以删除它。
发生消息程序,可以创建消息队列
9.7 运行结果
9.8 实例解析
发生者程序通过msgget来创建一个消息队列,然后用msgsnd向队列中增加消息。接收者用msgget获得消息队列标识符,然后开始接收消息,直到接收特殊的文本end为止。然后它用msgctl来删除消息队列以完成清理工作。