进程间通信介绍
进程间通信的概念
进程间通信简称IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。
进程间通信的目的
- 数据传输: 一个进程需要将它的数据发送给另一个进程。
- 资源共享: 多个进程之间共享同样的资源。
- 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的本质
我们在实际应用场景下,进程之间可能会存在特定的协同工作的场景,比如一个进程要把自己的数据交给另一个进程,让其进行处理,这就是所谓的进程间通信。但是我们知道由于各个运行进程之间具有独立性,一个进程看不到另一个进程的资源,如果要交互数据,成本一定很高, 因此各个进程之间要实现通信是非常困难的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域(可能以文件形式提供,也可能以队列的方式,也可能提供的就是原始的内存块)。
因此,进程间通信的本质就是,让不同的进程看到同一份资源。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
进程间通信发展
- 管道
- System V进程间通信
- POSIX进程间通信
进程间通信的分类
匿名管道 命名管道
System V 消息队列 System V 共享内存 System V 信号量
消息队列 共享内存 信号量 互斥量 条件变量 读写锁
管道
什么是管道
管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个“管道”。
例如,统计我们当前使用云服务器上的登录用户个数。
who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数。
其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输.
匿名管道
匿名管道用于进程间通信,且仅限于本地父子进程之间的通信。
创建匿名管道——pipe
pipe函数用于创建匿名管道
函数原型:
#include <unistd>
int pipe(int pipefd[2])
参数: fd:文件描述符数组,这是一个输出型参数,fd[0]表示读端,fd[1]表示写端
返回值: 创建管道成功返回0,失败返回-1
调用pipe函数后,OS会在fd_array数组中分配两个文件描述符给管道,一个是读,一个是写,
匿名管道原理
父进程调用pipe函数创建管道。
1.父进程创建管道
匿名管道的创建,需要通过下面这个系统调用: int pipe(int fd[2])
这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
首先让父进程以读和写的方式打开同一个文件,可以理解为以读方式打开一次,以写方式打开一次。相当于父进程打开一个文件,它打开这个文件并不是只打开一次,而是以读方式打开和以写方式打开,这样的话以读方式打开文件返回一个文件描述符,以写方式打开文件返回一个文件描述符,这两个文件描述符指向同一个文件。这样就相当于有了读端有了写端,这个过程可以称之为创建管道的过程。
什么叫做创建管道一句话概括:分别以读方式和以写方式打开两次同一个文件。
2.父进程fork出子进程 其实,所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。
看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?
我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
3.父子进程各自关闭不需要的文件描述符 但是管道只能进行单向数据通信。也就意味着要么父进程写子进程读,要么子进程去写父进程去读。总之一个管道只能进行单向数据通信,如果想双向通信可以建立多个管道。 管道只能进行单向通信,所以就要决定让谁读让谁写。
- 如果想让父进程读,就关闭父进程的写端,如果想让子进程读,就关闭子进程的写端。
- 如果想让父进程写,就关闭父进程的读端,如果想让子进程写,就关闭子进程的读端。
也就是说让父子进程各自关闭它们不需要的文件描述符来达到构建单向信道的功能。 为什么父子进程迟早要关掉一个,曾经要打开呢?
- 1.如果父进程只以读方式打开,子进程继承下去的文件描述符对应打开的文件也只是读方式,两个读不能通信。如果只以写方式打开,fork之后子进程被打开的文件是可写的,父子进程都只能写,这样不能通信。所以不打开rw,子进程拿到的文件打开方式必定和父进程一样,无法通信。
- 2.灵活的控制父子进程完成读写通信,到底是父进程读还是子进程写,完全取决于你的场景,所以把读写端都打开,你需要关哪个自己决定。
————————————————
注意:
- 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
- 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。
举例: 在以下代码当中,子进程向匿名管道当中写入数据,父进程从匿名管道当中将数据读出。
#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 *msg="hello world";
while(1)
{
write(pipefd[1],msg,strlen(msg));
sleep(1);
}
exit(0);
}
close(pipefd[1]);
while(1)
{
char buffer[64]={0};
ssize_t s=read(pipefd[0],buffer,sizeof(buffer));
if(s==0)
{
printf("child quit...\n");
break;
}
else if(s>0)
{
buffer[s]=0;
printf("child say to father# %s\n",buffer);
}
else
{
printf("read error...\n");
break;
}
}
return 0;
}
管道读写规则
pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:
int pipe2(int pipefd[2], int flags);
pipe2函数的第二个参数用于设置选项。
1、当没有数据可读时:
- O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来为止。
- O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
2、当管道满的时候:
- O_NONBLOCK disable:write调用阻塞,直到有进程读走数据。
- O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN。
3、如果所有管道写端对应的文件描述符被关闭,则read返回0。
4、如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5、当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
6、当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性。
管道的特点
1、管道内部自带同步与互斥机制。
我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:
- 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
- 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序.
2、管道的生命周期随进程。
管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。
3、管道提供的是流式服务。
对于进程A写入管道当中的数据,进程B每次从管道读取的数据的多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:
- 流式服务: 数据没有明确的分割,不分一定的报文段。
- 数据报服务: 数据有明确的分割,拿数据按报文段拿。
4、管道是半双工通信的。
在数据通信中,数据在线路上的传送方式可以分为以下三种:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。
管道的四种特殊情况
在使用管道时,可能出现以下四种特殊情况:
- 写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起(阻塞),直到管道里面有数据后,读端进程才会被唤醒。
- 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起(阻塞),直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
- 写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。(也就是说读端一直读,写端不写并且关闭,读取到0,代表文件结束。)
- 读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程杀掉(写端被OS发送13号信号SIGPIPE杀掉)。
其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。
第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。
第四种情况就是说:既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。(假设子进程是写端)而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。
我们可以通过以下代码看看情况四中,子进程退出时究竟是收到了什么信号。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int fd[2] = { 0 };
if (pipe(fd) < 0){
perror("pipe");
return 1;
}
pid_t id = fork();
if (id == 0){
close(fd[0]);
const char* msg = "hello father, I am child...";
int count = 10;
while (count--){
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]);
exit(0);
}
close(fd[1]);
close(fd[0]);
int status = 0;
waitpid(id, &status, 0);
printf("child get signal:%d\n", status & 0x7F);
return 0;
}
运行结果显示,子进程退出时收到的是13号信号。
通过kill -l命令可以查看13对应的具体信号。
$ kill -l
由此可知,当发生情况四时,操作系统向子进程发送的是SIGPIPE信号将子进程终止的。
管道的大小
管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,那么管道的最大容量是多少呢?怎么查看呢?
方法一:使用man手册
根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。
方法二:使用ulimit命令
我们还可以使用ulimit -a 命令,查看当前资源限制的设定。
管道的最大容量是 512 × 8=4096 字节。
方法三:自行测试
前面说到,若是读端进程一直不读取管道当中的数据,写端进程一直向管道写入数据,当管道被写满后,写端进程就会被挂起。据此,我们可以写出以下代码来测试管道的最大容量。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
int fd[2] = { 0 };
if (pipe(fd) < 0){
perror("pipe");
return 1;
}
pid_t id = fork();
if (id == 0){
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);
}
close(fd[1]);
waitpid(id, NULL, 0);
close(fd[0]);
return 0;
}
可以看到,在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起了,也就是说,我当前Linux版本中管道的最大容量是65536字节。
命名管道
命名管道的原理
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过路径+文件名打开同一个管道文件(此管道文件具有唯一性,因为路径——文件名可以唯一标识一个文件),此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
注意
1.命名管道也是管道,它也遵守管道的面向字节流,同步互斥,只能单向通信,生命周期随进程等等这些特点它都有,唯一和匿名管道不同的是它可以让不同的进程(毫不相关进程)通信。 2.普通文件需要将数据刷新到磁盘上,持久化存储。但是管道文件不需要把数据刷新到磁盘。
创建一个命名管道
1.命名管道可以从命令行上创建
命令行方法是使用下面这个命令:
mkfifo fifo
可以看到,创建出来的文件的类型是p,代表该文件是命名管道文件。
2.命名管道也可以从程序里创建
相关函数有:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);
- 第一个参数表示要创建的命名管道文件。以路径的方式给出,则将命名管道文件创建在指定路径下,以文件名的方式给出,则将命名管道文件默认创建在当前路径下。
- 第二个参数表示给要创建的命名管道设置权限。
返回值成功返回0,失败返回-1。 ———————————————— 注意第二个参数的权限问题:
例如,将mode设置为0666,按理说命名管道文件创建出来的权限如下:
prw-rw-rw-
但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。
umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。即prw-rw-r-
若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0。
umask(0);
———————————————— 从程序里创建命名管道示例:
1 #include<stdio.h>
2 #include<sys/stat.h>
3 #include<sys/types.h>
4
5 #define My_FIFO "./fifo"
6
7 int main()
8 {
9 umask(0);
10 if(mkfifo(My_FIFO,0666)<0)
11 {
12 perror("mkfifo");
13 return 1;
14 }
15 return 0;
16 }
运行代码后,命名管道fifo就在当前路径下被创建了。
命名管道的打开规则
1、如果当前打开操作是为读而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
- O_NONBLOCK enable:立刻返回成功。
2、如果当前打开操作是为写而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。
用命名管道实现serve&client通信
实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。
服务端的代码如下: server.c
#include "comm.h"
int main()
{
umask(0);
if (mkfifo(FILE_NAME, 0666) < 0){
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';
ssize_t s = read(fd, msg, sizeof(msg)-1);
if (s > 0){
msg[s] = '\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';
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 <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.h>
#define FILE_NAME "myfifo"
代码编写完毕后,先将服务端进程运行起来,之后我们就能看到这个已经被创建的命名管道文件。
接着再将客户端也运行起来,此时我们从客户端写入的信息被客户端写入到命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,此时这两个进程之间是能够通信的。
服务端和客户端之间的退出关系:
当客户端退出后,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中是直接退出了)。 当服务端退出后,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。
通信是在内存当中进行的
若是我们只让客户端向管道写入数据,而服务端不从管道读取数据,那么这个管道文件的大小会不会发生变化呢?
//server.c
#include "comm.h"
int main()
{
umask(0);
if (mkfifo(FILE_NAME, 0666) < 0){
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,也就是管道当中的数据并没有被刷新到磁盘,也就说明了双方进程之间的通信依旧是在内存当中进行的,和匿名管道通信是一样的。
命名管道和匿名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,由open函数打开。
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。
拓展:在命令行当中的管道(“|”)到底是匿名管道还是命名管道呢?
由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道(“|”)连接起来的各个进程之间是否具有亲缘关系。
我们如果通过管道(“|”)连接了三个进程,通过ps命令查看这三个进程就可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程。而它们的父进程实际上就是命令行解释器,这里为bash。
也就是说,由管道(“|”)连接起来的各个进程是有亲缘关系的,它们之间互为兄弟进程。
system V进程间通信
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。
system V IPC提供的通信方式有以下三种:
- system V共享内存
- system V消息队列
- system V信号量
其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。
system V共享内存
共享内存的基本原理
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。 这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。
共享内存的建立:
1.申请共享内存 2.不同的进程分别挂接对应的共享内存到自己的地址空间(即建立映射关系) 3.双方就看到了同一份资源。即可以进行正常通信了。
共享内存的释放:
1.将共享内存与地址空间去关联,即取消映射关系。 2.释放共享内存空间,即将物理内存归还给系统。
共享内存数据结构
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。
共享内存的数据结构如下:
struct shmid_ds {
struct ipc_perm shm_perm;
int shm_segsz;
__kernel_time_t shm_atime;
__kernel_time_t shm_dtime;
__kernel_time_t shm_ctime;
__kernel_ipc_pid_t shm_cpid;
__kernel_ipc_pid_t shm_lpid;
unsigned short shm_nattch;
unsigned short shm_unused;
void *shm_unused2;
void *shm_unused3;
};
当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个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;
};
共享内存的数据结构shmid_ds和ipc_perm结构体分别在/usr/include/linux/shm.h和/usr/include/linux/ipc.h中定义。
共享内存的创建
创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
int shmget(key_t key, size_t size, int shmflg);
shmget函数的参数说明:
- 第一个参数key,表示待创建共享内存在系统当中的唯一标识。
- 第二个参数size,表示待创建共享内存的大小。(这个大小理论上可以任意去指定,但建议是4KB的倍数大小。)
因为系统在分配共享内存的时候,是以4KB为基本单位的,即便你要4097,操作系统在分配的时候是4096+4096即8kb。但是你仍然只能用4097,就会浪费了4095个字节。所以建议4kb的整数倍,这样就不存在共享内存空间浪费的问题。
shmget函数的返回值说明:
- shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
- shmget调用失败,返回-1。
注意: 我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。
传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取
ftok函数的函数原型如下:
key_t ftok(const char *pathname, int proj_id);
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。
注意:
- 使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
- 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
传入shmget函数的第三个参数shmflg,常用的组合方式有以下两种:
组合方式 | 作用 |
---|
IPC_CREAT | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄 | IPC_EXCL | 如果存在与key相等共享内存就会出错返回,如果单独使用是没有任何意义的 |
- 使用组合IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
- 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。
至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c"
#define PROJ_ID 0x6666
#define SIZE 4096
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
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);
printf("shm: %d\n", shm);
return 0;
}
在Linux当中,我们可以使用ipcs命令查看有关进程间通信设施的信息。
单独使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
-q:列出消息队列相关信息。 -m:列出共享内存相关信息。 -s:列出信号量相关信息。
ipcs命令输出的每列信息的含义如下:
标题 | 含义 |
---|
key | 系统区别各个共享内存的唯一标识 | shmid | 共享内存的用户层id(句柄) | owner | 共享内存的拥有者 | perms | 共享内存的权限 | bytes | 共享内存的大小 | nattch | 关联共享内存的进程数 | status | 共享内存的状态 |
注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性.
- key:是一个用户层生成的唯一键值,核心作用是为了区分共享内存的“唯一性”,不能用来进行IPC资源的操作。类似于文件的inode号。
- shmid:是一个系统给我们返回的IPC资源标识符,用来进行操作IPC资源。类似于文件的fd。
共享内存的释放
当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。
此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。
1.使用命令释放共享内存资源
我们可以使用ipcrm -m shmid 命令释放指定id的共享内存资源。
$ ipcrm -m 2
2.使用程序释放共享内存资源
控制共享内存我们需要用shmctl函数,shmctl函数的函数原型如下:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmctl函数的参数说明:
- 第一个参数shmid,表示所控制共享内存的用户级标识符。
- 第二个参数cmd,表示具体的控制动作。
- 第三个参数buf,用于获取或设置所控制共享内存的数据结构。
shmctl函数的返回值说明:
- shmctl调用成功,返回0。
- shmctl调用失败,返回-1。
其中,作为shmctl函数的第二个参数传入的常用的选项有以下三个:
选项 | 作用 |
---|
IPC_STAT | 获取共享内存的当前关联值,此时参数buf作为输出型参数 | IPC_SET | 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值 | IPC_RMID | 删除共享内存段 |
想要释放共享内存可以选第三个选项IPC_RMID。 返回值:操作成功时返回 0,出错时返回 -1。
关联共享内存和去关联共享内存
将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat函数的参数说明:
- 第一个参数shmid,表示待关联共享内存的用户级标识符。
- 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
- 第三个参数shmflg,表示关联共享内存时设置的某些属性。
shmat函数的返回值说明:
- shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
- shmat调用失败,返回(void*)-1。
其中,作为shmat函数的第三个参数传入的常用的选项有以下三个:
选项 | 作用 |
---|
SHM_RDONLY | 关联共享内存后只进行读取操作 | SHM_RND | 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA) | 0 | 默认为读写权限 |
这时我们可以尝试使用shmat函数对共享内存进行关联:
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c"
#define PROJ_ID 0x6666
#define SIZE 4096
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
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);
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,即什么权限都没有,因此server进程没有权限关联该共享内存。
我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同。
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666);
此时再运行程序,即可发现关联该共享内存的进程数由0变成了1,而共享内存的权限显示也不再是0,而是我们设置的666权限。
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数
shmdt函数的函数原型如下:
int shmdt(const void *shmaddr);
shmdt函数的参数说明:
- 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。
shmdt函数的返回值说明:
- shmdt调用成功,返回0。
- shmdt调用失败,返回-1。
现在我们来取消共享内存与进程之间的关联:
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c"
#define PROJ_ID 0x6666
#define SIZE 4096
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
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);
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的过程,即取消了共享内存与该进程之间的关联。
注意: 将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。
用共享内存实现serve&client通信
在知道了共享内存的创建、关联、去关联以及释放后,现在可以尝试让两个进程通过共享内存进行通信了。在让两个进程进行通信之前,我们可以先测试一下这两个进程能否成功挂接到同一个共享内存上。
服务端负责创建共享内存,创建好后将共享内存和服务端进行关联,之后进入死循环,便于观察服务端是否挂接成功。
#include "comm.h"
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
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);
printf("shm: %d\n", shm);
char* mem = shmat(shm, NULL, 0);
while (1){
}
shmdt(mem);
shmctl(shm, IPC_RMID, NULL);
return 0;
}
客户端只需要直接和服务端创建的共享内存进行关联即可,之后也进入死循环,便于观察客户端是否挂接成功。
#include "comm.h"
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
if (key < 0){
perror("ftok");
return 1;
}
int shm = shmget(key, SIZE, IPC_CREAT);
if (shm < 0){
perror("shmget");
return 2;
}
printf("key: %x\n", key);
printf("shm: %d\n", shm);
char* mem = shmat(shm, NULL, 0);
int i = 0;
while (1){
}
shmdt(mem);
return 0;
}
为了让服务端和客户端在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享资源进行挂接。这里我们可以将这些需要共用的信息放入一个头文件当中,服务端和客户端共用这个头文件即可。
#include <stdio.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#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("%s\n", mem);
sleep(1);
}
此时先运行服务端创建共享内存,然后我们运行客户端,服务端就开始不断输出数据,说明服务端和客户端是能够正常通信的。
共享内存与管道进行对比
当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。共享内存是所有进程间通信方式中最快的一种通信方式。
我们先来看看管道通信: 从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
- 1.服务端将信息从输入文件复制到服务端的临时缓冲区中。
- 2.将服务端临时缓冲区的信息复制到管道中。
- 3.客户端将信息从管道复制到客户端的缓冲区中。
- 4.将客户端临时缓冲区的信息复制到输出文件中。
我们再来看看共享内存通信: 从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,需要程序员自己确保数据的安全性。
System V消息队列
消息队列的基本原理
消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。当两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
但邮件的通信方式存在不足的地方有两点,一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点。
消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
消息队列数据结构
当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构。
消息队列的数据结构如下:
struct msqid_ds {
struct ipc_perm msg_perm;
struct msg *msg_first;
struct msg *msg_last;
__kernel_time_t msg_stime;
__kernel_time_t msg_rtime;
__kernel_time_t msg_ctime;
unsigned long msg_lcbytes;
unsigned long msg_lqbytes;
unsigned short msg_cbytes;
unsigned short msg_qnum;
unsigned short msg_qbytes;
__kernel_ipc_pid_t msg_lspid;
__kernel_ipc_pid_t msg_lrpid;
};
可以看到消息队列数据结构的第一个成员是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;
};
共享内存的数据结构msqid_ds和ipc_perm结构体分别在/usr/include/linux/msg.h和/usr/include/linux/ipc.h中定义。
消息队列的创建
创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:
int msgget(key_t key, int msgflg);
说明一下:
- 创建消息队列也需要使用ftok函数生成一个key值,这个key值作为msgget函数的第一个参数。
- msgget函数的第二个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符)。
消息队列的释放
释放消息队列我们需要用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函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示待发送的数据块。
- 第三个参数msgsz,表示所发送数据块的大小
- 第四个参数msgflg,表示发送数据块的方式,一般默认为0即可。
msgsnd函数的返回值说明:
- msgsnd调用成功,返回0。
- msgsnd调用失败,返回-1。
其中msgsnd函数的第二个参数必须为以下结构:
struct msgbuf{
long mtype;
char mtext[1];
};
注意: 该结构当中的第二个成员mtext即为待发送的信息,当我们定义该结构时,mtext的大小可以自己指定。
从消息队列获取数据
从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msgrcv函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示获取到的数据块,是一个输出型参数。
- 第三个参数msgsz,表示要获取数据块的大小
- 第四个参数msgtyp,表示要接收数据块的类型。
msgrcv函数的返回值说明:
- msgsnd调用成功,返回实际获取到mtext数组中的字节数。
- msgsnd调用失败,返回-1。
System V信号量
信号量的理解
用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。
为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作:
- 一个是 P 操作,这个操作会把信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
- 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1 。 具体的过程如下:
- 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
- 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
- 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
可以发现,信号初始化为 1 ,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0 。
具体过程:
- 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
- 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
- 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
信号量数据结构
在系统当中也为信号量维护了相关的内核数据结构。
信号量的数据结构如下:
struct semid_ds {
struct ipc_perm sem_perm;
__kernel_time_t sem_otime;
__kernel_time_t sem_ctime;
struct sem *sem_base;
struct sem_queue *sem_pending;
struct sem_queue **sem_pending_last;
struct sem_undo *undo;
unsigned short sem_nsems;
};
信号量数据结构的第一个成员也是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;
};
共享内存的数据结构msqid_ds和ipc_perm结构体分别在/usr/include/linux/sem.h和/usr/include/linux/ipc.h中定义。
信号量相关函数
信号量集的创建
创建信号量集我们需要用semget函数,semget函数的函数原型如下:
int semget(key_t key, int nsems, int semflg);
说明一下:
- 创建信号量集也需要使用ftok函数生成一个key值,这个key值作为semget函数的第一个参数。
- semget函数的第二个参数nsems,表示创建信号量的个数。
- semget函数的第三个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 信号量集创建成功时,semget函数返回的一个有效的信号量集标识符(用户层标识符)。
信号量集的删除
删除信号量集我们需要用semctl函数,semctl函数的函数原型如下:
int semctl(int semid, int semnum, int cmd, ...);
信号量集的操作
对信号量集进行操作我们需要用semop函数,semop函数的函数原型如下:
int semop(int semid, struct sembuf *sops, unsigned nsops);
– the End –
以上就是我分享的进程通信相关内容,感谢阅读!
本文收录于专栏:Linux 关注作者,持续阅读作者的文章,学习更多知识! https://blog.csdn.net/weixin_53306029?spm=1001.2014.3001.5343
————————————————
|