进程间通信又称IPC(Inter-Process Communication),指多个进程之间相互通信,交换信息。
管道:匿名管道pipe、命名管道
System V IPC:System V 消息队列、System V 共享内存、System V 信号量。
POSIX IPC:消息队列、共享内存、信号量、互斥量、条件变量、读写锁。
进程间通信的本质就是让不同的进程看到同一份资源。
在我们的生活中打电话、发邮件、用QQ聊天都叫做通信,通信的本质是传递信息。但是传递信息这种说法并不是很直观,站在程序员的角度,通信的本质其实叫做传递数据。
进程间能直接相互传递数据吗?
比如说父进程把数据给子进程,子进程处理完数据再交给父进程,这种操作在进程这里是不可能做到的。因为进程具有独立性,所有的数据操作都会发生写时拷贝。所以即使父子进程之间也无法直接相互传递数据,更别谈两个进程之间毫无关系了。
所以进程间通信直接两个进程是不可能通信的,一定要通过中间媒介的方式来进行通信。
因为进程之间是相互独立的,具有独立性,发生写时拷贝背后的含义就是两个进程毫无关系,所以要进行通信的前提条件是必须要想办法让不同的进程看到同一份资源。 所谓公共资源其实就是内存空间。这份内存资源通常是由OS提供的。
管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
管道通信一共有两种通信方案,一种叫做匿名管道,一种叫做命名管道。这两种管道底层原理是基本一样的。
匿名管道最典型的特征是供具有血缘关系(常见于父子)的进程进行进程间通信。所有的通信方式,尤其是进程间通信,首要任务必须是保证不同的进程看到同一份资源。
1.父进程创建管道
首先让父进程以读和写的方式打开同一个文件,可以理解为以读方式打开一次,以写方式打开一次。相当于父进程打开一个文件,它打开这个文件并不是只打开一次,而是以读方式打开和以写方式打开,这样的话以读方式打开文件返回一个文件描述符,以写方式打开文件返回一个文件描述符,这两个文件描述符指向同一个文件。这样就相当于有了读端有了写端,这个过程可以称之为创建管道的过程。什么叫做创建管道一句话概括:分别以读方式和以写方式打开两次同一个文件。
2.父进程fork出子进程
父进程fork创建子进程。子进程是一个独立的进程,有自己独立的pcb、地址空间、文件描述符表,代码和父进程共享,数据写时拷贝这些都没有错。结构确实子进程都是私有一份的,但是结构里面填的数据是以父进程为模板的。
所以曾经父进程提前打开的文件,子进程的3号和4号文件描述符也指向父进程刚刚打开的文件。
至此完成了进程间通信的第一个核心任务,保证不同的进程看到同一份资源。
而这份同样的资源指的通常是系统提供的一段内存区域。这段内存区域现在可以简单的理解成父进程可以通过3或4往管道当中读写,对应读写的数据就在这个文件的缓冲区里,子进程就可以直接通过读写从缓冲区中把数据读到子进程。
这里管道其实就是文件,只不过这个文件不需要在磁盘上把数据进行持久化保存。
3.父子进程各自关闭不需要的文件描述符
但是管道不管是匿名管道还是命名管道,只能进行单向数据通信。也就意味着要么父进程写子进程读,要么子进程去写父进程去读。总之一个管道只能进行单向数据通信,如果想双向通信可以建立多个管道。
管道只能进行单向通信,所以就要决定让谁读让谁写。
如果想让父进程读,就关闭父进程的写端,如果想让子进程读,就关闭子进程的写端。
如果想让父进程写,就关闭父进程的读端,如果想让子进程写,就关闭子进程的读端。
让父子进程各自关闭它们不需要的文件描述符来达到构建单向信道的功能。
为什么父子进程迟早要关掉一个,曾经要打开呢?
1.如果父进程只以读方式打开,子进程继承下去的文件描述符对应打开的文件也只是读方式,两个读不能通信。如果只以写方式打开,fork之后子进程被打开的文件是可写的,父子进程都只能写,这样不能通信。所以不打开rw,子进程拿到的文件打开方式必定和父进程一样,无法通信。
2.灵活的控制父子进程完成读写通信,到底是父进程读还是子进程写,完全取决于你的场景,所以把读写端都打开,你需要关哪个自己决定。
为什么一定要关闭呢?
即便是不关闭此时父进程写,子进程读,通信管道也在啊,为什么一定要关呢?
这样虽然可以不过建议是一定要关的,一方面是在语义上证明了管道单向通信这样的特性,另一方面主要是为了防止误操作。
pipe接口用于创建匿名管道。
#include
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,输出型参数说人话就是我不想给你什么,我想通过调用你拿回来什么。
也就是说这个参数会通过调用pipe,拿到打开的管道文件的描述符。
这个数组一共有两个元素,意味着它一次拿到两个文件描述符,两个fd,一个是read,一个是write。
pipefd[0]:管道读端的文件描述符
pipefd[1]:管道写端的文件描述符
pipe函数调用成功时返回0,调用失败时返回-1。
子进程向父进程写入5条数据,父进程从匿名管道中读取数据。
#include
#include
#include
#include
#include
int main()
{
int pipe_fd[2] = {0}; // 创建管道
if(pipe(pipe_fd) < 0)
{
perror("pipe");
return 1;
}
printf("%d, %d\n", pipe_fd[0], pipe_fd[1]);
pid_t id = fork(); // 父进程fork出子进程
if(id < 0)
{
perror("fork");
return 2;
}
else if(id == 0) // child write
{
close(pipe_fd[0]); // 子进程写把不需要的读文件描述符关掉
const char* msg = "hello parent, nice to meet you!";
int i = 0;
for(i = 0; i < 5; ++i)
{
// 每隔1s向管道中写数据
write(pipe_fd[1], msg, strlen(msg));
sleep(1);
}
close(pipe_fd[1]); // 子进程发送完数据把不需要的写端关闭
exit(0);
}
else // father read
{
close(pipe_fd[1]); // 父进程读把不需要的写文件描述符关掉
char buffer[64];
while(1) // 父进程一直读,直到读完子进程发送的数据
{
buffer[0] = '\0';
ssize_t size = read(pipe_fd[0], buffer, sizeof(buffer) - 1);
if(size > 0)
{
buffer[size] = '\0';
printf("father get msg from child# %s\n", buffer);
}
else if(size == 0)
{
printf("pipe file close, child quit!\n");
break;
}
else
{
printf("read error!\n");
break;
}
}
int status = 0;
if(waitpid(id, &status, 0) > 0)
{
printf("child quit, wait success!\n");
}
close(pipe_fd[0]);
}
return 0;
}
1.当没有数据可读时
2.当管道满的时候
3.如果所有管道写端对应的文件描述符被关闭,则read返回0。
4.如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5.当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
6.当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
1.管道自带同步与互斥机制。 如果父进程是读端,子进程是写端。如果管道里面没有消息,父进程(读端)需要等待,等待子进程向管道内部写入数据。
如果管道里面写端已经写满了,不能继续写入,子进程(写端)就会等待,等待父进程把数据读走,管道内部有空闲空间再写入数据。
2.管道是单向通信的。 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
3.管道是面向字节流的。
4.管道只能保证具有血缘关系的进程进行通信,常用于父子。
5.管道可以保证一定程度的数据读取的原子性。
6.进程退出,曾经打开的文件也会被关掉,管道也是文件,管道的生命周期随进程。
管道的几种情况:
1.读端不读,写端一直写,写端会阻塞。
2.读端一直读,写端不写,读端会阻塞。
3.读端一直不读并且关闭了,写端一直写,写端被OS发送13号信号SIGPIPE杀掉。
4.读端一直读,写端不写并且关闭,读取到0,代表文件结束。
磁盘上有各种各样的文件,每个文件都有唯一的路径。要让两个毫不相关的进程进行进程间通信,第一件事是要先保证这两个进程看到同一份资源。因为路径本身具有唯一性,文件本身就具有保存数据的能力,所以让进程1以读方式打开文件,进程2以写方式打开同一个文件。打开以后内存当中一定会包含对应的struct_file结构体以及该文件对应的内存缓冲区。所以此时进程2可以向文件中写数据,进程1也可以读取数据。磁盘文件的路径本身具有唯一性,可以让不同的进程分别以读和写的方式分别打开同一个文件,这样它们就能看到同一份资源了,就能进行数据通信了,这就是命名管道的原理。
1.命名管道也是管道,它也遵守管道的面向字节流,同步互斥,只能单向通信,生命周期随进程等等这些特点它都有,唯一和匿名管道不同的是它可以让不同的进程通信。
2.普通文件需要将数据刷新到磁盘上,持久化存储。但是管道文件不需要把数据刷新到磁盘。
命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
mkfifo filename
#include
#include
int mkfifo(const char *filename, mode_t mode);
第一个参数表示要创建的命名管道文件。以路径的方式给出,则将命名管道文件创建在指定路径下,以文件名的方式给出,则将命名管道文件默认创建在当前路径下。
第二个参数表示给要创建的命名管道设置权限。
返回值成功返回0,失败返回-1。
如果当前打开操作是为读而打开FIFO时
如果当前打开操作是为写而打开FIFO时
server接受数据,client写数据。
server.c
#include
#include
#include
#include
#include
#define FIFO "./fifo"
int main()
{
int ret = mkfifo(FIFO, 0644); // 创建命名管道文件
if(ret < 0) {
perror("mkfifo");
return 1;
}
int fd = open(FIFO, O_RDONLY); // 以只读方式打开管道文件
if(fd < 0) {
perror("open");
return 2;
}
char buffer[128];
while(1) {
buffer[0] = '\0';
// 读取客户端发来的数据
ssize_t s = read(fd, buffer, sizeof(buffer)-1);
if(s > 0) {
buffer[s] = '\0';
printf("client# %s", buffer);
} else if(s == 0) {
printf("client quit...\n");
break;
} else {
perror("read");
return 3;
}
}
close(fd);
return 0;
}
client.c
#include
#include
#include
#include
#include
#include
#define FIFO "./fifo"
int main()
{
int fd = open(FIFO, O_WRONLY); // 以只写方式打开管道文件
if(fd < 0) {
perror("open");
return 1;
}
char buffer[128];
while(1) {
printf("Please Enter# ");
fflush(stdout);
buffer[0] = '\0';
// 从标准输入读数据
ssize_t s = read(0, buffer, sizeof(buffer)-1);
if(s > 0) {
buffer[s] = '\0';
// 将从键盘读到的数据写到管道中
write(fd, buffer, strlen(buffer));
} else if(s == 0) {
break;
} else {
perror("read");
return 2;
}
}
close(fd);
return 0;
}
OS在物理内存当中申请一块空间,这部分空间称为共享内存(其实就是一段内存空间)。OS把申请好的共享内存通过页表映射到进程A和进程B的共享区中(堆、栈之间)。这样这两个进程就可以使用各自的虚拟地址以及它自己的页表映射到同一块物理内存,这样两个不同的进程就看到了同一份资源。这就是共享内存的原理。
换言之,共享内存的建立有三个核心步骤:
1.申请共享内存
2.不同的进程分别挂接对应的共享内存到自己的地址空间(共享区)
3.双方就看到了同一份资源。即可以进行正常通信了。
操作系统内部提供了通信机制的(IPC),ipc模块。在系统当中可能有很多进程进行通信,因此OS中可能存在大量的共享内存,操作系统要提供数据结构管理共享内存。
共享内存数据结构:
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 */
};
其中shmid_ds结构体中套了一个结构体ipc_perm,在这个结构体中记录一个key用于标识系统中共享内存的唯一性。
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;
};
#include
#include
key_t ftok(const char *pathname, int proj_id);
ftok函数使用由给定的路径名(必须引用现有的、可访问的文件)和 proj_id 的最低有效 8 位生成一个 key_t 类型的 System V IPC key值。
成功时,返回生成的 key_t 值。 失败时返回 -1。
该函数调用成功时返回的key值用于shmget系统调用的第一个参数。
申请共享内存的接口:
#include
#include
int shmget(key_t key, size_t size, int shmflg);
参数说明:
因为系统在分配共享内存的时候,是以4KB为基本单位的,即便你要4097,操作系统在分配的时候是4096+4096即8kb。但是你仍然只能用4097,就会浪费了4095个字节。所以建议4kb的整数倍,这样就不存在共享内存空间浪费的问题。
选项 | 作用 |
---|---|
IPC_CREAT | 如果没有与key相等共享内存就创建,如果有就获取 |
IPC_EXCL | 如果存在与key相等共享内存就会出错,如果单独使用是没有任何意义的 |
如果这两个选项同时被设置,如果这个共享内存已经存在的话,这个函数就会出错返回。
它最大的意义在于如果此时调用shmget成功,一定得到的是全新的共享内存。
返回值:
成功时,返回一个有效的共享内存标识符。 失败时,返回-1。
shmid vs key
key:是一个用户层生成的唯一键值,核心作用是为了区分共享内存的“唯一性”,不能用来进行IPC资源的操作。类似于文件的inode号。
shmid:是一个系统给我们返回的IPC资源标识符,用来进行操作IPC资源。类似于文件的fd。
1.可以使用ipcrm -m shmid命令释放指定id的共享内存资源。
2.可以使用系统调用shmctl。
#include
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmctl函数用于System V 共享内存控制。
第一个参数shmid:共享内存id
第二个参数cmd:如何控制共享内存
第三个参数buf:获取或设置所控制共享内存的数据结构
第二个参数cmd常用的选项:
选项 | 作用 |
---|---|
IPC_STAT | 从相关的内核数据结构中复制信息将 shmid 放入 buf 指向的 shmid_ds 结构中。调用者必须对共享内存有读权限。 |
IPC_SET | 将 buf 指向的 shmid_ds 结构的某些成员的值写入与此共享内存段关联的内核数据结构,同时更新其 shm_ctime 成员。 |
IPC_RMID | 标记要销毁的段。 该段实际上只有在最后一个进程将其分离后才会被销毁(即,当关联结构 shmid_ds 的 shm_nattch 成员为零时)。 调用者必须是段的所有者或创建者,或者具有特权。 buf 参数被忽略。 |
想要释放共享内存可以选第三个选项IPC_RMID。
返回值:
操作成功时返回 0,出错时返回 -1。
shmat() 将由 shmid 标识的共享内存段附加到调用进程的地址空间。
如果 shmaddr 为 NULL,则系统选择一个合适的(未使用的)地址来附加该段。
#include
#include
void *shmat(int shmid, const void *shmaddr, int shmflg);
第一个参数:共享内存创建完以后的id值。
第二个参数:想要把共享内存挂接到进程地址空间共享区的哪个虚拟地址上。如果设置成NULL,操作系统选择一个合适的地址。
第三个参数:关联共享内存时设置的某些属性。设置为0表示默认读写权限。
返回值:
成功时 shmat() 返回关联的共享内存段的地址,失败返回(void *) -1。
#include
#include
int shmdt(const void *shmaddr);
shmdt() 将位于由 shmaddr 指定的地址的共享内存段从调用进程的地址空间中分离出来。 要分离的段当前必须使用 shmaddr 附加,该值等于附加的 shmat() 调用返回的值。
返回值成功返回0,失败返回-1。
头文件comm.h
#pragma once
#include
#define PATH_NAME "/home/beichuan/upup/shm"
#define PROJ_ID 0x6666
#define SIZE 4096
server.c
#include "comm.h"
#include
#include
#include
#include
int main()
{
// 创建key值
key_t k = ftok(PATH_NAME, PROJ_ID);
if(k < 0) {
perror("ftok");
return 1;
}
// 创建共享内存
int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0644); // 如果共享内存不存在,创建,存在出错返回
if(shmid < 0) {
perror("shmget");
return 2;
}
// 将当前进程和共享内存进行关联
char* start = (char*)shmat(shmid, NULL, 0);
// 使用共享内存进行通信
while(1) {
printf("%s\n", start);
sleep(1);
}
// 将当前进程和共享内存去关联
shmdt(start);
// 释放共享内存
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
client.c
#include "comm.h"
#include
#include
#include
#include
int main()
{
// 获取和server同一个key
key_t k = ftok(PATH_NAME, PROJ_ID);
if(k < 0) {
perror("ftok");
return 1;
}
// 不需要自己创建共享内存了,需要获取共享内存
int shmid = shmget(k, SIZE, IPC_CREAT);
if(shmid < 0) {
perror("shmget");
return 2;
}
// client挂接自己到共享内存
char* start = (char*)shmat(shmid, NULL, 0);
char c = 'A';
// 通信
while(c <= 'Z') {
start[c - 'A'] = c;
++c;
sleep(1);
}
// 去关联
shmdt(start);
return 0;
}
结果:
1.共享内存的生命周期随OS
2.共享内存不提供任何同步与互斥的操作,双方彼此独立
3.共享内存是所有的进程间通信中速度最快的