进程间通信介绍
概念
目的
本质
分类
管道
什么是管道
匿名管道
匿名管道的原理
pipe函数
匿名管道使用步骤
管道读写规则
管道的特点
管道的大小
命名管道
命名管道的原理
使用命令创建命名管道
创建一个命名管道
命令管道的打开规则
用命名管道实现serve&client通信
用命名管道实现进程遥控
用命名管道实现文件拷贝
命名管道和匿名管道的区别
命令行当中的管道
system V进程间通信
system V共享内存
共享内存的基本原理
共享内存数据结构
共享内存的建立与释放
共享内存的创建
共享内存的释放
共享内存的关联
共享内存的去关联
用共享内存实习serve&client通信
共享内存与管道进行对比
System V消息队列
消息队列的基本原理
消息队列数据结构
消息队列的创建
消息队列的释放
向消息队列发送数据
从消息队列获取数据
System V信号量
信号量相关概念
信号量数据结构
信号量相关函数
进程互斥
System V IPC联系
进程间通信简称为IPC是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统中同时运行,并互相传递,交换信息。这使得一个程序能够在同一时间里处理许多用户的要求。IPC方法包括管道,消息排队,旗语,共用内存以及套接字(本篇博客只介绍共享内存和管道).
进程间通信的本质就是,让不同的进程看到同一份资源。
由于各个运行进程之间具有独立性,这个独立性主要体现在数据方面,而代码逻辑层面可以私有也可以共有,因此各个进程之间要实现通信是很困难的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。
因此,进程间通信的本质就是,让不同的进程看到同一份资源,由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
管道
System V IPC
POSIX IPC
概念:管道是UNIX中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"。它的特点就是单向传输数据的,先进先出。
例如,统计我们当前使用云服务器上的登录用户个数。
其中,who命令和wc命令是两个程序,当运行起来就变成两个进程,who进程通过便准输出将数据打到管道中,wc进程再通过标准输入从管道当中读取数据。至此便完成了数据的传输,进而完成数据的进一步加工处理。(who命令用于查看当前云服务器的登录用户(一行一个用户),wc -l用于统计当前的行数)
匿名管道用于进程间通信,且仅限于本地父子进程之间的通信。
进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
注意:
pipe函数用于创建匿名管道,pip函数的函数原型如下:
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符
数组元素 | 含义 |
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
pipe函数调用成功时返回0,调用失败时返回-1。
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
2.父进程创建子进程
3.父进程关闭写端,子进程关闭读端
注意:
从文件描述符的角度再来看看这三个步骤:
2.父进程创建子进程
3.父进程关闭写端,子进程关闭读端
在以下代码中,子进程向匿名管道当中写入10行数据,父进程向匿名管道读出数据。
#include
#include
#include
#include
#include
#include
int main()
{ //创建匿名管道
int pipefd[2]={0};
if(pipe(pipefd)<0)
{
perror("pipe error!\n");
exit(-1);
}
//创建子进程
pid_t id=fork();
if(id<0)
{
perror("fork error!\N");
exit(-1);
}
else if(id==0)
{
//子进程关闭读端
close(pipefd[0]);
const char* msg="I am child...!\n";
int count=10;
while(count--)
{
write(pipefd[0],msg,strlen(msg]);
sleep(1);
}
}
else
{
//关闭写端
close(pipefd[1]);
char buf[64];
while(1)
{
ssize_t s=read(pipefd[0],buf,sizeof(buf)/sizeof(buf[0]);
if(s>0)
{
buf[s]=0;
printf("father get message:%s",buf);
}
else if(s==0)
{
printf("father read end of file...\n");
}
sleep(1);
}
}
return 0;
}
运行结果如下:
以四种情况来进行研究:
1.写端速度小于读端速度,管道大部分时间内为空,即读条件不满足 让子进程每5秒写一次,父进程一直在读,观察现象
代码如下:
#include
#include
#include
#include
#include
#include
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
if (ret == -1){
// 管道创建失败
perror("make piep");
exit(-1);
}
pid_t id = fork();
if (id < 0){
perror("fork failed");
exit(-1);
}
else if (id == 0){
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
//int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
sleep(5);// 管道大部分时间是空的,读条件不满足时,读端处于阻塞状态
printf("child is sending message...\n");
}
}
else{
// parent
close(pipefd[1]);
char buf[64];
//int count = 0;
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
}
}
return 0;
}
运行结果:读端处于阻塞
总结:当读条件不满足时,读端进程会处于阻塞,从task_struct会从运行队列调到等待队列,知道数据来才会转移到运行队列中
2.写端速度大于读端速度,管道大部分时间内是满的,即写调整不满足 让子进程一直写,父进程每3秒读一次,观察现象
代码改造:
pid_t id = fork();
if (id < 0){
perror("fork failed");
exit(-1);
}
else if (id == 0){
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
//int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
printf("child is sending message...\n");
}
}
else{
// parent
close(pipefd[1]);
char buf[64];
//int count = 0;
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
}
}
3.关闭写端 让写端先写5秒,然后关闭写端,观察现象
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
printf("child is sending message...\n");
printf("CHILD: %d\n", count++);
if (count == 5){
close(pipefd[1]);
exit(-1);
}
sleep(1);
}
// parent
close(pipefd[1]);
char buf[64];
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
}
如果关闭写端,读端进程会读到文件结尾
4.关闭读端 3秒后关闭读端
#include
#include
#include
#include
#include
#include
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
if (ret == -1){
// 管道创建失败
perror("make piep");
exit(-1);
}
pid_t id = fork();
if (id < 0){
perror("fork failed");
exit(-1);
}
else if (id == 0){
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
// int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
printf("child is sending message...\n");
sleep(1);
}
}
else{
// parent
close(pipefd[1]);
char buf[64];
int count = 0;
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
//sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
sleep(1);
if (count++ == 3){
close(pipefd[0]);// 读端关闭文件描述符,写端进程后序会被操作系统直接杀掉,没有进程读,写时没有意义的
break;
}
}
int status;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0){
// 等待成功
printf("child exit singal is %d\n", status&0x7f);
}
else{
// 等待失败
perror("wait failed");
exit(-1);
}
}
return 0;
}
运行结果:关闭读端后,子进程收到操作系统发送的13个信号杀死
读端关闭,写端进程会被操作系统发送信号杀死。
总结:管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。
1.当没有数据可读时:
2.当管道满的时候
3.如果所有管道写端对应的文件描述符被关闭,则read返回0
4.如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5.当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
6.当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性
管道内部自带同步与互斥机制
我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或读取操作,因此管道也就是一种临界资源。
临界资源需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能会出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写,交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:
实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作1完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
2.管道的生命周期随进程
管道本质是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有该文件的进程都退出后,该文件也会被释放掉,所以说管道的生命周期随进程。
3.管道提供的是流式服务
对于进程A写入管道当中的数据,进程B每次从管道读取的数据多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:
4.管道是半双工通信的
在数据通信中,数据在线路上的传送方式可以分为以下三种:
管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。
管道的容量是有限的,如果管道满了。那么写端将阻塞或失败,那么管道的最大容量是多少?
方法一:代码测试
若读端进程一直不读取管道中的数据,写端进程一直向管道写入数据,当管道被写满后,写端进程就会被挂起。据此,可以写入代码来测试管道的最大容量。
#include
#include
#include
#include
int main()
{
int fd[2] = { 0 };
if (pipe(fd) < 0){ //使用pipe创建匿名管道
perror("pipe");
return 1;
}
pid_t id = fork(); //使用fork创建子进程
if (id == 0){
//child
close(fd[0]); //子进程关闭读端
char c = 'a';
int count = 0;
//子进程一直进行写入,一次写入一个字节
while (1){
write(fd[1], &c, 1);
count++;
printf("%d\n", count); //打印当前写入的字节数
}
close(fd[1]);
exit(0);
}
//father
close(fd[1]); //父进程关闭写端
//父进程不进行读取
waitpid(id, NULL, 0);
close(fd[0]);
return 0;
}
可以看到,在读端进程不读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起,意思就是当前Linux版本中管道的最大容量为65536字节。
方法二:使用man手册
根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux2.6.11往后,管道的最大容量是65536字节。
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
注意:
我们可以使用mkfifo
命令创建一个命名管道。
可以看到,创建出来的文件类型是p,代表该文件是命名管道文件。
使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。
之前我们说过,当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。
在程序创建命名管道使用mkfifo函数,mkfifo函数的函数原型如下:
int mkfifo(const char *pathname, mode_t mode);
mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。
mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
例如,将mode设置为0666,则命名管道文件创建出来的权限如下:
但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。
若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用umask
函数将文件默认掩码设置为0
umask(0); //将文件默认掩码设置为0
mkfifo函数的返回值
创建一个名为fifo的命名管道:
#include
#include
#include
#define FILE_NAME "fifo"
int main()
{
umask(0); //将文件默认掩码设置为0
if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
perror("fifo");
return 1;
}
//create success...
return 0;
}
运行结果:
1.如果当前打开操作是为读而打开FIFO时
2.如果当前打开操作是为写而打开FIFO时。
实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。
服务端代码:
//server.c
#include "comm.h"
int main()
{
umask(0); //将文件默认掩码设置为0
if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
perror("mkfifo");
return 1;
}
int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
if (fd < 0){
perror("open");
return 2;
}
char msg[128];
while (1){
msg[0] = '\0'; //每次读之前将msg清空
//从命名管道当中读取信息
ssize_t s = read(fd, msg, sizeof(msg)-1);
if (s > 0){
msg[s] = '\0'; //手动设置'\0',便于输出
printf("client# %s\n", msg); //输出客户端发来的信息
}
else if (s == 0){
printf("client quit!\n");
break;
}
else{
printf("read error!\n");
break;
}
}
close(fd); //通信完毕,关闭命名管道文件
return 0;
}
而对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。
客户端代码:
//client.c
#include "comm.h"
int main()
{
int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件
if (fd < 0){
perror("open");
return 1;
}
char msg[128];
while (1){
msg[0] = '\0'; //每次读之前将msg清空
printf("Please Enter# "); //提示客户端输入
fflush(stdout);
//从客户端的标准输入流读取信息
ssize_t s = read(0, msg, sizeof(msg)-1);
if (s > 0){
msg[s - 1] = '\0';
//将信息写入命名管道
write(fd, msg, strlen(msg));
}
}
close(fd); //通信完毕,关闭命名管道文件
return 0;
}
.对于如何让客户端和服务端使用同一个命名管道文件,这里我们可以让客户端和服务端包含同一个头文件,该头文件当中提供这个共用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了。
共用头文件:
//comm.h
#pragma once
#include
#include
#include
#include
#include
#include
#define FILE_NAME "myfifo" //让客户端和服务端使用同一个命名管道
代码编写完毕后,先将服务端进程运行起来,之后我们就能在客户端看到这个已经被创建的命名管道文件。接着再将客户端也运行起来,此时我们从客户端写入的信息被客户端写入到命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,该现象说明服务端是能够通过命名管道获取到客户端发来的信息的,换句话说,此时这两个进程之间是能够通信的。
当客户端和服务端运行起来时,我们还可以通过ps命令查看这两个进程的信息,可以发现这两个进程确实是两个毫不相关的进程,因为它们的PID和PPID都不相同。也就证明了,命名管道是可以实现两个毫不相关进程之间的通信的。
服务端和客户端之间的退出关系
当客户端退出后,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中是直接退出了)。
当服务端退出后,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。
通信是在内存当中进行的
若是我们只让客户端向管道写入数据,而服务端不从管道读取数据,那么这个管道文件的大小会不会发生变化呢?
//server.c
#include "comm.h"
int main()
{
umask(0); //将文件默认掩码设置为0
if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
perror("mkfifo");
return 1;
}
int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
if (fd < 0){
perror("open");
return 2;
}
while (1){
//服务端不读取管道信息
}
close(fd); //通信完毕,关闭命名管道文件
return 0;
}
可以看到,尽管服务端不读取管道当中的数据,但是管道当中的数据并没有被刷新到磁盘,使用ll
命令看到命名管道文件的大小依旧为0,也就说明了双方进程之间的通信依旧是在内存当中进行的,和匿名管道通信是一样的。
比较有意思的是,我们可以通过一个进程来控制另一个进程的行为,比如我们从客户端输入命令到管道当中,再让服务端将管道当中的命令读取出来并执行。
下面我们只实现了让服务端执行不带选项的命令,若是想让服务端执行带选项的命令,可以对管道当中获取的命令进行解析处理。这里的实现非常简单,只需让服务端从管道当中读取命令后创建子进程,然后再进行进程程序替换即可。
这里也无需更改客户端的代码,只需改变服务端处理通信信息的逻辑即可。
#include "comm.h"
int main()
{
umask(0); //将文件默认掩码设置为0
if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
perror("mkfifo");
return 1;
}
int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
if (fd < 0){
perror("open");
return 2;
}
char msg[128];
while (1){
msg[0] = '\0'; //每次读之前将msg清空
//从命名管道当中读取信息
ssize_t s = read(fd, msg, sizeof(msg)-1);
if (s > 0){
msg[s] = '\0'; //手动设置'\0',便于输出
printf("client# %s\n", msg);
if (fork() == 0){
//child
execlp(msg, msg, NULL); //进程程序替换
exit(1);
}
waitpid(-1, NULL, 0); //等待子进程
}
else if (s == 0){
printf("client quit!\n");
break;
}
else{
printf("read error!\n");
break;
}
}
close(fd); //通信完毕,关闭命名管道文件
return 0;
}
此时服务端接收到客户端的信息后,便进行进程程序替换,进而执行客户端发送过来的命令。
这里我们再用命名管道实现一个文件的拷贝。
需要拷贝的文件是file.txt
,该文件当中的内容如下:
我们要做的就是,让客户端将file.txt
文件通过管道发送给服务端,在服务端创建一个file-bat.txt
文件,并将从管道获取到的数据写入file-bat.txt
文件当中,至此便实现了file.txt
文件的拷贝。
其中服务端需要做的就是,创建命名管道并以读的方式打开该命名管道,再创建一个名为file-bat.txt
的文件,之后需要做的就是将从管道当中读取到的数据写入到file-bat.txt
文件当中即可。
服务端的代码如下:
//server.c
#include "comm.h"
int main()
{
umask(0); //将文件默认掩码设置为0
if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
perror("mkfifo");
return 1;
}
int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
if (fd < 0){
perror("open");
return 2;
}
//创建文件file-bat.txt,并以写的方式打开该文件
int fdout = open("file-bat.txt", O_CREAT | O_WRONLY, 0666);
if (fdout < 0){
perror("open");
return 3;
}
char msg[128];
while (1){
msg[0] = '\0'; //每次读之前将msg清空
//从命名管道当中读取信息
ssize_t s = read(fd, msg, sizeof(msg)-1);
if (s > 0){
write(fdout, msg, s); //将读取到的信息写入到file-bat.txt文件当中
}
else if (s == 0){
printf("client quit!\n");
break;
}
else{
printf("read error!\n");
break;
}
}
close(fd); //通信完毕,关闭命名管道文件
close(fdout); //数据写入完毕,关闭file-bat.txt文件
return 0;
}
而客户端需要做的就是,以写的方式打开这个已经存在的命名管道文件,再以读的方式打开file.txt
文件,之后需要做的就是将file.txt
文件当中的数据读取出来并写入管道当中即可。
客户端的代码如下:
//client.c
#include "comm.h"
int main()
{
int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件
if (fd < 0){
perror("open");
return 1;
}
int fdin = open("file.txt", O_RDONLY); //以读的方式打开file.txt文件
if (fdin < 0){
perror("open");
return 2;
}
char msg[128];
while (1){
//从file.txt文件当中读取数据
ssize_t s = read(fdin, msg, sizeof(msg));
if (s > 0){
write(fd, msg, s); //将读取到的数据写入到命名管道当中
}
else if (s == 0){
printf("read end of file!\n");
break;
}
else{
printf("read error!\n");
break;
}
}
close(fd); //通信完毕,关闭命名管道文件
close(fdin); //数据读取完毕,关闭file.txt文件
return 0;
}
共用头文件的代码和之前的一样,如下:
//comm.h
#pragma once
#include
#include
#include
#include
#include
#include
#define FILE_NAME "myfifo" //让客户端和服务端使用同一个命名管道
编写完代码后,先运行服务端,再运行客户端,一瞬间这两个进程就相继运行结束了。
此时使用ll
命令就可以看到,已经完成了file.txt
文件的拷贝。
使用cat命令打印file-bat.txt
文件当中的内容,发现和file.txt
文件当中的内容相同,拷贝文件成功。
使用管道实现文件的拷贝有什么意义?
因为这里是使用管道在本地进行的文件拷贝,所以看似没什么意义,但我们若是将这里的管道想象成“网络”,将客户端想象成“Windows Xshell”,再将服务端想象成“centos服务器”。那我们此时实现的就是文件上传的功能,若是将方向反过来,那么实现的就是文件下载的功能。
现有data.txt
文件,文件当中的内容如下:
我们可以利用管道(“|”)同时使用cat命令和grep命令,进而实现文本过滤。
那么在命令行当中的管道(“|”)到底是匿名管道还是命名管道呢?
由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道(“|”)连接起来的各个进程之间是否具有亲缘关系。
下面通过管道(“|”)连接了三个进程,通过ps命令查看这三个进程可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程
而它们的父进程实际上就是命令行解释器,这里为bash
。
也就是说,由管道(“|”)连接起来的各个进程是有亲缘关系的,它们之间互为兄弟进程。
现在我们已经知道了,若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道。
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。
system V IPC提供的通信方式有以下三种:
其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。
说明一下:
system V共享内存和system V消息队列就类似于手机,用于沟通信息。system V信号量就类似于下棋比赛时用的棋钟,用于保证两个棋手之间的同步与互斥。
共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。
这里所说的开辟物理空间,建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。
共享内存的数据结构如下:
struct shmid_ds {
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
};
当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个key值,这个key值用于标识系统中共享内存的唯一性。
可以看到上面共享内存数据结构的第一个成员是shm_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:
struct ipc_perm{
__kernel_key_t key;
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid;
__kernel_gid_t cgid;
__kernel_mode_t mode;
unsigned short seq;
};
共享内存的建立大致包括以下两个过程:
共享内存的释放大致包括以下两个过程:
创建共享内存我们需要使用shmget函数,shmget函数的函数原型如下:
int shmget(key_t key,size_t size,int shmflg);
shmget函数的参数说明:
shmget函数的返回值说明:
注意:
我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作 。
传入shmget函数的第一个参数Key,需要我们使用ftok函数进行获取
ftok函数的函数原型如下 :
key_t ftok(const char *pathname, int proj_id);
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定对的文件必须存在且可存取。
传入shmget函数的第三个参数shmflg,常用的组合方式有以下两种:
组合方式 | 作用 |
IPC_CREAT | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄。 |
IPC_CREAT|IPC_EXCL | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回 |
换句话说:
至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:
#include
#include
#include
#include
#include
#define PATHNAME "./" //路径名
#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
if (key < 0){
perror("ftok");
return 1;
}
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存
if (shm < 0){
perror("shmget");
return 2;
}
printf("key: %x\n", key); //打印key值
printf("shm: %d\n", shm); //打印句柄
return 0;
}
运行结果:
在Linux中,可以使用ipcs命令查看有关进程间通信设施的信息。
单独使用ipcs命令时,会默认列出消息队列,共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
这里携带-m选项查看共享内存相关信息:
ipcs命令输出的每列信息含义如下:
标题 | 含义 |
key | 系统区别各个共享内存的唯一标识 |
shmid | 共享内存的用户层id |
owner |
共享内存的拥有者 |
perms | 共享内存的权限 |
nattch | 关联共享内存的进程数 |
status | 共享内存的状态 |
注意:key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的关系。
通过上面创建的共享内存实验发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,知道关机重启,同时也说明了IPC资源是由内核提供并维护的。
此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。
使用命令释放共享内存资源
注意:指定删除时使用的是共享内存的用户层id,即列表当中的shmid。
使用程序释放共享内存资源
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmctl函数的参数说明:
shmctl函数的返回值说明:
其中,作为shmctl函数的第二个参数传入的常用选项有以下三个:
其中,作为shmctl函数的第二个参数传入的常用选项有以下三个:
选项 | 作用 |
IPC_STAT | 获取共享内存的当前关联值,此时参数buf作为输出型参数 |
IPC_RMID | 删除共享内存段 |
IPC_SET | 在进程有足够权限的前提下,将共享内存当前关联值设置为buf所指的数据结构中的值 |
例如,在以下代码当中,共享内存被创建,两秒后程序自动移除共享内存,再过两秒程序就会自动退出。
我们可以在程序运行时,使用以下监控脚本时刻关注共享内存的资源分配情况
while :; do ipcs -m;echo "###################################";sleep 1;done
通过监控脚本可以确定共享内存确实创建并且成功释放了。
将共享内存链接到进程地址空间我们需要用shmat函数,shmat函数的原型如下说是:
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat函数参数说明:
shmat函数的返回值说明:
其中,作为shmat函数的第三个参数传入的常用选项有以下三个:
选项 | 作用 |
SHM_RDONLY | 关联共享内存后只进行读取操作 |
SHM_RND | 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式为:shmaddr-(shmaddr%SHMLBA) |
0 | 默认为读写权限 |
这时我们可以尝试使用shmat函数对共享内存进行关联
#include
#include
#include
#include
#include
#define PATHNAME "./" //路径名
#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
if (key < 0){
perror("ftok");
return 1;
}
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存
if (shm < 0){
perror("shmget");
return 2;
}
printf("key: %x\n", key); //打印key值
printf("shm: %d\n", shm); //打印句柄
printf("attach begin!\n");
sleep(2);
char* mem = shmat(shm, NULL, 0); //关联共享内存
if (mem == (void*)-1){
perror("shmat");
return 1;
}
printf("attach end!\n");
sleep(2);
shmctl(shm, IPC_RMID, NULL); //释放共享内存
return 0;
}
运行结果如下所示,发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,所以创建出来的共享内存的默认权限为0,即没有任何权限。
我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同。
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建权限为0666的共享内存
此时再运行程序,即可发现关联该共享内存的进程数由0变成了1,而共享内存的权限显示也不再是0,而是我们设置的666权限。
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:
int shmdt(const void *shmaddr);
shmdt函数参数的说明:
shmdt函数的返回值说明:
现在我们能够取消共享内存与进程之间的关联了
#include
#include
#include
#include
#include
#define PATHNAME "./" //路径名
#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
if (key < 0){
perror("ftok");
return 1;
}
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存
if (shm < 0){
perror("shmget");
return 2;
}
printf("key: %x\n", key); //打印key值
printf("shm: %d\n", shm); //打印句柄
printf("attach begin!\n");
sleep(2);
char* mem = shmat(shm, NULL, 0); //关联共享内存
if (mem == (void*)-1){
perror("shmat");
return 1;
}
printf("attach end!\n");
sleep(2);
printf("detach begin!\n");
sleep(2);
shmdt(mem); //共享内存去关联
printf("detach end!\n");
sleep(2);
shmctl(shm, IPC_RMID, NULL); //释放共享内存
return 0;
}
运行程序,通过监控即可发现该共享内存的关联数由1变为0的过程,即取消了共享内存与该进程之间的关联。
注意:将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的练习。
在知道了共享内存的创建,关联,去关联以及释放后,现在可以尝试让两个进程通过共享内存进行通信了。在让两个进程进行通信之前,我们可以先测试一下这两个进程能否成功挂接同一个共享内存上。
服务端负责创建共享内存,创建好后将共享内存和服务端进行关联,之后进入死循环,便于观察服务端是否挂接成功。
服务端代码如下:
//server.c
#include "comm.h"
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
if (key < 0){
perror("ftok");
return 1;
}
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建新的共享内存
if (shm < 0){
perror("shmget");
return 2;
}
printf("key: %x\n", key); //打印key值
printf("shm: %d\n", shm); //打印共享内存用户层id
char* mem = shmat(shm, NULL, 0); //关联共享内存
while (1){
//不进行操作
}
shmdt(mem); //共享内存去关联
shmctl(shm, IPC_RMID, NULL); //释放共享内存
return 0;
}
客户端只需要直接和服务端创建的共享内存进行关联即可,之后也进入死循环,便于观察客户端是否挂接成功。
客户端代码如下:
//client.c
#include "comm.h"
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID); //获取与server进程相同的key值
if (key < 0){
perror("ftok");
return 1;
}
int shm = shmget(key, SIZE, IPC_CREAT); //获取server进程创建的共享内存的用户层id
if (shm < 0){
perror("shmget");
return 2;
}
printf("key: %x\n", key); //打印key值
printf("shm: %d\n", shm); //打印共享内存用户层id
char* mem = shmat(shm, NULL, 0); //关联共享内存
int i = 0;
while (1){
//不进行操作
}
shmdt(mem); //共享内存去关联
return 0;
}
为了让服务端和客户端在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享资源进行挂接。这里我们可以将这些需要共用的信息放入一个头文件当中,服务端和客户端共用这个头文件即可。
共用头文件的代码如下:
//comm.h
#include
#include
#include
#include
#include
#include
#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名
#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小
通过监控脚本可以看到服务端和客户端所关联的是同一个共享内存,共享内存关联的进程数是2,表示服务端和客户端挂接共享内存成功。
此时我们就可以让服务端和客户端进行通信了,这里以简单的发送字符串为例。
客户端不断向共享内存写入数据:
//客户端不断向共享内存写入数据
int i = 0;
while (1){
mem[i] = 'A' + i;
i++;
mem[i] = '\0';
sleep(1);
}
服务端不断读取共享内存当中的数据并输出:
//服务端不断读取共享内存当中的数据并输出
while (1){
printf("client# %s\n", mem);
sleep(1);
}
此时先运行服务端创建共享内存,当我们运行客户端时服务端就开始不断输出数据,说明服务端和客户端是能够正常通信的。
当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。
我们先来看看管道通信:
从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
我们再来看看共享内存通信:
从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。
总结一下:
当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构。
消息队列的数据结构如下:
struct msqid_ds {
struct ipc_perm msg_perm;
struct msg *msg_first; /* first message on queue,unused */
struct msg *msg_last; /* last message in queue,unused */
__kernel_time_t msg_stime; /* last msgsnd time */
__kernel_time_t msg_rtime; /* last msgrcv time */
__kernel_time_t msg_ctime; /* last change time */
unsigned long msg_lcbytes; /* Reuse junk fields for 32 bit */
unsigned long msg_lqbytes; /* ditto */
unsigned short msg_cbytes; /* current number of bytes on queue */
unsigned short msg_qnum; /* number of messages in queue */
unsigned short msg_qbytes; /* max number of bytes on queue */
__kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */
__kernel_ipc_pid_t msg_lrpid; /* last receive pid */
};
可以看到消息队列数据结构的第一个成员是msg_perm
,它和shm_perm
是同一个类型的结构体变量,ipc_perm
结构体的定义如下
struct ipc_perm{
__kernel_key_t key;
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid;
__kernel_gid_t cgid;
__kernel_mode_t mode;
unsigned short seq;
};
创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:
int msgget(key_t key, int msgflg);
说明一下:
释放消息队列我们需要用msgctl函数,msgctl函数的函数原型如下:
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
说明一下:
msgctl函数的参数与释放共享内存时使用的shmctl函数的三个参数相同,只不过msgctl函数的第三个参数传入的是消息队列的相关数据结构。
向消息队列发送数据我们需要用msgsnd函数,msgsnd函数的函数原型如下:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
msgsnd函数的参数说明:
msgsnd函数的返回值说明:
其中msgsnd函数的第二个参数必须为以下结构
struct msgbuf{
long mtype; /* message type, must be > 0 */
char mtext[1]; /* message data */
};
注意: 该结构当中的第二个成员mtext即为待发送的信息,当我们定义该结构时,mtext的大小可以自己指定。
从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msgrcv函数的参数说明
msgrcv函数的返回值说明:
在系统当中也为信号量维护了相关的内核数据结构。
信号量的数据结构如下:
struct semid_ds {
struct ipc_perm sem_perm; /* permissions .. see ipc.h */
__kernel_time_t sem_otime; /* last semop time */
__kernel_time_t sem_ctime; /* last change time */
struct sem *sem_base; /* ptr to first semaphore in array */
struct sem_queue *sem_pending; /* pending operations to be processed */
struct sem_queue **sem_pending_last; /* last pending operation */
struct sem_undo *undo; /* undo requests on this array */
unsigned short sem_nsems; /* no. of semaphores in array */
};
信号量数据结构的第一个成员也是ipc_perm
类型的结构体变量,ipc_perm
结构体的定义如下
struct ipc_perm{
__kernel_key_t key;
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid;
__kernel_gid_t cgid;
__kernel_mode_t mode;
unsigned short seq;
};
信号量集的创建
创建信号量集我们需要用semget函数,semget函数的函数原型如下:
int semget(key_t key, int nsems, int semflg);
说明一下:
信号量集的删除
删除信号量集我们需要用semctl函数,semctl函数的函数原型如下:
int semctl(int semid, int semnum, int cmd, ...);
信号量集的操作
对信号量集进行操作我们需要用semop函数,semop函数的函数原型如下:
int semop(int semid, struct sembuf *sops, unsigned nsops);
进程间通信通过共享资源来实现,这虽然解决了通信的问题,但是也引入了新的问题,那就是通信进程间共用的临界资源,若是不对临界资源进行保护,就可能产生各个进程从临界资源获取的数据不一致等问题。
保护临界资源的本质是保护临界区,我们把进程代码中访问临界资源的代码称之为临界区,信号量就是用来保护临界区的,信号量分为二元信号量和多元信号量。
比如当前有一块大小为100字节的资源,我们若是以25字节为一份,那么该资源可以被分为4份,那么此时这块资源可以由4个信号量进行标识。
信号量本质是一个计数器,在二元信号量中,信号量的个数为1(相当于将临界资源看成一整块),二元信号量本质解决了临界资源的互斥问题,以下面的伪代码进行解释:
根据以上代码,当进程A申请访问共享内存资源时,如果此时sem为1(sem代表当前信号量个数),则进程A申请资源成功,此时需要将sem减减,然后进程A就可以对共享内存进行一系列操作,但是在进程A在访问共享内存时,若是进程B申请访问该共享内存资源,此时sem就为0了,那么这时进程B会被挂起,直到进程A访问共享内存结束后将sem加加,此时才会将进程B唤起,然后进程B再对该共享内存进行访问操作。
在这种情况下,无论什么时候都只会有一个进程在对同一份共享内存进行访问操作,也就解决了临界资源的互斥问题。
实际上,代码中计数器sem减减的操作就叫做P操作,而计数器加加的操作就叫做V操作,P操作就是申请信号量,而V操作就是释放信号量。
通过对system V系列进程间通信的学习,可以发现共享内存、消息队列以及信号量,虽然它们内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量。
这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构。
也就是说,在内核当中只需要将所有的IPC资源的ipc_perm成员组织成数组的样子,然后用切片的方式获取到该IPC资源的起始地址,然后就可以访问该IPC资源的每一个成员了。