Linux的进程间通信
目录
进程间通信介绍
进程间通信的概念
主要IPC方式
进程间通信的目的
进程间通信的本质
进程间通信的分类
管道
什么是管道
匿名管道
匿名管道的原理
pipe函数
匿名管道使用步骤
管道读写规则
匿名管道的特点
管道的四种特殊情况
管道的大小
命名管道
命名管道的原理
使用命令创建命名管道
在程序里创建一个命名管道
命名管道的打开规则
unlink删除命名管道
如何保证两个不相关的进程打开同一个命名管道通信
重点!不刷盘
具有inode
命名管道的应用
数据在内核缓冲区上,不刷盘
命名管道与匿名管道的区别
命令行当中的管道
system V进程间通信
System V共享内存
共享内存的基本原理
共享内存数据结构
共享内存的建立与释放
共享内存的创建
共享内存的释放
共享内存的关联
共享内存的去关联
共享内存与管道进行对比
System V消息队列
消息队列的基本原理
消息队列数据结构
消息队列的创建
消息队列的释放
从消息队列获取数据
System V信号量
信号量相关概念
信号量数据结构
信号量凭什么是进程通信一种
本质
二元信号量
原子?
信号量的设计
system V IPC联系
进程间通信介绍
进程间通信的概念
进程间通信(Inter-Process Communication, IPC)是指在不同进程之间传递数据或信号的机制。由于进程拥有独立的地址空间,无法直接访问彼此的内存,因此需要特定的IPC方法来实现数据交换和协调。
主要IPC方式
-
管道(Pipe)
-
匿名管道:用于父子进程或兄弟进程间的单向通信。
-
命名管道(FIFO):允许无亲缘关系的进程通过文件系统进行通信。
-
-
消息队列(Message Queue)
-
进程通过消息队列发送和接收消息,消息按类型存储,支持异步通信。
-
-
共享内存(Shared Memory)
-
多个进程共享同一块内存区域,速度最快,但需要同步机制(如信号量)来避免冲突。
-
-
信号量(Semaphore)
-
用于进程同步,控制对共享资源的访问,防止竞态条件。
-
-
信号(Signal)
-
用于通知进程发生了特定事件,如中断或错误。
-
-
套接字(Socket)
-
适用于网络通信,也可用于同一台机器上的进程间通信。
-
-
文件(File)
-
通过读写文件进行数据交换,适用于持久化存储。
-
进程间通信的目的
进程间通信(IPC)的主要目的是使不同进程能够共享数据、协调任务和同步操作。
-
数据共享:多个进程可能需要访问或修改同一数据,IPC提供了一种安全的方式来实现数据共享。
-
任务协调:多个进程可能需要协同完成一项任务,IPC帮助它们协调工作。
-
资源管理:多个进程可能需要共享有限的系统资源(如CPU、内存、文件等),IPC帮助它们合理分配和调度资源。
-
进程同步:多个进程的执行顺序可能需要协调,IPC提供同步机制(如信号量、锁等)来确保进程按正确顺序执行。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
-
事件通知:一个进程可能需要通知其他进程发生了某些事件,IPC提供了一种机制来发送和接收通知。
进程间通信的本质
让不同的进程看到同一份”资源“
资源是特定的内存空间。其中资源一般是由操作系统提供的。
我们进程访问这个空间,进行通信,本质就是访问操作系统。
由于各个运行进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的吗,是由成本的。
如果说各个进程间如果想要进行通信,一定要向操作系统申请第三方资源。使得进程间可以通过这个资源进行写入与读取,进而实现进程之间的通信。
因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。但由于操作系统不同和板块提供的第三方资源不同,所以通信又可以分很多的类。
进程间通信的分类
管道
- 匿名管道
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
管道
什么是管道
管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个“管道”。
在本质上管道就是基于文件级别的通信方式。
管道又分为匿名管道与命名管道。
管道的命名就是由我们日常生活中的管道命名而来,比如水龙头管道,其有一个特点就是水只能单向流动,所以管道有一个特点就是单向通信。
实际上,Linux就为类Unix操作系统,
例如:统计我们当前使用云服务器上的登录用户个数。
其中,根据我们以前学的,但我们执行一个指令时,其实就是创建一个who命令和wc命令都是两个程序, 当两个程序运行起来后就会变为进程,其中who进程通过标准输出将数据打到“管道”当中,然后wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。
解释:who是用于查看当前云服务器的登录用户(一行显示一个用户) ,wc -l用于统计当前的行数。
匿名管道
匿名管道的原理
匿名管道(Anonymous Pipe)是一种单向的进程间通信(IPC)机制,主要用于具有亲缘关系的进程(如父子进程或兄弟进程)之间的数据传输。它是操作系统提供的一种简单且高效的通信方式。
进程间通信的本质就是,让不同的进程看到同一份资源,其中匿名管道就是为了实现父子进程之间的通信,其原理就是让父子进程先看到同一份被打开的文件资源,又因为管道具有单向通信的特带你,所以父子进程就要对该文件进行写入或是读取操作,进而实现父子进程间通信。
注意:
- 一开始我们也说了要实现通信要借助第三方资源,其中这个第三方资源就是由操作系统提供的。那么这里父子进程看到的同一份文件资源就是由操作系统给予的,维护的。 这意味着父子进程看到的文件资源是相同的。
- 但是写时拷贝是一种优化技术,通常用于内存管理。所以对于文件缓冲区,写时拷贝不适用。父子进程对文件的写入操作会直接修改共享的缓冲区,不会创建副本(不会在创建一个文件缓冲区,单独为子进程使用,原本的文件缓冲区变为单独为父进程使用)。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
pipe函数
#include <unistd.h>
功能:创建一无名管道
原型
int pipe(int fd[2]);
参数
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:
数组元素 | 含义 |
---|---|
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
对于返回值:pipe函数调用成功时返回0,调用失败时返回-1。
匿名管道使用步骤
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
1、父进程调用pipe函数创建管道。
2、父进程调用fork函数创建子进程。 3、父进程关闭写端,子进程关闭读端。
注意:
- 因为管道只能够进行单向通信,因此当父进程创建完子进程后,需要确定父子进程谁读谁写,然后关闭对应的读写端。
- 从管道写端写入的数据会被存储在内核缓冲,如果缓冲区未满,写入操作会立即完成;如果缓冲区已满,写入操作会阻塞,直到有空间可用。
- 当进程从管道的读端读取数据时,数据会从内核缓冲区中取出。如果缓冲区为空,读取操作会阻塞,直到有数据可读。
- 在管道(Pipe)中,文件缓冲区是不发挥作用的。因为数据不需要持久化。
- 在规定上没有明确规定父子进程谁做读端好,谁做写端好。这一点设定可以根据自己要求。
同样,我们可以站在文件描述符的角度再来看看这三个步骤:
1、父进程调用pipe函数创建管道。
2、父进程调用fork函数创建子进程。
3、父进程关闭写端,子进程关闭读端。
例如,在以下代码当中,子进程向匿名管道当中写入10次数据,父进程从匿名管道当中将数据读出。
//child->write, father->read
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>int main()
{int fd[2]; // 0->读 1->写if(pipe(fd) < 0) // 使用pipe函数创建匿名管道{perror("pipe");return 1;}pid_t id = fork(); //使用fork创建子进程if(id == 0){// childclose(fd[0]); // 子进程关闭读端//子进程向管道写入数据const char* msg = "hello father, I am child...";int cnt = 10;while(cnt--){write(fd[1], msg, strlen(msg));sleep(1);}close(fd[1]);exit(0);}// fatherclose(fd[1]); //父进程关闭写端char buff[64];while (1){ssize_t s = read(fd[0], buff, sizeof(buff));if (s > 0){buff[s] = '\0';printf("child send to father:%s\n", buff);}else if (s == 0){printf("read file end\n");break;}else{printf("read error\n");break;}}close(fd[0]);waitpid(id, NULL, 0);return 0;
}
运行效果如下:
管道读写规则
pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:
int pipe2(int pipefd[2], int flags);
与pipe不同的是多了一个参数flags。该参数用于设置选项。
1、当没有数据可读时:
- O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来为止。
- O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
2、当管道满的时候:
- O_NONBLOCK disable:write调用阻塞,直到有进程读走数据。
- O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN。
3、如果所有管道写端对应的文件描述符被关闭,则read返回0。
4、如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5、当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
6、当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性。
匿名管道的特点
下面我有的使用匿名管道有的使用的管道。
如果用管道,那么同样命名管道也具有该特点。
如果用的是匿名管道,那么仅匿名管道有该特点。
1、只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个匿名管道由一个进程创建,然后该进程调用fork,次后父、子进程之间就可以应用该匿名管道。
这一点没有需要特别解释的,但是要注意的是如果看到有人说匿名管道是用于父子进程之间,那么你要知道这句话是错误的,匿名管道是只能用于具有共同祖先的进程(具有亲缘关系的进程)之间。并不是仅仅在父子之间。但同样别人说父子进程使用匿名管道进行通信你也要知道别人说的是正确的,只是不准确而已。
2、管道提供流式服务。
对于进程A写入管道当中的数据,进程B每次从管道读取的数据的多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:
- 流式服务: 数据没有明确的分割,不分一定的报文段。
- 数据报服务: 数据有明确的分割,拿数据按报文段拿。
3、一般而言,进程退出,匿名管道释放,所以匿名管道的生命周期随进程。
匿名管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说匿名管道的生命周期随进程。
4、一般而言,内核会对管道操作进行同步与互斥。
临界资源是指在并发环境中,一次只能被一个进程或线程访问的资源。而管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
临界资源是需要被保护的,若我们不进行任何保护,多个进程同时访问临界资源可能导致数据不一致或系统错误。比如说进程A向其写入了hello linux,但是进程B也向其写入hello world。但当进程A刚写入hello,进程B就向其写入hello world那么就会导致错误。
为了避免这些问题,内核会对管道操作进行同步与互斥:
- 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
- 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作,并且这种顺序在正常情况下是不变的。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
5、管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
这一点也不难理解,因为管道只允许单向通信。如果二者要进行双方通信时,就需要建立起两个管道。
扩展:
在数据通信中,数据在线路上的传送方式可以分为以下三种:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
管道的四种特殊情况
在使用管道时,可能出现以下四种特殊情况:
- 如果管道的写端正常但不写入数据,而读端一直在读取数据,最终管道会变空。此时,读端进程会被阻塞,直到写端写入新数据后,读端进程才会被唤醒。
- 如果管道的读端正常但不读取数据,而写端一直在写入数据,最终管道会变满。此时,写端进程会被放入等待队列,进入睡眠状态。当读端读取数据后,内核会唤醒等待队列中的写端进程。
- 如果管道的写端关闭,而读端一直在读取数据,最终管道会变空。此时,读端进程读取会返回0,表示EOF(文件结束符)。表示没有更多数据可读。读端进程不会被阻塞,而是会正常结束读取操作。
- 如果管道的读端关闭,而写端一直在写入数据,最终管道会变满。此时,写端进程会收到一个SIGPIPE信号,如果写端进程没有处理该信号,进程会终止,当然通常下写端进程终止。如果写端进程忽略或捕获了SIGPIPE信号,写操作会返回-1,并设置errno为EPIPE。
其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。这样的设定就很好的保护了管道文件的数据安全。
第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时操作系统就会认为,读端进程也就没必要浪费资源继续阻塞等待。可以执行该进程的其他逻辑了,但一般来说还是会被结束。
第四种情况也不难理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。
我们可以通过以下代码看看情况四中,子进程退出时究竟是收到了什么信号。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{int fd[2] = { 0 };if (pipe(fd) < 0) //使用pipe创建匿名管道{ perror("pipe");return 1;}pid_t id = fork(); //使用fork创建子进程if (id == 0){//childclose(fd[0]); //子进程关闭读端//子进程向管道写入数据const char* msg = "hello father, I am child...";int count = 10;while (count--){write(fd[1], msg, strlen(msg));sleep(1);}close(fd[1]); //子进程写入完毕,关闭文件exit(0);}//fatherclose(fd[1]); //父进程关闭写端close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)int status = 0;waitpid(id, &status, 0);printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号// 0x7f->1111111 低七位return 0;
}
运行结果显示,子进程退出时收到的是13号信号。
通过kill -l
命令可以查看13对应的具体信号。
由此可知,当发生情况四时,操作系统向子进程发送的是SIGPIPE
信号将子进程终止的。
管道的大小
管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,那么管道的最大容量是多少呢?
方法一:借助man手册
指令:man 7 pipe
根据man手册,看其官方的介绍得知,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。
然后我们可以使用uname -r命令,查看自己使用的Linux版本。
可以看到我的时2.6.11往后的。因此我的管道的最大容量是65536字节。
方法二:使用ulimit命令
其次,我们还可以使用ulimit -a
命令,查看当前资源限制的设定。
根据显示,管道的最大容量是512512 字节的块(512字节的块是单位,实际就为512字节):512 × 512 = 262,144字节。
这个值比65536大。其实这个大小,是用户层面的限制,用于控制单个进程可以使用的管道缓冲区大小。
而65536是默认容量:这是内核层面的限制,表示管道的实际最大容量。
方法三:自行测试
代码如下
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{int fd[2] = { 0 };if (pipe(fd) < 0){ //使用pipe创建匿名管道perror("pipe");return 1;}pid_t id = fork(); // 使用fork创建子进程if (id == 0){//child close(fd[0]);// 子进程关闭读端char c = 'a';int count = 0;// 子进程一直进行写入,一次写入一个字节while (1){write(fd[1], &c, 1);count++;printf("%d\n", count); //打印当前写入的字节数}close(fd[1]);exit(0);}// fatherclose(fd[1]); //父进程关闭写端// 父进程不进行读取waitpid(id, NULL, 0);close(fd[0]);return 0;
}
可以看到,在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被阻塞了,也就是说,我当前Linux版本中管道的最大容量是65536字节。
命名管道
命名管道的原理
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。其中命名管道是一种特殊类型的文件。
注意:
- 普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
- 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。
管道的应用的一个限制就是只能在具有共同祖先的进程间通信?
首先明确的说,这句话是错误的。应该改为匿名管道管道的应用的一个限制就是只能在具有共同祖先的进程间通信。因为管道包括匿名管道和命名管道。也只有匿名管道会限制在有共同祖先的进程间通信。所以看到这句话的时候要知道是错误的!!!
使用命令创建命名管道
命名管道可以在命令行上创建,命令行方法是使用下面这个命令:
mkfifo filename
可以看到,创建出来的文件的类型是p
,代表该文件是命名管道文件。
当然我的是Ubuntu,其中后面文件名还带了一个 | 也在代表着其为命名管道文件。
现在我们简单的设计一个使用场景,使用命名管道进行两个不相关的进程间通信。 我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。
对于进程A的指令:
while :; do echo "hello linux"; sleep 1; done > fifo
对于进程B的指令:
cat fifo
现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。
之前我们在匿名管道中说过,当管道的读端进程退出后, 写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。(后面会解释为什么是命令行解释器bash执行的我们写的shell脚本)
在程序里创建一个命名管道
我们不仅可以在命令行上创建命名管道,我们还可以在程序中调用mkfifo函数接口。mkfifo函数原型如下:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。
- 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
- 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。
注意:
一般来说我们都是使用第二种方式,因为打个比方,如果我们传一个文件名,我们只需要这样,
这会在当前工作目录下创建一个名为
my_fifo
的命名管道文件。mkfifo("my_fifo", 0666);
但我们要使用第一种方法,我们就需要这样使用该函数
mkfifo("/tmp/my_fifo", 0666);
这会在
/tmp
目录下创建一个名为my_fifo
的命名管道文件。而且,如果你只传递了一个目录路径(例如/tmp/
),而没有指定文件名,则会导致错误。除此之外我们还要传完整的路径,可以穿绝对路径,也可以传相对路径。
mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
这个就不解释了,不清楚的可以看一下文件权限。
写文章-CSDN创作中心
mkfifo函数的返回值。
- 命名管道创建成功,返回0。
- 命名管道创建失败,返回-1。
创建命名管道示例:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>#define FILE_NAME "myfifo"int main()
{umask(0); // 将文件默认掩码设置为0if(mkfifo(FILE_NAME, 0666) < 0) // 调用mkfifo创建命名管道{// 失败perror("mkfifo");return 1;}printf("mkfifo success\n");return 0;
}
运行代码后,命名管道myfifo就在当前路径下被创建了。
命名管道的打开规则
1、如果当前打开操作是为读而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
- O_NONBLOCK enable:立刻返回成功。
2、如果当前打开操作是为写而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。、
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。
unlink删除命名管道
调用函数:unlink;这个函数比较简单,可以自己查看man手册
man 2 unlink
调用失败返回-1。成功返回一个非负整数。
如何保证两个不相关的进程打开同一个命名管道通信
对于两个毫不相关的进程,如果说它俩在同一目录下,还好处理,但是如果二者路径上都没有关系,那么我们如何保证二者打开同一个命名管道进行通信,怎么保证二者正常的通信呢?
其实这一点我们可以用路径+文件名来确保唯一性。这样就可以让不同或者说毫不相关的进程之间通信。
当然路径可以使用绝对路径,也可以使用相对路径。一般来说相对路径使用的多。
重点!不刷盘
这一点与匿名管道一样,因为命名管道也是单行通信。也不会刷盘,也就跟我前面的解释一下。
具有该性质。
具有inode
命名管道是内存级文件,它具有inode。
可以使用下面的指令查看:
ls -l -i
可以看到确实具有inode
命名管道的应用
这段使用内容比较多。后面会单开一篇文章展示使用。后面会补上文章链接。
数据在内核缓冲区上,不刷盘
其命名管道的数据是存储在内核缓冲区上。 同样匿名管道也是如此。不会刷入磁盘上
命名管道与匿名管道的区别
特性 | 匿名管道(Anonymous Pipe) | 命名管道(Named Pipe,FIFO) |
---|---|---|
创建方式 | 通过 pipe() 系统调用创建 | 通过 mkfifo() 系统调用创建,也可以用命令行 |
文件类型 | FIFO 文件 | 无文件类型 |
文件系统路径 | 无 | 有(例如 /tmp/my_fifo ) |
进程关系限制 | 只能用于具有共同祖先的进程 | 可用于任意进程 |
生命周期 | 随进程结束而销毁 | 持久化,直到显式删除 |
使用场景 | 父子进程或兄弟进程间通信 | 任意进程间通信 |
写端 | 通过 write() 向匿名管道写入数据 | 通过 write() 向命名管道写入数据 |
读端 | 读端进程通过 | 读端进程通过 |
有读端没写端 | 操作系统会关闭读端 | 写端进程会被阻塞 |
有写端没读端 | 操作系统会关闭写端 | 读端进程会被阻塞 |
命令行当中的管道
现创建test.txt
文件,文件当中的内容如下:
我们可以利用管道(“|”)同时使用cat命令和grep命令,进而实现文本过滤。
cat data.txt | grep linux
那么此时就会有问题了。此时命令行当中的管道(“|”)到底是匿名管道还是命名管道呢?
由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道(“|”)连接起来的各个进程之间是否具有亲缘关系。
我们现在使用sleep创建三个进程。
指令:
sleep 100 | sleep 200 | sleep 300
使用指令ps查看PPID其相关关系。
ps axj | head -1 && ps axj | grep sleep | grep -v grep
下面通过管道(“|”)连接了三个进程,通过ps命令查看这三个进程可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程。
而它们的父进程实际上就是命令行解释器,这里为bash
。
也就是说,由管道(“|”)连接起来的各个进程是有亲缘关系的,它们之间互为兄弟进程。
现在我们已经知道了,若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道。
system V进程间通信
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。
system V IPC提供的通信方式有以下三种:
-
system V共享内存:允许两个或多个进程共享一段内存区域,是进程间通信中最快的方式,因为数据不需要在进程间复制。
-
system V消息队列:允许一个或多个进程写入或读取消息,可以看作是一个消息链表,每个消息都有一个类型和一个优先级
-
system V信号量:用于同步进程,控制多个进程对共享资源的访问。System V信号量分为二进制信号量和计数信号量。
其中system V共享内和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然它并不能直接让进程间通信,但还是属于通信范畴,服务于进程间通信。
他们三者的区别就类似于:system V共享内和system V消息队列是用于通信的手机,而system V信号量是用于下棋比赛时用的棋钟,用于保证两个棋手之间的同步与互斥。
System V共享内存
共享内存的基本原理
共享内存的机制确实类似于动态库的加载。操作系统会在物理内存的共享区创建一块区域,并通过页表将其映射到需要通信的进程的虚拟地址空间的共享区中。这样,多个进程可以通过访问各自的虚拟地址空间中的共享区,实现对同一块物理内存的共享访问。
需要注意的是:
物理内存是实际的硬件资源,它本身并没有栈区、堆区或共享区的概念。所以物理内存本身并不区分共享区、栈区、堆区等。这些内存区域的划分是在虚拟地址空间中进行的,而不是在物理内存中。 而且虚拟地址空间中的不同区域(如栈区、堆区、共享区)最终都会通过页表映射到物理内存的某个位置,但这些区域在物理内存中可能是分散的,并不连续。
而图中的物理内存划分是为了方便观察而已。
其中共享内存是最快的IPC形式。一旦这样的内存映射到它的进程的地址空间上,这些进程间数据的传递不再设计到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。但这里的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。
共享内存数据结构
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。其基本原理还是先描述,再组织。
共享内存的数据结构如下:
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 */
};
当我们申请了一块共享内存后,为了要实现进程间通信的进程能够看见同一个共享内存,因此还要设定一个唯一标识符,而这个标识符就在共享内存创建的时候就有了,其就是key值,这个key值用于标识系统中共享内存的唯一性,并且是用户提供的,用于标识共享内存段的唯一性。
所以其key也是要被操作系统维护再共享内存的数据结构内,其可以看到上面共享内存数据结构的第一个成员是shm_perm
,shm_perm
是一个ipc_perm
类型的结构体变量,每个共享内存的key值存储在shm_perm
这个结构体变量当中,其中ipc_perm
结构体的定义如下:
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;
};
共享内存的建立与释放
共享内存的建立,从我们用户来看,我们只需要执行下面几步就可以
-
生成 key 值。
- 调用
shmget
系统调用创建共享内存段。 - 调用
shmat
将共享内存段附加到自己的虚拟地址空间。
从用户的角度来看确实很简单,但是从操作系统来说就比较麻烦了,简单来说分两个过程
- 在物理内存当中申请共享内存空间。
- 将申请到的共享内存挂接到地址空间,即建立映射关系。
同样在用户来看,我们释放只需要
- 调用
shmdt
将其从进程的地址空间中分离。 - 调用
shmctl
删除共享内存段。
从操作系统来说,共享内存的释放大致分两个过程:
- 将共享内存与地址空间去关联,即取消映射关系。
- 释放共享内存空间,即将物理内存归还给系统。
下面我们分别介绍建立与释放,我们先说明用户需要做什么,需要注意什么,然后再同操作系统的角度来解释细节。
共享内存的创建
创建共享内存我们需要用shmget函数:
头文件:
#include <sys/ipc.h>
#include <sys/shm.h>功能: 用来创建共享内存(于物理内存上创建)原型
int shmget(key_t key, size_t size, int shmflg);参数key:这个共享内存段名字size:共享内存大小shmflg:由九个权限标志构成,他们的用法和创建文件时使用的mode模式标志是一样的。
返回值:成功返回一个非负整数,技改共享内存段的标识码;失败返回 -1
注意: 我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。
传入shmget函数的第一个参数key,实际上需要我们使用ftok函数进行获取。
- 但是key虽然是一个 key_t 类型,但是它其实为一个数字,但它是几并不重要,就算最后设计为100012,也没关系,关键在于它的设计要能再内核中具有唯一性,能够让不同的进程进行唯一性标识。
- 同样我们第一个进程可以通过key的创建共享内存,第二个及以后的进程只需要拿着同一个key就可以和第一个进程看到同一个共享内存。
- 对于一个已经创建好的共享内存,key就在共享内存的描述对象中。
- 所以第一次创建共享内存时,就需要一个key值,调用ftok函数来创建。
ftok函数的函数原型如下:
头文件
#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);
ftok就是将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。
注意:
- 使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
- 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
注意:第二个参数一般建立设置为4096的整数倍。比如说如果传的4097,但实际上为4096*2
传入shmget函数的第三个参数shmflg,常用的组合方式有以下两种:
组合方式 | 作用 |
---|---|
IPC_CREAT | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄 |
IPC_CREAT | IPC_EXCL | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回 |
换句话说:
- 使用组合IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
- 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。
- IPC_EXCL不单独使用!!!
现在在用户层面我们已经了解了共享内存的创建,也大致知道怎么创建了,那么我们从用户角度来谈一下细节。
用户调用
shmget
,触发系统调用进入内核。内核检查参数合法性。
内核在全局共享内存表中查找是否已存在对应的共享内存段。
如果不存在且允许创建,则:
分配共享内存段描述符。
分配物理内存页。
初始化共享内存段并添加到全局表。
返回共享内存标识符(
shmid
)给用户空间。
至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string.h>#define PATHNAME "/home/haha" // 路径名#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小int main()
{key_t key = ftok(PATHNAME, PROJ_ID); //获取key值if (key < 0){perror("ftok");return 1;}int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存if (shm < 0){perror("shmget");return 2;}printf("key: %x\n", key); //打印key值printf("shm: %d\n", shm); //打印句柄return 0;
}
该代码编写完毕运行后,我们可以看到输出的key值和句柄值:
Linux当中,我们可以使用ipcs
命令查看有关进程间通信设施的信息。
图中有三部分打印,分别是默认列出消息队列、共享内存以及信号量相关的信息。如果我们想看单独一行的信息,可以加上选项
- -q:列出消息队列相关信息。
- -m:列出共享内存相关信息。
- -s:列出信号量相关信息。
例如,携带-m选项查看共享内存相关信息:
此时我们通过ipc查看,我们确实正确使用了ftok与shmget函数创建了共享内存。
ipcs
命令输出的每列信息的含义如下:
标题 | 含义 |
---|---|
key | 系统区别各个共享内存的唯一标识 |
shmid | 共享内存的用户层id(句柄) |
owner | 共享内存的拥有者 |
perms | 共享内存的权限 |
bytes | 共享内存的大小 |
nattch | 关联共享内存的进程数 |
status | 共享内存的状态 |
注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,是操作系统内部使用的,用于唯一标识共享内存段,二者关系就类似与key是名字(但这个名字特殊,具有唯一性),shmid是句柄。
共享内存的释放
通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。
此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。
使用命令释放共享内存资源
我们可以使用ipcrm -m shmid
命令释放指定id的共享内存资源。
ipcrm -m shmid
注意:
指定删除时使用的是共享内存的用户层id,即列表当中的shmid。
其在删除时并不会删除已存在的key值,但它不再与任何共享内存段关联。但会通过shmid找到共享内存段表,查找对应的共享内存段描述符,会释放物理内存,删除共享内存段描述符,更新内核数据结构。
除此之外共享内存段占用的物理内存页也会被释放,共享内存的数据结构也会被删除。
需要注意的是以上是建立在引用计数为 0 的情况下。
调用释放共享内存资源
控制共享内存我们需要用shmctl函数,shmctl函数的函数原型如下:
头文件
#include <sys/ipc.h>
#include <sys/shm.h>功能:用于控制共享内存原型int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数shmid:由shmget返回的共享内存标识码cmd:将要采取的动作(有三个可取值)buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0,失败返回-1
其中,作为shmctl函数的第二个参数传入的常用的选项有以下三个:
选项 | 作用 |
---|---|
IPC_STAT | 获取共享内存的当前关联值,此时参数buf作为输出型参数 |
IPC_SET | 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值 |
IPC_RMID | 删除共享内存段 |
同样调用shmctl与命令行的ipcrm -m shmid在操作系统的角度做着同样的操作。
例如,对上面的代码进行修改,当共享内存被创建,两秒后程序自动移除共享内存,再过两秒程序就会自动退出。
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string.h>#define PATHNAME "/home/haha" // 路径名#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小int main()
{key_t key = ftok(PATHNAME, PROJ_ID); //获取key值if (key < 0){perror("ftok");return 1;}int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存if (shm < 0){perror("shmget");return 2;}printf("key: %x\n", key); //打印key值printf("shm: %d\n", shm); //打印句柄sleep(2);shmctl(shm, IPC_RMID, NULL); //释放共享内存sleep(2);return 0;
}
我们可以调用监控脚本查看一下
while :; do ipcs -m;echo "###################################";sleep 1;done
通过监控脚本可以确定共享内存确实创建并且成功释放了。
共享内存的关联
将共享内存连接到进程地址空间我们需要用shmat函数:
功能:将共享内存段连接到进程地址空间
原型void *shmat(int shmid, const void *shmaddr, int shmflg);
参数shmid:共享内存标识shmaddr:指定连接的地址 通常设置为null,让操作系统连接一个合适的地址shmflg:他的三个可能取值是SHM_RND和SHM_RDONLY和0
返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1
其中,作为shmat函数的第三个参数传入的常用的选项:
选项 | 作用 |
---|---|
SHM_RDONLY | 关联共享内存后只进行读取操作 |
SHM_RND | 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA) |
0 | 默认为读写权限 |
- shmaddr为NULL,核心自动选择一个地址
- shmaddr不为NULL且shmflg无SHM_RND标识,则以shmaddr为连接地址
- shmaddr不为NULL且shmflg设置了SHM_RND标识,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)
- shmflg = SHM_RDONKY,表示连接操作用来只读共享内存
这时我们可以尝试使用shmat函数对共享内存进行关联。
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string.h>#define PATHNAME "/home/haha" // 路径名#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小int main()
{key_t key = ftok(PATHNAME, PROJ_ID); //获取key值if (key < 0){perror("ftok");return 1;}int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存if (shm < 0){perror("shmget");return 2;}printf("key: %x\n", key); //打印key值printf("shm: %d\n", shm); //打印句柄printf("attach begin!\n");char* mem = shmat(shm, NULL, 0); // 关联共享内存if (mem == (void*)-1){perror("shmat");return 1;}printf("attach end!\n");sleep(2);shmctl(shm, IPC_RMID, NULL); //释放共享内存sleep(2);return 0;
}
代码运行后发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,所以创建出来的共享内存的默认权限为0,即什么权限都没有,因此server进程没有权限关联该共享内存。我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同。
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建权限为0666的共享内存
此时再运行程序,即可发现关联该共享内存的进程数由0变成了1,而共享内存的权限显示也不再是0,而是我们设置的666权限。
共享内存的去关联
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数:
功能:将共享内存段与当前进程脱离
原型int shmdt(const void *shmaddr);
参数shmaddr:由shmat所返回的指针
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存
现在我们就能够取消共享内存与进程之间的关联了
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string.h>#define PATHNAME "/home/haha" // 路径名#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小int main()
{key_t key = ftok(PATHNAME, PROJ_ID); //获取key值if (key < 0){perror("ftok");return 1;}int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建权限为0666的共享内存if (shm < 0){perror("shmget");return 2;}printf("key: %x\n", key); //打印key值printf("shm: %d\n", shm); //打印句柄printf("attach begin!\n");char* mem = shmat(shm, NULL, 0); // 关联共享内存if (mem == (void*)-1){perror("shmat");return 1;}printf("attach end!\n");sleep(2);printf("detach begin!\n");sleep(2);shmdt(mem); //共享内存去关联printf("detach end!\n");sleep(2);shmctl(shm, IPC_RMID, NULL); //释放共享内存sleep(2);return 0;
}
运行程序,通过监控即可发现该共享内存的关联数由1变为0的过程,即取消了共享内存与该进程之间的关联。
共享内存与管道进行对比
当我们共享内存创建好后,就不需要再调用系统接口来进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。
使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
- 服务端将信息从输入文件复制到服务端的临时缓冲区中。
- 将服务端临时缓冲区的信息复制到管道中。
- 客户端将信息从管道复制到客户端的缓冲区中。
- 将客户端临时缓冲区的信息复制到输出文件中。
而共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
- 从输入文件到共享内存。
- 从共享内存到输出文件。
所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。但是我们可以通过特殊的设定,从而有同步互斥的效果。
System V消息队列
消息队列的基本原理
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。
总结一下:
- 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
- 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
- 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。
虽然这一部分内容为了解内容,但还是简单的介绍一下。
消息队列数据结构
当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构。
消息队列的数据结构如下:
struct msqid_ds {struct ipc_perm msg_perm;struct msg *msg_first; /* first message on queue,unused */struct msg *msg_last; /* last message in queue,unused */__kernel_time_t msg_stime; /* last msgsnd time */__kernel_time_t msg_rtime; /* last msgrcv time */__kernel_time_t msg_ctime; /* last change time */unsigned long msg_lcbytes; /* Reuse junk fields for 32 bit */unsigned long msg_lqbytes; /* ditto */unsigned short msg_cbytes; /* current number of bytes on queue */unsigned short msg_qnum; /* number of messages in queue */unsigned short msg_qbytes; /* max number of bytes on queue */__kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */__kernel_ipc_pid_t msg_lrpid; /* last receive pid */
};
可以看到消息队列数据结构的第一个成员是msg_perm
,它和shm_perm
是同一个类型的结构体变量,ipc_perm
结构体的定义如下:
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;
};
消息队列的创建
创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:
int msgget(key_t key, int msgflg);
说明一下:
- 创建消息队列也需要使用ftok函数生成一个key值,这个key值作为msgget函数的第一个参数。
- msgget函数的第二个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符)。
消息队列的释放
释放消息队列我们需要用msgctl函数,msgctl函数的函数原型如下:
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
说明一下:
msgctl函数的参数与释放共享内存时使用的shmctl函数的三个参数相同,只不过msgctl函数的第三个参数传入的是消息队列的相关数据结构。
向消息队列发送数据
向消息队列发送数据我们需要用msgsnd函数,msgsnd函数的函数原型如下:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
msgsnd函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示待发送的数据块。
- 第三个参数msgsz,表示所发送数据块的大小
- 第四个参数msgflg,表示发送数据块的方式,一般默认为0即可。
msgsnd函数的返回值说明:
- msgsnd调用成功,返回0。
- msgsnd调用失败,返回-1。
其中msgsnd函数的第二个参数必须为以下结构:
struct msgbuf{long mtype; /* message type, must be > 0 */char mtext[1]; /* message data */
};
注意: 该结构当中的第二个成员mtext即为待发送的信息,当我们定义该结构时,mtext的大小可以自己指定。
从消息队列获取数据
从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msgrcv函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示获取到的数据块,是一个输出型参数。
- 第三个参数msgsz,表示要获取数据块的大小
- 第四个参数msgtyp,表示要接收数据块的类型。
msgrcv函数的返回值说明:
- msgsnd调用成功,返回实际获取到mtext数组中的字节数。
- msgsnd调用失败,返回-1。
我们可以通过指令查看我们创建的消息队列。
指令:
ipcs -q
下面就给一个简易的使用代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <mqueue.h> // POSIX 消息队列头文件
#include <fcntl.h> // 定义 O_* 常量
#include <sys/stat.h> // 定义 mode 常量#define QUEUE_NAME "/my_message_queue" // 消息队列名称
#define MAX_MSG_SIZE 1024 // 消息的最大大小
#define MSG_BUFFER_SIZE (MAX_MSG_SIZE + 10) // 缓冲区大小int main() {mqd_t mq; // 消息队列描述符struct mq_attr attr;char buffer[MSG_BUFFER_SIZE];int msg_priority = 0;// 设置消息队列属性attr.mq_flags = 0; // 阻塞标志attr.mq_maxmsg = 10; // 队列中最大消息数attr.mq_msgsize = MAX_MSG_SIZE; // 每条消息的最大大小attr.mq_curmsgs = 0; // 当前队列中的消息数// 创建消息队列mq = mq_open(QUEUE_NAME, O_CREAT | O_RDWR, 0666, &attr);if (mq == (mqd_t)-1) {perror("mq_open");exit(EXIT_FAILURE);}printf("Message queue created: %s\n", QUEUE_NAME);// 发送消息到队列// 调用msgsnd函数// .......// 从队列接收消息// 调用msgrcv函数// ......// 关闭消息队列mq_close(mq);// 删除消息队列mq_unlink(QUEUE_NAME);printf("Message queue deleted: %s\n", QUEUE_NAME);return 0;
}
System V信号量
信号量相关概念
- 由于进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系叫做进程互斥。
- 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
- 在进程中涉及到临界资源的程序段叫临界区。
- 在特性方面:IPC资源必须删除,否则不会自动删除,因为system V IPC的生命周期随内核。
信号量数据结构
在系统当中也为信号量维护了相关的内核数据结构。
信号量的数据结构如下:
struct semid_ds {struct ipc_perm sem_perm; /* permissions .. see ipc.h */__kernel_time_t sem_otime; /* last semop time */__kernel_time_t sem_ctime; /* last change time */struct sem *sem_base; /* ptr to first semaphore in array */struct sem_queue *sem_pending; /* pending operations to be processed */struct sem_queue **sem_pending_last; /* last pending operation */struct sem_undo *undo; /* undo requests on this array */unsigned short sem_nsems; /* no. of semaphores in array */
};
信号量数据结构的第一个成员也是ipc_perm
类型的结构体变量,ipc_perm
结构体的定义如下:
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;
};
然而上面的数据结构,一般来说是加深理解,但始终他为了解知识,那么下面我们深入了解一下其本质
信号量凭什么是进程通信一种
信号量虽然不像前两个system V通信可以实用性的通信,但它还是作为其一,这是因为如下:
- 通信不仅仅是通信数据,互相协同也是也是通信。
- 协同本质也是通信,信号量做到的就是首先被所有的进程看到!!!
本质
信号量本质上是一个受保护的计数器,其核心功能是:
-
记录可用资源数量(当信号量 > 0时)
-
同步多线程/进程对共享资源的访问(当信号量 ≤ 0时,阻塞等待)
光看着句话还是不明所以然。下面我们就给一个情景模拟一下,就明白了。
就比方所我们在网上订票,电影院的老板在此次的电影放映中仅仅放出了100张票,作为顾客的我们,上网订票成功,那么总票数就会减减,这就相当于我们申请成功,计数器减减。但是我们申请成功了,但是我们并没有真正的要到了资源或者来说我们现在并没有看。所以在订票成功只是对这个电影进行了预订机制。票数的计数器保证了进入电影院的人数要保证小于等于100。所以对于每一个人,想要看这场电影,就需要申请成功,保证买到票,使得票的计数器减减。
在上面的这个过程中,我们网上买票成功就类似于
-
权限获取阶段:当线程成功申请信号量时(计数器值 > 0),表示该线程获得了访问共享资源的准入资格,此时信号量计数器会减少1。这类似于网上订票成功(获得观影资格),但此时还未实际使用资源(尚未入场观影)。
-
控制机制特性:信号量本质上是一种资源访问的准入控制系统,它通过计数器限制可同时访问资源的执行流数量,但并不直接管理资源的具体使用过程。
-
访问流程规范:任何执行流必须严格遵守"先申请信号量,后访问资源"的流程。就像观众必须先成功购票才能入场一样,执行流必须确保信号量申请成功后才能操作共享资源。
下面给出图形补充描述
二元信号量
首先解释一下什么是二元信号量。
-
定义:信号量计数器最大值被限制为1(只有0和1两种状态)。
-
行为:
-
1:表示资源空闲,允许一个线程获取
-
0:表示资源被占用,其他线程必须等待
-
其本质就是为一把锁。
因为它的行为和互斥锁(Mutex)几乎一致:
操作 | 二元信号量 | 互斥锁(Mutex) |
---|---|---|
初始化 | 初始值=1 | 初始状态=未锁定 |
加锁 | sem_wait() (P操作) | pthread_mutex_lock() |
解锁 | sem_post() (V操作) | pthread_mutex_unlock() |
阻塞行为 | 值为0时新线程阻塞 | 已锁定时新线程阻塞 |
其就是信号量的一种特殊形式,这里简单提一下,了解便可。
原子?
对于一些操作,我们一般来说原子的是更为安全的,但这里的原子又是什么意思呢?
在物理,化学上规定,原子不可分,所以简单来说就是原子性操作就是不可再分的,也就是一步走完的。
就比方说下面的一个简单代码
int cnt = 10;
cnt--;
其中cnt--,这一步在汇编语言中其实要分为很多步的,
- 他是先从内存中获取cnt变量的内容然后用cpu寄存器存起来。
- 然后再在cpu中进行--操作。
- 然后再将计算的结果写回cnt变量的存储位置。
虽然说在汇编语言上仅仅三步,但实际上并非如此,他还要在内核态,用户态来回转换,这又有几步操作。
所以我们说cnt--,它就不是原子的操作,它可以分好几步进行。就相对于原子操作不安全,可以你在执行第二步时突然时间片到了要执行别的了,别的操作可能恰好是cnt=50。这就会出现安全隐患。
信号量的设计
刚才我们也说了,申请信号量,本质就是对计数器--,专业术语就是P操作。
释放资源,释放信号量,那就是对计数器++,专业术语就是V操作。
其中P,V操作都是原子的,都是安全的。
system V IPC联系
通过对system V系列进程间通信的学习,可以发现共享内存、消息队列以及信号量,虽然它们内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量。
这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC或system V资源,就在该数组当中开辟一个这样的结构。这也貌似体现了继承。所以说语言还是在一定程度上继承了操作系统。