数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
管道
- 匿名管道pipe
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
其实管道的本质是一种特殊的文件,主要用途就是用于进程间通信
匿名管道的进程间通信,仅限于父子进程之间的通信或者兄弟进程之间的通信
首先,管道的通信原理,其实是让两个进程之间可以看到同一份资源,操作系统创建管道,其实就是在内核空间里创建一个缓冲区,用来当作管道文件,管道的大小一般是64kb,这时,只要两个进程对这个缓冲区进行读写操作,就能实现进程间通信。
而这个缓冲区是操作系统帮进程创建的,两个进程怎么才能找到这个缓冲区,并且对这个缓冲区进行操作呢?
其实,进程在创建管道的时候,会对应的创建出两个file结构体,这两个file结构体里,分别有对这个缓冲区的读和写操作的方法,而这个进程只需要拿到这两个file结构体所对应的文件描述符,就能对这个缓冲区进行读写操作。
而要让两个进程都拿到这两个文件描述符,就是让父进程创建管道,获得对应的文件描述符,然后父进程创建子进程,子进程就会复制父进程的文件描述符到自己的进程中,这时两个进程都会拿到这个缓冲区的操作方法,自然都可以对这个缓冲区进行读写操作,实现进程间通信。
#include
int pipe(int pipefd[2]);
参数:pipefd[2]是一个输出型参数,传入pipefd的地址,在函数运行过程中,操作系统会帮进程创建管道,并且把能对管道操作的操作文件的文件描述符填入数组中。
数组元素 | 含义 |
---|---|
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
返回值:
成功返回0,失败返回错误代码
pipe代码演示
#include
#include
#include
#include
#include
int main() {
int pipefd[2];
//使用pipe创建管道
if (-1 == pipe(pipefd)) {
perror("pipe error!\n");
return 1;
}
//创建子进程
pid_t pid = fork();
if (pid < 0) {
perror("fork error!\n");
}
else if (pid == 0) {
//child child进行写操作
close(pipefd[0]);
char* msg = "child write success!\n";
ssize_t ret = write(pipefd[1], msg, strlen(msg));
}
else {
//father father进行读操作
close(pipefd[1]);
char buffer[64];
sleep(1);
ssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (s > 0) {
buffer[s] = 0;
}
printf(buffer);
}
wait(NULL);
close(pipefd[0]);
close(pipefd[1]);
return 0;
}
验证一下所有读端被关闭的情况:
#include
#include
#include
#include
#include
#include
int main() {
int pipefd[2];
if (-1 == pipe(pipefd)) {
perror("pipe error!\n");
return 1;
}
pid_t pid = fork();
if (pid < 0) {
perror("fork error!\n");
}
else if (pid == 0) {
//child child进行写操作
close(pipefd[0]);
char* msg = "child test write!\n";
int count = 0;
while (1) {
//子进程不断写入
ssize_t s = write(pipefd[1], msg, strlen(msg));
}
}
else {
//father father进行读操作
close(pipefd[1]);
char buffer[64];
int count = 0;
while (1) {
if (count == 5) {
//父进程读取五次后关闭读端
close(pipefd[0]);
break;
}
ssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1);
count++;
printf(buffer);
}
int status = 0;
//父进程等待子进程获取子进程退出状态
pid_t ret = waitpid(pid, &status, 0);
//打印子进程收到的信号
printf("get child signal: %d\n", status & 0x7f);
}
return 0;
}
子进程收到了13号信号退出
13号信号是SIGPIPE
坏的管道,写入到没有读端的管道,读端进程已经被信号杀死
匿名管道只能用于两个有亲缘关系的进程之间通信,是因为匿名管道创建的缓冲区没有标识符,只有返回给进程的文件描述符,文件描述符是匿名管道的操作句柄,而有亲缘关系的进程,复制了创建管道的进程的文件描述符表,也就有了管道操作的文件描述符,就能对管道进行读写操作,完成通信。
但是如果两个没有亲缘关系的进程要进行通信,那么我们就要借助命名管道,命名管道其实是一个特殊的文件,它创建出来是会显示到目录里,可以查看到的文件,两个进程通过这个文件,就能操作同一个管道,也就是访问同一块资源,完成通信。
命名管道和匿名管道只是创建和打开方式不同,一旦管道创建并且打开后,命名管道和匿名管道就没有区别了。
$ mkfifo filename
创建出来的文件,文件类型是p,说明是一个管道文件,文件大小是0。
是因为这个进程间通信所用的缓冲区是在内核中,而这个fifo文件只是一个符号型文件,通信的数据并没有存到fifo文件中,所以fifo大小一直是0。
#include
#include
int mkfifo(const char *pathname, mode_t mode);
参数:
- 第一个参数 pathname表示创建的命名管道的路径和名称(不指明路径默认是当前路径)
- 第二个参数是设置创建的管道文件的权限。
注意:
实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。
若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0。
进程里设置的umask不会影响系统里的umask值
返回值:
成功返回0,失败返回-1
创建一个命名管道:
#include
#include
#include
int main() {
//先设置文件默认掩码
umask(0);
if (-1 == mkfifo("./fifo", 0666));
return 0;
}
命名管道的读写规则和匿名管道完全一致
既然命名管道可以实现两个不相关进程间的通信,那么我们写两个小程序来模拟服务器和客户端之间的通信。
首先编写服务端,服务端需要创建命名管道,然后循环读取管道中的内容,就能收到客户端发到管道里的数据。
#include
#include
#include
#include
#include
#include
#include
//服务器端,接收数据
int main() {
umask(0);
//服务端创建命名管道
if (-1 == mkfifo("fifo", 0664)) {
perror("mkfifo error!\n");
}
//创建缓冲区读取数据
char buffer[64];
//以只读方式打开管道文件
int fd = open("fifo", O_RDONLY);
if (fd < 0) {
perror("open error!\n");
return 1;
}
//循环读取管道中内容
while (1) {
//从管道中读取内容到buffer缓冲区
ssize_t s = read(fd, buffer, sizeof(buffer) - 1);
if (s > 0) {
buffer[s] = 0;
//打印缓冲区内容
printf("client msg: %s", buffer);
}
//如果客户端进程退出,也就是写端关闭,根据管道的读写特性,读端在读取到文件结尾就会返回0
if (s == 0) {
perror("client close");
break;
}
if (s < 0) {
perror("read error\n");
return 1;
}
}
close(fd);
return 0;
}
对于客户端来说,服务端已经创建好了管道文件,服务端只需要打开管道文件,并且不断往管道写入数据即可。
#include
#include
#include
#include
#include
#include
#include
//客户端,发送数据
int main() {
char buffer[64];
//以只写方式打开管道文件
int fd = open("fifo", O_WRONLY);
if (fd < 0) {
perror("open error!\n");
return 1;
}
while (1) {
//输出提示符
printf("Please Enter Message #:");
//刷新标准输出缓冲区
fflush(stdout);
//把shell标准输入的数据写入到buffer缓冲区
ssize_t s = read(0, buffer, sizeof(buffer) - 1);
if (s > 0) {
buffer[s] = 0;
//把buffer缓冲区的内容写入到管道文件中
write(fd, buffer, s);
}
}
close(fd);
return 0;
}
创建方式不同
匿名管道通过pipe函数创建,命名管道通过mkfifo函数创建,而且命名管道可以通过shell命令行创建
操作方法不同
匿名管道的操作句柄是文件描述符,命名管道是通过文件名操作的
除此之外,匿名管道和命名管道是一样的。
- 管道内部自带同步与互斥机制,在同一时刻只允许一个进程对其进行写入或是读取操作。
- 管道的生命周期随进程,进程退出,管道释放。
- 管道提供的是流式服务,以字节为单位持续传输,先进先出
- 管道是半双工通信的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
管道的本质是通过文件系统实现的通信,而system V IPC则是操作系统直接提供的通信方式。
system V标准的进程间通信分为三种,system V共享内存,system V信号量,system V消息队列。
其中,system V共享内存和system V 消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。
进程间通信的原理是让两个进程同时看到同一份资源,对同一份资源的读写操作就有了通信。
管道是通过在内核开辟一块缓冲区,并用文件系统对这块缓冲区进行操作,实现进程间通信,期间要进行多次数据拷贝,效率不够高。
而共享内存是操作系统直接在物理内存中申请一块内存,通过页表映射到进程的进程地址空间,两个进程可以直接看到这份资源,对这块内存的操作并不需要借助文件系统,所以共享内存是进程间通信最快的方式。
共享内存是一块内存,要对这块内存进行管理,就得遵循"先描述,再组织"的原则,描述共享内存的数据结构叫做shmid_ds,用来描述共享内存。
其中第一个成员shm_perm,是一个ipc_perm的结构体变量,我们再转到ipc_perm。
第一个成员key,就是共享内存的标识,key值用于标识系统中共享内存的唯一性。
首先一块共享内存需要被操作系统创建,刚创建好的共享内存和进程是没有关系的,由内核创建,生命周期是随内核的,我们创建好的共享内存,要让进程可以操作共享内存,就得先把共享内存和进程关联起来,获得共享内存的地址信息,直接把信息写入地址,对应关联的另一个进程就会马上看到写入的信息,完成通信。
在使用完后,也需要取消进程和内存的关联,最后再主动删除共享内存。
为了确保两进程使用的是同一块共享内存,我们会调用一个接口来获取一个唯一值,作为共享内存的标识。
这个key值在创建共享内存时被传入,填入共享内存的描述结构体中,作为共享内存的唯一标识。
#include
#include
key_t ftok(const char *pathname, int proj_id);
参数:
有关该函数的三个常见问题:
- pathname是目录还是文件的具体路径,是否可以随便设置?
- pathname指定的目录或文件的权限是否有要求?
- proj_id是否可以随便设定,有什么限制条件?
解答:
- ftok根据路径名,提取文件信息,再根据这些文件信息及project ID合成key,该路径可以随便设置。
- 该路径是必须存在的,ftok只是根据文件inode在系统内的唯一性来取一个数值,和文件的权限无关。
- proj_id是可以根据自己的约定,随意设置。这个数字,有的称之为project ID; 在UNIX系统上,它的取值是1到255;
转自satellite13
返回值:
这个接口的返回值,就是生成的key值,IPC键值。
#include
#include
#include
#define PATHNAE "/home/xiaomage/workspace/IPC/shm"
#define PROJ_ID 8888
int main() {
key_t key = ftok(PATHNAE, PROJ_ID);
printf("key:%d\n", key);
return 0;
}
shmget接口
#include
int shmget(key_t key, size_t size, int shmflg);
参数:
- 第一个参数key,就是我们用ftok函数运算出来的IPC键值
- 第二个参数是设置共享内存的大小,一般设置为4096的整数倍
- 第三个参数由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
关于第三个参数的权限,我们一般使用两个,IPC_CREAT和IPC_EXCL,IPC_CREAT可以单独使用,也可以或上IPC_EXCL来使用
常用组合方式:
组合方式 | 作用 |
---|---|
IPC_CREAT | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄 |
IPC_CREAT | IPC_EXCL | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回 |
- 如果只单独使用使用IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存
- 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。
也可以在这个参数里或上创建的共享内存的权限信息。
返回值:
shmat接口
#include
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
- 第一个参数shmid,就是成功创建共享内存返回的标识符,也就是这个共享内存的操作句柄
- 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,让系统自己分配合适位置。
- 第三个参数shmflg,它的两个可能取值是SHM_RND和SHM_RDONLY,设置关联的权限,一般我们取0
返回值
它的返回值是一个void* 类型的地址,就是把共享内存映射到进程地址空间上的地址的起始地址,类似于malloc函数申请内存成功的返回值。
shmdt接口
#include
int shmdt(const void *shmaddr);
参数:
参数shmaddr就是shmat的返回值,也就是shmat返回的起始地址。
返回值:
shmctl接口
#include
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
- 第一个参数,共享内存的标识符
- 第二个参数,具体的控制操作
- 第三个参数,用于设置或者查询共享内存的数据结构,一般设为NULL
第二个参数,一般有三个,对应不同的操作,要删除就传入IPC_RMID
选项 | 操作 |
---|---|
IPC_RMID | 删除共享内存 |
IPC_STAT | 获取共享内存的当前关联值,此时参数buf作为输出型参数 |
IPC_SET | 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值 |
返回值:
两个进程使用公共头文件comm.h
#pragma once
#include
#include
#include
#include
#include
#include
#define PATHNAME "/home/xiaomage/workspace/IPC/shm" //ftok的第一个参数,任意真实路径
#define PROJ_ID 2022331 //ftok的第二个参数,任意整数
#define SIZE 4096 //shmget的第二个参数,一个page的大小 4096字节
客户端要获取唯一值key,创建共享内存,关联共享内存,然后再读取共享内存中的数据,然后取消关联,删除共享内存。
客户端代码
#include"comm.h"
// 服务端
int main() {
// 1 获取IPC键值
key_t key = ftok(PATHNAME, PROJ_ID);
// 2 创建共享内存
int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666);
// 3 关联共享内存
char* msg = (char*)shmat(shmid, NULL, 0);
// 4 服务端读取数据并打印
int count = 0;
while (1) {
printf("client message:%s\n", msg);
sleep(1);
if (++count == 27) {
break;
}
}
// 5 取消关联共享内存
shmdt(msg);
// 6 释放共享内存资源
shmctl(shmid, IPC_RMID, NULL);
printf("IPC over!\n");
return 0;
}
客户端首先也要获取到使用同样的参数获得和服务端一样的key值,然后获取共享内存的标识符,用标识符关联到共享内存,往内存里写入数据,然后和共享内存取消关联,最后释放共享内存资源。
#include"comm.h"
// 客户端
int main() {
// 1 获取IPC键值
key_t key = ftok(PATHNAME, PROJ_ID);
// 2 获取共享内存的shmid
int shmid = shmget(key, SIZE, IPC_CREAT);
// 3 关联共享内存
char* msg = (char*)shmat(shmid, NULL, 0);
// 4 客户端写入数据到共享内存
char c = 'A';
for (; c <= 'Z'; c++) {
msg[c - 'A'] = c;
sleep(1);
}
// 5 取消关联共享内存
shmdt(msg);
printf("client close!\n");
return 0;
}
注意:共享内存可以随意进行读写操作,不提供任何同步和互斥机制
选项:
- -q:列出消息队列相关信息。
- -m:列出共享内存相关信息。
- -s:列出信号量相关信息。
选项:
- -q:释放消息队列资源。
- -m:释放共享内存资源。
- -s:释放信号量资源。
注意:选项后要加上对应的标识符(shmid),也就是创建成功后的返回值。
- 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
- 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
- IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
信号量主要用于同步和互斥的。
进程互斥
- 由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥
- 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
- 在进程中涉及到互斥资源的程序段叫临界区