💬上节内容讲述的是:父子进程可以通过匿名管道的方式进行通信,但匿名管道的致命局限 ------ 只能用于父子进程间通信 ------ 无法实现任意进程间的数据交互。那两个毫不相干的进程想进行通信,或传输数据,该如何呢?
文章目录
- 一、命名管道
- 二、共享内存
- [三、System V消息队列(用的很少,链接即可)](#三、System V消息队列(用的很少,链接即可))
- [四、System V信号量(用的很少,链接即可)](#四、System V信号量(用的很少,链接即可))
一、命名管道
让两个毫不相干的进程进行通信的本质:还是让两个进程看到同一份资源(内存),实际是两个进程看到同一个文件的内容(内容从磁盘拷贝到内存中,即文件内核缓冲区)
至此,引入了命名管道。
命名管道是匿名管道的升级版。在文件系统中创建了一个有名字 的管道文件,打破了亲缘关系的限制,让任意进程都能通过同一个文件路径,实现安全的进程间通信。
讲解管道文件:
Linux支持一种特殊的文件:管道文件(不是普通的文件,普通文件会被刷新到磁盘上)。特点:只会被打开,不需要将内容从内存刷新到磁盘
(只要打开,所有内容按文件来,是内存级)
| 特性 | 匿名管道 (pipe) | 命名管道 (FIFO) |
|---|---|---|
| 关系 | 只能亲缘进程 | 任意无关进程 |
| 文件 | 无实体文件 | 有实体管道文件 |
| 创建 | pipe | mkfifo |
| 打开 | 自动继承 fd | 用 open()打开 |
| 生命周期 | 随进程 | 随文件,可永久存在 |
| 通信方式 | 半双工 | 半双工 |
- 两个进程,都打开同一个文件,那OS会不会将这个文件 (inode,属性,内容)在内存中加载两次呢?不会的
进程 A和 B都打开同一个文件,他们有自己独立的文件描述符表。但进程B并没有拷贝A的struct file,而是自己有一个不一样的,这是因为这俩进程读写偏移量 f_pos不一样
(若是两个进程共享同一个 struct file:偏移量是同一个变量,A 往后读一点,B 的读写位置也跟着往后跑,互相抢位置、乱套)【匿名管道中,子进程直接拷贝父进程的strut file】



- 如何不同的,没有血缘关系的进程,保证看到的就是同一份资源呢?
命名管道 = 一个文件系统路径名 + 一块内核缓冲区
所有进程只要打开同一个路径下的同一个文件,就会自动指向内核里同一块共享内存。
-
特点:文件有路径,有名字(基于文件的方式,而且有名字,就决定了这个管道就做命名管道)(匿名管道没有磁盘映像,没有名字,没有路径)
-
命名管道如何被看见?
FIFO是命名管道(a named pipe),mkfifo是在指定路径下创建一个命名管道
命名管道挂载到文件系统,有路径就能全网可见。
-是普通文件,d是目录,p是管道文件
- 两个进程都可以读写,熟练使用read函数和write函数(这两个是系统调用)
cpp
//从管道文件里"拿"数据
ssize_t read(int fd, void *buf, size_t count);
//从fd这个被打开的管道文件里,将(最多count)个字节,读到我自己准备好的buf这块内存里
//往管道文件里"放"数据
ssize_t write(int fd, const void *buf, size_t count);
//把buf里的数据,count个字节的内容写到fd这个文件/管道里去
- 要使用命名管道来通信,首先得有一个进程创建管道(哪一个进程都可以创建)
int n = mkfifo(const char *pathname,mode_t mode);,然后根据n判断是否创建成功(成功返回0,失败-1)。管道文件fifo就是为了在之后,让两个进程通过管道来通信的。

注意点: mkfifo(path, 0666),最终创建出来的权限不一定是 666,会受系统默认的umask 影响。比如系统 umask 是 022,最终权限会变成:666 - 022 = 644。这是 Linux 安全机制,不影响使用,只是权限更严格。
所以先umask(0);,排除默认umask的影响
- 负责创建管道的进程,记得删除管道
在代码中:int unlink(const char *pathname);
在终端的时候,可以unlink fifo来删除管道文件
服务端:创建管道,以读的方式打开
客户端:直接打开管道文件即可,写的方式打开管道文件
- 一定要两个端都打开管道才能通信
- 如果write方没有打开文件,读方就会堵塞在open内部,直到有人把管道文件打开,open才会返回(基本保证open的时间点一致)

-
同时打开两个进程,才能使得成功

-
管道通信,简单来理解,就是:新建一种特殊的文件(以p开头,文件被打开是需要载入到内存中的。磁盘上只有文件属性,大小永远 0 字节。管道文件是专门为了通信,数据全程存在内核内存缓冲区,绝不落地磁盘),然后两个进程打开同一个路径下的文件,看/获取同一份资源
二、共享内存
💬管道虽然简单易用,但性能并不是最优的。每次通信都需要经过内核缓冲区,涉及两次数据拷贝。有没有更快的IPC方式呢?答案是共享内存------最快的进程间通信方式!
-
System V IPC主要有三种通信方式:共享内存、消息队列、信号量;
-
共享内存的原理:
动态库(本质上普通文件)加载到内存里,然后映射给进程的地址空间,进程就能看见了。所以动态库可以在内存里实现多进程共享
原理:两个进程都有自己独立的地址空间
第一步:先在物理内存中申请一段空间4KB
第二步:将这段内存映射到进程A的地址空间中:在(进程A的地址空间的)共享区划分出4KB,通过页表映射。进程B同样如此
在双方的应用层,两进程使用各自的虚拟地址的起始地址,完成对物理内存中的公共内存的访问
以上步骤由OS完成,而我们使用系统调用即可

- 不再使用共享内存,则
进程A将该虚拟地址空间free掉,再去掉页表的映射关系,B同样如此。当关联关系被取消,OS会将(没人使用的)共享内存释放掉 - 在OS内,会存在多对进程,都使用不同的共享内存通信
- 4导致在OS内,有多个共享内存同时存在(有的正在使用,有的新建还没和进程关联,有的正准备释放),所以OS需要使用[ 先描述,后组织 ]的方法管理共享内存
共享内存:一定有:对应的(描述共享内存的)内核结构体对象+物理内存。(结构体包含了共享内存的属性,大小,who创建,when,谁和该内存关联)。管理共享内存 --->管理结构体 --->对链表的增删查改
同时,进程也有内核结构体对象,所以进程和共享内存的关系:两个内核结构体之间的关系
这里可以看出:不单单是在物理内存申请共享内存,同时还要在内核创建描述共享内存的结构体
- 当共享内存的引用计数为0,OS就知道该释放掉它了
创建共享内存
- 创建共享内存使用的接口
cpp
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
(1)size_t size:指定要创建的共享内存字节数。
(2)int shmflg:IPC_CREAT:如果(目标共享内存)不存在,则新建;存在则直接获取(这个可以用于客户端,不用创建共享内存,只需要获取时使用这个)
IPC_CREAT | IPC_EXCL:强制新建,已存在直接失败。使用这个,则保证:如果shmget成功返回,一定是一个全新的内存(服务端可使用)
(3)返回值:合法的共享内存标识符
- 如何让另一个进程也知道共享内存的id呢?

所以,不能由OS来给共享内存申请id。在创建共享内存之前,A和B先约定一个key(用来标识共享内存的唯一性),创建方将key设置进共享内存结构体内部,A和B约定的,它俩都能看见。(key是在用户层构建并传入OS)
(1) 如何评估:目标共享内存是否存在?
创建方使用的是IPC_CREAT | IPC_EXCL:强制新建。
在使用key创建时,如果返回值 ≥0:之前不存在,现在新建成功一个共享内存,返回合法 shmid;返回 -1:共享内存已经存在!(我不覆盖,直接报错)
(2)如何保证两个进程拿到同一个共享内存
两个进程约定同一个key,一个创建,一个查找
内核在 IPC 维护一张共享内存表,以 key 为唯一索引:
进程 A、进程 B:相同的 key 调用 shmget
内核查到同一个 key 对应的 shm 段,返回同一个 shmid
再 shmat 挂载,就映射到同一块物理内存
(3)如何约定一个统一的key呢? 使用ftok
使用算法构建一个冲突概率比较小的key:
key_t key = ftok(const char *pathname, int proj_id);
路径写本路径即可,proj_id自己随便写一个,日常开发直接固定一个整数即可,不用纠结含义,纯区分标识
在创建时,使用key来判断共享内存的唯一性
在使用时,使用的是:shmget的int返回值
(1)key:只有一种用途:在操作系统内部 区分共享内存的唯一性
(2)shmid:(在创建好共享内存之后,会返回标识符shmid)用它来管理共享内存
(3)创建方把key设计进来,而获取方拿着对应的key查对应的共享内存的数据结构体
- 如何查看已经创建出来的共享内存:
ipcs -m

注意点:
1.共享内存也有权限perms(和文件类似,都有权限问题),共享内存的权限在shmget时设置
2.nattach表示有n个进程和该共享内存关联)
-
如何查看已经创建出来的资源:
ipcs

-
共享内存的资源:声明周期随内核!
文件的生命周期随进程(进程通过 "打开 / 关闭" 控制文件的引用计数,通过 "删除文件名" 切断目录映射,两者共同决定文件的生命周期终点。)
对于共享内存来说,就算进程结束了,只要没有显示地删除共享内存,共享内存资源将一直存在,IPC资源也依旧被占用(共享内存的资源,生命周期随内核)
- 共享内存的删除方法:
(1)终端指令:删除创建的共享内存:ipcrm -m shmid
(2)代码删除:int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数shmid:shmget 返回的那个 ID
参数cmd:让内核做的操作(命令):IPC_RMID:删除共享内存
参数struct shmid_ds *buf:用来存放 / 设置共享内存信息的结构体指针
cpp
// 删除 shmid 对应的共享内存
shmctl(shmid, IPC_RMID, NULL);
代码:
- 代码:共享内存的建立&&删除


共享内存映射到进程的地址空间
共享内存弄好之后,就要将共享内存映射到进程A和B的地址空间中。(如何映射:当共享内存创建好之后,共享内存的物理地址也就有了,然后在进程的堆和栈之间(共享区)开辟一段虚拟空间。物理地址、虚拟地址都有了,修改页表,映射关系就有了。可以在进程这返回内存块的起始地址
映射:void *shmat(int shmid, const void* shmaddr, int shmflg);
让调用(该系统调用shmat的)进程,它的堆栈之间的映射区和共享内存进行映射。映射成功返回起始虚拟地址(申请内存malloc也是返回虚拟地址)

shmat的作用:把内核里的共享内存段,挂载到当前进程的虚拟地址空间,让进程可以像读写普通内存一样读写共享内存。
(1)shmid:表示想要映射的共享内存
(2)const void *shmaddr:指定挂载到进程的哪个虚拟地址(99% 场景直接填 NULL→ 让内核自动选择合适的地址)
(3)int shmflg(挂载标志)填 0:默认读写模式;SHM_RDONLY:只读挂载(不能写)
未来访问共享内存,有起始虚拟地址+共享内存大小,就可以线性访问共享内存的任意一个字节
共享内存并不是单纯地在物理内存里开辟一块内存即可,它有专门描述共享内存的结构体
共享内存一旦建立映射,通信双方拿到的是共享内存的起始虚拟地址,它访问共享内存就像访问自己的空间一样,直接用就行
如何将进程和共享内存去关联:shmdt,然后再删除共享内存:ipcrm -m 内存的shmid
问题:
- 在命名管道中,读写都是使用系统调用read、write(它们是向内核缓冲区写入)。但是在共享内存中读写没有使用,直接向共享内存中写入
堆和栈之间的空间(共享区)属于用户空间,可以让用户直接使用【可知:读写共享内存,并没有出现系统调用】

- 之前是两个进程通信,那如果把其中的一个进程换成磁盘呢?
通过 mmap 系统调用,把磁盘上的文件内容映射到进程的虚拟地址空间,进程以访问内存的方式读写文件内容;
对映射内存的修改会首先作用于内核页缓存,由内核后台异步延迟刷盘同步到磁盘;
多个进程映射同一个磁盘文件,共享同一份内存映射,从而实现基于文件的共享内存进程通信。
这也是动态库加载到内存,让进程看到的底层原理(利用:mmap 文件映射 + 共享内存机制,达到:代码段物理内存只保留一份,多进程共享同一块物理内存)

(动态库只读代码段是共享内存;可写数据段用 写时复制 COW,进程修改时单独拷贝一份,互不干扰。)
-
管道通信,如果读端打开了,但是写端没有打开,那读端也
管道自带同步阻塞机制 ,内核帮你做好读写等待(举例:读端已经打开,若写端没打开/没写入数据,那读端就不许读)
共享内存本身无任何同步机制,只是一块裸内存,必须自己加信号量 / 互斥锁 / 条件变量做同步,否则进程互相干扰,数据乱套(不管有没有数据、对方有没有写完,进程都能直接读写内存地址,立刻执行、绝不阻塞。)也就是共享内存,对于数据没有保护机制 -
如何有同步机制呢?可以在共享内存的基础上,再在两进程之间设置一个管道(通过管道来告知读端何时读取0)
三、System V消息队列(用的很少,链接即可)
再来回顾一下IPC 的本质:让进程看到同一份资源。
管道是让进程看到同一个文件、共享内存是让进程看到同一份内存;消息队列是让两个进程看到同一个队列。
在 Linux 原生进程间通信体系中,System V 消息队列是传统且经典的 IPC 机制,它允许互不相关的多个进程,以带类型的消息 为单位,往内核(OS)维护的队列里发送、接收数据,无需管道的字节流流式传输,也不用共享内存手动同步锁,内核负责帮进程排队存储消息、按消息类型精准检索读取,天然支持异步通信、按类型筛选收发,成为单机多进程之间结构化数据异步交互的经典实现方式。
消息队列可以两个朝向的发送消息
-
问题1:带类型的消息:
消息本身是没有类型的,(但为了知道该消息是哪个进程的,哪个消息是A发的,A收哪个类型的消息)我们给消息设置类型,比如:
int type = 1或2; -
过程:OS提供一个队列结构,进程A使用某种系统调用,将消息/数据拷贝到队列(为消息新创建一个队列结点,数据放在结点中,进程就把消息交给OS),把所有通信数据放在OS,一个一个的队列结点形成一个消息队列
-
数据块的形式
cpp
struct msgbuf {
long mtype; /* message type, must be > 0 */
char mtext[1];/* message data */
};
mtype是消息的类型(大于0)。进程A想发送几个消息,就建立几个队列节点。同样进程B也是。那A和B还能认清哪个是自己的数据吗?所以,不能只新建独立结点,还需要进程A,B给自己的数据打上类型标签(主要是为了在一个队列中区分哪一个数据是我要的,哪一个数据是我发的)。
mtext是数据的正文
- 消息队列提供了一个(从一个进程)向(另外一个进程)发送一块
有类型的数据的方法。 - 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值。
特性方面: - IPC 资源必须手动删除,否则不会自动清除,除非重启系统;
所以 System V IPC 资源的生命周期随内核。
操作系统提供一个队列结构(queue),让进程A将发送的数据通过某种调用,将数据拷贝到队列。同时在内核中新建一个队列结点,至此,进程就将数据交给了操作系统。
-
但n对进程之间都想进行互传的时候,操作系统里就会有多个消息队列,这个时候OS需要将这些消息队列管理起来(先描述,后管理),在内部一定有信息队列的结构体
-
和共享内存一样,想确保两个进程看到的是同一个消息队列 :两进程约定一个key(仍然是
ftok),然后由创建者将key设置到消息队列的描述结构体中。共享内存是关联以及去关联,信息队列是发和收 -
和共享内存的接口很类似,所以叫做System V 标准
创建消息队列:
msgget

cpp
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
//IPC_RMID是用来删除
cpp
//发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
//失败返回值是-1 //指定的缓冲区地址 //指定的大小
cpp
//读取数据块
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg); //读取数据的类型
- 查看信息队列:
ipcs -q
消息队列的生命周期也是随内核(需要显式的删除)
四、System V信号量(用的很少,链接即可)
信号量重要!但system v版本的不重要
共享内存没有保护机制,会导致数据不一致----解决方案:信号量
数据不一致的原因:
1). 共享资源没有被保护
2). 双方代码都访问了没被保护的共享资源
概念
共享资源:多个进程能够看到的同一份资源(共享资源没有被保护,保护起来就叫临界资源)
无论是多个进程还是多个线程,一旦涉及到并发编程,都绕不开5个概念:
被保护起来的共享资源 --->临界资源
在进程中访问临界资源的程序段 --->临界区
没有访问临界资源的代码 --->非临界区 (保护机制就是保护临界区的代码)

那如何保护临界区代码呢?(互斥)
任意时刻,只允许一个进程(执行流)访问临界资源 --->叫作:互斥
两个进程访问临界区,需要竞争那把锁,获得锁之后再访问。访问时加锁,访问完解锁。可以看出来,锁也要被共享(谁来保证锁的安全)
多个执行流在访问临界资源时,具有一定的顺序性 --->同步
所谓的对共享资源进行保护,本质是对访问共享资源的代码进行保护
原子性:要么做,要么不做(只看结果,学习了考第一名,没学习)
信号量
- 举个例子:电影院放映厅有n个位置,将一个大的共享资源(放映厅)变成x个小的共享资源(座位),买票就是对资源的预订机制
回归正题:放映厅也就是共享内存,将共享内存里的空间划分为一小块一小块,进程会分块使用。只要:进程不访问同一个位置;不要让过多的进程都访问 ---->就可以实现进程的并发访问(运行多个进程访问共享内存的不同区域。虽然都在访问共享内存,但是它们访问的不是同一个区域,互不干扰)
通俗来说:信号量就是计数器,用来描述临界资源中,资源数量的多少
流程:每一个进程想访问临界资源(共享内存)的一小部分 ----> 需要申请信号量 (申请成功之后才能访问某一小块资源) --->申请信号量的本质:对资源的预定机制 ;作用:保护临界区
细节1:首先,信号量要能被进程看到才可以。使得信号量本身就是共享资源了
细节2:二元信号量:互斥

- 信号量并不是一个简单的整型值int,而是一个结构体
为什么信号量 可以算作通信的范畴?
(不管是命名还是匿名管道,都是传输数据,可以理解是通信。共享内存,两个映射,一个写,一个读,能理解是通信。消息队列,有类型数据块,一个写一个读,也是)信号量好像并没有传递数据,为什么也是通信
2.1 根据刚刚所学,进程拥有独立性,想访问同一份资源,需要让他们看到同一份资源,但进程直接不可能看到同一份资源,所以才有这么多通信方法。为了保护临界资源,需要先访问信号量,每个进程都先看到同一个信号量。 (System V解决这个问题)
2.2 不是传递数据才是通信IPC,通信的本质是(特定信息的传递)。通知/同步/互斥也算。
信号量的接口和系统调用
- 信号量(Semaphore):sem
创建信号量:semget


- 信号量,查看:
ipcs -s
cpp
hpr@hyc-alicloud:~$ ipcs -s
------ Semaphore Arrays --------
key semid owner perms nsems
//自己设置的key, semget的返回值,谁创建的, 权限, 创建了几个信号量
- 删除信号量:semctl
也可以删除信号量:ipcsrm -s semid
cpp
int semctl(int semid, int semnum, int cmd, ...);
IPC_RMID
-
信号量的PV操作:semop

-
信号量的初始值:信号量的初始化值并不是在创建时获取的,而是在调用semctl的时候
-
OS内有大量的信号量集合,也需要进行管理:先描述,后管理
信号量在内核中,一定是某种结构体(包含信号量的锁,计数器等等)
-
共享内存、消息队列、信号量,这三个 System V IPC 对象,内核维护的结构体格式完全统一(三个结构体:shmid_ds / msqid_ds / semid_ds)。三大结构体首成员全是
ipc_perm类型成员,ipc_perm内部第一个成员都是key

共享内存、消息队列、信号量都是用key来表示IPC资源的唯一性