进程间通信的本质是让不同的进程能看到同一份资源(内存、文件缓冲等等)。这一个资源由不同的部分提供,就有了不同的进程间通信方式。
管道:匿名管道、命名管道。
System V:System V消息队列、System V共享内存、System V 信号量。
POSIX:消息队列、共享内存、信号量、互斥量、条件变量、读写锁。
本文重点介绍管道和System V共享内存。
管道是Unix中最古老的进程间通信的形式。把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
管道通信本质是文件,操作系统没有做过多的工作。
管道只能进行单向通信。
首先大致了解一下pipe函数:
头文件:<unistd.h>
功能:创建一无名管道
原型:int pipe(int fd[2]);
参数:
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
下面简单使用一下pipe函数。
#include
#include
int main()
{
//child->write, father->read
int fd[2] = { 0,0 };
if (pipe(fd) < 0)
{
//调用失败
perror("pipe!");
return 1;
}
//根据文件描述符的分配规则,这里一定是3、4
//如果在这之前关闭了某个文件描述符,则按照分配规则依次分配
printf("fd[0]: %d\n", fd[0]);//3
printf("fd[1]: %d\n", fd[1]);//4
return 0;
}
然后用代码实现子进程向父进程发送字符串。
#include
#include
#include
#include
#include
#include
int main()
{
int fd[2] = { 0,0 };
if (pipe(fd) < 0)
{
perror("pipe!");
return 1;
}
//fd[0]表示读端, fd[1]表示写端
//子进程写,父进程读
pid_t id = fork();
if (id == 0)
{
//child
close(fd[0]);//关闭读
int count = 5;
const char* msg = "hello father!\n";
while (count--)
{
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]);
exit(2);
}
//father
close(fd[1]);//关闭写
char buffer[64];
while (1)
{
ssize_t ret = read(fd[0], buffer, sizeof(buffer));
if (ret > 0)//如果没有读到结尾,ret就是字符个数
{
buffer[ret] = '\0';//由于文件的读写没有'\0',所以需要手动添加
printf("child send to father : %s\n", buffer);
}
else if (ret == 0)//读到结尾
{
printf("read file end!\n");
break;
}
else if (ret < 0)
{
printf("read error!\n");
break;
}
}
close(fd[0]);
waitpid(id, NULL, 0);
return 0;
}
几个小问题
可不可以定义一个全局的数据区,子进程向其中写入内容,然后父进程再从中读取。
不可以,因为进程间具有独立性,不能相互干扰,子进程在写入内容时会写时拷贝,这样代码中看起来是同一块数据区,但映射到物理内存后是不同的两块内容,互相不可访问。
既然会有写时拷贝,那为什么上面代码中的写入没有写时拷贝呢?
因为上面在write时将内容写进了操作系统提供的文件区内,而不是写入到了子进程自己的数据区内,写入的内容不属于父进程或子进程,不需要写时拷贝。
sleep(1)在子进程中才有,但父进程打印时却也是每隔一秒打印一次,为什么?
在多执行流(比如这里同时有父子进程)下,访问同一份资源,这个资源叫临界资源。
在上面的代码中,可能会发生子进程写入到一半,父进程就来读取,这显然是不被允许的,所以就需要同步与互斥来解决。
这里主要是互斥,即任何时刻都只能有一个进程正在使用某种资源,管道内部自动提供了同步与互斥机制。当子进程写入并sleep时,父进程阻塞地等待,由于子进程sleep,导致父进程也看起来sleep。
如果写端关闭,那么读端read就会返回0,代表读取结束。
下面从父子进程来理解匿名管道。
既然最后只能留一个读写端,那么父进程最开始为什么要把读写端都打开呢?
父进程打开读写端是为了创建子进程时子进程得到的读写端也都打开,而最后各自只剩一个读写端是因为进程间的通信是单向的。
通常规定fd[0]是读文件描述符,而fd[1]是写文件描述符。
下面用代码验证最后一种情况:
#include
#include
#include
#include
#include
#include
int main()
{
int fd[2] = { 0,0 };
if (pipe(fd) < 0)
{
perror("pipe!");
return 1;
}
//fd[0]表示读端, fd[1]表示写端
//子进程写,父进程读
pid_t id = fork();
if (id == 0)
{
//child
close(fd[0]);//关闭读
int count = 5;
const char* msg = "hello father!\n";
while (conut--)//共写5次
{
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]);
exit(0);
}
//father
close(fd[1]);//关闭写
char buffer[64];
while (1)
{
ssize_t ret = read(fd[0], buffer, sizeof(buffer));
if (ret > 0)//如果没有读到结尾,ret就是字符个数
{
buffer[ret] = '\0';//由于文件的读写没有'\0',所以需要手动添加
printf("child send to father : %s\n", buffer);
}
else if (ret == 0)//读到结尾
{
printf("read file end!\n");
break;
}
else if (ret < 0)
{
printf("read error!\n");
break;
}
//读一次后关闭读端,然后break
//也就是只读了一次
close(fd[0]);
break;
}
sleep(2);
int status = 0;
waitpid(id, &status, 0);
printf("child quit, signal : %d\n", status & 0x7F);//打印信号值
return 0;
}
上面四种情况中的第一种情况说明管道是可能被写满的,那么管道具体是多大呢?
通过man 7 pipe可以查看到管道的大小在Linux下的要求。
将上面子进程内的代码如下改动,并在父进程的代码下将打印内容去掉。
if (id == 0)
{
//child
close(fd[0]);//关闭读
int count = 0;
char c = 'a';
//const char* msg = "hello father!\n";
while (1)
{
write(fd[1], &c, 1);//每次写入一个字节
count++;//计数++
printf("count : %d\n", count);//打印,当管道满后不再写入
}
close(fd[1]);
exit(0);
}
count打印到65536后停止,说明这里我的Linux系统管道的大小是65536字节。
匿名管道使用的限制就是只能在具有共同祖先的进程间通信。如果想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
命名管道本质是一种特殊类型的文件。
用mkfifo可以创建一个命名管道。
创建了命令管道之后,如下写一个每秒输出一次hello的脚本并重定向。
现象如下,上面的进程不断向fifo中写入(write)内容,而下面的进程不断通过cat将写入的内容读出并打印在显示器上。这样上下两个进程间就实现了通信。
当结束负责read的进程时还可以验证前面四种情况中的最后一种情况:负责write的进程立即停止。
如果觉得上面的脚本不容易读懂或是过程太复杂,下面再举一个简单的例子:
代码创建命名管道使用的仍是mkfifo,只不过这时它是个函数。
创建命名管道文件很简单,代码如下:
#include
#include
#include
#define FILE_NAME "myfifo"
int main()
{
umask(0);
if (mkfifo(FILE_NAME, 0644) < 0)
{
perror("mkfifo");
return 1;
}
return 0;
}
这样就在当前目录下创建了一个命名管道,可以用它来实现进程间通信。
下面实现简单的server和client之间的通信,重点只有创建命名管道的部分,剩下的对文件进行读写都是前面写过很多次的。
server.c的代码:
//server.c代码如下
#include
#include
#include
#include
#include
#define FILE_NAME "myfifo"
int main()
{
//命名管道已经创建
//下面从文件中读取内容之前已经写过很多次了,不是重点
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));
if (s > 0)//读取成功
{
msg[s] = '\0';
printf("client send : %s\n", msg);
}
else if (s == 0)
{
printf("client quit\n");
break;
}
else
{
printf("read error\n");
break;
}
}
close(fd);
return 0;
}
//server.c
client.c的代码:
//client.c的代码
#include
#include
#include
#include
#include
#define FILE_NAME "myfifo"
int main()
{
//重点是这里的创建命名管道
if (mkfifo(FILE_NAME, 0644) < 0)//创建命名管道失败
{
perror("mkfifo");
return 1;
}
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:\n");
ssize_t s = read(0, msg, sizeof(msg));//从键盘读入
if (s > 0)
{
msg[s] = '\0';
write(fd, msg, strlen(msg));//写入管道中
}
}
close(fd);
return 0;
}
//client.c
效果如下:
命令行中连接两条命令(运行起来也是进程)的管道是匿名管道。
运行三条命令并用管道连接,查看它们的信息。
上面只能证明具备匿名管道的条件,但并不能证明就是匿名管道。但事实上确实是匿名管道,可以用这个例子来帮助记忆。
共享内存区是最快的进程间通信形式。一旦这样的内存映射到共享它的进程的地址空间,进程间数据的传递不再涉及到内核,或者说进程不再通过执行进入内核的系统调用来传递彼此的数据。
共享内存和消息队列以传送数据为目的,而信号量是为了保证进程的同步与互斥而设计的,但也属于通信范畴。
共享内存本质就是修改页表的映射关系,在不同进程的虚拟地址空间中开辟空间,和同一块物理内存对应。完成开辟空间、建立映射、开辟虚拟空间、返回给用户信息等等一系列操作都有系统接口可用,是操作系统完成的。
共享内存建立过程:
申请共享内存需要用到shmget函数:
这个函数的三个参数都多少有一些坑,下面一一说明。
显然系统中可能会同时存在多个共享内存,为了管理这些共享内存,操作系统就需要维护与其相关的内核数据结构。其次,保证共享内存的唯一性,这就需要上面shmget函数中的第一个参数key传入一个系统中独一无二的值。
那么这个独一无二的key如何获得呢?需要再调用函数ftok。
第二个参数size是需要申请共享内存的大小(单位是字节)。具体有什么坑在后面写代码时说明。
PAGE_SIZE的大小是4096字节,对应一页数据。在操作系统层面,进行内存申请和释放(尤其是和外设进行IO时),一般是按页来访问的。
而申请共享内存时是按页对齐的,比如调用函数时申请4097个字节的空间,那么操作系统底层会分配出两页,即4096*2的空间(但查看时看到的仍是4097),这显然浪费了许多空间,所以在调用该函数时,申请的大小最好是页大小的整数倍。
该参数有下面两个常用的宏:
这两个都是宏,而且也都是只有一位为1的宏,上面两个宏按位或后的语义是如果共享内存不存在则创建,若已存在则函数调用出错返回。
返回值叫做共享内存句柄,其实就是一个可以在用户层标识共享内存的值。
在server.c中写入如下代码,先用ftok和shmget创建一段共享内存。
#include
#include
#include
#include
//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666
#define SIZE 4096//设置为4096
int main()
{
key_t k = ftok(PATH_NAME, PROJ_ID);
if (k < 0)
{
printf("ftok error!\n");
return 1;
}
printf("%x\n", k);
int shm = shmget(k, SIZE, IPC_CREAT | IPC_EXCL);
if (shm < 0)
{
perror("shm");
return 2;
}
return 0;
}
但是注意,上面ipcs命令是在程序运行结束后才使用的,也就是说进程已经结束了,但共享内存仍然存在。事实上,共享内存的生命周期是跟随内核的,也就是说除非进程主动删除或在命令行中用命令删除,否则共享内存就一直存在直到关机。
再进一步,System V包括的共享内存、消息队列、信号量的生命周期都是跟随内核的。所以这部分内存一定是操作系统提供并维护的。
上面的列表中key是在内核层面上,多个进程之前区分共享内存的方式,而shmid是在进程内部区分某个共享内存资源的方式。
上面提到除非进程主动删除或在命令行中用命令删除,否则共享内存就一直存在直到关机,下面就说一下如何删除共享内存。
#include
#include
#include
#include
#include
//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666
#define SIZE 4096//设置为4096
int main()
{
key_t k = ftok(PATH_NAME, PROJ_ID);
if (k < 0)
{
printf("ftok error!\n");
return 1;
}
printf("%x\n", k);
int shm = shmget(k, SIZE, IPC_CREAT | IPC_EXCL);
if (shm < 0)
{
perror("shm");
return 2;
}
printf("create success\nsleeping\n");
int count = 5;
while(count--)
{
printf("%d\n", count);
sleep(1);
}
// 选项设置IPC_RMID
shmctl(shm, IPC_RMID, NULL);
printf("remove success\n");
return 0;
}
从下面动图中可以看到进程成功创建并删除了共享内存。
上面讲了创建和删除共享内存,下面介绍如何将申请的共享内存和进程关联起来,实现进程间通信。
shmaddr为NULL,操作系统会自动选择一个地址。
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)。
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存。
上面的选项具体使用时细节很多,这里只举最简单常用的例子,将shmaddr设置为NULL,shmflg设置为0。
返回值返回的是共享内存映射到进程地址空间的虚拟地址的起始地址。(请认真理解,返回值很重要,可类比malloc的返回值)。
下面两部分代码将上面几个过程结合起来,并简单地在进程间用字符串通信。
server.c代码如下:
#include
#include
#include
#include
#include
//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666
#define SIZE 4096//设置为4096
int main()
{
key_t k = ftok(PATH_NAME, PROJ_ID);
if (k < 0)
{
printf("ftok error!\n");
return 1;
}
printf("%x\n", k);
//创建共享内存 设置权限
int shm = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0644);
if (shm < 0)
{
perror("shm");
return 2;
}
char* mem = shmat(shmid, NULL, 0);//关联共享内存
//使用共享内存
while (1)
{
printf("client send : %s\n", mem);
}
shmdt(mem);//去关联
shmctl(shm, IPC_RMID, NULL);//删除共享内存
return 0;
}
client.c的代码如下:
#include
#include
#include
#include
#include
//随便写,冲突的话再改
#define PATH_NAME "/home/yh/lesson15/shm"
#define PROJ_ID 0x6666
#define SIZE 4096//设置为4096
int main()
{
key_t k = ftok(PATH_NAME, PROJ_ID);
if (k < 0)
{
printf("ftok error!\n");
return 1;
}
printf("%x\n", k);
int shmid = shmget(k, SIZE, IPC | CREAT);
if (shm < 0)
{
perror("shm");
return 2;
}
char* mem = shmat(shmid, NULL, 0);//关联共享内存
//使用共享内存
int i = 0;
while (i < 26)
{
mem[i] = 'A' + i;
sleep(1);
i++;
mem[i] = '\0';
}
shmdt(mem);//去关联
//client中不需要删除共享内存
return 0;
}
注意上面向共享内存中读写时,并没有像使用管道时通过系统调用接口(read、write)实现,而是直接使用(像malloc申请的堆空间一样)。因此拷贝次数少、不提供同步与互斥,所以这也是进程间通信最快的方式。