管道
System V IPC
POSIX IPC
我们这里主要学习匿名管道,命名管道,共享内存,其他的内容后面了解。
前面说了进程间通信目的、发展、分类,现在我们先来说第一个发展,管道。
现实中的管道:在日常生活中,我们可以理解就是一根运输资源的管道,至于运输的资源,可以是水、石油、天然气等。
Linux中的管道:我们把一个进程链接到另一个进程的一个数据流称为“管道”。管道是UNIX中最孤独老的进程间通信的方式。
进程间通信的本质:让不同的进程,看到同一份系统资源(这份系统资源是系统通过某种方式提供的系统内存)。
某种方式决定了通信策略的差异。那么系统到底如何进行通信?继续往下看。
函数原型
#include <unistd.h> 功能:创建一无名管道 原型 int pipe(int fd[2]); 参数 fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端 返回值:成功返回0,失败返回错误代码
前面我们学了基础IO,知道文件描述符的本质,我们先站在文件描述符角度,深刻理解管道。
这里,我们可以看到创建一个pipe文件描述符数组后,拿到文件描述符3和4;
#include <stdio.h> #include <sys/types.h> #include <unistd.h> int main() { 文件描述符数组 int pipe_fds[2]; //创建失败 if(pipe(pipe_fds) == -1) { perror("pipe fail"); return -1; } 拿到文件描述符 printf("%d, %d\n",pipe_fds[0],pipe_fds[1]); return 0; }
拿到的对应文件描述符,3对应read,4对应write。
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了“Linux一切皆文件思想”
接下来,我们就可以开始进行进程间的通信,fork出一个子进程,父进程读,子进程写。
#include <stdio.h> #include <sys/types.h> #include <string.h> #include <stdlib.h> #include <fcntl.h> int main() { int pipe_fds[2] = { 0 }; //创建失败 if(pipe(pipe_fds) == -1) { perror("pipe fail\n"); return 1; } //打印对应文件描述符 printf("%d, %d\n",pipe_fds[0],pipe_fds[1]); pid_t id = fork();//创建子进程 //fork失败 if(id < 0) { perror("fork fail\n"); return 2; } else if(id == 0) { close(pipe_fds[0]);//关闭读,保证单向通信 const char *msg = "I am a child!!!"; //我们写入五次数据 int count = 5; while(count--) { write(pipe_fds[1], msg, strlen(msg));//strlen(msg)不需要+1 sleep(1); } close(pipe_fds[1]);//发送完毕,需要关闭 exit(0); //子进程任务完毕,退出 } else { close(pipe_fds[1]); char buffer[64]; while(1) { buffer[0] = 0; //先初始化为0 ssize_t s = read(pipe_fds[0], buffer, sizeof(buffer) - 1);//注意这里是字节大小 //C语言中,最后一位是'0' if(s > 0) { buffer[s] = 0;//最后一位抹0 printf("parent get message from child : %s\n", buffer); } else if(s == 0) { printf("child is quit!!!\n");; break; } else { break; } } //需要等待,不然成为僵尸进程都不知道 int status = 0; //等待子进程退出 if(waitpid(id, &status, 0) > 0) { printf("child is quit success\n"); } //不需要读取 关闭 close(pipe_fds[0]); } return 0; }
从这里的结果,我们可以看到父进程一直在等待子进程,并读取到子进程写入的数据,这里就实现了管道,一个进程写入,一个进程读取,看到同一份资源。
我们在看看下面的图,加深理解!!!
这里提出几个问题
答:如果不打开读写rw,子进程拿到的文件打开方式必定和父进程一样,无法进行通信(比如都只能读或者都只能写)。打开读写更加灵活(可以父进程读/写,子进程读/写)。
答:父进程读,子进程写,如果不关闭对应读/写,会发生误操作,比如父进程写,子进程也在写。
答:等待,等待管道内部有数据就绪(子进程写入)。
答:当然不能,需要等待,等待管道内部有空闲空间(父进程读取)。
父进程读取,子进程写入,这里体现了进程同步。
特性:
我们在来看一下,如果一直写,不读或者关闭,会怎么样那?
读取关闭,当然是一直写入数据,一直写入毫无意义,本质就是在浪费系统资源。写进程会立马被OS终止掉。
一直写入,我们在来看看能写多久,也就是管道有多大?
我们这里可以看到写到4368就不写了,大约4KB的内容
Linux中规定也是4字节,这里可以通过man命令进行查看
综上,我们说了管道读写两个方面的内容。
总结一下:
read端 | write端 | 结果 |
---|---|---|
不读 | 写 | write阻塞 |
读 | 不写 | read阻塞 |
不读&关闭 | 写 | write被OS发送SIGPIPE杀掉 |
读 | 不写&关闭 | read读取到‘0’,文件结束 |
我们可以做个小实验,sleep也可以创建匿名管道
[dy@VM-12-10-centos noname]$ sleep 1000 | sleep 2000 | sleep 3000 & [1] 19253 这里可以看到三个进程的父进程pid是一样的,所以他们是兄弟进程,符合匿名管道的特性 [dy@VM-12-10-centos noname]$ ps axj | head -1 && ps axj | grep sleep PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 13679 19251 19251 3359 pts/0 19330 S 1003 0:00 sleep 1000 13679 19252 19251 3359 pts/0 19330 S 1003 0:00 sleep 2000 13679 19253 19251 3359 pts/0 19330 S 1003 0:00 sleep 3000 1498 19310 1381 1381 ? -1 S 0 0:00 sleep 60 13679 19331 19330 3359 pts/0 19330 R+ 1003 0:00 grep --color=auto sleep
底层for循环创建出三个sleep
原理:
mkfifo myfifo
我们创建了如何去查看这个管道文件的内容?我们先写一段命令将数据写入,并查看该文件
持续写入的命令: while :; do echo "hello bit"; sleep 1; done > myfifo 查看命令: cat myfifo
这里的命令实现了两个进程简单的通信
int mkfifo(const char *filename,mode_t mode);
创建命名管道:
int main(int argc, char *argv[]) { mkfifo("p2", 0644);//对应名称、权限 return 0; }
如果当前打开操作是为读而打开FIFO时
如果当前打开操作是为写而打开FIFO时
server.c #include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #define FIFO "./fifo" int main() { int ret = mkfifo(FIFO, 0644);//目录,权限 if(ret < 0) { perror("mkfifo fail"); return 1; } //管道创建好,后面就是文件操作 int fd = open(FIFO, O_RDONLY); if(fd < 0) { perror("open fail"); return 2; } char buffer[128]; while(1) { printf("Server# "); fflush(stdout);//缓冲区中,需要强制刷新 buffer[0] = 0;//初始化 ssize_t s = read(fd, buffer, sizeof(buffer) - 1); if(s > 0) { buffer[s] = 0;//最后一位置'0' printf("Client#: %s\n",buffer); } else if(s == 0) { printf("client quit!!!\n");//终止情况 break; } else { break; } } close(fd);//打开记得关闭 return 0; }
client.c #include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #define FIFO "./fifo" #include <string.h> int main() { //server中已经创建好管道文件,这里就不需要在去创建 int fd = open(FIFO, O_WRONLY); if(fd < 0) { perror("open fail"); return 2; } char buffer[128]; while(1) { printf("Please Enter# "); fflush(stdout);//缓冲区中,需要强制刷新 buffer[0] = 0;//初始化 //最后一位为'0',C语言本质,文件不管终止符 ssize_t s = read(0, buffer, sizeof(buffer) - 1);//此时从输入流读取数据 if(s > 0) { buffer[s] = 0;//最后一位置'0' write(fd, buffer, strlen(buffer)); } else if(s == 0) { printf("client quit!!!\n");//终止情况 break; } else { break; } } close(fd);//打开记得关闭 return 0; }
我们可以看到client进程写入,server进程读取,这就是通过程序完成命名管道的创建。其实创建好管道文件好,后面的内容就是基础IO里面的打开读写等操作。
例外我们可以清楚的看到管道文件的大小是0,充分证明了管道文件不存在磁盘上,而是系统的内存区域。
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。
原理:共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。
共享内存的数据结构如下:
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函数
功能:用来创建共享内存 原型 int shmget(key_t key, size_t size, int shmflg); 参数 key:这个共享内存段名字 size:共享内存大小 shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的 返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1
我们把具有标定某种资源能力的东西叫做句柄,而这里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相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回 |
现在我们可以使用shmget和ftok函数创建一块共享内存了,代码,运行截图如下:
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #define PATHNAME "./tmp" //路径 #define PROJ_ID 0x6666 int main() { key_t k = ftok(PATHNAME, PROJ_ID); if(k < 0) { perror("ftok"); return 1; } int shmid = shmget(k, 4096, IPC_CREAT); if(shmid < 0) { perror("shmget"); return 2; } printf("key:%x\n", k); printf("shmid:%d\n",shmid); return 0; }
对应的key值和句柄值
此时我们通过命令ipcs -m 查看共享内存段
至此,我们完成了共享内存的创建
通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。
此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。
ipcrm -m 1
shmctl函数
功能:用于控制共享内存 原型 int shmctl(int shmid, int cmd, struct shmid_ds *buf); 参数 shmid:由shmget返回的共享内存标识码 cmd:将要采取的动作(有三个可取值) buf:指向一个保存着共享内存的模式状态和访问权限的数据结构 返回值:成功返回0;失败返回-1
采取的动作cmd
可以通过下面命令检查,或者直接查询两次ipcs -m
while :; do ipcs -m;echo "###################################";sleep 1;done
将共享内存连接到进程地址空间,使用shmat函数:
功能:将共享内存段连接到进程地址空间 原型 void *shmat(int shmid, const void *shmaddr, int shmflg); 参数 shmid: 共享内存标识 shmaddr:指定连接的地址 shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY 返回值:成功返回一个指针(返回共享内存映射到进程地址空间中的起始地址),指向共享内存第一个节;失败返回-1
shmaddr为NULL,核心自动选择一个地址 shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。 shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA) shmflg=SHM_RDONLY,表示连接操作用来只读共享内存
接下来我们使用shmat函数对共享内存进行关联。
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "./tmp" //路径 #define PROJ_ID 0x6666 int main() { key_t k = ftok(PATHNAME, PROJ_ID); if(k < 0) { perror("ftok"); return 1; } int shmid = shmget(k, 4096, IPC_CREAT); if(shmid < 0) { perror("shmget"); return 2; } printf("key:%x\n", k); printf("shmid:%d\n",shmid); printf("attach begin\n"); sleep(2); char* m = shmat(shmid, NULL, 0); if(m == (void*)-1) { perror("shmat"); return 3; } printf("attach end\n"); sleep(2); shmctl(shmid, IPC_RMID, NULL); return 0; }
代码运行后发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,所以创建出来的共享内存的默认权限为0,即什么权限都没有,因此server进程没有权限关联该共享内存。
我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同
int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0666);//创建权限为0666的共享内存
此时我们可以看到关联该共享内存的进程数+1,权限也为666
取消共享内存与进程地址空间之间的关联,需要使用shmdt函数
功能:将共享内存段与当前进程脱离 原型 int shmdt(const void *shmaddr); 参数 shmaddr: 由shmat所返回的指针(关联共享内存的起始地址,即调用shmat函数时得到的起始地址。) 返回值:成功返回0;失败返回-1 注意:将共享内存段与当前进程脱离不等于删除共享内存段
注意:将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系
共享内存的创建、关联、脱离、释放我们已经知道是怎么回事了,接下来我们继续用客户/服务端模式来进行进程间通信。我们先看看这两个进程能都关联上共享内存。
通过命令我们看到都可以关联上共享内存
comm.h #include <stdio.h> #include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "./tmp" //路径名 #define PROJ_ID 0x6666 //整数标识符 #define SIZE 4096 //共享内存的大小
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); //关联共享内存 int i = 0; while (i < 26){ printf("%s\n",mem); ++i; sleep(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 (i < 26 ){ mem[i] = 'A' + i; i++; mem[i] = 0; sleep(1); } shmdt(mem); //共享内存去关联 return 0; }
我们通过共享内存段,client写,server读取,不断拿到数据,结果如下:
至此,我们就完成了客户端与服务端的通信。
共享内存创建好之后就不需要系统调用接口进行通信,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。
管道:
从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
共享内存:
从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。