Linux高性能服务器编程 各种进程间通信方式详解
各种进程间通信方式详解
1.基本原理
进程地址空间的3g-4g是内核空间,0-3g是进程自己的,但是每个进程的3-4g是内核空间就一份,大家都相同的。
所以通信就是进程在内核空间中的一块缓冲区进行写而另外一个进程读从而完成了通信。这两个进程可以是父子进程,也可以不是。
2.基本概念
“IPC” 全称是 “Inter - Process Communication”,即进程间通信。
1.进程间通信的重要性
- 在操作系统中,每个进程都有自己独立的地址空间。这使得进程之间的数据是相互隔离的,这种隔离性在保证进程独立性和安全性的同时,也带来了进程间共享数据和协同工作的困难。进程间通信机制就是为了解决这个问题,使得不同的进程能够相互交换信息、同步操作,从而共同完成复杂的任务。
2.IPC 方式分类汇总
1.基于内核的通信
- 管道(Pipe)
- 原理:管道是一种半双工的通信机制,它在操作系统内核中创建一个缓冲区,用于连接一个写进程和一个读进程。数据在管道中单向流动,写进程将数据写入管道缓冲区的一端,读进程从另一端读取数据。当管道缓冲区满时,写进程会被阻塞,直到读进程读取数据腾出空间;同理,当管道缓冲区空时,读进程会被阻塞,直到写进程写入数据。
- 示例场景:在 Unix/Linux 系统中,
pipe
函数用于创建管道。常见于父子进程之间的通信,例如,一个进程用于生成数据(如执行ls
命令列出文件列表),另一个进程用于处理这些数据(如对文件列表进行过滤)。 - 优点:实现简单,使用方便,在简单的父子进程通信场景中效率较高。
- 缺点:半双工通信方式限制了数据传输的灵活性;只能用于有亲缘关系(如父子进程)的进程之间通信;管道缓冲区大小有限,数据量较大时可能需要频繁地进行读写操作。
- 匿名管道(Anonymous Pipe):主要用于父子进程之间的通信,它和管道类似,但是没有名字,在创建时直接返回文件描述符供父子进程使用。
- 命名管道(Named Pipe):也叫 FIFO(先进先出)管道,它可以在不相关的进程之间进行通信。进程通过打开一个命名管道文件来进行读写操作,就像使用普通管道一样,但是它的名字在文件系统中是可见的,允许不同的进程通过文件名来访问它。
- 信号(Signal)
- 原理:信号是一种异步事件通知机制,用于通知进程发生了某个特定的事件。信号由操作系统内核发送给进程,进程可以选择忽略信号、执行默认的信号处理动作或者自定义信号处理函数来响应信号。
- 示例场景:当用户在终端中按下
Ctrl + C
组合键时,内核会向当前正在运行的前台进程发送一个SIGINT
(中断信号)。进程可以根据需要处理这个信号,比如安全地退出程序或者进行一些清理工作。 - 优点:可以用于处理各种异步事件,如硬件中断、软件异常等;能够及时通知进程重要的事件发生。
- 缺点:信号携带的信息量有限,通常只是一个信号编号;信号处理函数的执行时机是不确定的,可能会在进程执行的任意时刻被触发,这可能会影响进程的正常执行流程。
- 共享映射区(Memory - Mapped Files)
- 原理:共享映射区是将文件内容映射到进程的地址空间中,使得多个进程可以通过访问这个共享的内存区域来实现通信。进程对映射区域的操作就如同对文件进行操作一样,这些操作会直接反映在文件和其他共享该映射区域的进程中。
- 示例场景:多个进程需要共同操作一个配置文件,通过将该配置文件映射到共享映射区,进程可以直接在内存中读取和修改配置信息,而不需要频繁地进行文件 I/O 操作。
- 优点:结合了内存操作的高效性和文件存储的持久性;可以方便地在不相关的进程之间实现通信,只要它们能访问到同一个文件。
- 缺点:对文件的操作需要注意同步问题,否则可能导致数据不一致;文件大小可能会限制共享映射区的大小。
- 本地套接字(Unix Domain Socket)
- 原理:本地套接字提供了一种类似于网络套接字的进程间通信方式,但它只用于本地进程之间的通信。它使用文件系统中的特殊文件(如 Unix 系统中的套接字文件)来标识通信端点,进程通过对这些文件进行读写操作来实现通信。
- 示例场景:在一个复杂的本地服务系统中,不同的服务进程(如数据库服务进程和应用服务进程)之间通过本地套接字进行通信,以实现数据的请求和响应。
- 优点:可以在不相关的本地进程之间进行通信;支持双向通信,通信方式灵活;与网络套接字类似,有成熟的编程接口。
- 缺点:相比其他本地通信方式(如共享内存),可能会有一定的性能损耗,因为它涉及到操作系统内核的更多参与。
- 共享内存(Shared Memory)
- 原理:共享内存允许两个或多个进程共享同一块物理内存区域。这些进程将共享内存区域映射到自己的虚拟地址空间中,从而可以直接读写这块内存区域,就像访问自己的内存一样。为了避免多个进程同时访问共享内存导致数据不一致,通常需要配合使用信号量等同步机制。
- 示例场景:在高性能的多进程计算任务中,如多进程并行处理一个大型数组,通过共享内存可以让每个进程快速地访问和修改数组中的元素。
- 优点:是所有进程间通信方式中速度最快的,因为进程直接访问内存,不需要进行数据的复制和传输等操作。
- 缺点:需要解决同步问题,否则容易出现数据竞争和不一致的情况;共享内存的管理(如创建、映射、删除等操作)相对复杂。
- 信号量(Semaphore)
- 原理:信号量是一种用于进程同步和互斥的机制,本质上是一个非负整数计数器。它有两个基本操作:
P
操作(也称为wait
操作)和V
操作(也称为signal
操作)。P
操作会将信号量的值减 1,如果信号量的值小于 0,则进程会被阻塞;V
操作会将信号量的值加 1,如果信号量的值小于等于 0,则会唤醒一个被阻塞的进程。信号量主要用于控制对共享资源的访问。 - 示例场景:在多个进程访问一个共享缓冲区时,通过信号量来控制同时访问缓冲区的进程数量,避免数据冲突。
- 优点:有效地实现了进程之间的同步和互斥,保证了共享资源的合理访问。
- 缺点:使用不当可能会导致死锁等问题;信号量的操作相对复杂,需要仔细设计。
- 原理:信号量是一种用于进程同步和互斥的机制,本质上是一个非负整数计数器。它有两个基本操作:
- 消息队列(Message Queue)
- 原理:消息队列是一个由内核维护的消息链表。进程可以向消息队列发送消息,也可以从消息队列接收消息。消息队列有一个消息类型的属性,接收进程可以根据消息类型有选择地接收消息。当消息队列满时,发送消息的进程可能会被阻塞;当消息队列空时,接收消息的进程可能会被阻塞。
- 示例场景:在一个分布式系统的本地节点中,不同的进程可以通过消息队列传递任务请求和结果,方便进行任务的分发和处理。
- 优点:可以实现异步通信,发送和接收消息的进程不需要同时运行;消息队列可以存储多个消息,方便进行消息的缓存和排队。
- 缺点:消息的传递需要在内核的消息队列中进行,相对共享内存等方式速度较慢;消息队列的大小有限,可能会出现消息满的情况。
共享内存和共享映射区的区别
- 共享内存是直接在内存中开辟一块共享区域供进程访问,主要关注内存的共享访问。而共享映射区是将文件内容映射到内存中,重点在于将文件和内存关联起来,通过内存操作来间接操作文件。虽然它们都涉及内存共享的概念,但共享映射区更侧重于文件与内存的交互,共享内存则更侧重于进程间对内存的直接共享访问。
2.基于文件系统的通信原理
- 原理:进程可以通过读写文件来进行通信。一个进程将数据写入文件,另一个进程从文件中读取数据。这种方式简单直观,但效率可能相对较低,因为文件系统操作涉及磁盘 I/O,速度比内存操作慢得多。而且,需要注意文件的并发访问问题,例如多个进程同时写一个文件可能会导致数据混乱,通常需要使用文件锁等机制来进行同步。
3.基于网络的通信原理(适用于不同主机上的进程通信)
- 原理:在不同主机上的进程可以通过网络协议进行通信。最常见的是基于 TCP/IP 协议栈的通信方式。一个进程作为服务器,在指定的端口上监听客户端的连接请求。当客户端进程发起连接请求并建立连接后,双方可以通过套接字(Socket)进行数据的发送和接收。数据在网络中以数据包的形式传输,经过多个网络设备(如路由器、交换机等)的转发,最终到达目标进程。这种通信方式需要考虑网络协议、IP 地址、端口号、网络安全等诸多因素。
3.管道(使用最简单)
1.概述
管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。调用pipe系统函数即可创建一个管道。有如下特质:
- 其本质是一个伪文件(实为内核缓冲区)
- 由两个文件描述符引用,一个表示读端,一个表示写端
- 规定数据从管道的写端流入管道,从读端流出
管道的原理:
管道实为内核使用环形队列机制,借助内核缓冲区(4k)实现
管道的局限性:
1 数据不能进程自己写,自己读
2.管道中数据不可反复读取。一旦读走,管道中不再存在
3 采用双向半双工通信方式,数据只能在单方向上流动
4 只能在有公共祖先的进程间使用管道
双向半双工例子:
对于管道来说,一旦我进程间通信时,第一次发生数据交换是A进程读B进程写,那第二次就不可以A写B读了,只能是A读B写。
常见通信方式:单工通信、半双工通信、全双工通信。
注意:因为父子进程共享文件描述符所以父进程已经创建并打开的管道子进程也能用
2.使用
创建并打开管道
1 |
|
参数:
fd[0]表示读端
fd[1]表示写端
返回值:
成功0失败-1
父进程写子进程读:
一开始父子进程都持有读端和写端
父进程关闭写端子进程关闭读端后就有一条明确的数据流通方向
1 |
|
3.管道的读写行为
1.读管道
1. 管道中有数据,read返回实际读到的字节数
2.管道中无数据:
(1)管道写端被全部关闭,没有人会继续往管道写数据了,read返回0(好像读到文件结尾)
(2)写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出cpu)
2.写管道
管道读端全部被关闭,进程异常终止(也可使用捕捉SIGPIPE信号,使进程不终止)
管道读端没有全部关闭:
(1)管道已满,write阻塞
(2)管道未满,write将数据写入,并返回实际写入的字节数
3.练习
1.管道实现 ls|wc-l
使用管道实现父子进程间通信,完成:ls|wc-l。假定父进程实现ls,子进程实现wc
Is命令正常会将结果集写出到stdout,但现在会写入管道的写端;wc-l 正常应该从stdin读取数据,但
此时会从管道的读端读
1.创建打开管道
2.fork子进程
3.关闭父进程读端,关闭子进程写端
4.父进程调用execlp执行ls命令
5.子进程调用execlp执行wc -l
6.父进程调用dup2把标准输出重定向到管道写端(往管道写数据,原来ls的输出是在标准输出的,现在输出到管道)
7.子进程调用dup2把标准输入重定向到管道读端(从管道拿数据,原来wc从标准输入拿数据,现在从管道拿)
1 |
|
- 程序不时的会出现先打印$提示符,再出程序运行结果的现象。
- 这是因为:父进程执行ls命令,将输出结果给通过管道传递给子进程去执行wc命令,这时父进程若先于子进程打印wc运行结果之前被shell使用wait函数成功回收,shell就会先于子进程打印wc运行结果之前打印$提示符。
- 在这之中子进程一定得等父进程写完数据以后才会执行自己的代码,所以一定是父进程先执行完毕。
- 所以解决方法:让子进程执行ls,父进程执行wc命令。或者在兄弟进程间完成。
2.管道实现兄弟进程通信
1 |
|
注意要关闭父进程持有的读端和写端,不然形不成数据的单向流动
3.测试管道是否允许一个pipe有一个写端多个读端?有一个读端多个写端?
是允许的,但是一般都是写成一个读端一个写端
- 管道允许一个读端多个写端
- 管道是可以有一个读端和多个写端的。这在很多场景下是非常有用的,例如在日志系统中,多个不同的进程可以作为写端向一个管道写入日志信息,而一个专门的日志收集进程作为读端从管道中读取这些日志信息进行处理。多个写端可以同时向管道写入数据,不过需要注意数据的同步问题,因为如果多个写端同时写入可能会导致数据混乱,通常需要配合信号量等同步机制来保证数据的有序写入。
- 管道也允许一个写端多个读端
- 管道同样允许一个写端多个读端。当数据被写入管道后,所有的读端都可以读取到这些数据。数据从管道中被读取后,对于管道中的其他读端来说,数据仍然存在(只要没有被其他读端全部读取完)。
- 例如,在一个数据分发系统中,一个进程作为写端向管道写入数据,多个其他进程作为读端可以从管道中读取相同的数据进行不同的处理,如一个读端用于数据显示,另一个读端用于数据存储等。管道中的数据是可以被多个读端共享读取的,并不是一个读端读取后数据就消失了。管道内部维护了一个缓冲区,数据存储在这个缓冲区中,读端从缓冲区读取数据,只要缓冲区中的数据没有被全部读取,其他读端仍然可以读取剩余的数据。
测试代码:
1 |
|
当两个子进程快速地向管道写入数据时,管道缓冲区可能在第一个子进程写入
1.hello\n
后,父进程就开始读取数据。由于管道缓冲区的数据可能没有被第二个子进程的2.world\n
完全覆盖或者父进程读取操作已经完成,就可能导致父进程只读取到1.hello\n
而没有读取到2.world\n
。管道的读取操作在缓冲区有数据时就会开始读取,而不会等待所有子进程都写入数据。
sleep
函数在这里起到了让父进程暂停一下的作用,给两个子进程足够的时间将数据都写入管道缓冲区,从而保证父进程能够读取到两个子进程写入的完整数据。
4.管道大小
默认4KB
5.管道优劣
**优点:**简单,相比信号,套接字实现进程间通信,简单很多
缺点:
- 1.只能单向通信,双向通信需建立两个管道
- 2.只能用于父子、兄弟进程(有共同祖先)间通信。该问题后来使用fifo有名管道解决
6.有名管道FIFO
FIFO常被称为命名管道,以区分管道(pipe)。管道(pipe)只能用于“有血缘关系”的进程间。但通过FIFO,不相
关的进程也能交换数据。
FIFO是Linux基础文件类型中的一种。但,FIFO文件在磁盘上没有数据块,仅仅用来标识内核中一条通道。备
进程可以打开这个文件进行read/write,实际上是在读写内核通道,这样就实现了进程间通信。
创建方式:
1.命令:mkfifo 管道名
1 | mkfifo 管道名 |
2.库函数:
1 |
|
参数:
pathname:文件名
mode:8进制的权限,比如0644这种的
**返回值:**成功:0;失败 :- 1
一旦使用mkfifo创建了一个FIFO,就可以使用open打开它,常见的文件I/O函数都可用于fifo。如:close、read、
write、unlink等。
例子:
1 | int ret = mkfifo("mytestfifo", 0664); |
实现没有血缘关系的进程通信
读端
1 |
|
写端
1 |
|
4.文件实现进程间通信
已经过时的东西,过一遍有个印象即可
父子进程
这个理所当然可以通信,因为父子进程文件描述符表都是一样的
1 | /* |
无血缘关系
1 | /* |
1 | /* |
5.共享映射区(无血缘关系用的)
1.概述
原理:共享映射区是将文件内容映射到进程的地址空间中,使得多个进程可以通过访问这个共享的内存区域来实现通信。进程对映射区域的操作就如同对文件进行操作一样,这些操作会直接反映在文件和其他共享该映射区域的进程中。
示例场景:多个进程需要共同操作一个配置文件,通过将该配置文件映射到共享映射区,进程可以直接在内存中读取和修改配置信息,而不需要频繁地进行文件 I/O 操作。
优点:结合了内存操作的高效性和文件存储的持久性;可以方便地在不相关的进程之间实现通信,只要它们能访问到同一个文件。
缺点:对文件的操作需要注意同步问题,否则可能导致数据不一致;文件大小可能会限制共享映射区的大小。
**存储映射I/O(Memory-mapped l/O)使一个磁盘文件与内存存储空间中的一个缓冲区相映射。**于是当从缓冲区中取数据,就相当于读文件中的相应字节。于此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不适用read和write函数的情况下,使用地址(指针)完成I/O操作。
使用这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过mmap函数来实
现。
2.mmap&&munmap函数
创建共享内存映射
1 | include<sys/mman.h> |
参数:
addr: 指定映射区的首地址。通常传NULL,表示让系统自动分配
length:共享内存映射区的大小。(<= 文件的实际大小)
prot: 共享内存映射区的读写属性。PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE
flags: 标注共享内存的共享属性,共享就是对文件的修改会写回磁盘,私有就不会写回磁盘。MAP_SHARED、MAP_PRIVATE
fd: 用于创建共享内存映射区的那个文件的 文件描述符,就是要映射到内存的文件
offset:默认0,表示映射文件全部。偏移位置,从哪里开始映射。需是 4k 的整数倍
返回值:
成功:映射区的首地址
失败:MAP_FAILED (void*(-1)), errno—-就是把-1强转为void *了
释放共享内存映射
1 | int munmap(void *addr, size_t length); |
参数
addr:mmap 的返回值,共享内存映射首地址
length:大小
返回值
成功0,失败-1
函数使用
1 |
|
3.mmap注意事项
思考 :
可以open的时候O_CREAT一个新文件来创建映射区吗 ?
如果open时O_RDONLY,mmap时PROT参数指定PROT_READ|PROT_WRITE会怎样 ?
文件描述符先关闭,对mmap映射有没有影响 ?
如果文件偏移量为1000会怎样 ?
对mem越界操作会怎样?
如果mem++,munmap可否成功 ?
mmap什么情况下会调用失败 ?
很多参数都会导致失败
如果不检测mmap的返回值,会怎样?
会死得很惨
使用注意事项:
- 用于创建映射区的文件大小为 0,却指定非0大小创建映射区,出 “总线错误”。
- 用于创建映射区的文件大小为 0,也指定0大小创建映射区, 出 “无效参数”。
- 用于创建映射区的文件读写属性为,只读,映射区属性为 读、写。 出 “无效参数”; 文件和映射区都是只读的是可以的;文件只有写权限,映射区只有写权限也会报错。(2答案)
- 创建映射区,需要read权限。当访问权限指定为 “共享”MAP_SHARED是, mmap的读写权限应该 <=文件的open权限。 映射区只写不行。
- 文件描述符fd,在mmap创建映射区完成即可关闭。后续访问文件,用 地址访问。(3答案)
- offset 必须是 4096的整数倍。(MMU 映射的最小单位 4k )(4答案)
- 对申请的映射区内存,不能越界访问。 (5答案)
- 读写都没问题,但是munmap会失败,munmap用于释放的 地址,必须是mmap申请返回的地址。(6答案)
- 映射区访问权限为 “私有”MAP_PRIVATE, 对内存所做的所有修改,只在内存有效,不会反应到物理磁盘上。
- 映射区访问权限为 “私有”MAP_PRIVATE, 只需要open文件时,文件有读权限,用于创建映射区即可。
mmap函数的保险调用方式:
1 | 1. fd = open("文件名", O_RDWR); |
4.mmap实现进程通信
父子进程
父子等有血缘关系的进程之间也可以通过mmap建立的映射区来完成数据通信。
但相应的要在创建映射区的时候指定对应的标志位参数flags:
MAP_PRIVATE:(私有映射)父子进程各自独占映射区;
MAP_SHARED:(共享映射) 父子进程共享映射区;
结论:
父子进程共享:1. 打开的文件 2.mmap建立的映射区(但必须要使用MAP_SHARED)
流程:
父子进程使用 mmap 进程间通信:
1.父进程 先 创建映射区。 open( O_RDWR) mmap( MAP_SHARED );
2.指定 MAP_SHARED 权限
3.fork() 创建子进程。
4.一个进程读, 另外一个进程写。
练习
练习:父进程创建映射区,然后fork子进程,子进程修改映射区内容,而后,父进程读取映射区内容,查验是
否共享
1 |
|
无血缘关系
流程: 【要求会写】
1.两个进程 打开同一个文件,创建映射区。
2.指定flags 为 MAP_SHARED。
3.一个进程写入,另外一个进程读出。
【注意】:无血缘关系进程间通信。
mmap:数据可以重复读取。
fifo:数据只能一次读取。
读端
1 |
|
写端
1 |
|
5.mmap匿名映射区
匿名映射:只能用于 血缘关系(父子)进程间通信。
1 | p = (int *)mmap(NULL, 40, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0); |
映射区大小想要多少写多少
权限想要啥写啥
文件描述符的地方传-1
flags要 | 下面提到的两个宏
/dev/zero 从这个文件里面拿数据可以随便拿,想要多大拿多大的数据,只不过读出来都是文件空洞
6.本地套接字(最稳定)
进程间通信(IPC): pipe、fifo、mmap、信号、本地套接字(domain)— CS模型
1.本地套接字和网络套接字对比
对比网络编程 TCP C/S模型, 注意以下几点:
1.参数
1 | int socket(int domain, int type, int protocol); |
2.地址结构:
以下程序将UNIX Domain socket绑定到一个地址。
1 | size =offsetof(struct sockaddr_un, sun_path) + strlen(un.sun_path); |
1 | sockaddr_in --> sockaddr_un |
不用ip和port,改成用socket文件名了
bind()函数调用成功,会创建一个 socket。因此为保证bind成功,通常我们在 bind之前, 可以使用 unlink(“srv.socket”);
客户端不能依赖 “隐式绑定”。并且应该在通信建立过程中,创建且初始化2个地址结构:
1.client_addr --> bind()
2.server_addr –> connect();
2.本地套接字实现进程间通信
实现server和client这两个进程间的通信,其实就是各自建立一个socket进行通信
serve.c
1 |
|
client.c
初始化两个地址结构
1.客户端自己的socket的地址结构
2.服务器端的socket地址结构(用于connect,连接客户端和这个服务器)
1 |
|
warp.c
1 |
|
warp.h
1 |
|
3.本地套接字和网络套接字实现通信对比
7.信号量
1.信号量原语
多个进程同时访问系统上某个资源时,如同时写一个数据库的某条记录,或同时修改某个文件,就需要考虑进程同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。通常,进程对共享资源的访问的代码只是很短的一段**,但这段代码引发了进程之间的竞态条件,我们称这段代码为关键代码区,或临界区,对进程同步,就是确保任一时刻只有一个进程能进入关键代码段。**
Dekker算法和Peterson算法试图从语言本身(不需要内核支持)解决进程同步问题,但它们依赖于忙等待,即进程要持续不断地等待某个内存位置状态的改变,这种方式的CPU利用率太低,不可取。
Dijkstra提出的信号量(Semaphore)是一种特殊的变量,它只能取自然数值且只支持两种操作:等待(wait)和信号(signal)。但在Linux/UNIX中,等待和信号都已经具有特殊含义,所以对信号量的这两种操作更常用的称呼是P、V操作,这两个字母来自荷兰语单词passeren(传递,就好像进入临界区)和vrijgeven(释放,就好像退出临界区)。
假设有信号量SV,对它的P、V操作含义如下:
- P(SV),如果SV的值大于0,就将它减1,如果SV的值为0,则挂起进程的执行。
- V(SV),如果有其他进程因为等待SV而挂起,则唤醒之,如果没有,则将SV加1。
信号量的取值可以是任何自然数,但最常用的、最简单的信号量是二进制信号量,它只能取0或1两个值,我们仅讨论二进制信号量。使用二进制信号量同步两个进程,以确保关键代码段的独占式访问的例子:
上图中,当关键代码段可用时,二进制信号量SV的值为1,进程A和B都有机会进入关键代码段,如果此时进程A执行了P(SV)操作将SV减1,则进程B再执行P(SV)操作就会被挂起,直到进程A离开关键代码段,并执行V(SV)操作将SV加1,关键代码段才重新变得可用。
不能使用普通变量来模拟二进制信号量,因为所有高级语言都没有一个原子操作可以同时完成以下两步操作:检测变量是否为true/false,如果是则将它设置为false/true。
Linux信号量的API定义在sys/sem.h
头文件中,主要包括3个系统调用:semget
、semop
、semctl
。它们被设计为操作一组信号量,即信号量集,而不是单个信号量。
2.semget
系统调用
semget
系统调用创建一个新的信号量集,或获取一个已经存在的信号量集。
1 |
|
参数
key
:键值,用来标志全局唯一的信号量集,要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。
num_sems
:指定要创建/获取的信号量集中信号量的数目,如果是创建信号量,该值必须指定,如果是获取已经存在的信号量,该值可以设置为0。
sem_flags
:指定一组标志,低端的9个bite是信号量的权限,格式和含义与open
的mode
参数一致。此外,它可以和IPC_CREAT
标志做按位或运算以创建新的信号量集。还可以联合使用IPC_CREAT
和IPC_EXCL
标志确保创建新的、唯一的信号量集,如果这时候该信号量集已经存在,semget
返回错误并设置errno为EEXIST
。
返回值
semget
成功返回一个正整数,也就是信号量集的标识符,失败返回-1并设置errno。
如果用semget
创建一个新的信号量集,与之相关的内核数据结构体semid_ds
将被创建并初始化。
1 | struct semid_ds { |
3.semop
系统调用
semop
系统调用改变信号量的值,即执行P、V操作,在讨论semop
函数前,先介绍与每个信号量关联的一些重要的内核变量:
1 | unsigned short semval; /* 信号量的值 */ |
semop
函数对信号量的操作实际就是改变上述内核变量的操作,该函数定义如下:
1 |
|
参数
sem_id
:semget
调用返回的信号量集标识符,指定被操作的目标信号量集。
sem_ops
:指向一个 sembuf
类型结构体的数组:
1 | struct sembuf |
sem_num
:信号量集中信号量的编号,0代表信号量集的第一个信号量,以此类推。sem_op
:指定操作类型,可选值:正整数,0、负整数,同时受到sem_flg
的影响。op>0执行V操作,op小于0执行P操作- 通常
P
操作值为 - 1,V
操作值为 1
- 通常
sem_flg
:可选值为IPC_NOWAIT
,SEM_UNDO
,0
。IPC_NOWAIT
代表非阻塞操作,SEM_UNDO
代表撤销操作,0代表阻塞操作- 当
sem_flg
设置为IPC_NOWAIT
时,如果信号量操作(如sem_op
中的减法操作使得信号量的值小于 0)不能立即执行,操作不会阻塞等待信号量状态改变,而是立即返回一个错误,错误码通常为EAGAIN
。这种方式适用于不希望进程在信号量操作上长时间阻塞的场景,例如在一些对实时性要求较高的应用中,当获取不到信号量时可以先去执行其他任务。 - 当
sem_flg
设置为SEM_UNDO
时,系统会记录信号量操作,以便在进程异常终止时自动撤销(调整)信号量的值,以避免信号量状态被错误地锁定或者资源无法释放的情况。例如,如果一个进程对信号量进行了P
操作(减操作)获取资源后异常终止,没有来得及进行V
操作(加操作)释放资源,设置了SEM_UNDO
的信号量系统会自动进行适当的调整,保证信号量状态的正确性。 - 当
sem_flg
为 0 时,信号量操作会按照正常的阻塞方式执行。对于P
操作(sem_op
为负数),如果信号量的值不够减,进程会被阻塞,直到信号量的值满足操作要求(例如其他进程进行了V
操作增加了信号量的值)。这种方式在需要确保资源按照顺序被访问和操作,且允许进程等待资源可用的场景下非常有用,比如在经典的生产者 - 消费者模型中,消费者进程等待生产者生产出产品(通过信号量控制),此时使用阻塞式操作可以保证消费者在没有产品时等待,直到生产者生产出产品后再继续执行。
num_sem_ops
:指定要执行的操作个数,即sem_ops
数组中元素的个数。semop
函数对sem_ops
数组参数中的每个成员按数组顺序依次执行操作,且该过程是原子操作,以避免别的进程在同一时刻按不同顺序对该信号集中的信号量执行semop
函数导致的竞态条件。
返回值
semop
成功返回0,失败返回-1并设置errno。
sem_op值的不同操作规则:
- 当
sem_op
大于 0 时,表示进程要增加信号量的值。操作要求调用进程对被操作信号量集拥有写权限。若设置了SEM_UNDO
标志,系统将更新进程的semadj
变量。 - 当
sem_op
等于 0 时,表示这是一个 “等待 0” 操作。操作要求调用进程对被操作信号量集拥有读权限。如果信号量的值为 0,调用立即成功;如果不是 0,则操作失败或阻塞进程直到信号量变为 0。在这种情况下,当IPC_NOWAIT
标志被指定时,操作立即返回一个错误,并设置errno
为EAGAIN
。若未指定IPC_NOWAIT
标志,信号量的semncnt
值加 1,进程将被投入睡眠直到满足特定条件。 - 当
sem_op
小于 0 时,表示对信号量值进行减操作,即期望获得信号量。操作要求调用进程对被操作信号量集拥有写权限。如果信号量的值semval
大于或等于sem_op
的绝对值,操作成功,调用进程立即获得信号量,并且系统将该信号量的semval
值减去sem_op
的绝对值。若设置了SEM_UNDO
标志,则系统将更新进程的semadj
变量。
4.semctl
系统调用
semctl
系统调用允许调用者对信号量进行直接控制:
1 |
|
参数:
sem_id
参数是由semget
调用返回的信号量集标识符,用于指定被操作的信号量集。sem_num
参数指定被操作的信号量在信号量集中的编号。command
参数指定要执行的命令,有些命令需要调用者传递第 4 个参数。
第四个参数可以自定义,但是系统给出了推荐的定义格式:
1 | union semun |
1 | struct seminfo |
返回值:
semctl
成功时的返回值取决于command
参数,失败时返回 - 1,并设置errno
。
注意事项
在GETNCNT
、GETPID
、GETVAL
、GETZCNT
和SETVAL
操作中,操作的是单个信号量,此时sem_num
参数指定单个信号量在信号量集中的编号。而其他操作针对的是整个信号量集,此时sem_num
参数被忽略。
5.特殊键值 IPC_PRIVATE
semget
的调用者可以给其key
参数传递一个特殊键值IPC_PRIVATE
(其值为0),这样无论该信号量是否已存在,semget
函数都将创建一个新信号量,使用该键值创建的信号量并非像它的名字声称的那样是进程私有的,其他进程,尤其是子进程,也有方法来访问这个信号量,所以semget
函数的man手册的BUGS部分上说,使用名字IPC_PRIVATE
有些误导(历史原因),应称为IPC_NEW
。
6.信号量实现进程间通信
1 |
|
总体功能:
生产者生产一个,消费者拿一个,然后再生产,然后再消费
1. 数据结构定义
定义了ListNode
结构体来表示链表的节点,包含一个int
类型的数据成员用于存放生产者生产的数据,以及一个指向下一个节点的指针成员。
2. 信号量操作相关部分
union semun
结构体:用于给semctl
函数传递参数,根据不同的命令可以传递不同类型的值,在这里主要用于初始化信号量的值。semaphore_op
函数:封装了semop
函数,用于对信号量进行操作。它接受信号量集标识符、信号量编号以及操作值作为参数,构造struct sembuf
结构体并调用semop
函数来执行信号量操作,操作失败时会输出错误信息并终止程序。
3. 生产者逻辑
- 在
producer
函数中,首先定义了链表头指针head
,并在循环中不断生产数据。每次生产时,先通过malloc
函数申请一个新的链表节点内存空间,将数据存入节点。 - 然后通过
semaphore_op
函数获取空闲缓冲区信号量(这里代表链表中可插入新节点的空位),接着将新节点插入到链表头部(简单实现了链表插入操作,实际可根据需求调整插入逻辑),再通过semaphore_op
函数增加产品信号量,表示链表中有新的数据可供消费者消费,最后通过sleep
函数模拟生产过程的时间间隔。
4. 消费者逻辑
- 在
consumer
函数中,通过循环不断尝试消费数据。首先通过semaphore_op
函数获取产品信号量,只有当链表中有数据(信号量值大于等于 1)时才能继续执行。 - 接着取出链表头节点,将其数据打印出来模拟消费过程,然后释放该节点占用的内存空间,最后通过
semaphore_op
函数增加空闲缓冲区信号量,表示链表腾出了一个空位可供生产者插入新节点,同样通过sleep
函数模拟消费过程的时间间隔。
5. 主函数部分
- 在
main
函数中,通过semget
函数创建包含两个信号量的信号量集,分别用于控制空闲缓冲区(链表插入空位)和产品(链表中可消费的数据)。 - 使用
semctl
函数结合union semun
结构体来初始化这两个信号量的初始值。 - 通过
fork
函数创建子进程,子进程执行consumer
函数作为消费者,父进程执行producer
函数作为生产者。 - 最后在程序结束时,通过
semctl
函数删除信号量集,释放相关系统资源。
这样就通过 System V IPC 信号量实现了一个基于链表作为共享数据结构的生产者 - 消费者模型,确保了生产者和消费者对链表的并发访问是安全有序的。
运行结果:
7.无血缘关系进程通信
ftok
函数的定义和功能- 函数原型:
key_t ftok(const char *pathname, int proj_id);
- 功能:
ftok
函数用于生成一个唯一的key
(键值),这个key
通常用于 System V IPC(进程间通信)机制中,如创建共享内存、消息队列和信号量集等。它将一个文件路径名(pathname
)和一个项目标识符(proj_id
)组合起来,生成一个适合作为 System V IPC 资源标识符的key
值。
- 函数原型:
- 参数解释
const char *pathname
:- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
."
)或者一个程序相关的配置文件路径等。ftok
函数会使用文件的inode
(索引节点)信息作为生成key
的一部分。 - 注意,如果文件被删除然后重新创建,即使文件名相同,
inode
可能会改变,这会导致ftok
生成不同的key
值。
- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
int proj_id
:- 这是一个
0 - 255
之间的整数,作为项目标识符。它和文件路径的inode
信息一起组合生成key
。不同的项目可以使用不同的proj_id
来区分,这样即使基于同一个文件路径,不同的项目也能生成不同的key
值用于各自的 IPC 资源。例如,一个程序中有两个不同的模块需要使用消息队列进行通信,它们可以使用相同的文件路径但不同的proj_id
来生成不同的key
,以创建两个独立的消息队列。
- 这是一个
- 返回值
- 成功时,
ftok
函数返回一个key_t
类型的非负整数,这个整数可以作为shmget
、msgget
、semget
等 System V IPC 函数的key
参数来创建或获取对应的 IPC 资源。 - 失败时,返回-1,并且会设置errno来指示错误原因。常见的错误原因包括:
EACCESS
:没有权限访问pathname
指定的文件。ENOENT
:pathname
指定的文件不存在。
- 成功时,
发送端
1 |
|
读入端
1 |
|
运行结果:
8.共享内存
共享内存的基本原理
- 共享内存是一种进程间通信(IPC)机制。当多个进程需要共享数据时,可以创建一块共享内存区域。通过
shmget
函数创建共享内存段后,这块内存区域就存在于系统的内存空间中。 - 然后,各个进程可以通过
shmat
函数将这块共享内存连接到自己的进程地址空间。连接后,进程就可以像访问自己的本地内存一样访问共享内存中的数据。
共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输,这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件,因此,共享内存通常和其他进程间通信方式一起使用。
Linux共享内存的API都定义在sys/shm.h
头文件中,包括4个系统调用shmget
、shmat
、shmdt
、shmctl
。
1.shmget
系统调用
shmget
系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。
1 |
|
参数详解
key_t key
:- 这是一个键值,用于标识共享内存段。它可以是由
ftok
函数生成的值,也可以是IPC_PRIVATE
(用于创建私有共享内存段,通常在父子进程间使用)。ftok
函数根据一个文件路径和一个项目标识符生成一个唯一的key_t
值。
- 这是一个键值,用于标识共享内存段。它可以是由
size_t size
:- 这个参数指定了要创建或获取的共享内存段的大小,单位是字节。如果是创建新的共享内存段,这个大小必须大于 0。如果
shmget
函数用于获取已存在的共享内存段,这个参数通常可以被忽略(但仍需提供一个合理的值)。
- 这个参数指定了要创建或获取的共享内存段的大小,单位是字节。如果是创建新的共享内存段,这个大小必须大于 0。如果
int shmflg
:- 这个参数用于控制共享内存段的创建和访问权限,它由以下几种标志组成:
IPC_CREAT
:如果共享内存段不存在,则创建它。如果和IPC_EXCL
一起使用(IPC_CREAT | IPC_EXCL
),则只有在共享内存段不存在时才创建,若已存在则shmget
函数返回 - 1 并设置errno
为EEXIST
。IPC_EXCL
:与IPC_CREAT
一起使用,用于确保创建的是一个新的、唯一的共享内存段。- 权限标志:如
0666
等,用于指定共享内存段的访问权限,格式与文件权限相同(用户、组、其他用户的读、写、执行权限)。 SHM_HUGETLB
标志:当在shmflg
参数中使用SHM_HUGETLB
标志时,表示请求使用大页(huge pages)来分配共享内存。大页是一种内存管理技术,它使用比标准内存页更大的页面大小(通常为 2MB 或 1GB,取决于系统配置)。SHM_NORESERVE
标志:SHM_NORESERVE
标志用于在创建共享内存时,不预留交换空间(swap space)。通常情况下,当创建共享内存段时,系统会为其预留相应的交换空间,以确保在内存不足时可以将部分数据交换到磁盘上。使用SHM_NORESERVE
可以避免这种交换空间的预留。这样,当物理内存不足时,对该共享内存执行写操作将触发SIGESV信号。
- 这个参数用于控制共享内存段的创建和访问权限,它由以下几种标志组成:
返回值
成功时,
shmget
函数返回一个非负整数,即共享内存段的标识符(shmid
)。这个标识符可以用于后续的shmat
(连接共享内存段到进程地址空间)、shmdt
(从进程地址空间分离共享内存段)和shmctl
(控制共享内存段的操作,如删除、获取状态等)操作。失败时,函数返回 - 1,并设置errno
变量来指示错误原因。常见的错误原因包括:
EINVAL
:size
小于SHMMIN
(最小共享内存大小)或者key
无效。EEXIST
:当使用IPC_CREAT | IPC_EXCL
标志且共享内存段已经存在时。ENOENT
:当key
对应的共享内存段不存在且没有使用IPC_CREAT
标志时。
如果 shmget 用于创建共享内存,则这段共享内存的所有字节都被初始化为 0,与之关联的内核数据结构 shmid_ds 将被创建并初始化。shmid_ds 结构体的定义如下:
1 | struct shmid_ds |
shmget 对 shmid_ds 结构体的初始化包括:
- 将 shm_perm.cuid 和 shm_perm.uid 设置为调用进程的有效用户 ID。
- 将 shm_perm.cgid 和 shm_perm.gid 设置为调用进程的有效组 ID。
- 将 shm_perm.mode 的最低 9 位设置为 shmflg 参数的最低 9 位。
- 将 shm_segsz 设置为 size。
- 将 shm_lpid、shm_nattach、shm_atime、shm_dtime 设置为 0。
- 将 shm_ctime 设置为当前的时间。
2.shmat
和 shmdt
系统调用
共享内存被创建/获取后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中,使用完共享内存后,我们也需要将它从进程地址空间中分离,这两项任务分别由以下两个系统调用实现:
1 |
|
1.shamt
1 | void* shmat(int shm_id, const void* shm_addr, int shmflg); |
功能:
- 将由
shm_id
标识的共享内存段连接到调用进程的地址空间。连接成功后,进程可以像访问普通内存一样访问共享内存中的数据。
- 将由
参数:
shm_id
:- 这是共享内存段的标识符,由
shmget
函数成功创建或获取共享内存段时返回。它唯一标识了一个共享内存段。
- 这是共享内存段的标识符,由
shm_addr
:- 用于指定连接共享内存段的地址。
- 如果
shm_addr
为NULL
,则由系统选择合适的连接地址。 - 如果
shm_addr
不为NULL
,并且shmflg
中没有设置SHM_RND
标志,那么共享内存段将被连接到shm_addr
所指定的地址。 - 如果
shm_addr
不为NULL
,并且shmflg
中设置了SHM_RND
标志,那么连接的地址将是shm_addr
向下舍入到系统页面大小(page - size)的整数倍的地址。
shmflg
:- 包含一些控制连接操作的标志。
SHM_RND
:如上述,用于对连接地址进行舍入操作。SHM_RDONLY
:如果设置了这个标志,那么共享内存段将以只读方式连接到进程地址空间。如果不设置此标志,共享内存段将以读写方式连接。
返回值:
成功时,返回指向连接后的共享内存段在进程地址空间中的起始地址的指针。并修改shmid_ds中的字段
- 将 shm_nattach 加 1。
- 将 shm_lpid 设置为调用进程的 PID。
- 将 shm_atime 设置为当前时间。
失败时,返回(void *)-1,并且会设置errno
来指示错误原因。常见的错误原因包括:
EINVAL
:shm_id
无效,或者shm_addr
和shmflg
的组合无效。ENOMEM
:没有足够的内存来连接共享内存段。
shm_id
参数是由shmget
函数返回的共享内存标识符。shm_addr
参数指定将共享内存关联到进程的哪块地址空间,最终效果还受到shmflg
参数的可选标志SHM_RND
的影响。
如果 shm_addr 为 NULL,则被关联的地址由操作系统选择。这是推荐的做法,以确保代码的可移植性。
如果 shm_addr 非空,并且 SHM_RND 标志未被设置,则共享内存被关联到 addr 指定的地址处。
如果 shm_addr 非空,并且设置了 SHM_RND 标志,则被关联的地址是 [shm_addr-(shm_addr % SHMLBA)]。SHMLBA 的含义是 “段低端边界地址倍数”(Segment Low Boundary Address Multiple),它必须是内存页面大小 (PAGE_SIZE) 的整数倍。现在的 Linux 内核中,它等于一个内存页大小。SHM_RND 的含义是圆整 (round),即将共享内存被关联的地址向下圆整到离 shm_addr 最近的 SHMLBA 的整数倍地址处。
除了 SHM_RND 标志外,shmflg 参数还支持如下标志:
SHM_RDONLY。进程仅能读取共享内存中的内容。若没有指定该标志,则进程可同时对共享内存进行读写操作(当然,这需要在创建共享内存的时候指定其读写权限)。
SHM_REMAP。如果 shmaddr 已经被关联到一段共享内存上,则重新关联。
SHM_EXEC。它指定对共享内存段的执行权限。对于共享内存的执行权限,执行权限和读权限是一样的。
2. shmdt
1 | int shmdt(const void* shm_addr); |
功能:
将之前由
shmat
连接到进程地址空间的共享内存段分离。分离操作并不会删除共享内存段本身,只是将该共享内存段从调用进程的地址空间中移除。shm_addr
:- 这是共享内存段在进程地址空间中的起始地址,即
shmat
函数成功连接共享内存段时返回的地址。
- 这是共享内存段在进程地址空间中的起始地址,即
成功时,返回
0
。失败时,返回-1,并且会设置errno来指示错误原因。成功调用会修改内核数据结构 shmid_ds 的部分字段,如下:
将 shm_nattach 减 1。
将 shm_lpid 设置为调用进程的 PID。
将 shm_dtime 设置为当前时间。
常见的错误原因包括:
EINVAL
:shm_addr
不是有效的共享内存段地址。
shmdt
函数将关联到shm_addr
参数处的共享内存从进程中分离,它成功时返回0,失败则返回-1并设置errno。
调用该函数使得它从进程空间分离的含义
- 不删除本身:
- 共享内存段在系统内存中是独立存在的。即使一个进程不再需要使用它,这个共享内存段本身并不会消失。这是因为其他进程可能还在使用这块共享内存进行数据交互。例如,有进程 A、B、C 都连接到了同一块共享内存。如果进程 A 调用
shmdt
,只是进程 A 不再能访问这块共享内存,但进程 B 和 C 仍然可以正常访问,共享内存段本身依然存在于系统内存中。
- 共享内存段在系统内存中是独立存在的。即使一个进程不再需要使用它,这个共享内存段本身并不会消失。这是因为其他进程可能还在使用这块共享内存进行数据交互。例如,有进程 A、B、C 都连接到了同一块共享内存。如果进程 A 调用
- 从进程地址空间移除:
- 在进程调用
shmdt
之前,共享内存段是映射到该进程的地址空间中的。这意味着进程可以直接通过指针访问共享内存中的数据。当调用shmdt
后,这个映射关系就被解除了。就好像在进程的 “视野” 中,这块共享内存消失了。虽然共享内存还在系统中,但该进程已经无法再通过之前的指针去访问其中的数据了。这就是所谓的从进程地址空间移除。
- 在进程调用
3.shmctl 系统调用
shmctl
系统调用控制共享内存的某些属性。
1 |
|
函数功能
shmctl
函数用于对共享内存段进行控制操作,如获取共享内存段的状态信息、设置共享内存段的属性、删除共享内存段等。
参数详解
int shm_id
:- 这是共享内存段的标识符,由
shmget
函数成功创建或获取共享内存段时返回。它唯一标识了一个共享内存段,shmctl
函数将对这个标识符所对应的共享内存段进行操作。
- 这是共享内存段的标识符,由
int command
:(见下表)- 这是一个控制命令,用于指定对共享内存段进行何种操作,常见的命令有:
IPC_STAT
:获取共享内存段的状态信息,并将其存储到buf
所指向的struct shmid_ds
结构体中。这个结构体包含了共享内存段的各种属性,如操作权限、大小、最后访问时间等。IPC_SET
:根据buf
所指向的struct shmid_ds
结构体中的信息来设置共享内存段的属性。例如,可以修改共享内存段的操作权限等。IPC_RMID
:删除由shm_id
标识的共享内存段。这是一个非常重要且具有危险性的操作,一旦执行,共享内存段及其所包含的数据将被永久删除,所有关联到该共享内存段的进程将无法再访问它。
- 除了上述常见命令外,还有一些其他命令,如
SHM_LOCK
(锁定共享内存段到物理内存,防止被交换到磁盘)和SHM_UNLOCK
(解锁共享内存段,允许其被交换到磁盘)等,但这些命令的可用性可能取决于操作系统的支持。
- 这是一个控制命令,用于指定对共享内存段进行何种操作,常见的命令有:
struct shmid_ds* buf
:这是一个指向struct shmid_ds结构体的指针,其作用取决于command
参数的值:
- 当
command
为IPC_STAT
时,buf
用于存储获取到的共享内存段的状态信息。 - 当
command
为IPC_SET
时,buf
指向的结构体中的信息将被用于设置共享内存段的属性。 - 如果
command
不涉及IPC_STAT
或IPC_SET
操作(如IPC_RMID
),buf
通常可以设置为NULL
。
- 当
返回值(见下表)
成功时:
- 如果
command
是IPC_STAT
或IPC_SET
,返回0
表示操作成功。 - 如果
command
是IPC_RMID
,返回0
表示共享内存段已成功删除。
- 如果
失败时:
返回-1,并设置errno
变量来指示错误原因。常见的错误原因包括:
EINVAL
:shm_id
无效,或者command
参数无效,或者buf
指向的结构体无效(在IPC_STAT
或IPC_SET
操作时)。EPERM
:调用进程没有足够的权限来执行请求的操作(例如,没有权限删除共享内存段或修改其属性)。
4.函数接口使用
1 |
|
5.共享内存的 POSIX 方法
mmap函数和munmap函数利用mmap
函数的MAP_ANONYMOUS
标志可以实现父、子进程间的匿名内存共享。通过打开同一个文件,mmap
也可以实现无关进程之间的内存共享。
Linux提供了另一种在无关进程间共享内存的方式,这种方式无须任何文件的支持,但它需要先用shm_open
函数来创建或打开一个POSIX共享内存对象。
1.shm_open
1 |
|
shm_open
函数的使用方法与open
系统调用完全相同。
- 功能:
- 用于创建或打开一个共享内存对象。这个函数是基于 POSIX 标准的,提供了一种可移植的方式来操作共享内存。
- 参数:
const char* name
:- 这是共享内存对象的名字。从可移植性的角度考虑,名字应该遵循特定格式:以
/
开始,后面跟着多个非/
字符,并且以\0
结尾,长度通常不超过NAME_MAX
(一般为 255)。例如"/my_shared_memory"
。
- 这是共享内存对象的名字。从可移植性的角度考虑,名字应该遵循特定格式:以
int oflag
:- 用于指定打开共享内存对象的方式,它是以下标志的按位或组合:
O_RDONLY
:以只读方式打开共享内存对象。如果只指定了这个标志,那么进程只能从共享内存中读取数据。O_RDWR
:以可读可写方式打开共享内存对象,这是最常用的方式之一,允许进程对共享内存进行读写操作。O_CREAT
:如果共享内存对象不存在,则创建它。当使用这个标志时,mode
参数用于指定新创建的共享内存对象的访问权限(类似文件权限)。O_EXCL
:通常与O_CREAT
一起使用。如果name
指定的共享内存对象已经存在,shm_open
调用将返回错误(-1
);如果不存在,则创建一个新的共享内存对象。这可以用于确保创建的是一个全新的、未被使用过的共享内存对象。O_TRUNC
:如果共享内存对象已经存在,将其截断(长度变为 0)。这个操作会丢失共享内存对象中原先存储的数据。
- 用于指定打开共享内存对象的方式,它是以下标志的按位或组合:
mode_t mode
:- 当
oflag
中包含O_CREAT
标志时,mode
参数用于指定共享内存对象的访问权限。它类似于文件权限,例如0666
表示用户、组和其他用户都有读写权限。权限的低 9 位用于设置实际的权限。
- 当
返回值:
- 返回值:
- 成功时,返回一个非负整数,即共享内存对象的文件描述符。这个文件描述符可以用于后续的操作,如
mmap
(将共享内存映射到进程地址空间)等操作。 - 失败时,返回-1,并且会设置errno来指示错误原因。常见的错误原因包括:
EEXIST
:当O_CREAT
和O_EXCL
一起使用且共享内存对象已经存在时。EINVAL
:name
参数不符合格式要求,或者oflag
参数无效。EACCES
:没有足够的权限按照oflag
和mode
指定的方式创建或打开共享内存对象。
- 成功时,返回一个非负整数,即共享内存对象的文件描述符。这个文件描述符可以用于后续的操作,如
2.shm_unlink
和打开的文件最后需要关闭一样,由shm_open
函数创建的共享内存对象用完后也需要删除,可通过shm_unlink
函数实现。
1 |
|
功能:
- 用于删除一个共享内存对象。当一个进程调用
shm_unlink
后,共享内存对象会被标记为删除,但实际的删除操作会在所有进程都关闭了对该共享内存对象的引用(通过shm_open
打开得到的文件描述符都被关闭)后才会执行。
- 用于删除一个共享内存对象。当一个进程调用
参数:
shm_unlink
函数将name
参数指定的共享内存对象标记为等待删除,当所有使用该共享内存对象的进程都使用munmap
函数将它从进程中分离后,系统将销毁这个共享内存对象所占据的资源。
返回值:
成功时,返回
0
。失败时,返回-1,并且会设置errno来指示错误原因。常见的错误原因包括:
EINVAL
:name
参数不符合格式要求。ENOENT
:指定的共享内存对象不存在。
如果代码中使用了以上POSIX共享内存函数,则编译时需要指定链接选项-lrt
。
3.使用案例
1 |
|
代码解释
- 首先,使用
shm_open
创建或打开一个名为/my_shared_memory
的共享内存对象,权限为0666
,并以读写方式打开。 - 然后,使用
ftruncate
设置共享内存对象的大小为MEMORY_SIZE
(这里定义为 4096 字节)。 - 接着,使用
mmap
将共享内存映射到进程的地址空间,以便可以像访问普通内存一样访问共享内存。 - 之后,向共享内存中写入了一个字符串
Hello, shared memory!
。 - 再然后,使用
munmap
解除共享内存的映射。 - 接着,使用
close
关闭共享内存对象的文件描述符。 - 最后,使用
shm_unlink
删除共享内存对象。
4.实现生产者消费者模型
使用共享内存实现生产者 - 消费者模型(基于shm_open
和shm_unlink
函数)
- 以下是使用
shm_open
和shm_unlink
函数实现的生产者 - 消费者模型的 C++ 代码示例:
1 |
|
整体代码解释:
- 共享内存结构体定义:
- 定义了
SharedMemory
结构体,包含一个整数数组作为缓冲区,以及in
和out
索引,用于生产者和消费者操作。
- 定义了
- 生产者函数:
- 通过
mmap
将共享内存映射到进程地址空间。 - 使用
sem_wait
等待empty_sem
信号量(表示缓冲区有空闲位置),生产一个随机数放入缓冲区,更新in
索引,然后使用sem_post
释放full_sem
信号量(表示缓冲区有数据可供消费)。
- 通过
- 消费者函数:
- 同样通过
mmap
映射共享内存。 - 使用
sem_wait
等待full_sem
信号量,从缓冲区取出数据,更新out
索引,然后使用sem_post
释放empty_sem
信号量。
- 同样通过
- 主函数:
- 使用
shm_open
创建共享内存对象,并设置大小。 - 创建
empty_sem
和full_sem
两个信号量,分别用于控制缓冲区的空闲和满状态。 - 映射共享内存后初始化
in
和out
索引。 - 创建生产者和消费者线程并等待它们结束。
- 最后关闭并删除信号量,解除共享内存映射,关闭并删除共享内存对象。
- 使用
5.无血缘关系的进程之间通信
写入端
1 |
|
读出端
1 |
|
运行结果:
6.共享内存实例–实现生产者消费者模型
使用 System V IPC 的共享内存实现生产者 - 消费者模型
- 以下是使用 System V IPC 的
shmget
、shmat
、shmdt
和shmctl
函数实现生产者 - 消费者模型的 C 代码:
1 |
|
整体代码解释:
- 共享内存结构体定义:
- 定义了
SharedMemory
结构体,包含一个整数数组作为缓冲区,以及in
和out
索引,用于生产者和消费者操作。
- 定义了
- 信号量操作函数:
semaphore_op
函数用于对信号量进行操作,实现P
操作(sem_op
为负,获取资源)和V
操作(sem_op
为正,释放资源)。
- 生产者函数:
- 通过
shmat
将共享内存连接到进程地址空间。 - 使用
semaphore_op
等待空闲缓冲区信号量(semid
的第 0 个信号量),生产一个随机数放入缓冲区,更新in
索引,然后使用semaphore_op
释放产品信号量(semid
的第 1 个信号量)。
- 通过
- 消费者函数:
- 同样通过
shmat
连接共享内存。 - 使用
semaphore_op
等待产品信号量,从缓冲区取出数据,更新out
索引,然后使用semaphore_op
释放空闲缓冲区信号量。
- 同样通过
- 主函数:
- 使用
shmget
创建共享内存,并使用semget
创建信号量集。 - 初始化信号量,一个表示空闲缓冲区数量,一个表示产品数量。
- 使用
fork
创建子进程,子进程作为消费者,父进程作为生产者。 - 最后等待子进程结束,并使用
shmctl
和semctl
删除共享内存和信号量集。
- 使用
7.无血缘关系的进程之间通信
ftok
函数的定义和功能- 函数原型:
key_t ftok(const char *pathname, int proj_id);
- 功能:
ftok
函数用于生成一个唯一的key
(键值),这个key
通常用于 System V IPC(进程间通信)机制中,如创建共享内存、消息队列和信号量集等。它将一个文件路径名(pathname
)和一个项目标识符(proj_id
)组合起来,生成一个适合作为 System V IPC 资源标识符的key
值。
- 函数原型:
- 参数解释
const char *pathname
:- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
."
)或者一个程序相关的配置文件路径等。ftok
函数会使用文件的inode
(索引节点)信息作为生成key
的一部分。 - 注意,如果文件被删除然后重新创建,即使文件名相同,
inode
可能会改变,这会导致ftok
生成不同的key
值。
- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
int proj_id
:- 这是一个
0 - 255
之间的整数,作为项目标识符。它和文件路径的inode
信息一起组合生成key
。不同的项目可以使用不同的proj_id
来区分,这样即使基于同一个文件路径,不同的项目也能生成不同的key
值用于各自的 IPC 资源。例如,一个程序中有两个不同的模块需要使用消息队列进行通信,它们可以使用相同的文件路径但不同的proj_id
来生成不同的key
,以创建两个独立的消息队列。
- 这是一个
- 返回值
- 成功时,
ftok
函数返回一个key_t
类型的非负整数,这个整数可以作为shmget
、msgget
、semget
等 System V IPC 函数的key
参数来创建或获取对应的 IPC 资源。 - 失败时,返回-1,并且会设置errno来指示错误原因。常见的错误原因包括:
EACCESS
:没有权限访问pathname
指定的文件。ENOENT
:pathname
指定的文件不存在。
- 成功时,
发送端
1 |
|
接收端
1 |
|
运行结果:
9.消息队列
1.消息队列进程间通信原理
System V IPC 消息队列是一种进程间通信(IPC)机制,它允许不同进程通过发送和接收消息来进行通信。消息队列就像是一个邮箱系统,进程可以将消息(信件)发送到队列(邮箱)中,其他进程可以从这个队列中接收消息。
消息队列是在两个进程间传递二进制数据块的方式,每个数据块都有一个特定类型,接收方可以根据类型来有选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。
原理:
多个进程通过共享消息队列的标识符(msqid
)来访问同一个消息队列。发送进程将消息放入消息队列后,消息队列会按照一定的规则(如先进先出)存储这些消息。接收进程可以根据消息类型等条件从消息队列中取出消息。这样,不同进程之间就可以通过消息队列进行数据传输和通信,实现进程间的同步和信息共享。例如,在生产者 - 消费者模型中,生产者进程将生产的数据作为消息发送到消息队列,消费者进程从消息队列中接收消息并进行消费,通过消息类型等机制可以确保消息的正确发送和接收,从而实现生产者和消费者之间的协调工作。
Linux消息队列的API都定义在sys/msg.h
头文件中,包括4个系统调用:msgget
、msgsnd
、msgrcv
、msgctl
。
2.msgget 系统调用
msgget
系统调用创建一个消息队列,或获取一个已有的消息队列:
1 |
|
- 参数:
key_t key
:- 这是一个键值,用于标识一个全局唯一的消息队列。可以通过
ftok
函数生成一个唯一的key
值,或者使用IPC_PRIVATE
来创建一个私有消息队列(通常用于具有亲缘关系的进程,如父子进程)。
- 这是一个键值,用于标识一个全局唯一的消息队列。可以通过
int msgflg
:- 用于控制消息队列的创建和访问权限,它由以下几种标志组成:
IPC_CREAT
:如果消息队列不存在,则创建它。如果和IPC_EXCL
一起使用(IPC_CREAT | IPC_EXCL
),则只有在消息队列不存在时才创建,若已存在则msgget
函数返回 - 1 并设置errno
为EEXIST
。权限标志
:如0666
等,用于指定消息队列的访问权限,格式与文件权限相同(用户、组、其他用户的读、写、执行权限)。
- 用于控制消息队列的创建和访问权限,它由以下几种标志组成:
- 返回值:
- 成功时,返回一个非负整数,即消息队列的标识符(
msqid
)。 - 失败时,函数返回 - 1,并设置
errno
变量来指示错误原因,例如EEXIST
(当IPC_CREAT | IPC_EXCL
且队列已存在时)、ENOENT
(当没有IPC_CREAT
且队列不存在时)等。
- 成功时,返回一个非负整数,即消息队列的标识符(
如果它用于创建消息队列的话,与之相关的内核数据结构msqid_ds将被创建并初始化。
1 | struct msqid_ds |
3.msgsnd 系统调用
msgsnd
系统调用将一条消息添加到消息队列中:
1 |
|
参数:
int msqid
:
消息队列的标识符,由
msgget
函数返回。const void* msg_ptr
:指向要发送消息的指针。消息的结构必须以一个长整型成员变量开始,这个长整型变量用于存放消息类型,后面可以跟随消息的实际数据。
msg_ptr
参数指向一个准备发送的消息,消息被定义为如下类型:c1
2
3
4struct msgbuf{
long mtype; /* 消息类型 */
char mtext[512]; /* 消息数据 */
};
size_t msg_sz
:- 这是消息数据部分的大小,不包括消息类型的长整型变量所占的字节数。
int msgflg
:- 控制消息发送的行为,和semget的flag一样的,常用的标志有:
0
:表示阻塞发送,如果消息队列已满,则发送进程会阻塞,直到有空间可以发送消息。IPC_NOWAIT
:表示非阻塞发送,如果消息队列已满,则msgsnd
函数立即返回 - 1,并设置errno
为EAGAIN
。
- 控制消息发送的行为,和semget的flag一样的,常用的标志有:
返回值:
- 成功时,返回
0
。 - 失败时,返回 - 1,并设置
errno
来指示错误原因,如EAGAIN
(非阻塞发送时队列已满)、EINVAL
(参数无效)、EIDRM
(消息队列已被删除)等。
处于阻塞状态的msgsnd调用可能被如下两种异常情况所中断:
消息队列被移除。此时msgsnd调用将立即返回并设置errno为EIDRM。
程序接收到信号。此时msgsnd调用将立即返回并设置errno为EINTR。
msgsnd成功时将修改内核数据结构msqid_ds的部分字段,如下所示:
将msg_qnum加1。
将msg_lspid设置为调用进程的PID。
将msg_stime设置为当前的时间。
4.msgrcv 系统调用
msgrcv
系统调用从消息队列中获取消息:
1 |
|
参数:
int msqid
:- 消息队列的标识符。
void* msg_ptr
:- 一个指向接收消息缓冲区的指针。与
msgsnd
类似,缓冲区的结构应以一个长整型开始用于存放接收到的消息类型,后面是存放消息数据的空间。
- 一个指向接收消息缓冲区的指针。与
size_t msg_sz
:- 这是接收消息缓冲区中数据部分的大小。
long int msgtype
:- 指定要接收的消息类型,可以有以下几种取值:
0
:接收(读取)消息队列中的第一条消息,不考虑消息类型。> 0
:接收第一个消息类型等于msgtype
的消息。(除非指定了标志MSG_EXCEPT,见后文)< 0
:接收(读取)消息队列中第一个类型值比msgtype的绝对值小的消息。
- 指定要接收的消息类型,可以有以下几种取值:
int msgflg
:- 控制消息接收的行为,常用标志有:
0
:表示阻塞接收,如果消息队列中没有符合条件的消息,则接收进程会阻塞,直到有符合条件的消息到达。IPC_NOWAIT
:表示非阻塞接收,如果消息队列中没有符合条件的消息,则msgrcv
函数立即返回 - 1,并设置errno
为ENOMSG
。MSG_EXCEPT
。如果msgtype大于0,则接收消息队列中第一个非msgtype类型的消息。MSG_NOERROR
。如果消息数据部分的长度超过了msg_sz,就将它截断。
- 控制消息接收的行为,常用标志有:
返回值:
- 成功时,返回接收到的消息数据部分的字节数。
- 失败时,返回 - 1,并设置
errno
来指示错误原因,如ENOMSG
(非阻塞接收时没有符合条件的消息)、EINVAL
(参数无效)、EIDRM
(消息队列已被删除)等。
处于阻塞状态的msgrcv调用还可能被如下两种异常情况所中断:
消息队列被移除。此时msgrcv调用将立即返回并设置errno为EIDRM。
程序接收到信号。此时msgrcv调用将立即返回并设置errno为EINTR。
msgrcv成功时将修改内核数据结构msqid_ds的部分字段,如下所示:
将msg_qnum减1。
将msg_lrpid设置为调用进程的PID。
将msg_rtime设置为当前的时间。
5.msgctl 系统调用
msgctl
系统调用,用于对消息队列进行控制操作(控制消息队列某些属性),如获取消息队列的状态信息、设置消息队列的属性、删除消息队列等。
1 |
|
参数:
int msqid
:- 消息队列的标识符。
int command
:- 这是一个控制命令,用于指定对消息队列进行何种操作(见下表),常见的命令有:
IPC_STAT
:获取消息队列的状态信息,并将其存储到buf
所指向的struct msqid_ds
结构体中。这个结构体包含了消息队列的各种属性,如操作权限、当前消息数量等。IPC_SET
:根据buf
所指向的struct msqid_ds
结构体中的信息来设置消息队列的属性。例如,可以修改消息队列的操作权限等。IPC_RMID
:删除由msqid
标识的消息队列。这是一个非常重要且具有危险性的操作,一旦执行,消息队列及其所包含的消息将被永久删除。
- 这是一个控制命令,用于指定对消息队列进行何种操作(见下表),常见的命令有:
struct msqid_ds* buf
:- 这是一个指向struct msqid_ds结构体的指针,其作用取决于command参数的值:
- 当
command
为IPC_STAT
时,buf
用于存储获取到的消息队列的状态信息。 - 当
command
为IPC_SET
时,buf
指向的结构体中的信息将被用于设置消息队列的属性。 - 如果
command
不涉及IPC_STAT
或IPC_SET
操作(如IPC_RMID
),buf
通常可以设置为NULL
。
- 当
- 这是一个指向struct msqid_ds结构体的指针,其作用取决于command参数的值:
返回值:
- 成功时,取决于command(见下表)。
- 失败时,返回 - 1,并设置
errno
来指示错误原因,如EINVAL
(msqid
无效,或者command
参数无效,或者buf
指向的结构体无效(在IPC_STAT
或IPC_SET
操作时))、EPERM
(调用进程没有足够的权限来执行请求的操作)等。
6.函数使用案例
以下是使用 System V IPC 消息队列相关函数(msgget
、msgsnd
、msgrcv
和msgctl
)的一个简单 C 语言案例:
1 |
|
代码解释:
消息结构体定义
- 定义了
struct msgbuf
结构体,它包含一个长整型mtype
(用于表示消息类型)和一个字符数组mtext
(用于存储消息内容)。
创建消息队列(msgget
)
- 使用
IPC_PRIVATE
作为key
来创建一个新的私有消息队列,权限设置为0666
(用户、组和其他用户都有读写权限)。如果msgget
调用成功,返回消息队列标识符msqid
;否则,打印错误信息并返回。
发送消息(msgsnd
)
- 初始化
send_msg
结构体,设置mtype
为1
,并将消息内容设置为"Hello, World!"
。 - 使用
msgsnd
函数将消息发送到消息队列中。msgsnd
函数的参数包括消息队列标识符msqid
、消息结构体指针&send_msg
、消息数据部分大小sizeof(send_msg.mtext)
和标志0
(表示阻塞发送,如果队列满则等待)。如果发送失败,打印错误信息并返回。
接收消息(msgrcv
)
- 初始化
recv_msg
结构体。 - 使用
msgrcv
函数从消息队列中接收消息。msgrcv
函数的参数包括消息队列标识符msqid
、接收消息结构体指针&recv_msg
、接收消息数据部分大小sizeof(recv_msg.mtext)
、要接收的消息类型1
和标志0
(表示阻塞接收,如果没有符合条件的消息则等待)。如果接收失败,打印错误信息并返回。 - 接收到消息后,打印出消息内容。
获取消息队列状态(msgctl
)
- 定义
struct msqid_ds
类型的变量buf
。 - 使用
msgctl
函数的IPC_STAT
命令获取消息队列的状态信息,并将其存储在buf
中。如果获取状态失败,打印错误信息并返回。 - 打印出消息队列中的消息数量(
buf.msg_qnum
)。
删除消息队列(msgctl
)
- 使用
msgctl
函数的IPC_RMID
命令删除消息队列。如果删除失败,打印错误信息并返回。
7.实现生产者消费者模型
**消息队列并不能实现互斥。**以下是使用上述消息队列函数实现的生产者 - 消费者模型的 C 代码:
1 |
|
代码解释:
消息结构体定义:
- 定义了
struct msgbuf
结构体,包含一个长整型的mtype
(消息类型)和一个字符数组mtext
(用于存放消息数据)。
生产者函数:
- 生成一个随机数,将其转换为字符串后放入消息结构体的
mtext
中,然后使用msgsnd
将消息发送到消息队列中,发送的消息类型为1
,发送操作是阻塞的(msgflg
为0
)。
消费者函数:
- 使用
msgrcv
从消息队列中接收消息类型为1
的消息,接收操作是阻塞的(msgflg
为0
),接收到消息后打印出消息内容。
主函数:
- 使用
msgget
创建一个私有消息队列。 - 通过
fork
创建子进程,子进程作为消费者,父进程作为生产者。 - 最后等待子进程结束,并使用
msgctl
删除消息队列。
并不能够实现互斥,只是能够通信。
运行结果:
8.无血缘关系进程通信
ftok
函数的定义和功能- 函数原型:
key_t ftok(const char *pathname, int proj_id);
- 功能:
ftok
函数用于生成一个唯一的key
(键值),这个key
通常用于 System V IPC(进程间通信)机制中,如创建共享内存、消息队列和信号量集等。它将一个文件路径名(pathname
)和一个项目标识符(proj_id
)组合起来,生成一个适合作为 System V IPC 资源标识符的key
值。
- 函数原型:
- 参数解释
const char *pathname
:- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
."
)或者一个程序相关的配置文件路径等。ftok
函数会使用文件的inode
(索引节点)信息作为生成key
的一部分。 - 注意,如果文件被删除然后重新创建,即使文件名相同,
inode
可能会改变,这会导致ftok
生成不同的key
值。
- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
int proj_id
:- 这是一个
0 - 255
之间的整数,作为项目标识符。它和文件路径的inode
信息一起组合生成key
。不同的项目可以使用不同的proj_id
来区分,这样即使基于同一个文件路径,不同的项目也能生成不同的key
值用于各自的 IPC 资源。例如,一个程序中有两个不同的模块需要使用消息队列进行通信,它们可以使用相同的文件路径但不同的proj_id
来生成不同的key
,以创建两个独立的消息队列。
- 这是一个
- 返回值
- 成功时,
ftok
函数返回一个key_t
类型的非负整数,这个整数可以作为shmget
、msgget
、semget
等 System V IPC 函数的key
参数来创建或获取对应的 IPC 资源。 - 失败时,返回-1,并且会设置errno来指示错误原因。常见的错误原因包括:
EACCESS
:没有权限访问pathname
指定的文件。ENOENT
:pathname
指定的文件不存在。
- 成功时,
发送端
1 |
|
接收端
1 |
|
运行结果:
10.IPC 命令
以上3种System V IPC进程间通信方式都使用一个全局唯一的键值来描述一个共享资源,当程序调用semget
、shmget
、msgget
时,就创建了这些共享资源的一个实例。Linux提供ipcs
命令来观察当前系统上拥有哪些共享资源实例:
输出结果分段显示了系统拥有的消息队列、共享内存、信号量资源,可见,该系统目前尚未使用任何消息队列和信号量,但分配了一组键值为0的共享内存。这些信号所有者正是apache,它们是由httpd服务器程序创建的。
我们还可用ipcrm
命令删除遗留在系统中的共享资源。
11.在进程间传递文件描述符
fork
调用后,父进程中打开的文件描述符在子进程中仍然保持打开,所以文件描述符可以很方便地从父进程传递到子进程。注意,传递一个文件描述符并不是传递一个文件描述符的值,而是在接收进程中创建一个新的文件描述符,且新文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。
要想在两个不相干的进程之间传递文件描述符,在Linux下,可利用UNIX域socket在进程间传递特殊的辅助数据,以实现文件描述符的传递,下例代码中,子进程中打开一个文件描述符,然后将它传递给父进程,父进程则通过读取该文件描述符来获得文件内容:
1 |
|
结构体 iovec和msghdr
见:Linux高性能服务器编程 | 读书笔记 | 4. 高级 I/O 函数-CSDN博客的通用数据读写函数部分