提示:文章内容较长,请参考目录阅读
这里写目录标题
前言:进程间通信介绍
目的
每一个进程的数据都存储在物理内存当中,在访问空间时,通过各自的页表映射关系访问到物理内存,从进程的角度看,每个进程都拥有4G的虚拟空间,但是进程并不清楚数据在物理内存中如何存储,页表如何映射,所以进程具有独立性。而进程间通信的本质就是进程之间的数据交换。
常见进程间通信方式
管道、共享内存、消息队列、信号量、信号、网络,其中网络是最大的,应用最广泛的进程间通信方式。
一、管道
管道是什么
管道符号:“|”
管道的本质:内核中的一块缓冲区,供进程进行读写,交换数据。 这里的内核指的是内核空间,与用户空间对应。
匿名管道
接口简介
int pipe(int pipefd[2]);
- 功能:创建匿名管道
- 参数:pipefd[]是一个整型数组,为出参,在调用函数中定义,在pipe函数中进行赋值,其中fd[0]表示读端,fd[1]表示写端
- 返回值:创建成功返回0,失败返回-1,并设置错误码
程序
#include<stdio.h>
#include<unistd.h>
int main()
{
int fd[2];//定义一个数组,作为pipe函数的参数
int ret=pipe(fd);
if(ret<0)
{
//创建失败
perror("pipe");
return 0;
}
//创建成功,打印文件描述符
printf("fd[0]:%d\n",fd[0]);
printf("fd[1]:%d\n",fd[1]);
printf("My pid:%d\n",getpid());
while(1)
{
sleep(1);//程序不退出
}
return 0;
}
运行结果
查看该进程fd文件
可以看到,3号和4号文件描述符对应的正是管道的读写两端。
fork共享管道
原理
不同的进程,要用同一个匿名管道进行通信,则需要进程拥有该管道的读写两端的文件描述符,所以匿名管道只让能具有亲缘关系的进程进行进程间间通信,且父进程需要先创建匿名管道,再创建子进程。如图所示:
上图中管道在内核空间,而父子进程的程序都在用户空间中。可以令子进程进行写,父进程进行读,关闭各自不用的文件描述符,仍可以进行单向通信。
站在文件描述符角度理解管道
-
父进程创建管道
-
父进程调用fork创建子进程
-
父进程关闭fd[0],子进程关闭fd[1]
程序
匿名管道的特性
半双工
即数据只能从管道的写端流向读端,不支持双向通信,如图:
无标识符
所以匿名管道只支持具有亲缘关系的进程间通信。这里需要注意的是,有亲缘关系的进程要进行通信也需要满足一定条件,即==父进程先创建匿名管道,后创建子进程,这样才能保证父子进程同时拥有该管道读写两端的文件描述符。
生命周期
管道的生命周期跟随进程,进程退出后,管道也会被销毁
大小
管道的大小为64k,可以通过一个程序进行验证,如图:
这个程序会死循环向管道写入,不进行读取,每写入一个字符打印一次,可以看到打印结果最终停在了65536,说明此时管道已经写满,由此可以知道管道大小为65536字节,64k。
字节流
- 即管道的读或写都是将管道内容读走或者写走,而不是拷贝,通过程序验证:
- 管道的读端还可以定义要读的内容的,将上面的程序稍作修改验证这一特性。
关于 pipe_size
pipe_size是保证通过管道进行读写时的原子性的阈值,可以通过ulimit -a 指令查看。
原子性:一个操作要么不间断地全部执行,要么一个也不执行,没有中间状态。
对于管道来说,原子性的含义就是,当一个进程往管道中进行写的时候,如果写入的内容小于4096字节,则另一个进程不会进行读。
阻塞属性
调用pipe创建出来的读写两端文件描述符的属性默认都是阻塞属性。write一直进行写,不去读,则写满之后write阻塞;read一直进行读,管道内部被读完之后,read会阻塞。
- 写阻塞
- 读阻塞
设置管道的非阻塞属性
接口简介
int fcntl(int fd, int cmd, ... /* arg */ );
参数
- fd,待要操作的文件描述符
- cmd,告知fcntl函数要进行的操作
F_GETFL:获取文件描述符属性信息
F_SETFL:设置文件描述符属性信息,设置新的属性信息放到可变参数列表中
返回值
- F_GETFL:返回文件描述符属性信息
- F_SETFL:0,设置成功;-1,设置失败
设置非阻塞属性步骤
- 获取文件描述符原来的属性:
int flag=fcntl(fd[0],F_GETFL);
- 设置文件描述符新的属性,要将原来的属性也带上:新的属性=原有属性 | 增加的属性
fcntl(fd[0],F_SETFL,flag | O_NONBLOCK);
设置读端为非阻塞属性
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
int main()
{
int fd[2];
int ret=pipe(fd);
if(ret<0)
{
perror("pipe");
return 0;
}
int pid=fork();
//父进程进行写,子进程进行读
//验证父进程分别关闭\不关闭写时的结果
if(pid<0)
{
perror("fork");
return 0;
}
else if(pid==0)
{
//child;
//设置读端为非阻塞属性
close(fd[1]);
int flag=fcntl(fd[0],F_GETFL);
fcntl(fd[0],F_SETFL,flag | O_NONBLOCK);
char read_buf[1024]={0};
ssize_t read_size=read(fd[0],read_buf,sizeof(read_buf)-1);
printf("%ld\n",read_size);
perror("read");
}
else
{
//parent
//写端关闭\不关闭
close(fd[0]);
//close(fd[1]);
while(1)
{
sleep(1);
}
}
return 0;
运行结果
-
读端为非阻塞,父进程不关闭写
结果分析:管道中没有内容,进行读取,返回-1,errno设置为EAGAIN,资源暂时不可用,表示可以再进行读。 -
读端为非阻塞,父进程关闭写
结果分析:读取成功,但没有读到内容
设置写端为非阻塞属性
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
int main()
{
int fd[2];
int ret=pipe(fd);
if(ret<0)
{
perror("pipe");
return 0;
}
int pid=fork();
//子进程进行写,父进程进行读
//设置写端为非阻塞属性
//关闭\不关闭读端
if(pid<0)
{
perror("fork");
return 0;
}
else if(pid==0)
{
//child,write
close(fd[0]);
int flag=fcntl(fd[1],F_GETFL);
fcntl(fd[1],F_SETFL,flag | O_NONBLOCK);//设置fd[1]为非阻塞属性
//sleep(1);
ssize_t write_size=0;
while(1)
{
//非阻塞写入一般搭配循环使用
write_size=write(fd[1],"a",1);
if(write_size<0)
{
break;
}
}
printf("%ld\n",write_size);
perror("write");
}
else
{
//parent,read
close(fd[1]);
//close(fd[0]);
while(1)
{
sleep(1);
}
}
return 0;
}
运行结果
- 子进程一直写入,父进程不关闭读
结果分析:write管道被写满,资源暂时不可用,因为管道没有空闲空间了。 - 子进程一直写入,父进程关闭读
结果分析:父进程关闭读后,没有进程从管道读取内容,子进程向管道写入内容后,管道破碎,调用写端的进程收到SIGPIPE,导致进程崩溃。
管道读写规则总结
-
当没有数据可读时
未设置O_NONBLOKC: read调用阻塞,等到有数据来为止
设置O_NONBLOCK:read返回-1,errno值为EAGAIN -
当管道写满时
未设置O_NONBLOKC:write调用阻塞,等到有数据被读走为止
设置O_NONBLOCK: write返回-1,errno值为EAGAIN -
若写端文件描述符关闭,read会返回0
-
若读端文件描述符被关闭,调用write会产生SIGPIPE信号,管道破碎,进程退出
扩展知识:宏与位图
在设置文件的非阻塞属性时,我们用O_NONBLOCK与原有属性进行按位或,获得了新的属性,在学习文件操作接口时,opend的打开方式也用到了按位或,如:open(“./1.txt”,O_RDWR | O_CREAT,0664),表示打开当前路径下的"1.txt"文件,若不存在,则创建这个文件,两种打开方式之间是按位或的关系。为什么通过按位或就能添加一个新的属性呢?写一个程序对此进行验证。
通过下面这个程序获取文件描述符修改前后的值:
运行结果:
1和2049的二进制表示如图:
在Linux内核源码中查找关于O_NONBLOCK的定义:
fcntl.h文件中对于文件操作相关宏的定义:
命名管道
创建命名管道
函数创建
通过在程序中调用函数创建一个命名管道
** 指令创建**
mkfifo filename
通过命名管道进行进程间通信
运行结果
二、共享内存
原理
在物理内存中开辟一段空间,不同进程通过页表将物理内存映射到自己的进程虚拟地址空间中,通过操作自己进程虚拟地址空间中的地址,来操作共享内存。
共享内存:
映射关系
共享内存函数
shmget
#include <sys/shm.h>//共享内存相关函数都包含在此头文件中
int shmget(key_t key, size_t size, int shmflg);
- 功能:创建或者获取共享内存
- 参数
key:共享内存标识符。
size:共享内存大小。
shmglg: 创建共享内存时传递的属性信息,用法与创建文件时mode的用法相同
IPC_CREAT:若获取的共享内存不存在,则创建。
IPC_EXCL | IPC_CREAT: 如果获取的共享内存存在,则创建;不存在,则创建。这个操作本质上时获取重新创建的共享内存。后面按位或权限,如:IPC_EXCL | IPC_CREAT | 0664。 - 返回值
成功:返回共享内存操作句柄
失败:返回-1
shmat
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 功能:将共享内存连接到进程地址空间
- 参数
shmid:共享内存操作句柄,与shmget函数的返回值类型相同。
shmaddr:指定要连接的地址,即要将共享内存附加到共享区中哪一个地址上,一般让操作系统自己分配,传递NULL。
shmflg:以什么权限将共享内存附加到进程当中。SHM_RDONLY,只读;0,可读可写。注:这里的权限指的是进程对共享内存的操作权限,并不是共享内存本身的权限。 - 返回值
成功:返回一个指针,指向附加到的共享内存的第一个字节
失败:返回-1
shmdt
int shmdt(const void *shmaddr);
- 功能:将共享内存段与进程分离
- 参数
shmaddr:shmat函数所返回的指针 - 返回值
成功:返回0
失败:返回-1
shmctl
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 功能:控制共享内存
- 参数
shmid:共享内存的操作句柄,入参
cmd:告诉shmctl需要完成的功能,有下面三个取值
IPC_SET:设置共享内存属性信息
IPC_STAT:获取共享内存属性信息
IPC_RMID:删除共享内存,进行删除操作时,第三个参数buf传NULL
buf:共享内存数据结构buf,如图:
- 返回值
成功:返回0
失败:返回-1
共享内存特性
通过ipcs指令可以查看共享内存信息
- 共享内存生命周期跟随操作系统
- 共享内存写时是覆盖写的方式,读的时候访问地址,不会将共享内存中的内容读走,与管道中的字节流不同
实例代码
- 程序
- 运行结果
这里打印的地址是附加到的进程中共享区的地址,并非在物理内存中创建的共享内存的地址
通过ipcs可以查看到程序中创建的共享内存
共享内存的删除
- 函数删除
- 指令删除
ipcrm -m [shmid]
- 共享内存被删除以后,物理内存中的空间会被销毁
- 如果删除共享内存时,共享内存附加的进程数量为0,则内核中描述该共享内存的结构体也会被释放。
- 如果删除共享内存时,共享内存附加的进程数量不为0,则会将该共享内存的key值变为0x00000000。表示当前该共享内存不能被其他进程所附加,共享内存的状态会被置为destory。附加进程全部退出后,该共享内存在内核的结构体会被释放。如图:
三、消息队列
特性
- 消息队列(msgqueue)采用链表来实现,由系统进行维护
- 系统中可能有多个消息队列,每个消息队列用msqid来进行区分
- 进行进程间通信时,一个进程将消息加到MQ尾端,另一个队列从消息队列中取消息,按照队列先进先出的的原则。支持按照消息类型先进先出,即可以指定只取某一类型的消息,在读取该类型消息时只要满足先进先出即可。
- 消息队列的生命周期跟随操作系统,删除指令:
ipcrm -q msqid
函数接口
msgget
#include <sys/msg.h>//消息队列相关函数头文件
int msgget(key_t key, int msgflg);
- 功能
创建消息队列 - 参数
key:消息队列的标识符
msgflg:创建的标志,如:IPC_CREAT,如果不存在则创建,按位或权限 - 返回值
成功:返回消息队列id
失败:返回-1,设置errno
msgsnd
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
- 功能
发送消息 - 参数
msqid:消息队列id
msgp:指向msgbuf的指针,用来指定发送的消息
msgsz:要发送消息的长度
msgflg:创建标记,如果指定IPC_NOWAIT,失败会立即返回。0,阻塞发送;IPC_NOWAIT,非阻塞发送。 - 返回值
失败返回-1,成功返回0
msgrcv
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);
- 功能
接收消息 - 参数
msqid:消息队列id
msgp:指向msgbuf的指针,用来接收消息
msgsz:要接受消息的长度
msgtyp:接收消息的方式。若msgtyp=0,读取队列中的第一条消息;若msgtyp>0,读取队列中类型为msgtyp的第一条消息,若在msgflg中指定了MSG_EXCEPT,则将读取队列中类型不等于msgtyp的第一条消息
msgflg:创建标记,如果指定IPC_NOWAIT,获取失败会立即返回 - 返回值
成功返回实际读取字节数,失败返回-1
msgctl
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
- 功能:操作消息队列
- 参数
msqid:消息队列id
cmd:控制命令。IPC_RMID,删除命令;IPC_STAT,获取状态。 - 返回值:成功时根据不同的cmd有不同的返回值,失败返回-1
实例程序
运行结果
通过ipcs查看消息队列信息
执行读取的程序后,消息数量会减为0
这时候如果再执行读取的程序,会发生阻塞或报错返回
- msgrcv的参数msgflg设置为0时,第二次读取发生阻塞
- msgrcv的参数msgflg设置为IPC_NOWAIT,获取失败会立即返回
四、信号量
原理
信号量本质上就是资源计数器,保障多个进程访问临界资源,执行临界区代码时,时能够互斥访问,同时也可用于同步。
- 临界资源:多个进程都可以访问到的资源,如一块共享内存
- 临界区代码:访问临界资源的代码
互斥
概念
- 定义:同一时刻,多个进程中,只有一个进程可以访问临界区资源。PS:上厕所的例子,厕所里只能有一个人,门关上时,其他人不能进来
多个进程通过信号量保证互斥的时候,需要先获取信号变量才能访问临界资源,如果获取不了则进行阻塞等待。
不保证互斥存在的问题
以共享内存为例,当两个进程附加同一块共享内存后,这两个进程都可以对这块共享内存中的内容进行读写操作,假设共享内存中保存了一个数据100,A进程对数据进行+1操作,但是当A进程将数据从内存中读取到CPU后,A进程的时间片用完了,会将读到的100保存在该进程的上下文信息中,此时B进程读取到了内存中的100并进行了+1操作,将内存中的数据改写成了101,这时再次轮到A进程的时间片,A进程会使用上下文信息中保存的100,并根据程序计数器中保存的内容执行下一步的+1的代码,最终加完结果仍然是101,写回内存中。如此一来便产生了程序结果的二义性,对100进行了两次+1操作得到的结果确实101。
同步
概念:当临界资源空闲后,通知等待的进程进行访问。
PS:停车场的例子,假设一个停车场有四个车位,可以记为有4个资源,当停车场停满以后资源数量为0,后来的车辆便要排队进行等待,而如果有车辆离开停车场,便需要通知等待的车辆进入停车场,避免资源的浪费,这个过程就是同步。