一. 进程间通信介绍
1.1 进程间通信目的
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
1.2 进程间通信分类
管道
匿名管道pipe
命名管道
System V IPC
System V 消息队列
System V 共享内存
System V 信号量
POSIX IPC
消息队列
共享内存
信号量
互斥量
条件变量
读写锁
二. 管道
管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
2.1 匿名管道
匿名管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用
如何创建匿名管道
#include <unistd.h>
功能:创建一无名管道
原型: int pipe(int fd[2]);
参数 fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
管道读写规则
当没有数据可读时
O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
当管道满的时候
O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
如果所有管道写端对应的文件描述符被关闭,则read返回0
如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程
退出
当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
通过匿名管道实现进程间通信的步骤如下:
父进程创建管道,得到两个⽂件描述符指向管道的两端
父进程fork出子进程,⼦进程也有两个⽂件描述符指向同⼀管道。
父进程关闭fd[0],子进程关闭fd[1],即⽗进程关闭管道读端,⼦进程关闭管道写端(因为管道只支持单向通信)。⽗进程可以往管道⾥写,⼦进程可以从管道⾥读,管道是⽤环形队列实现的,数据从写端流⼊从读端流出,这样就实现了进程间通信。
当一个管道建立时,它会创建两个文件描述符:fd[0]为读而打开,fd[1]为写而打开。如下图:
要关闭管道只需将这两个文件描述符关闭即可。
代码实例:
#include<iostream>
#include<assert.h>
#include<cstdio>
#include<cstring>
#include<string>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
//不定义全局的buffer来进行通信的原因是存在写时拷贝,无法更改通信
int main()
{
//1.创建管道
int pipefd[2] = {0};//pipefd[0]:读端 pipefd[1]:写端
int n = pipe(pipefd);
// pipe函数定义中的fd参数是一个大小为2的一个数组类型的指针。
//该函数成功时返回0,并将一对打开的文件描述符值填入fd参数指向的数组。失败时返回 -1并设置errno。
//通过pipe函数创建的这两个文件描述符 fd[0] 和 fd[1] 分别构成管道的两端,往 fd[1] 写入的数据可以从 fd[0] 读出
assert(n != -1);//debug assert是有效的,
(void)n; //避免没有被使用而出现的报警
#ifdef DEBUG//条件编译,在debug模式下才会编译后面代码
cout<<"pipefd[0]:"<<pipefd[0]<<endl;//3
cout<<"pipefd[1]:"<<pipefd[1]<<endl;//4
#endif
//创建子进程
pid_t id= fork();
assert(id != -1);
if(id == 0)
{
//子进程
//3.构建单向通信的信道,父进程写入,子进程读取
//3.1 关闭进程不需要的fd
close(pipefd[1]);
char buffer[1024*8];//设置缓冲区
while(true)
{
//sleep(10);
//如果写入的一方,fd没有关闭,则有数据就读,没有数据就等
//如果写入的一方,fd关闭,read会返回0,表示读到文件的结尾
ssize_t s = read(pipefd[0],buffer,sizeof(buffer)-1);
if(s > 0)
{
buffer[s] = 0;
cout<<"child get a message ["<< getpid() <<"] father#"<<buffer<<endl;
}
else if(s == 0)
{
cout<<"wirte quit(father),me quit"<<endl;
break;
}
}
exit(0);
}
//父进程
//3.构建单向通信的信道,父进程写入,子进程读取
//3.1 关闭进程不需要的fd
close(pipefd[0]);
string message = "我是父进程,我正在给你发信息";
int count = 0;//记录发送消息的条数
char send_buffer[1024*8];
while(true)
{
//3.2 构建一个变化的字符串
snprintf(send_buffer,sizeof(send_buffer),
"%s[%d] : %d",message.c_str(),getpid(),count++);
//3.3写入
write(pipefd[1],send_buffer,strlen(send_buffer));
sleep(1);
cout << "father send a message ["<< getpid()<<"] :" <<count << endl;
if(count == 10)
{
cout<<"write quit(father)"<<endl;
break;
}
}
close(pipefd[1]);
pid_t ret = waitpid(id, nullptr , 0);
cout<<"id:"<< id<<" ret:"<< ret <<endl;
assert(ret > 0);
(void)ret;
close(ret < 0);
return 0;
}
结果:
站在文件描述符角度-深度理解管道
站在内核角度-管道本质
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了“Linux一切皆文件思想”。
使用管道注意以下四个情况
如果所有指向管道写端的文件描述符都关闭了(管道写端的引用计数等于0),而仍然有进程 从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样。
如果有指向管道写端的文件描述符 没关闭(管道写端的引用计数大于0),而持有管道写进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读 取后, 再次read会阻塞,直到管道中有数据可读了才读取数据并返回。
如果所有指向管道 读端的文件描述符 都关闭了(管道读端的引用计数等于0),这时有进程向管道的写端write,那么该进程会收到信号 SIGPIPE,通常会导致进程异常终止。
如果有指向管道读端的文件描述符没关闭(管道读端的引用计数大于0),而持有管道读端 进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再 次write会阻塞,直到管道中有空位置了才写入数据并返回。
2.2 命名管道[FIFO]
命名管道如何创建
在shell中可以使用mkfifo命令创建一个命名管道,格式为
$ mkfifo filename
FIFO管道可通过mkfifo()函数创建,函数原型为:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
创建成功返回0,出错返回1。函数第一个参数为普通的路径名,即创建后FIFO文件的名字,第二个参数与打开普通文件的open函数中的mode参数相同。如果要创建的FIFO文件已经存在,则会返回EEXIST错误,因此在创建前应先检查是否创建成功,若文件已存在,只要调用打开FIFO的函数即可。
匿名管道与命名管道的区别
匿名管道由pipe函数创建并打开。
命名管道由mkfifo函数创建,打开用open
FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完
成之后,它们具有相同的语义
命名管道的打开规则
如果当前打开操作是为读而打开FIFO时
O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
O_NONBLOCK enable:立刻返回成功
如果当前打开操作是为写而打开FIFO时
O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
O_NONBLOCK enable:立刻返回失败,错误码为ENXIO
三. system V共享内存
共享内存示意图
3.1共享内存通信的原理
在Linux中,每个进程都有属于自己的进程控制块(PCB)和地址空间(Addr Space),并且都有一个与之对应的页表,负责将进程的虚拟地址与物理地址进行映射,通过内存管理单元(MMU)进行管理。两个不同的虚拟地址通过页表映射到物理空间的同一区域,它们所指向的这块区域即共享内存。
3.2 共享内存相关函数
需要头文件#include <sys/ipc>
shmget ( ):创建共享内存
int shmget(key_t key, size_t size, int shmflg);
[参数key]:由ftok生成的key标识,标识系统的唯一IPC资源。
[参数size]:需要申请共享内存的大小。在操作系统中,申请内存的最小单位为页,一页是4k字节,为了避免内存碎片,我们一般申请的内存大小为页的整数倍。
[参数shmflg]:如果要创建新的共享内存,需要使用IPC_CREAT,IPC_EXCL,如果是已经存在的,可以使用IPC_CREAT或直接传0。
[返回值]:成功时返回一个新建或已经存在的的共享内存标识符,取决于shmflg的参数。失败返回-1并设置错误码。
shmat ( ):挂接共享内存
void *shmat(int shmid, const void *shmaddr, int shmflg);
[参数shmid]:共享存储段的标识符。
[参数*shmaddr]:shmaddr = 0,则存储段连接到由内核选择的第一个可以地址上(推荐使用)。
[参数shmflg]:若指定了SHM_RDONLY位,则以只读方式连接此段,否则以读写方式连接此段。
[返回值]:成功返回共享存储段的指针(虚拟地址),并且内核将使其与该共享存储段相关的shmid_ds结构中的shm_nattch计数器加1(类似于引用计数);出错返回-1。
shmdt ( ):去关联共享内存
当一个进程不需要共享内存的时候,就需要去关联。该函数并不删除所指定的共享内存区,而是将之前用shmat函数连接好的共享内存区脱离目前的进程。
int shmdt(const void *shmaddr);
[参数*shmaddr]:连接以后返回的地址。
[返回值]:成功返回0,并将shmid_ds结构体中的 shm_nattch计数器减1;出错返回-1。
shmctl ( ):销毁共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
[参数shmid]:共享存储段标识符。
[参数cmd]:指定的执行操作,设置为IPC_RMID时表示可以删除共享内存。
[参数*buf]:设置为NULL即可。
[返回值]:成功返回0,失败返回-1。
运行结果
我们在代码中让运行结束时直接删除共享内存
但是如果不按照写的那样在client中写入quit退出
下一次运行时就会由于共享内存存在无法运行
这个时候想删除共享内存就需要用命令
在Linux下查看共享内存:ipcs -m
在linux下想删除共享内存:ipcrm -m [shmid]
共享内存的结论
1.只要通信双方使用共享内存【shm】,一方想共享内存中写入数据,另一方就可立马看到共享内存是所有的
进程间通信(IPC),速度最快的,因为不需要过多的拷贝(不需要将数据给操作系统)
2.共享内存缺乏访问控制,会带来并发问题【如果想进行一定的访问控制,可以用管道来进行共享内存的同步】
四、system V消息队列
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
特性方面: IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
五、system V信号量
信号量主要用于同步和互斥的,下面先来看看什么是同步和互斥
进程互斥
由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥
系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
在进程中涉及到互斥资源的程序段叫临界区
特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
信号量的本质就是一个计数器,用来衡量临街资源中资源数目的目的。