目录
一、进程间通信介绍
二、管道
1.什么是管道(pipe)
2.重定向和管道
(1)为什么要有管道的存在
(2)重定向和管道的区别
3.匿名管道
(1)匿名管道原理
(2)站在文件描述符角度理解匿名管道
(3)创建匿名管道
(4)匿名管道读写规则
(5)匿名管道特点
(6)匿名管道4种特殊情况
(7)匿名管道大小
4.命名管道
(1)命名管道原理
(2)创建命名管道
(3)命名管道的数据不会刷新到磁盘
5.匿名管道和命名管道的区别
三、System V IPC
1.System V标准
2.共享内存
(1)原理
(2)步骤
(3)函数
shmget
shmctl
shmat
shmdt
(4)使用
3.共享内存和管道区别
四、消息队列
1.原理
2.数据结构
3.步骤
4.函数
(1)msgget
(2)msgctl
(3)msgsnd
(4)msgrcv
五、信号量
1.原理
2.数据结构
3.函数
(1)semget
(2)semctl
(3)semop
六、System V IPC总结
一、进程间通信介绍
之前学习的进程,都是各自运行,互不干扰,进程之间没有协同。然而有许多场景下是需要进程之间相互协同的,由于进程是程序员写的,因此进程之间的协同本质上就是程序员之间的协同,比如一个程序员从数据库里面拿数据,另一个程序员要把从数据库里面拿到的数据进行格式化,写成特定格式,还有一个程序员根据格式化的数据进行统计,如果把这些工作量当成意见工作去处理的话,如果其中这三个环节有任何一个环节出错了,那么这个工作就进行不下去了,需要逐一去排查到底是哪个环节出错了,耗时久且效率低。
因此把这个工作可以分为3个部分,分别让3个不同的进程去做:1个进程从数据库拿数据,1个进程做数据格式化,1个进程做数据分析。这就做到了在业务层面上用进程进行解耦。一旦拿数据有问题就去找拿数据的进程,一旦格式化有问题就去找格式化的进程,一旦数据分析有问题就去找数据分析的进程。业务层面上的解耦能够增加代码的可维护性,这就是进程之间的协同。比如过滤出文件中含字母'i'的行:
cat fdProcess.c运行起来就是一个进程,核心工作只是打印数据,用grep来过滤含有字母'i'的行,数据源是从上一个进程cat fdProcess.c通过管道来交给grep的。这就叫做协同。
就算是父子进程,共享了进程的代码和数据,写的时候都必须分开,用写时拷贝来写。两个相互独立的进程,交互数据,成本很高,各自连对方保存数据的地址空间都看不到,因为独立的进程使用独立的进程地址空间,页表映射到不同的物理内存,所以看不到对方的数据,因此要完成进程间通信,不能只在应用层解决,必须也要操作系统参与进来,要让操作系统设计通信方式。
通信的本质就是传递数据,这些数据需要一个进程向公共资源里面去放,另一个进程从公共资源向外拿,而公共资源还需要有暂存数据的能力。这个公共资源肯定不属于这两个进程,因为进程具有独立性,如果这个公共资源是进程A的,那么进程B是看不到的:
从上图可以看出进程间通信有以下3种方式,目的是为了让不同的进程看到同一份资源:
- 管道
- System V进程间通信
- POSIX进程间通信
同时需要先了解以下概念:
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止 时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另 一个进程的所有陷入和异常,并能够及时知道它的状态改变
二、管道
1.什么是管道(pipe)
管道是Unix最古老的进程间通信的形式。把从一个进程连接到另一个进程的数据流称为管道,Linux 管道使用竖线'|'连接多个命令,这被竖线'|'称为管道符。
当在两个命令之间设置管道时,管道符'|'左边命令的输出就变成了右边命令的输入。只要第一个命令向标准输出写入,而第二个命令是从标准输入读取,那么这两个命令就可以形成一个管道。大部分的 Linux 命令都可以用来形成管道。
如下所示,对于命令cat fdProcess.c|grep -i 'i',管道符'|'之前的进程cat fdProcess.c是标准输入进程,管道符'|'之后的进程grep -i 'i'是标准输出进程,第一个命令向标准输出写入,第二个命令是从标准输入读取,这两个命令形成了管道,管道作用于内核。
如果没有管道,那么这两条命令就得分两次执行。因此用管道执行也能达到同样的效果。对于一些备份压缩复制需求的命令就可以避免创建临时文件。
管道特点:
- 命令的语法紧凑并且使用简单。
- 管道将多个命令串联到一起完成复杂任务。
- 从管道输出的标准错误会混合到一起。
2.重定向和管道
(1)为什么要有管道的存在
既然有重定向,为什么还要有管道呢?比如如下命令使用重定向将可执行程序process1的输出都放入file中:
process1 > file
但是如果想让可执行程序process1 的输出传递到可执行程序process2呢?需要:
process1 > temp && process2 < temp
这个命令做了3步:
- 运行名为process1
- 将输出保存到名为temp的文件中
- 运行名为的程序process2,假装用户在键盘上输入temp的内容。
有没有发现这样做很麻烦,既要创建临时文件,又要用户在键盘上输入呢,但是管道就很简单呀:
process1 | process2
的效果和命令process1 > temp && process2 < temp的作用是一样的。
(2)重定向和管道的区别
管道也有重定向的作用,因为它改变了数据的输入输出方向。冲重定向使用">"将文件和命令连接起来,用文件来接收命令的输出,而管道使用"I"将命令和命令连接起来,用第二个命令来接收第一个命令的输出。
使用重定向一定要小心一些,如果连续键入如下两条命令:
cd /usr/bin
ls > less
第一条命令将当前目录切换到了大多数程序所存放的目录,第二条命令是告诉 Shell 用 ls 命令的输出重写文件 less。因为 /usr/bin 目录已经包含了名称为 less的文件,第二条命令用 ls 输出的文本重写了 less 程序,因此破坏了文件系统中的 less 程序,这就破坏了less文件。这是使用重定向操作符错误重写文件的一个教训,所以在使用重定向时要谨慎。
管道分为匿名管道和命名管道。
3.匿名管道
(1)匿名管道原理
匿名管道仅限于本地父子进程之间通信,不支持跨网络之间的两个进程之间的通信。
进程在操作文件时,通过文件描述符找到文件,如果需要读文件就直接执行读方法。使用fork创建子进程之后,那么子进程就拥有了自己的PCB,父进程指向的struct file文件描述符表结构也需要给子进程拷贝一份。
这是因为:
- file_struct结构是属于进程的,因为file_struct能够让进程看到已经打开了多少个文件以及文件之间的关系,因此file_struct是属于进程的。file_struct属于进程,那么它一定属于父进程,在创建子进程的时候,也必须为子进程复制这份file_struct结构。因为进程具有独立性,所以内核数据结构也必须保持独立。
- 如果让子进程也看到了父进程的文件了,那么父进程的文件进行读写时,缓冲区也被子进程看到了,这就没有做好进程独立性。
因此操作系统会将这个结构给子进程也拷贝一份:
基于文件的通信方式就叫做管道。进程、struct_file、缓冲区、操作方法等都是操作系统提供的,文件不属于进程,属于操作系统。父进程先打开文件,让子进程继承,虽然结构上互相独立,但它们指向同一个文件,一个向文件写,另一个从文件读,两个进程看到了同一份公共资源,这就满足了进程通信的前提。
(2)站在文件描述符角度理解匿名管道
- 父进程创建管道
管道可以看做文件的内核缓冲区,父进程创建管道时,分别以读方式和写方式打开同一文件:
- 父进程fork出子进程
当父进程创建出子进程后,父进程的所有文件描述符表信息会被子进程继承,虽然父子进程各自拥有独立的文件描述符,但是内容是一样的,所以父子进程都可以看到曾经打开的读端和写端进行读写,不过管道只能单向通信,只能有一个读端,一个写端。
所以父进程一开始就有两个文件描述符,一个读端,一个写端,这样子进程继承复制了父进程的文件描述符后,也有读端和写端。否则如果父进程一开始只有读端,没有写端,那么子进程也只有读端,没有写端,那么两个读端是不能进行读写的。
- 父进程关闭读端(写端),子进程关闭写端(读端)
至于父子进程谁关闭读端,谁关闭写端,取决于父进程读还是子进程读,现在来看一下父进程写,子进程读的情况,现在关闭父进程的读端和子进程的写端:
(3)创建匿名管道
第一步:父进程使用pipe函数来创建管道
#include <unistd.h>int pipe(int pipefd[2]);
参数:pipefd文件描述符数组,元素个数为2,是输出型参数,通过这个参数读取到打开的两个文件描述符。其中pipefd[0]为读操作,pipefd[1]为写操作,且顺序不能颠倒。
返回值:成功返回0,失败返回-1。
现在来创建一个管道:
#include<stdio.h>
#include<unistd.h>int main()
{int pipefd[2] = {0};if(pipe(pipefd) != 0)//匿名管道创建失败{perror("pipe error!");return 1;}printf("pipefd[0]:%d\n",pipefd[0]);printf("pipefd[1]:%d\n",pipefd[1]);return 0;
}
执行结果如下:
可以看到文件描述符分别为3和4,因为0、1、2都被标准输入、标准输出、标准错误占用了:
第二步:父进程fork出子进程
#include<stdio.h>
#include<unistd.h>int main()
{int pipefd[2] = {0};if(pipe(pipefd) != 0)//匿名管道创建失败了{perror("pipe error!");return 1;}printf("pipefd[0]:%d\n",pipefd[0]);printf("pipefd[1]:%d\n",pipefd[1]);if(fork() == 0)//子进程{}//父进程return 0;
}
第3步:创建单向信道
现在如果想让父进程读,子进程写,那么就要关闭父进程的写端和子进程的读端,即关闭父进程的写文件描述符和子进程的读文件描述符。为了让子进程关闭读文件描述符后不要继续向后执行,使用eixt函数来终止。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>int main()
{int pipefd[2] = {0};if(pipe(pipefd) != 0)//匿名管道创建失败了{perror("pipe error!");return 1;}printf("pipefd[0]:%d\n",pipefd[0]);printf("pipefd[1]:%d\n",pipefd[1]);if(fork() == 0)//子进程{close(pipefd[0]);//子进程关闭读文件描述符exit(0);}//父进程close(pipefd[1]);//父进程关闭写文件描述符return 0;
}
现在已经建立了父子进程,并且父子进程都看到了同一份资源。现在让子进程写入,需要调用write方法,让父进程读取,需要调用read方法,write和read方法的使用请参考文章【Linux】-- 基础IO和动静态库第一章节第1节的内容 第一章节第1节的内容。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
int main()
{int pipefd[2] = {0};if(pipe(pipefd) != 0)//匿名管道创建失败了{perror("pipe error!");return 1;}printf("pipefd[0]:%d\n",pipefd[0]);printf("pipefd[1]:%d\n",pipefd[1]);if(fork() == 0)//子进程{close(pipefd[0]);//子进程关闭读文件描述符const char *string_write = "lunch ";while(1){write(pipefd[1],string_write,strlen(string_write));//子进程向文件缓冲区写,pipe只要有缓冲区就一直写入}close(pipefd[1]);exit(0);}//父进程close(pipefd[1]);//父进程关闭写文件描述符while(1){sleep(1);char string_read[64] ={0};size_t readLength = read(pipefd[0],string_read,sizeof(string_read));//父进程从文件缓冲区读,pipe只要有缓冲区就一直读if(readLength == 0)//读到内容为空{printf("child quit...\n");break;}else if(readLength > 0)//读到了正常内容{string_read[readLength] = 0;printf("child write# %s\n",string_read);}else//读出错{printf("read error...\n");break;}close(pipefd[0]);}return 0;
}
可以看到执行结果如下,子进程写入,父进程读取:
对于字节流,只要缓冲区有数据,就把缓冲区的所有数据全部读出来,一次读取一个字节。
(4)匿名管道读写规则
pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:
int pipe2(int pipefd[2], int flags);
对于flags:
- 当没有数据可读时
O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
- 当管道满的时候
O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
- 如果所有管道写端对应的文件描述符被关闭,则read返回0
- 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
- 当要写入的数据量<=PIPE_BUF时,linux将保证写入的原子性。
- 当要写入的数据量>PIPE_BUF时,linux将不再保证写入的原子性。
(5)匿名管道特点
- 只能用于具有共同祖先的进程之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,父、子进程之间就可用该管道通信(具有亲缘关系的进程,祖孙进程也可以)
- 管道提供流式服务,原子性写入(读端读取的数据是任意的,底层没有对数据做明确分割,报文段不定,因此是流式服务)
- 父子进程退出,管道文件释放,所以管道的生命周期随进程
- 内核会对管道操作进行同步与互斥
- 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
(6)匿名管道4种特殊情况
- 读端不读或者读的慢,写端要等读端
- 读端关闭,写端收到SIGPIPE信号直接终止
- 写端不写或写的慢,读端要等写端
- 写端关闭,读端读完pipe内部的数据然后再读,会读到0,表明读到文件结尾
(7)匿名管道大小
如果让子进程无限循环每次往管道里写一个字符,并且计数,父进程从管道里面不读取数据,当计数不再增长时,计数值就为管道的大小:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
int main()
{int pipefd[2] = {0};if(pipe(pipefd) != 0)//匿名管道创建失败了{perror("pipe error!");return 1;}printf("pipefd[0]:%d\n",pipefd[0]);printf("pipefd[1]:%d\n",pipefd[1]);if(fork() == 0)//子进程{close(pipefd[0]);//子进程关闭读文件描述符int count = 0;while(1){write(pipefd[1],"a",1);count++;printf("count:%d\n",count);}close(pipefd[1]);exit(0);}//父进程close(pipefd[1]);//父进程关闭写文件描述符while(1)//父进程不读取{sleep(1);}return 0;
}
运行结果如下,从1打印到65536:
这说明管道大小为65536B=64KB。这也说明了如果写端向管道写满数据以后,那么写端就不写了,等待读端读;同理,如果读端把管道数据读完了,管道没数据,那么读端就不读了,等待写端写。
管道在被写端写满以后,读端要拿走数据,如果一次拿走4KB,写端才会写,否则不会触发写端去写,为什么是4KB呢?让父进程读取的时候,存放数据的数组大小从1KB开始向上递增到4KB的时候,写端才写:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
int main()
{int pipefd[2] = {0};if(pipe(pipefd) != 0)//匿名管道创建失败了{perror("pipe error!");return 1;}printf("pipefd[0]:%d\n",pipefd[0]);printf("pipefd[1]:%d\n",pipefd[1]);if(fork() == 0)//子进程{close(pipefd[0]);//子进程关闭读文件描述符const char *string_write = "lunch ";int count = 0;while(1){//write(pipefd[1],string_write,strlen(string_write));//子进程向文件缓冲区写,pipe只要有缓冲区就一直写入write(pipefd[1],"a",1);count++;printf("count:%d\n",count);}close(pipefd[1]);exit(0);}//父进程close(pipefd[1]);//父进程关闭写文件描述符while(1){sleep(3);char string_read[1024*4+1] ={0};//按照1024*1 1024*2 1024*3 1024*4向上递增size_t readLength = read(pipefd[0],string_read,sizeof(string_read));//父进程从文件缓冲区读,pipe只要有缓冲区就一直读printf("readLength = %d\n",readLength);string_read[readLength] = 0;printf("father take:%c\n",string_read[0]);}return 0;
}
可以看到,管道写入字符的计数一开始增加到了65536B,父进程读走4KB之后,子进程继续写,每写一次,count计数就会++ :
为什么读走4KB的时候,写端才写,而读走1KB 2KB 3KB时不写呢?这是因为要保证写入和读取的原子性:假如还没读够4KB,就把写端唤醒了,那么写端就要来写了,这就变成了,写端在写的同时,读端要来读,这就违背了管道半双工通信,不能同时读写的原则。同理,如果写端写的特别慢,读端读的特别快,当缓冲区没有数据时,会等待数据写入进去后,读端再读 。因此要保证同步。
4.命名管道
(1)命名管道原理
匿名管道用于有血缘关系的进程间通信,那么对于没有血缘关系的进程,他们之间如何通信呢?这就要用到命名管道,命名管道是一种特殊的文件,使用FIFO(First In First Out)来进行通信。
如何让两个没有血缘关系的不相干的进程看到操作系统提供的同一份资源?对于文件系统来说当进程A把磁盘文件打开,向磁盘里面写数据,写完之后关闭这个磁盘文件,进程B再把这个磁盘文件打开并读取数据:
但是这样做有点慢,因为进程A再内存中打开这个文件,为这个文件建立内存相关的数据结构和缓冲区,进程B也在内存中打开同一个文件,这样就是一个通过读的方式打开,一个通过写的方式打开,进程可以向这个内存文件写,进程B可以从这个内存文件读,暂时先不把数据刷新到磁盘,否则效率会降低,这是基于内存进行数据之间的通信,那么A进程和B进程就可以通过这个内存文件进行不相关的进程间的通信。
不相关的A进程和B进程是如何看到同一份资源的呢?路径+文件名能唯一指定一个文件,这样就能让进程A和进程B打开同一份文件。现在需要1个文件,同时满足:
- 文件被打开时,数据不要被刷新到磁盘上,而是保存临时数据
- 这个文件也必须在磁盘上也有对应的文件名
符合这些条件的只有命名管道。而且这个文件是有名字的,通过路径+文件名确定唯一性来做到的:
(2)创建命名管道
命名管道有两种创建方式:
- 通过mkfifo命令创建
mkfifo name
如创建一个名为testFifo的管道文件:
可以看到文件类型为p,p表明这是一个管道文件。创建了命名管道文件后,就可以通信了:
echo和cat是两个不同的指令,但是运行起来是两个进程,左侧的消息打印到了右侧的屏幕上,一个进程把自己的内容写入到了命名管道文件中,通过命名管道文件把数据传递给另一个进程。
- 通过mkfifo函数创建
mkfifo函数的作用是生成一个FIFO的特殊文件,即命名管道
#include <sys/types.h>#include <sys/stat.h>int mkfifo(const char *pathname, mode_t mode);
pathname:文件名
mode:管道的默认权限,可用过umask来设置
返回值:成功返回0,失败返回-1
现在使用mkfifo函数创建命名管道,server.c创建管道文件,并给管道文件分配权限:
#include<stdio.h>
#include<sys/stat.h>
#include<sys/types.h>#define fifo_file ./fifo_fileint main()
{if(mkfifo(fifo_file,0666) < 0)//创建一个命名管道{perror("mkfifo");return 1;}return 0;
}
client.c暂时什么都不做:
#include<stdio.h>int main()
{return 0;
}
Makefile一次生成两个可执行文件:
.PHONY:all
all:client serverclient:client.cgcc -o $@ $^server:server.cgcc -o $@ $^.PHONY:clean
clean:rm -rf client server fifo_file
编译后,生成两个可执行程序:
现在通信想让client和server可执行程序互相传递详细,那么 client和server可执行程序运行起来就是两个进程,而且是两个毫不相干的进程,没有血缘关系。
执行srver课执行程序后,生成fifo_file命名管道,文件类型是p,但是权限是644,并不是666:
这是因为fifo文件的参数mode受系统umask影响,可以查看到Umask的值是2:
那么可以看出mode = mode & ~umask(666&~002),如果修改umask的值,比如创建命名管道文件时将umask清0:
server.c
#include<stdio.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#define fifo_file "./fifo_file"int main()
{umask(0);//将umask清0if(mkfifo(fifo_file,0666) < 0)//创建一个命名管道{perror("mkfifo");return 1;}int fd = open(fifo_file,O_RDONLY);if(fd < 0){perror("open");return 2;}while(1){char buffer[64] = {0};ssize_t read_length = read(fd,buffer,sizeof(buffer)-1);if(read_length > 0)//读取成功{buffer[read_length-1] = 0;printf("client # %s\n",buffer);}else if(read_length == 0){printf("client quit\n");}else{perror("read");break;}}close(fd);return 0;
}
这时可以看到命名管道文件的权限变成了666:
对于client和server进程,想让server读,client写,不推荐用c/c++接口,有缓冲区,而系统调用没有缓冲区,推荐使用系统调用接口,client使用系统调用接收标准输入并写入到命名管道文件中:
client.c
#include<stdio.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<string.h>#define fifo_file "./fifo_file"//client不需要创建命名管道文件,只需要获取就可以了
int main()
{int fd = open(fifo_file,O_WRONLY);if(fd < 0){perror("open");return 1;}while(1){printf("请输入# ");//client的输入提示fflush(stdout);//刷新一下标准输出char buffer[64] = {0};//先把数据从标准输入拿到client进程内部ssize_t read_length = read(0,buffer,sizeof(buffer)-1);if(read_length > 0){buffer[read_length-1] = 0;//拿到了数据write(fd,buffer,strlen(buffer));}}close(fd);return 0;
}
现在运行,得先让server跑起来创建一个命名管道,然后再运行client端,就可以再client端写入数据了:
从以上就可以看出,对于两个不想管的进程,通过命名管道,一个进程把消息发给了另外一个进程。因此一旦有了命名管道,只需要让通信双方进程按照文件操作即可。由于命名管道也是基于字节流的,因此实际上,信息传递的时候,需要通信双方定制“协议”。
现在让client控制server,让server去执行任务。可以让server执行程序替换,比如当client接收标准输入写入到命名管道文件中的字符串为"show"时,就会执行ls命令:
server.c
#include<stdio.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<unistd.h>
#include<stdlib.h>
#include<wait.h>
#include<string.h>
#include<sys/wait.h>#define fifo_file "./fifo_file"int main()
{umask(0);if(mkfifo(fifo_file,0666) < 0)//创建一个命名管道{perror("mkfifo");return 1;}int fd = open(fifo_file,O_RDONLY);if(fd < 0){perror("open");return 2;}//业务逻辑,进行读写while(1){char buffer[64] = {0};ssize_t read_length = read(fd,buffer,sizeof(buffer)-1);if(read_length > 0)//读取成功{buffer[read_length] = 0;if(strcmp(buffer,"show") == 0){printf("the string is show\n");if(fork() == 0){execl("/usr/bin/ls","ls","-l",NULL);//程序替换exit(1);}waitpid(-1,NULL,0);}else{printf("client # %s\n",buffer);}}else if(read_length == 0){printf("client quit\n");}else{perror("read");break;}}close(fd);return 0;
}
client.c
#include<stdio.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<string.h>#define fifo_file "./fifo_file"//client不需要创建命名管道文件,只需要获取就可以了
int main()
{int fd = open(fifo_file,O_WRONLY);if(fd < 0){perror("open");return 1;}while(1){printf("请输入# ");//client的输入提示fflush(stdout);//刷新一下标准输出char buffer[64] = {0};//先把数据从标准输入拿到client进程内部ssize_t read_length = read(0,buffer,sizeof(buffer)-1);if(read_length > 0){buffer[read_length - 1] = 0;//拿到了数据write(fd,buffer,strlen(buffer));}}close(fd);return 0;
}
现在运行,得先让server跑起来创建一个命名管道,然后再运行client端,就可以再client端写入数据了,在client输入"show"之后,server就将ls的内容展示出来了:
可以看到通过命名管道把数据从一个进程传递给另外一个进程,并且也实现了让一个进程控制了另外一个进程去执行任务,达到了进程间通信的目的。
(3)命名管道的数据不会刷新到磁盘
假如让server进程每隔20秒读一次,而client不断往管道发消息,那么数据只能在管道文件:
server.c
#include<stdio.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<unistd.h>
#include<stdlib.h>
#include<wait.h>
#include<string.h>
#include<sys/wait.h>#define fifo_file "./fifo_file"int main()
{umask(0);if(mkfifo(fifo_file,0666) < 0)//创建一个命名管道{perror("mkfifo");return 1;}int fd = open(fifo_file,O_RDONLY);if(fd < 0){perror("open");return 2;}//业务逻辑,进行读写while(1){char buffer[64] = {0};sleep(20);//等待20秒再读ssize_t read_length = read(fd,buffer,sizeof(buffer)-1);if(read_length > 0)//读取成功{buffer[read_length] = 0;if(strcmp(buffer,"show") == 0){printf("the string is show\n");if(fork() == 0){execl("/usr/bin/ls","ls","-l",NULL);//程序替换exit(1);}waitpid(-1,NULL,0);}else{printf("client # %s\n",buffer);}}else if(read_length == 0){printf("client quit\n");}else{perror("read");break;}}close(fd);return 0;
}
client.c不用修改:
#include<stdio.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<string.h>#define fifo_file "./fifo_file"//client不需要创建命名管道文件,只需要获取就可以了
int main()
{int fd = open(fifo_file,O_WRONLY);if(fd < 0){perror("open");return 1;}while(1){printf("请输入# ");//client的输入提示fflush(stdout);//刷新一下标准输出char buffer[64] = {0};//先把数据从标准输入拿到client进程内部ssize_t read_length = read(0,buffer,sizeof(buffer)-1);if(read_length > 0){buffer[read_length - 1] = 0;//拿到了数据write(fd,buffer,strlen(buffer));}}close(fd);return 0;
}
这20秒内,client向命名管道写,但是server没有从命名管道读,按理来说,命名管道里面有内容,大小不为0,但是在这20秒之内发现命名管道的fifo_file的大小为0,这就说明了命名管道的数据,由于效率问题,不会刷新到磁盘。
5.匿名管道和命名管道的区别
创建与打开的方式不同:
- 匿名管道由pipe函数创建并打开
- 命名管道由mkfifo函数创建,由open函数打开
后面就有相同的语义了
三、System V IPC
1.System V标准
System V是一种用于在操作系统层面上进行进程间通信的标准,system V标准给用户提供了系统调用接口,只要用户使用它所提供的系统调用就可以完成进程间通信。IPC(Inter-Process Communication)是进程间通信。System V IPC不用基于文件进行通信。
如何把系统调用接口提供给用户使用呢?System V是操作系统内核的一部分,是为操作系统中多进程提供的一种通信方案。但是操作系统不相信任何用户,采用系统调用为用户提供功能。所以System V进程间通信,存在专门用来通信的接口:System call(系统调用)
这就需要制定一套标准用来在同一主机内进行进程间通信:System V。System V进程间通信分为3种:
- System V消息队列
- System V共享内存
- System V信号量
消息队列模型通过在协作进程间交换消息来实现通信。共享内存模型会建立起一块供协作进程共享的内存区域,进程通过向此共享区域读出或写入数据来交换信息。以下是消息队列和共享内存的通信模型:
消息队列的实现经常采用系统调用,因此需要消耗更多时间使内核介入,但是共享内存只在建立共享内存区域时需要系统调用,一旦建立共享内存,所有访问都是常规内存访问,不需要借助内核。
由于消息队列和共享内存用来传递消息,信号量用来实现进程间同步和互斥。因此主要来看看进程间通信方式中效率较高的共享内存。
2.共享内存
(1)原理
把申请的共享内存映射到不同进程的地址空间当中。有进程A和进程B,进程A通过页表映射找到进程A的代码和数据,同样,进程B也通过页表映射找到进程B的代码和数据,由于两个进程的数据结构相互独立,且物理内存当中的代码和数据也相互独立,因此两个进程不会互相干扰。
在物理内存开辟一块共享内存空间后,需要通过系统调用把开辟的内存空间经过页表映射到进程地址空间,那么共享内存在进程地址空间也有了虚拟地址,叫做共享存储器映射区,再把共享存储器映射区的虚拟地址填到页表当中,这样共享内存的虚拟地址和物理地址就建立起了对应关系,而且各个进程也就看到了共享内存同一份资源。
以上的过程也是让进程挂接到共享内存空间上的过程。操作系统内可能存在多个共享内存,那么操作系统需要管理这些共享内存,管理还是先描述再组织。
如何保证能够让多个进程看到同一个共享内存呢?
共享内存一定要有唯一标识ID,就能让不同进程识别到同一个共享内存资源。那么这个ID一定在描述共享内存的数据结构中。
(2)步骤
可以总结出使用共享内存的过程:
- 创建共享内存
- 关联(挂接)
- 去关联(去挂接)
- 释放共享内存
(3)函数
shmget
使用shmget函数创建共享内存,来申请一块共享内存空间:
#include <sys/ipc.h>
#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);
key | 通过ftok函数生成 |
size | 建议为4KB的整数倍,操作系统为了提高内存和硬盘的数据交换的速度,以4KB为单位 |
shmflg | hmflg标志有多个,先了解最常用的两个标志IPC_CREAT和IPC_EXCL就可以了 |
返回值 | 成功就返回共享内存地址,失败就返回-1 |
其中,shmget第一个参数key是通过ftok函数生成的:
#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);
pathname | 自定义的文件路径名 |
proj_id | 序号,低8位被使用,非0 |
返回值 | 返回key,会被设置进共享内存在内核的数据结构里面 |
shmget第三个参数shmflg标志有多个,先了解最常用的两个标志IPC_CREAT和IPC_EXCL就可以了:
创建共享内存后,如何查看共享内存呢?ipcs命令用于报告进程间通信设施状况,其中:
ipcs -m //查看共享内存(Shared Memory Segments)
ipcs -q //查看消息队列(Message Queue)
ipcs -s //查看信号量(Semaphore Arrays)
shmctl
使用完共享内存后,如果不删除的话,共享内存会一直存在,直到系统重启。如何删除呢?有两种删除方式,一种是命令删除:
ipcrm -m shmid
key只是用来在系统层面进行唯一标识,不能用来管理共享内存。而shmid是操作系统给用户返回的id,用来在用户层进行共享内存管理,所以ipcrm是用户层的命令。 以上是命令删除,那么如何在代码中删除共享内存呢?
因此另外一种删除共享内存的方式就是使用shmctl函数控制共享内存:
#include <sys/ipc.h>
#include <sys/shm.h>int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmid | 操作系统给用户返回的id |
cmd | 选项,有多个 |
buf | data structure数据结构类型指针 |
返回值 | 删除成功返回0,失败返回-1 |
其中cmd选项有多个:
IPC_STAT | 将shmid的内核数据结构拷贝到buf指向的shmid_ds结构中 |
IPC_SET | 将buf指向的shmid_ds结构的一些成员的值写入与此共享内存段相关的内核数据结构,同时更新其shm_ctime成员 |
IPC_RMID | 删除共享内存 |
第三个参数
其中,shmid_ds数据结构如下:
struct shmid_ds
{struct ipc_perm shm_perm; /* Ownership and permissions */size_t shm_segsz; /* Size of segment (bytes) */time_t shm_atime; /* Last attach time */time_t shm_dtime; /* Last detach time */time_t shm_ctime; /* Last change time */pid_t shm_cpid; /* PID of creator */pid_t shm_lpid; /* PID of last shmat(2)/shmdt(2) */shmatt_t shm_nattch; /* No. of current attaches */...
};
shmat
使用shmat把共享内存映射到调用进程的地址空间(关联:增加共享内存和进程地址空间映射关系的页表项)
#include <sys/types.h>
#include <sys/shm.h>void *shmat(int shmid, const void *shmaddr, int shmflg);
shmid | 操作系统给用户返回的id |
shmaddr | 表明把共享内存挂接到进程地址空间的哪些范围中 |
shmflg | 有多个,先了解最常用的两个标志IPC_CREAT和IPC_EXCL就可以了,同shmget函数的shmflg标志,这里设置为0就可以了 |
返回值 | 返回共享内存挂接到进程地址空间的虚拟地址,同申请堆空间的malloc返回值是一样的 |
shmdt
shmdt用来断开共享内存和进程地址空间的映射(去关联:删除共享内存和进程地址空间映射关系的页表项,而不是释放共享内存)
#include <sys/types.h>
#include <sys/shm.h>int shmdt(const void *shmaddr);
shmaddr | 要断开映射的共享内存地址,且必须和shmat的参数shmaddr相同 |
返回值 | 成功断开返回0,失败返回-1 |
(4)使用
两个进程使用共享内存通信,需要进行创建、关联、去关联、删除的步骤,现在使用上面的函数来进行server和client两个进程间的通信。
comm.h来包含头文件
#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/types.h>
#include<stdio.h>
#include<unistd.h>#define PATH_NAME "/home/delia/linux/20230627-sharedMemory/shared/server.c" //ftok的路径
#define PROJ_ID 0x6666
#define SIZE 4097
server端需要生成唯一ID,创建共享内存,关联共享内存,去关联共享内存,删除共享内存:
server.c
#include "comm.h"int main()
{key_t key = ftok(PATH_NAME,PROJ_ID);//生成唯一ID保证在统一系统当中
找到共享内存if(key < 0){perror("fork");return 1;}//1.创建共享内存int shmid = shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0666);//共享内存不存在就创建,权限为666,共享内存可以用文件权限来约束if(shmid < 0){perror("shmget");return 2;}printf("key = %u,shmid = %d\n",key,shmid);sleep(1);//2.关联char *mem = shmat(shmid,NULL,0);printf("attaches shm success\n");sleep(15);//通信逻辑while(1){sleep(1);//printf("%s\n",mem);}//3.去关联shmdt(mem);printf("detaches shm success\n");//4.删除共享内存shmctl(shmid,IPC_RMID,NULL);sleep(5);printf("key = %u,shmid = %d after shmctl\n",key,shmid);return 0;
}
client端需要和客户端一样生成同一个唯一ID,创建使用同一个共享内存,关联共享内存,去关联共享内存,不需要删除共享内存,因为server端已经删除了:
client.c
#include "comm.h"int main()
{key_t key = ftok(PATH_NAME,PROJ_ID);//生成唯一ID保证在统一系统当中
找到共享内存if(key < 0){perror("ftok");return 1;}printf("%u\n",key);//1.创建共享内存int shmid = shmget(key,SIZE,IPC_CREAT);//共享内存已存在就返回已存在共享内存if(shmid < 0){perror("shmget");return 2;}//2.关联char *mem = shmat(shmid,NULL,0);sleep(5);printf("client process attaches success\n");//通信逻辑char c = 'A';while(c <= 'G'){mem[c - 'A'] = c;c++;mem[c - 'A'] = 0;sleep(2);}//3.去关联shmdt(mem);printf("client process detaches success\n");return 0;
}
Makefile
.PHONY:all
all:server clientserver:server.cgcc -o $@ $^
client:client.cgcc -o $@ $^.PHONY:clean
clean:rm -f server client
make之后,使用命令
while :; do ipcs -m;sleep 1;echo "#################"; done
来查看共享内存的挂接进程的数量变化:当server端和client端进程都没有开启时,看到共享内存信息的nattch的个数为0,当server和client端都运行起来之后,发现nattch的个数变成了2,client所写的消息就会被server读取,当client端去关联之后,nattch变成了1,最后当server端退出时,共享内存被删除,nattch又变成了0:
key | 系统区别各个共享内存的唯一标识 |
shmid | 共享内存的用户层id(句柄) |
owner | 共享内存的拥有者 |
perms | 共享内存的权限 |
bytes | 共享内存的大小 |
nattch | 关联共享内存的进程数 |
status | 共享内存的状态 |
从以上可以看出,共享内存有以下特点:
- 共享内存一旦建立好并映射进自己进程的地址空间,该进程就可以看到该共享内存,就像malloc的空间一样,不需要任何系统调用接口(比如read、write会将数据从内核拷贝到用户或从用户拷贝到内核)。
- 共享内存是所有进程间通信中速度最快的,这是因为将一块共享内存映射到不同的进程地址空间,共享内存地址对应在内存上的空间就拿到了,所以server和Client有任何一方写了,另一方马上就看到了。
- 生命周期随内核,而且不提供同步互斥机制,需要程序员自行保证数据的安全。
3.共享内存和管道区别
从共享内存的特点可以看出:
(1)创建好共享内存后,就不需要再调用系统接口进行通信了, 而管道创建好后还需要调用read、write等系统接口进行通信。
(2) 共享内存没有同步互斥机制,但是管道有同步互斥机制。
(3)共享内存是所有进程间通信方式中速度最快的,将数据从一个进程传输带另一个进程,管道需要进行4次拷贝,共享内存需要进行2次拷贝,共享内存需要的拷贝次数少。
使用管道,将文件从一个进程传到另一个进程需要4次拷贝:
- 服务端把信息从输入文件复制到服务端的临时缓冲区
- 把服务端的临时缓冲区信息复制到管道中
- 客户端把信息从管道复制到客户端的缓冲区
- 把客户端临时缓冲区的信息复制到输出文件中
使用共享内存,将文件从一个进程传到另一个进程需要2次拷贝:
- 将信息从输入文件拷贝到共享内存
- 将信息从共享内存拷贝到输出文件
四、消息队列
1.原理
消息队列是一个消息的链表,可以把消息看作一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向消息队列中按照一定的规则添加新消息,对消息队列有读权限的进程则可以从消息队列中读走消息。消息队列的生命周期是随内核的。
队列的每个成员都是数据块,每个数据块包含类型和信息两部分。这个队列也遵循先进先出,即从队头读取消息,向队尾写入消息:
每个数据块都有类型,这就说明,各个数据块的类型可以不同,因此,接收者进程接收的数据块可以有不同的类型值。消息队列的资源必须手动删除,因为system V IPC资源的生命周期是随内核的。
2.数据结构
消息对中的数据块如何管理呢?还是先描述,再组织。使用命令:
cat /usr/include/linux/msg.h
就能够看到消息队列的数据结构如下:
struct msqid_ds
{ struct ipc_perm msg_perm; /* Ownership and permissions */time_t msg_stime; /* Time of last msgsnd(2) */time_t msg_rtime; /* Time of last msgrcv(2) */time_t msg_ctime; /* Time of last change */unsigned long __msg_cbytes; /* Current number of bytes inqueue (nonstandard) */msgqnum_t msg_qnum; /* Current number of messagesin queue */msglen_t msg_qbytes; /* Maximum number of bytesallowed in queue */pid_t msg_lspid; /* PID of last msgsnd(2) */pid_t msg_lrpid; /* PID of last msgrcv(2) */
};
第一个ipc_perm 结构体是不是有点熟悉呢?它和shm_perm是同类型的结构体,使用命令:
cat /usr/include/linux/ipc.h
就能够看到ipc_perm 的结构体定义如下:
struct ipc_perm
{key_t __key; /* Key supplied to msgget(2) */uid_t uid; /* Effective UID of owner */gid_t gid; /* Effective GID of owner */uid_t cuid; /* Effective UID of creator */gid_t cgid; /* Effective GID of creator */unsigned short mode; /* Permissions */unsigned short __seq; /* Sequence number */
};
3.步骤
消息队列使用过程如下:
- 创建
- 发送
- 接收
- 释放
4.函数
(1)msgget
使用msgget来创建消息队列:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgget(key_t key, int msgflg);
key | 通过ftok函数生成 |
msgflg | msgflg标志有多个,先了解最常用的两个标志IPC_CREAT和IPC_EXCL就可以了 |
返回值 | 创建成功就返回消息队列标识符,失败就返回-1 |
同shmget一样,msgget第一个参数key是通过ftok函数生成的:
#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);
pathname | 自定义的文件路径名 |
proj_id | 序号,低8位被使用,非0 |
返回值 | 返回key,会被设置进共享内存在内核的数据结构里面 |
msgget第三个参数msgflg标志有多个,先了解最常用的两个标志IPC_CREAT和IPC_EXCL就可以了:
(2)msgctl
使用msgctl来释放消息队列:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgctl(int msqid, int cmd, struct msqid_ds *buf);
使用完消息队列后,如果不删除的话,消息队列会一直存在,直到系统重启。如何删除呢?有两种删除方式,一种是命令删除:
ipcrm -q msqid
那么如何在代码中删除共享内存呢?因此另外一种删除消息队列的方式就是使用msgctl函数控制消息队列:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgctl(int msqid, int cmd, struct msqid_ds *buf);
msqid | 消息队列的用户层id |
cmd | 选项,有多个 |
buf | data structure数据结构类型指针 |
返回值 | 删除成功返回0,失败返回-1 |
其中cmd选项有多个:
IPC_STAT | 将msqid的内核数据结构拷贝到buf指向的msqid_ds结构中 |
IPC_SET | 将buf指向的msqid_ds结构的一些成员的值写入与此共享内存段相关的内核数据结构,同时更新其msq_ctime成员 |
IPC_RMID | 删除共享内存 |
(3)msgsnd
使用msgsnd向消息队列发送数据:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
msqid | 操作系统给用户返回的id |
msgp | 待发送数据块 |
msgsz | 待发送数据块大小 |
msgflg | 发送数据块的方式,一般为0 |
返回值 | 0表示调用成功,-1表示调用失败 |
其中第二个参数msgp的结构为:
struct msgbuf{long mtype; /* message type, must be > 0 */char mtext[1]; /* message data */
};
其中mutex为待发送的信息,mutex大小可以由我们自己指定。
(4)msgrcv
使用msgrcv从消息队列获取消息:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);
msqid | 操作系统给用户返回的id |
msgp | 获取到的数据块 |
msgsz | 获取到的数据块大小 |
msgtyp | 获取到的数据块的类型 |
msgflg | 获取数据块的方式,一般为0 |
返回值 | >0表示实际获取的字节数,-1表示调用失败 |
五、信号量
1.原理
前面的管道、共享内存、消息队列都以传输数据为目的,但是信号量不以传输数据为目的,通过共享资源的方式,来达到多个进程同步互斥的目的。
信号量,有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。本质就是一个计数器,衡量临界资源中的资源数。
这就像坐火车一样,并不是因为坐在座位上,这个作为才属于某一个人,而是买了票的时候,这个作为就已经属于买票的人了,因此买票的本质就是对临界资源的预订,票的数量就是信号量。如以下代码:
信号量相关概念:
- 临界资源:被多个执行流同时访问的资源,一次只允许一个进程使用。比如管道、共享内存、消息队列、信号量。
- 临界区:进程中访问临界资源的代码(和临界资源配套)为了保护数据安全,就要把临界区保护起来,就有了信号量。
- 原子性:一件事情要么做完,要么不做,没有中间状态。
- IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核。
信号量本质是对临界资源的统计,更是操作系统对临界资源的预定机制,信号量要诶预订,所有线程要访问临界资源,得先申请信号量,那么所有的进程就得先看到信号量,信号量就是临界资源,要保护信号量这个临界资源,信号量的常见操作即PV操作就必须保证原子性。
2.数据结构
使用命令:
cat /usr/include/linux/sem.h
就能够看到信号量的数据结构如下:
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 结构体是不是有点熟悉呢?它和shm_perm、msg_perm是同类型的结构体,使用命令:
cat /usr/include/linux/ipc.h
就能够看到ipc_perm 的结构体定义如下:
struct ipc_perm
{key_t __key; /* Key supplied to msgget(2) */uid_t uid; /* Effective UID of owner */gid_t gid; /* Effective GID of owner */uid_t cuid; /* Effective UID of creator */gid_t cgid; /* Effective GID of creator */unsigned short mode; /* Permissions */unsigned short __seq; /* Sequence number */
};
3.函数
(1)semget
使用semget创建信号量:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semget(key_t key, int nsems, int semflg);
key | 操作系统给用户返回的id |
nsems | 创建的信号量的个数 |
semflg | semflg标志有多个,先了解最常用的两个标志IPC_CREAT和IPC_EXCL就可以了 |
返回值 | 创建成功就返回信号量标识符,-1表示创建失败 |
(2)semctl
使用semctl删除信号量:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semctl(int semid, int semnum, int cmd, ...);
semid | 信号量的用户层id |
semnum | 信号量序号 |
cmd | 信号量的控制操作标识 |
返回值 | 创建成功就返回信号量标识符,-1表示创建失败 |
(3)semop
使用semop来进行信号量的PV操作:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semop(int semid, struct sembuf *sops, unsigned nsops);
semid | 信号量的用户层id |
sops | 是sembuf类型的操作指针 |
nsops | 单个信号量的操作 |
返回值 | 创建成功就返回信号量标识符,-1表示创建失败 |
使用命令:
cat /usr/include/linux/sem.h
可以看到sembuf结构体:
struct sembuf
{unsigned short sem_num; /* semaphore index in array */short sem_op; /* semaphore operation */short sem_flg; /* operation flags */
};
sem_num | 指定要操作的信号量,0表示第一个信号量,1表示第二个信号量,…… |
sem_op | 信号量操作 |
sem_flg | 操作标识 |
六、System V IPC总结
从以上内容可以看出,共享内存、消息队列、信号量,虽然属性和实现起来有差别,但是他们维护的数据结构的成员却是一样的,即ipc_perm结构体,这样每次要申请System V IPC时,无论是共享内存、消息队列、信号量,都会在数组中开辟ipc_perm这样的结构:
那么内核可以分配一个ipc_perm数组,用来指向每一个IPC资源。