赞
踩
进程间通信简称IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。
本质是:让不同的进程看到同一份资源。
由于因为进程间之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。
因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
管道
System V IPC
POSIX IPC
管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
我们可以用who | wc -l的命令看当前使用云服务器上的登录用户个数。
who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数。
who和wc这两个命令都是程序,当它们运行起来的时候,就是两个进程,who这个进程通过标准输出流进入到管道中,wc进程再通过标准输入流对管道内数据进行读入,最后再通过标准输出流给用户。
匿名管道的本质就是进行本地父子进程之间的通信,也就是让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
我们需要注意的点在于:
匿名管道的使用需要使用pipe和fork两个函数的配合。
#include <stdio.h> #include <unistd.h> #include <string.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> #define Ssize 64 // child->write father->read int main() { int fd[2] = { 0 }; // 定一下fd文件描述符 if(pipe(fd) < 0) { // 调用失败 perror("pepe failed\n"); return 1; } // 创建子进程 pid_t id = fork(); if(id < 0) { // 创建失败 perror("fork failed\n"); return 1; } else if(id == 0) { // child close(fd[0]); // 关闭读端 // 向管道写入数据 const char* msg = "hello my father, I am your child..."; for(int i = 0; i < 10; i++) { write(fd[1], msg, strlen(msg)); sleep(1); } close(fd[1]); // 关闭文件 exit(0); } // father close(fd[1]); // 关闭写端 char buff[Ssize]; // 文件缓冲区 while(1) { ssize_t s = read(fd[0], buff, sizeof(buff)); // 读数据 if(s == 0) { // 读结束 printf("father read end\n"); break; } else if(s > 0) { // 开始输出给显示器 buff[s] = '\0'; printf("child send msg to father, is:%s\n", buff); } else { // 读错误 perror("read failed\n"); break; } } close(fd[0]); //父进程读取完毕,关闭文件 waitpid(id, NULL, 0); return 0; }
pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:
int pipe2(int pipefd[2], int flags);
pipe2函数的第二个参数用于设置选项。
当没有数据可读时
当管道满的时候
如果所有管道写端对应的文件描述符被关闭,则read返回0
如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
我们先来介绍一下临界资源:
我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
管道的本质是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。
当进程1将数据写到管道中后,进程2将数据拿出的时候是随机拿取的,拿多少取多少是不固定的,这种被称为流式服务。
在数据通信中,数据在线路上的传送方式可以分为以下三种:
管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。
前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。
第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。
第四种情况也不难理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。
下面的代码反应了第四种情况,我们想看一下子进程收到了什么信号。
#include <stdio.h> #include <unistd.h> #include <string.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> #define Ssize 64 // child->write father->read int main() { int fd[2] = { 0 }; // 定一下fd文件描述符 if(pipe(fd) < 0) { // 调用失败 perror("pepe failed\n"); return 1; } // 创建子进程 pid_t id = fork(); if(id < 0) { // 创建失败 perror("fork failed\n"); return 1; } else if(id == 0) { // child close(fd[0]); // 关闭读端 // 向管道写入数据 const char* msg = "hello my father, I am your child..."; for(int i = 0; i < 10; i++) { write(fd[1], msg, strlen(msg)); sleep(1); } close(fd[1]); // 关闭文件 exit(0); } // father 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看一下吧:
操作系统向子进程发送SIGPIPE信号将子进程停止。
我们从上面的管道特点肯定能够看出管道是有大小的,如果管道已满,那么写端将阻塞或失败,那么管道的最大容量是多少呢?
2.6.11版本往后的linux中,其管道最大容量都是65536bytes。
管道最大的容量为512*8bytes = 4096bytes。
#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 failed\n"); return 1; } pid_t id = fork(); if(id < 0) { perror("fork failed\n"); exit(0); } else if(id == 0) { // child close(fd[0]); // 关闭读端 char a = 'c'; int count = 0; while(1) // child一直写 { write(fd[1], &a, 1); count++; printf("count = %d\n", count); } close(fd[1]); exit(0); } // father close(fd[1]); // 关闭写端 // 不读 waitpid(id, NULL, 0); close(fd[0]); return 0; }
我们发现到65536的时候就被挂起了,说明就是管道最大容量就是65536了。
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
[Semi@hecs-91732 fifo]$ mkfifo fifo
使用mkfifo命令创建一个命名管道。
创建出来的文件的类型是p,代表该文件是命名管道文件。
使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程(进程1)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程2)当中用cat命令从命名管道当中进行读取。
现象就是当进程1启动后,进程2会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。
当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。
在程序中创建命名管道使用mkfifo函数,mkfifo函数的函数原型如下:
int mkfifo(const char *filename,mode_t mode);
第一个参数是filename,示要创建的命名管道文件。
注意点:
mode就是权限的意思,我们一般是0666,而当然内部是有掩码也就是我们常用的umask,我们提前将umask设置成0,再进行mode权限设置成0666即可。
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> int main() { umask(0); // 掩码设置成0 // 使用mkfifo创建命名管道文件 if (mkfifo(FILE_NAME, 0666) < 0) { perror("mkfifo"); return 1; } // 创建成功 // ... return 0; }
如果当前打开操作是为读而打开FIFO时。
如果当前打开操作是为写而打开FIFO时。
我们创建两个源文件,一个是serve.c,另一个是client.c,即服务端和客户端,服务端是用来创建管道的,并且其进行读取功能,而客户端是用来写入管道数据的。所以我们先将服务端跑起来,因为它执行的是创建管道和只读功能,进而再让客户端跑起来进行键盘输入。
serve.c
// 读取 #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_SIZE 128 #define FILE_NAME "myfifo" int main() { umask(0); if(mkfifo(FILE_NAME, 0666) < 0) // 创建管道 { perror("mkfifo failed\n"); return 1; } int fd = open(FILE_NAME, O_RDONLY); // 以只读方式打开,因为这个源文件只读取 if(fd < 0) { perror("open failed\n"); return 2; } char msg[FILE_SIZE]; // 来个数组用来存取 while(1) { // 每次读之前将msg内清空 msg[0] = '\0'; // 从命名管道中读取信息 ssize_t s = read(fd, msg, sizeof(msg) - 1); if(s < 0) { perror("read failed\n"); break; } else if(s == 0) { printf("read quit\n"); break; } else { // 成功 msg[s] = '\0'; // 分割 printf("client# %s\n", msg); } } close(fd); // 管道文件关闭 return 0; }
由于管道已经被创建了,所以在客户端只需要进行打开相对应的管道进行只写的操作即可。
client.c
// 写入 #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_SIZE 128 #define FILE_NAME "myfifo" int main() { int fd = open(FILE_NAME, O_WRONLY); // 以只写方式打开 if(fd < 0) { perror("open failed\n"); return 1; } char msg[FILE_SIZE]; // 创建一个数组用来存储 while(1) { msg[0] = '\0'; // 每次读之前将内部清零 printf("Please Enter# "); // 提示客户端输入 fflush(stdout); // 刷新缓冲区 // 从0这个标准输入流中读取 ssize_t s = read(0, msg, sizeof(msg) - 1); if(s < 0) { perror("read failed\n"); break; } else if(s == 0) { printf("read quit\n"); break; } else { // 写入操作 msg[s - 1] = '\0'; // 将最后用\0隔开提取字符 write(fd, msg, strlen(msg)); // 写入操作 } } close(fd); return 0; }
代码编写完毕后,先将服务端进程运行起来,之后我们就能在客户端看到这个已经被创建的命名管道文件。
接着再将客户端也运行起来,此时我们从客户端写入的信息被客户端写入到命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,该现象说明服务端是能够通过命名管道获取到客户端发来的信息的。
当客户端退出后,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了。
当服务端退出后,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。
我们只让客户端一直写,而服务端不进行读取,我们看一下myfifo管道文件是否改变:
serve.c
// 读取 #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_SIZE 128 #define FILE_NAME "myfifo" int main() { umask(0); if(mkfifo(FILE_NAME, 0666) < 0) // 创建管道 { perror("mkfifo failed\n"); return 1; } int fd = open(FILE_NAME, O_RDONLY); // 以只读方式打开,因为这个源文件只读取 if(fd < 0) { perror("open failed\n"); return 2; } char msg[FILE_SIZE]; // 来个数组用来存取 while(1) { // 啥事不干 } close(fd); // 管道文件关闭 return 0; }
client.c
// 写入 #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_SIZE 128 #define FILE_NAME "myfifo" int main() { int fd = open(FILE_NAME, O_WRONLY); // 以只写方式打开 if(fd < 0) { perror("open failed\n"); return 1; } char msg[FILE_SIZE]; // 创建一个数组用来存储 while(1) { msg[0] = '\0'; // 每次读之前将内部清零 printf("Please Enter# "); // 提示客户端输入 fflush(stdout); // 刷新缓冲区 // 从0这个标准输入流中读取 ssize_t s = read(0, msg, sizeof(msg) - 1); if(s < 0) { perror("read failed\n"); break; } else if(s == 0) { printf("read quit\n"); break; } else { // 写入操作 msg[s - 1] = '\0'; // 将最后用\0隔开提取字符 write(fd, msg, strlen(msg)); // 写入操作 } } close(fd); return 0; }
两个进程之间的通信,并不是简单的发送字符串而已,服务端是会对客户端发送过来的信息进行某些处理的。
client.c
// 写入 #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_SIZE 128 #define FILE_NAME "myfifo" int main() { int fd = open(FILE_NAME, O_WRONLY); // 以只写方式打开 if(fd < 0) { perror("open failed\n"); return 1; } char msg[FILE_SIZE]; // 创建一个数组用来存储 while(1) { msg[0] = '\0'; // 每次读之前将内部清零 printf("Please Enter# "); // 提示客户端输入 fflush(stdout); // 刷新缓冲区 // 从0这个标准输入流中读取 ssize_t s = read(0, msg, sizeof(msg) - 1); if(s < 0) { perror("read failed\n"); break; } else if(s == 0) { printf("read quit\n"); break; } else { // 写入操作 msg[s - 1] = '\0'; // 将最后用\0隔开提取字符 write(fd, msg, strlen(msg)); // 写入操作 } } close(fd); return 0; }
serve.c:
// 读取 #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_SIZE 128 #define FILE_NAME "myfifo" int main() { umask(0); if(mkfifo(FILE_NAME, 0666) < 0) // 创建管道 { perror("mkfifo failed\n"); return 1; } int fd = open(FILE_NAME, O_RDONLY); // 以只读方式打开,因为这个源文件只读取 if(fd < 0) { perror("open failed\n"); return 2; } char msg[FILE_SIZE]; // 来个数组用来存取 while(1) { msg[0] = '\0'; //每次读之前将msg清空 //从命名管道当中读取信息 ssize_t s = read(fd, msg, sizeof(msg) - 1); if(s > 0) { msg[s] = '\0'; printf("client# %s\n", msg); // 传到服务端进行计算 char* lable = "+-*/%"; char* p = msg; int flag = 0; while(*p) { switch(*p) { case '+': flag = 0; break; case '-': flag = 1; break; case '*': flag = 2; break; case '/': flag = 3; break; case '%': flag = 4; break; } p++; } char* data1 = strtok(msg, "+-*/%"); // 分割第一个 char* data2 = strtok(NULL, "+-*/%"); // 传入的参数为NULL,使得该函数默认使用上一次未分割完的字符串继续分割 int num1 = atoi(data1); // 分解转化成数num1 int num2 = atoi(data2); // 分解转化成数num2 int ret = 0; switch(flag) { case 0: ret = num1 + num2; break; case 1: ret = num1 - num2; break; case 2: ret = num1 * num2; break; case 3: ret = num1 / num2; break; case 4: ret = num1 % num2; break; } printf("%d %c %d = %d\n", num1, lable[flag], num2, ret); //打印计算结果 } else if(s == 0) { printf("quit\n"); break; } else { perror("read failed\n"); break; } } close(fd); // 管道文件关闭 return 0; }
我们可以通过一个进程1控制进程2的行为,比如我们从客户端输入命令到管道当中,再让服务端将管道当中的命令读取出来并执行。也就是说可以控制进程2的命令行的命令执行,我们只实现不带-的命令行,因为制作-和上面进行程序替换的逻辑差不多,我们只需要更改serve.c中的代码即可,client.c中的代码不需要进行改变。
serve.c:
// 读取 #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_SIZE 128 #define FILE_NAME "myfifo" int main() { umask(0); if(mkfifo(FILE_NAME, 0666) < 0) // 创建管道 { perror("mkfifo failed\n"); return 1; } int fd = open(FILE_NAME, O_RDONLY); // 以只读方式打开,因为这个源文件只读取 if(fd < 0) { perror("open failed\n"); return 2; } char msg[FILE_SIZE]; // 来个数组用来存取 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); pid_t id = fork(); if(id == 0) { // child execlp(msg, msg, NULL); exit(1); } waitpid(-1, NULL, 0); //等待子进程 } else if(s == 0) { printf("quit\n"); break; } else { perror("read failed\n"); break; } } close(fd); // 管道文件关闭 return 0; }
服务端接收到客户端的信息后,便进行进程程序替换,进而执行客户端发送过来的命令。
让客户端将file.txt文件通过管道发送给服务端,在服务端创建一个file_bat.txt文件,并将从管道获取到的数据写入file-bat.txt文件当中,至此便实现了file.txt文件的拷贝。
serve.c:
#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_NAME "myfifo" int main() { umask(0); if(mkfifo(FILE_NAME, 0666) < 0) { perror("mkfifo failed\n"); return 1; } // 1、以读方式打开管道文件 int fd = open(FILE_NAME, O_RDONLY); if(fd < 0) { perror("fd open failed\n"); return 2; } // 2、写的方式写入目标文件 int fdout = open("log-bat.txt", O_CREAT | O_WRONLY, 0666); if(fdout < 0) { perror("fdout open failed\n"); return 3; } char msg[128]; while(1) { msg[0] = '\0'; // 先都清空 // 3.0、从管道中读取信息 ssize_t s = read(fd, msg, sizeof(msg) - 1); if(s > 0) { // 3.1、以写的方式写入目标文件 write(fdout, msg, s); } else if(s == 0) { printf("quit\n"); break; } else { perror("read failed\n"); break; } } close(fd); close(fdout); return 0; }
client.c:
#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <fcntl.h> #define FILE_NAME "myfifo" int main() { // 以只写方式打开管道文件 int fd = open(FILE_NAME, O_WRONLY); if(fd < 0) { perror("fd open failed\n"); return 1; } // 以只读的方式打开目标文件 int fdin = open("log.txt", O_RDONLY); if(fdin < 0) { perror("fdin open failed\n"); return 2; } // 从log.txt读取数据写到管道里面 char msg[128]; while(1) { ssize_t s = read(fdin, msg, sizeof(msg)); if(s > 0) { write(fd, msg, s); // 写到管道里 } else if(s == 0) { printf("quit\n"); break; } else { perror("fdin read failed\n"); break; } } close(fd); close(fdin); return 0; }
我们若是将这里的管道想象成“网络”,将客户端想象成“Windows Xshell”,再将服务端想象成“centos服务器”。那我们此时实现的就是文件上传的功能,若是将方向反过来,那么实现的就是文件下载的功能。
[Semi@hecs-91732 Bat]$ cat test.txt | grep fifo
有一个问题:那么在命令行当中的管道(“|”)到底是匿名管道还是命名管道呢?
由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道(“|”)连接起来的各个进程之间是否具有亲缘关系。
我们试试用三个毫不相干的进程来看一下他们的PID和PPID:
也就是说,由管道(“|”)连接起来的各个进程是有亲缘关系的,它们之间互为兄弟进程。
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而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;
};
共享内存的数据结构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函数的参数说明:
shmget函数的返回值说明:
我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。
函数原型:
key_t ftok(const char *pathname, int proj_id);
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。
注意点:
1、使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
2、需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
即第一个IPC_CREAT一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
第二个IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/Semi/linux/class6/shm/shm.c" #define PROJ_ID 0X6666 #define SIZE 4096 int main() { // 获取key值 key_t key = ftok(PATHNAME, PROJ_ID); if(key < 0) { perror("ftok failed\n"); return 1; } // 写shmget int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); if(shm < 0) { perror("shmget failed\n"); return 2; } printf("key:%d\n", key); printf("shm:%d\n", shm); return 0; }
运行一下看一下key和shm的值:
使用ipcs命令看一下有关进程间通信设施的信息。
单独使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
-q:列出消息队列相关信息。
-m:列出共享内存相关信息。
-s:列出信号量相关信息。
我们这里只看共享内存相关信息,所以用的是-m选项:
那肯定是创建成功了,我们看上面的信息。
ipcs各参数的含义:
注意点:key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的的关系。
我们从上面可以发现,当我们的进程退出的时候,而共享内存却没有退出,这就说明共享内存的生命周期肯定不是跟随进程的,实际上,管道的生命周期是跟随进程的,而共享内存的生命周期是跟随内核走的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。如果我们退出进程后不对共享内存进行释放,那么这个共享内存会一直都存在着,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。
我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。
ipcrm -m shmid
使用上面的命令就可以将我们的共享内存进行释放掉,我们可以清晰的看到我自己的数据shmid是0,所以我只需要用这个命令后面变成0即可。
控制共享内存我们需要用shmctl函数,shmctl函数的函数原型如下:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmctl函数的参数说明:
shmctl函数的返回值说明:
作为shmctl函数的第二个参数传入的常用的选项有以下三个:
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/Semi/linux/class6/shm/shm.c" #define PROJ_ID 0X6666 #define SIZE 4096 int main() { // 获取key值 key_t key = ftok(PATHNAME, PROJ_ID); if(key < 0) { perror("ftok failed\n"); return 1; } // 写shmget int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); if(shm < 0) { perror("shmget failed\n"); return 2; } printf("key:%d\n", key); printf("shm:%d\n", shm); sleep(4); shmctl(shm, IPC_RMID, NULL); //释放共享内存 sleep(4); return 0; }
while脚本。
while :; do ipcs -m;echo "###################################";sleep 1;done
将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat函数的参数说明:
shmat函数的返回值说明:
作为shmat函数的第三个参数传入的常用的选项有以下三个:
#define _GNU_SOURCE #include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/Semi/linux/class6/shm/shm.c" #define PROJ_ID 0X6666 #define SIZE 4096 int main() { // 获取key值 key_t key = ftok(PATHNAME, PROJ_ID); if(key < 0) { perror("ftok failed\n"); return 1; } // 写shmget int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); if(shm < 0) { perror("shmget failed\n"); return 2; } printf("key:%x\n", key); printf("shm:%d\n", shm); printf("attach begin!\n"); char* mem = shmat(shm, NULL, 0); if(mem == (void*)-1) { perror("shamt error\n"); return 5; } printf("attach end\n"); sleep(4); shmctl(shm, IPC_RMID, NULL); //释放共享内存 return 0; }
代码运行后发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,所以创建出来的共享内存的默认权限为0,即什么权限都没有,因此shm进程没有权限关联该共享内存。
我们将进程改成0666即可。
完美链接数为1。
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:
int shmdt(const void *shmaddr);
shmdt函数的参数说明:
shmdt函数的返回值说明:
将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。
服务端负责创建共享内存,创建好后将共享内存和服务端进行关联,之后进入死循环,便于观察服务端是否挂接成功。
serve.c:
#define _GNU_SOURCE #include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/Semi/linux/class6/serve.c" #define PROJ_ID 0X6666 #define SIZE 4096 int main() { key_t key = ftok(PATHNAME, PROJ_ID); // key值 if(key < 0) { perror("ftok error\n"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); // 新的共享内存 if(shm < 0) { perror("shmget error\n"); 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; }
client.c:
#define _GNU_SOURCE #include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/Semi/linux/class6/serve.c" #define PROJ_ID 0X6666 #define SIZE 4096 int main() { key_t key = ftok(PATHNAME, PROJ_ID); // 获取和serve.c一样的key值 if(key < 0) { perror("ftok error\n"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT); // 获取与用户层一样的shm,id还有一样的共享内存 if(shm < 0) { perror("shmget error\n"); return 2; } printf("key#%x\n", key); printf("shm#%d\n", shm); // 关联共享内存 char* mem = shmat(shm, NULL, 0); if(mem == (void*)-1) { perror("shmat error\n"); return 3; } while(1) { // 啥也不做... } shmdt(mem); // 解关联 return 0; }
先后运行服务端和客户端后,通过监控脚本可以看到服务端和客户端所关联的是同一个共享内存,共享内存关联的进程数也是2,表示服务端和客户端挂接共享内存成功。
serve.c补充内容:
while(1)
{
// 不断从共享内存中读取数据并打印出来
printf("client#%s\n", mem);
sleep(1);
}
client.c补充内容:
int i = 0;
while(1)
{
// 不断对共享内存写入数据
mem[i] = 'a' + i;
i++;
mem[i] = '\0';
sleep(1);
}
此时先运行服务端创建共享内存,当我们运行客户端时服务端就开始不断输出数据,说明服务端和客户端是能够正常通信的。
当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。
我们通信的速度取决于拷贝的次数,也就是从一个文件到另一个文件的次数。
从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。
共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
共享内存的数据结构msqid_ds和ipc_perm结构体分别在/usr/include/linux/msg.h和/usr/include/linux/ipc.h中定义。
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函数的返回值说明:
其中msgsnd函数的第二个参数必须为以下结构:
struct msgbuf
{
long mtype; /* message type, must be > 0 */
char mtext[1]; /* message data */
};
从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msgrcv函数的参数说明:
msgrcv函数的返回值说明:
共享内存的数据结构msqid_ds和ipc_perm结构体分别在/usr/include/linux/sem.h和/usr/include/linux/ipc.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类型的结构体变量,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资源的每一个成员了。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。