💪 今日博客励志语录:
你讨厌的现在,是未来的你拼命想回去修正的战场。
★★★ 本文前置知识:
匿名管道
命名管道
共享内存
前置知识回顾(如果对此内容熟悉的读者,可以跳过)
那么我们知道进程之间具有通信的需求,因为有的任务需要进程之间合作协同来完成,那么这个时候就需要进程之间分工合作,那么进程之间就需要知道彼此的进度以及完成情况,所以进程之间具有通信的需求,但是由于进程之间的独立性,那么进程无法访问到彼此的数据比如地址空间以及页表等等,那么操作系统为了满足进程间通信的需求以及保证进程之间的独立性,那么此时操作系统让进程间通信的核心思想就是创建一份公共内存区域,然后让通信的进程双方看到这块公共区域从而实现通信,而此前,我们学习了三种进程间通信的方式,分别是匿名管道以及命名管道和共享内存,那么接下来我们就来简单回顾这三种方式
1.匿名管道
那么我们知道进程间通信的核心思想就是闯进一份公共的内存区域,然后让通信的进程双方能够看到,而对于父子进程来说,那么当我们的父进程在代码层面调用fork接口,那么此时会创建一个子进程,那么该子进程会拷贝父进程的task_struct结构体并且修改其中的部分属性并且与父进程共享物理内存页,那么我们知道task_struct结构体中有一个关键的属性,那么其指向了一个指针数组,那么该指针数组就是文件描述符表,其中每一个元素都是一个指向file结构体的指针,记录了父进程打开的所有的文件,那么由于子进程是拷贝父进程的task_struct结构体,其中就包括拷贝父进程的指针数组,那么意味着子进程会继承以及共享父进程打开的文件,那么根据刚才进程通信的核心思想,那么此时文件就可以作为这个公共资源来让父子进程实现通信,那么这就要求父进程在调用fork创建子进程之前,先打开一个用于和之后创建的子进程通信的文件,那么打开之后再调用fork创建子进程,那么子进程就可以顺利的继承该文件,然后父子进程就可以向该文件读写从而发送消息来实现通信,这就是父子进程通信的原理
但是如果父进程打开的该文件是一个普通文件,然后父进程以读写方式打开,获取其文件描述符,然后再创建完子进程,那么父子进程就可以持有这个文件描述符来向该文件读写从而发送消息,但是由于普通文件的读写偏移量是共用的,那么意味着只要一个进程读或者写,那么其都会修改偏移量,而读写共用一个偏移量意味着进程无法读取到之前的内容,并且如果通信双方进程都向文件中有写入,进程也无法区分该内容究竟是哪个进程写的导致内容混乱,所以这里我们知道父子进程要通过该文件进行通信,必然双方不能既可以读又可以写,否则会造成内容混乱,只能一个进程只读而另一个只写,所以这里父进程可以分别以只读和只写打开同一个文件,那么此时会创建两个file结构体,然后父子进程各自持有这两者中的其中一个的文件描述符,那么两个file结构体都有各自的偏移量,那么父子进程之间就可以实现通信
那么这个方式肯定是正确的,但是唯一的问题就是这里创建的是磁盘级别的文件,那么意味着该文件保存的进程间通信的消息会被写入到磁盘中,而磁盘是用来保存需要长期存储的数据的,而这些通信之间的消息显然不会长期存储,所以这里最完美的情况则是创建一个内存级别的文件,那么创建内存级别的文件就不再是调用open接口,而是调用pipe系统调用接口,那么它底层会为我们创建一个内存级别的文件,并且还会分别创建只读以及只写的file结构体,并且返回这两个结构体对应的文件描述符,然后父进程调用完pipe接口,然后再调用fork接口来创建子进程,此时子进程就会和父进程共同持有只读以及只写的file结构体的文件描述符,接着双发各自持有其中一个读写端,也就是一个只读,一个只写,然后双方就可以利用文件描述符来访问该文件从而进行读或者写,而该文件就是我们的匿名管道文件,这就是匿名管道通信的原理
2.命名管道
那么匿名管道则是针对父子进程之间通信,因为创建子进程,其中子进程的task_struct结构体是拷贝复制父进程的task_struct结构体并且其中修改部分属性,所以子进程能够继承或者说共享与父进程打开的文件,那么所以父子之间可以通过文件描述符来访问文件,而对于非父子进程来说,那么它们之间便无法通过文件描述符来访问文件由于非父子关系,而根据上文讲述的进程通信的原理,那么首先是要创建一份共享资源,那么这个共享资源此时还是由文件来承担,那么就需要我们创建一个文件,那么有了匿名管道的通信经验,这里我们就知道通信的进程双方各自调用open接口应该以读或者写打开该文件而不能以读写模式打开该文件,那么此时双方进程各自持有读写一端的文件描述符,然后就调用write或者read来对文件进行读写即可,而这里有上文匿名管道的经验,那么我们也知道,这里的文件一定也是内存级别文件,而与匿名管道的区别则是,由于后序我们要通过open接口打来创建好的该管道文件,而open接口接收一个带有路径以及文件名的字符串,所以这里的管道一定是具有路径和名称的,这也是为什么称之其为命名管道文件,那么这里就需要我们调用mkfifo接口来创建命名管道文件
3.共享内存
而这里的第三种通信方式也就是共享内存,本质上就是一份或者多份物理内存页,然后被系统用来分配通信的进程所使用,而根据通信的核心思想,那么假设系统为我们分配好了物理内存页,那么此时我们首先解决了第一步创也就是建一份共享资源解决了,那么下一步就是让通信的进程双方看到该共享资源,而我们知道进程访问数据,那么它手头上持有只有虚拟地址,也就是说进程只能通过虚拟地址来访问内存中的数据,然后再经过页表的映射再转化为实际的物理内存地址,所以这里要让进程双方看见该共享内存,那么系统就得想办法把在通信的进程双方的页表建立该共享内存对应的物理内存页的虚拟地址到物理地址的映射条目,以及进程的进程地址空间设置好相应的共享内存段,那么这一步我们称之为挂接,最后再让进程持有该物理内存页的虚拟地址从而让进程双方能够访问到共享内存,那么这就是共享内存通信的一个大思想,那么接下来我们再来回顾一下该过程的一个具体细节
那么假设现在有一对采取共享内存通信的进程A和B,那么他们做的第一步就是请求操作系统为该通信的进程双方分配物理内存用来通信,而在内核中通信的进程不只有一对,而是多对,那么意味着系统中存在多份共享内存,那么系统肯定就要管理这么多的共享内存,那么管理的方式就是先描述再组织,所以系统会为每一个共享内存创建一个结构体,其中记录了其相关属性,并且为了区分不同的共享内存,那么每一个共享内存肯定被分配了一个唯一的标识符shmid,而请求系统创建共享内存这个动作就是我们调用系统提供的shmget接口来完成,那么该接口会返回就给通信的进程双方返回该共享内存的标识符shmid,而shmid也是该结构体中的其中一个重要属性,所以到时候,通信的进程双方再通过该标识符,让系统将对应的共享内存挂接到通信的进程双方即可
而现在关键问题就是这里请求操作系统为该通信的进程双方创建一份共享内存的动作假设交给进程A来完成,而此时不需要进程B再重复请求系统创建共享内存了,而进程B只需要获取A进程请求系统为其创建好的共享内存的shmid,那么问题就是其中此时A进程请求该系统创建共享内存,那么A进程作为请求方,那么其调用完shmget,那么系统成功创建完共享内存肯定会向其向其返回shmid,所以A进程能够成功获取shmid,而对于B进程来说,它则需要让操作系统返回为A进程请求系统为其创建好的共享内存的shmid,但是共享内存那么多,系统怎么知道哪一个共享内存是用来该对进程通信用的呢,并且进程之间具有独立性,那么它也不可能访问到另一个A进程的数据从而获取shmid
所以这个时候就需要key值登场了,那么它和shmid一样叫共享内存的标识符,它也是共享内存结构体中的其中一个属性,但是与shmid不同的是,那么它是用户态的标识符,在双方进程通信之前,都会先事先持有该key,那么接下来我可以来举一个例子来理解这里所谓的key:假设A进程和B进程分别对应今晚住酒店的一对情侣,那么此时A到酒店前台来预定了今晚的房间,那么酒店前台的人会告诉他房间号是多少,但是由于A和B彼此之间不能见面交流(进程的独立性),那么A为了让B找到房间号,所以他们在预定酒店之前彼此都持有一个相同的标记,那么A会让酒店前台的服务员到该预定的房间门口画一个标记,然后B来获取A今晚预定的酒店的房间号的时候,那么就只需要告诉前台的服务员标记长什么样子,然后前台的服务员就会依次查看各个房间的门口,然后找到带有特定对应的标记房门,然后将房间号告诉给B
那么通过这个例子,你应该能够理解key虽然也叫共享内存的标识符,但是它的作用是不一样的,而再刚才的例子中,这个标记就是key值,而酒店前台的服务人员就是操作系统,那么这里我们就能理解shmget的第一个参数key的作用了:那么一个进程用来创建共享内存,并且告诉该系统key值也就是向shmget接口传递第一个参数,然后系统分配好了物理内存页以及创建了对应的结构体的同时还会设置好该结构体对应的key值字段,就像上文例子中服务员在房间门口画标记一样,那么另一个进程只需告诉系统key值,然后系统会遍历共享内存的结构体,然后找到匹配的key值,最后返回shmid,而上文我加粗了“特定对应的标记”这几个字,那么是因为每一个通信的进程双方获取对应共享内存的shmid,都会通过借助key值来寻找,所以这里的key值会有冲突,意味着会有相同的key值的共享内存存在因为key是由用户设定不是由内核设定,就会导致返回不属于该通信进程双方的共享内存的shmid,所以为了尽量避免相同的key值,可以调用ftok函数来生成一个key值,那么它会接收一个已存在的带有路径和文件名的字符串和一个整数,然后根据路径和文件名得到其inode编号然后与整数进行相应的运算得到一个冲突概率较小的key值,那么shmget接口的参数就是一个key值和一个宏定义,那么这个宏定义就决定了该接口的行为,那么IPC_CREAT是创建一个共享内存并返回其shmid,而IPC_CREAT|IPC_EXEL则是返回一个已创建的共享内存shmid
那么获取完共享内存之后,那么下一步便是挂载,此时通信进程双方持有共享内存的shmid,那么接下来就各自调用shmat接口,然后让操作系统将对应的共享内存挂载进程双方,也就是添加页表的映射条目以及设置好地址空间的共享内存段,那么最后返回该共享内存对应的物理内存页的起始的虚拟地址,那么有了虚拟地址之后,我们就可以将这个虚拟看做一个类似于在堆上申请的字符数组的首元素的地址,那么进程双方就以字节为单位向共享内存中写入以及读取了,那么这就是共享内存通信的一个大致原理
消息队列
那么继匿名管道以及命名管道和共享内存,那么这次我们就要介绍我们的通信的第四种方式,便是我们的消息队列,那么消息队列从名字我们便能看出一些内容来,那么它的名字带有队列两个字,那么没错,消息队列的底层结构其实就是队列这个数据结构,那么这个数据结构不是由我们通信的进程来创建,而是由我们系统来创建,那么队列的特性我们也知道,那么便是先进先出,从队尾插入从队头删除,那么队列的实现一般采取的是双向链表来实现
1.原理
那么消息队列是如何实现我们的通信的了,那么我们知道消息队列本质上就是一个链表结构的一个队列,那么该队列中或者说链表中的每一个节点就是一个消息块,那么这个消息块可能是A进程或者B进程写的,那么之前学过匿名以及命名管道以及共享内存的读者,那么知道采取这三种方式的通信的进程双方不能同时对共享资源进行读写,也就是只能一个进程担任信息的发送方,也就是作为写端,而另一个进程作为接收方,也就是作为读端,那么之所以进程只能担任一个角色就是因为通信的进程双方在进行读取或者写入的时候都是访问的同一份共享资源,那么以匿名管道为例,那么如果通信的进程双方都进行读写,就会导致内容混乱,那么一个进程写入后的内容可能被另一个进程所覆盖,而在消息队列这个场景下,那么如果通信的进程此时有发送消息的需求,那么它就只需要创建一个节点,然后将消息写入,再将节点放入到队列当中,那么在这个场景下,那么每一个进程只会操作自己的消息块或者说节点,而不会存在两个或者多个进程共同使用一个消息块的情况,所以消息队列的优势相较于前面的共享内存以及匿名和命名管道相比,就是进程既可以作为发送方也可以作为接收方,那么如果想要读取消息,那么就直接从队列中取出节点即可
msgget
那么了解了消息队列通信的一个大致的原理之后,那么我们再来梳理一下消息队列通信的一个过程,那么假设现在我们有一个进程对,A和B进程,那么这两个进程之间要采取消息队列的方式进行通信,那么首先第一步就是需要A或者B其中一个进程来请求操作系统为该通信的进程双方创建一个消息队列,那么假设这个动作交给A完成,那么对于B来说,那么它要做的就是获取A请求操作系统为其创建好的消息队列,那么在系统中通信的进程不只有一个,那么意味着内核中的消息队列也不只有一个,那么系统要管理这么多的消息队列,那么采取的方式就是先描述,再组织,那么内核会为消息队列定义一个struct msg_queue结构体,其中记录了该消息队列的相关属性,包括最后一次发送消息的进程的PID以及节点数等等,同时为了区分内核中不同的消息队列,那么每一个消息队列也具有唯一的一个标识符msqid,所以在请求系统创建好消息队列之后,那么A和B进程就需要获取内核为它们创建好的消息队列的标识符,从而再之后的消息的发送以及接收便于告诉内核我是发送消息到哪个消息队列以及接收哪个消息队列的消息
那么这里A进程请求完系统,那么系统创建好消息队列后,肯定会返回消息队列的msgid给A进程,那么关键是B进程怎么得知系统为其创建好的消息队列是哪一个呢,那么它不可能访问A进程的数据来获取msqid,所以这里就会面临和共享内存同样的问题,那么解决方法也是一样,那么就是A和B进程事先会约定一个key值,那么A进程在请求系统创建消息队列的时候,会把key值交给内核,那么内核创建好消息队列以及对应的结构体之后,同时会设置好该结构体的key值字段,那么B进程只需要告诉系统key值,那么系统只需遍历结构体然后找到匹配的key值,最后返回对应的msqid即可
那么这个请求队列创建消息队列以及获取已经创建好的消息队列的内容便是msgget接口的一个内容
msgget
头文件
:<sys/types.h> <sys/ipc.h> <sys/msg.h>函数声明
:int msgget(ket_t key,size_t size,int msgflg);返回值
:调用成功返回msgid,调用失败则返回-1,并设置errno
那么第一个参数就是接收key值,那么至于为什么要接收key值以及key值的作用,那么上文已经说过
而第二个参数就是接收一个宏,那么这个宏就控制了msgget的行为,那么是创建消息队列还是获取已经存在的消息队列:
IPC_CREAT (01000)
:如果消息队列不存在则创建IPC_EXCL (02000)
:不能单独使用,与IPC_CREAT一起使用,若消息队列已存在则失败
msgsnd
那么第一个过程也就是创建消息队列就已经结束,那么此时A和B进程已经获取了消息队列的标识符也就是msqid,那么下一步就是发送消息和接收消息,那么这里我们假设A进程它作为发送发,而B进程作为接收方,我们知道消息队列本质就是一个链表,那么这里A进程就需要定义自己的消息块:
struct msgbuf {
long mtype; // 消息类型/优先级
char mtext[]; // 自定义数据区
};
//包含在头文件中,不需要用户自己定义
那么这里的消息块是由两部分所组成,分别是消息类型以及数据区,那么数据区就是写入的消息,而这里的mytype字段则有两个作用,那么它即可以作为消息类型也可以作为优先级,那么这里我们先讲解一下mytype作为消息类型的一个含义,那么之前的匿名管道以及命名管道和共享内存,那么我们通信的场景都是只建立在一对进程上,也就是只有两个进程通信,而在消息队列这里,通信的进程就不仅仅只局限与一对进程而是一个进程组,那么这里A进程发出的消息,可以给进程B发,也可以给进程c发或者进程D,那么到时候这个消息肯定是针对发给某个特定的进程,那么我们就可以利用mytype给当前信息做一个标记,比如0代表是给进程B发送的,1代表是给进程C发送的,那么到时候b进程就只接受0号类型的消息而不接受1号类型的消息
同理这里的mytype还可以用作优先级,那么如果当前消息是特别重要的消息,那么我们可以将mytype设置为2,如果是正常消息则设置为1,那么mytype值越大意味着优先级越高,那么优先级越高那么意味着它越容易被弹出被进程接收到,所以这里mytype也有优先级的一个场景
而这里的mtext数组就是存放我们的消息内容,并且该数组是柔性数组,那么所谓的柔性数组,就是我们结构体里面定义一个长度为0或者没有长度的数组:
struct node
{
datatype member1;
datatype array[];//或者datatype[0];
}
那么此时这种写法,那么编译器默认不会为该结构体的所有的成员变量开辟空间,那么这里编译器就将最后一个成员变量也就是该数组识别为一个标识符,那么如果我们要开辟空间,那么则是我们需要在malloc或者new的时候额外指定一个长度或者说大小,那么该大小就是给柔性数组分配的大小,并且注意柔性数组一定得得是最后一个成员变量,如果是中间的某个成员变量,因为柔性数组的长度是由用户决定而编译器未知,而结构体的各个成员变量的偏移量要遵从内存对齐,而由于中间的柔性数组的大小未知,那么编译器无法确定柔性数组之后的成员变量,所以柔性数组只能在末尾,其次它不能是栈对象,因为栈对象要求编译期间就能确定结构体的大小,而这里的柔性数组的长度是可以变化,比如:
struct node { datatype member1; datatype array[]; }; int main() { int N; scanf("%d",&N); struct node* ptr=(struct node*)malloc(sizeof(node)+N); }
在这个场景下,那么编译期间是编译器是无法确定该node结构体的大小,只能在运行期间获取了用户的键盘输入之后才能确定大小,所以意味着柔性数组所在的结构体的,只能是堆对象,不能是栈对象
其次柔性数组的意义也就是为了节省空间,避免空间的利用率,那么这里如果你开辟了假设100个字节的数组,但是其中只用了4字节,那么明显大部分空间浪费,而有的小伙伴可能会采取的是指针,定义一个指针成语变量,到时候创建一个结构体对象,再按需malloc一定大小的数组,然后让指针指向该动态数组的首元素从而减少空间的浪费
struct node { datatype member1; datatype* ptr; }; int main(){ struct node l1; l1.ptr=(datatype*)malloc(size); }
那么这里理论上肯定是可行的,但并不是最完美的,因为指针的出现,必然会让CPU进行二次寻址,那么访问两次内存,虽然节省了空间,但是却付出了性能的代价,而我们一般在意时间以及性能上的损失,所以这里最优秀的还是柔性数组的设计
那么有了消息块之后,那么接下来发送方进程只需要调用msgsed接口:
msgsnd
头文件
:<sys/types.h> <sys/ipc.h> <sys/msg.h>函数声明
:int msgsnd(int msqid,const void* msgp,size_t msgsz,int msgflg);返回值
:调用成功返回0,调用失败则返回-1,并设置errno
那么这里的第一个参数就是发送的消息队列的标识符,第二个参数就是消息块也就是结构体,而第三个参数就是消息的长度,那么这里的第三个参数就要注意,很多读者传的第三个参数是结构体的大小,也就是sizeof(msgp),但其实第三个参数传递的是结构体的数组也就是消息的长度,那么之所以传的是消息的长度,那么是因为到时候内核获取到消息块之后,那么这个结构体的两个成员变量分别是mytype以及存储消息的数组会被嵌入或者说拷贝到消息队列的链表的节点的结构体中,那么这里的mytype是第一个成员变量,那么其数据类型固定是long类型,那么系统就可以获取第二个成员变量也就是柔性数组的起始位置,然后系统在根据我们传递的第三个参数也就是msgsz来确定拷贝的数组长度避免越界,所以这里我们就只需要传递数组中的消息的长度即可:
// 发送示例
// 发送一条消息
struct msgbuf* msg = (struct msgbuf*)malloc(sizeof(long) + text_len);
msg->mtype = 1; // 消息类型 (8字节)
strcpy(msg->mtext, "Hello"); // 消息内容
// text_len = strlen("Hello") + 1 = 6
msgsnd(msqid, msg, 6, 0); // 第三个参数只指定柔性数组大小
而这里msgsnd的第4个参数则是一个宏定义,因为我们一个消息队列中存储的节点的上限是由要求的,那么意味着会存在消息队列已满的情况,那么消息队列已满,此时消息的发送方就要等待一个空闲的节点,那么此时就会被陷入阻塞,那么这里的第4个参数就是可以采取等待的方式:
msgflg
: 控制标志,可以是以下值的组合:
IPC_NOWAIT
: 如果消息队列已满,立即返回而不等待
0
:阻塞等待直到有空间可用
// 位置:include/linux/msg.h
//消息队列节点对应的结构体
struct msg_msg {
struct list_head m_list; // 链表指针(连接队列节点)
long mtype; // ⭐ 消息类型/优先级
size_t m_ts; // ⭐ 消息正文长度(字节)
// 大消息分块管理
struct msg_msgseg *next; // 指向下一个分块(>4KB消息)
// 安全模块相关
void *security; // SELinux/AppArmor安全上下文
// ⭐ 核心:消息内容存储区
unsigned char payload[]; // 柔性数组(存储实际消息)
};
struct list_head {
struct list_head *next, *prev;
};
msgcrv
那么消息发出去之后,那么就得有接收方进程来接收消息队列中的消息,那么接收方进程也得准备一个缓冲区用来拷贝接收的消息,那么接收方接收消息就是调用msgrcv接口:
msgrcv
头文件
:<sys/types.h> <sys/ipc.h> <sys/msg.h>函数声明
:int msgrcv(int msqid,const void* msgp,size_t msgsz,long msgtyp,int msgflg);返回值
:调用成功返回0,调用失败则返回-1,并设置errno
那么这里的第一个参数我们已经很熟悉了,而这里的第二个参数就是我们要定义一个结构体来作为缓冲区,其中的消息会被写入到该结构体的柔性数组中去,而第三个参数就是读取的消息的长度,按照该长度拷贝到柔性数组中去,而第四个参数就是消息的类型
而最后一个参数则是接收一个宏定义来控制该接口的行为:
IPC_NOWAIT
:如果没有符合条件的消息,立即返回而不等待
MSG_NOERROR
:如果消息长度超过 msgsz,截断消息而不返回错误
MSG_EXCEPT (Linux特有)
:接收类型不等于 msgtyp 的第一条消息
struct msgbuf {
long mtype; // 消息类型,必须 > 0
char mtext[100]; // 消息数据(实际可以是任意长度)
};
int main()
{
struct msgbuf msg;
msgrcv(msqid,&msg,sizeof(msg.mtext),0,IPC_NOWAIT);
}
msgctl
那么最后我们使用完该消息队列资源,结束通信之后,那么接下来要做的就是清理消息队列资源,那么就需要一个进程来请求操作系统来删除该消息队列,那么就需要调用msgctl接口:
msgctl
头文件
:<sys/types.h> <sys/ipc.h> <sys/msg.h>函数声明
:int msgctl(int msqid,int cmd,struct msqid_ds *buf);返回值
:调用成功返回0,调用失败则返回-1,并设置errno
那这里的msgctl的第二个参数就是接收一个宏定义来控制其行为:
IPC_STAT - 获取消息队列的状态信息,存储在 buf 指向的结构中
IPC_SET - 设置消息队列的参数,从 buf 指向的结构中获取
IPC_RMID - 立即删除消息队列
IPC_INFO - 获取系统范围内的消息队列限制信息 (Linux 特有)
MSG_INFO - 获取消息队列资源消耗信息 (Linux 特有)
MSG_STAT - 类似 IPC_STAT ,但通过索引查找队列 (Linux 特有)
其中要删除,我们就传IPC_RMID即可,而第三个参数就传NULL,而如果你想要查看当前消息队列的属性,那么你可以传IPC_STAT,然后再自己定义一个struct msqid_ds结构体作为输出型参数传递第三个参数,到时候就可以访问成员变量来查看其属性
struct msqid_ds {
struct ipc_perm msg_perm; /* 所有权和权限 */
time_t msg_stime; /* 最后发送消息的时间 */
time_t msg_rtime; /* 最后接收消息的时间 */
time_t msg_ctime; /* 最后修改时间 */
unsigned long msg_cbytes; /* 当前队列中的字节数 */
msgqnum_t msg_qnum; /* 当前队列中的消息数 */
msglen_t msg_qbytes; /* 队列最大字节数 */
pid_t msg_lspid; /* 最后发送消息的进程PID */
pid_t msg_lrpid; /* 最后接收消息的进程PID */
};
2.补充知识
1.消息队列的模型优化
那么大部分读者可能会认为消息队列的一个模型就是内核会创建以及维护一个链表,然后该链表中的所有的节点便是包含通信的进程发送的消息,但是在我们上文的介绍中,我们知道一个进程发送的消息块,除了有自己进程自己要发送的消息的内容,那么还有一个mytype字段,而mytype字段其中一个非常关键的含义,便是优先级,那么我们知道虽然队列这个数据结构是从队尾插入然后从队头删除,但是由于消息有优先级的存在,那么虽然该消息是当前队列中新发送的消息,但是它的位置却不一定在队尾,因为它还有优先级存在,所以系统还得根据其优先级调整它的位置,而队列是由链表实现的,那么如果所有节点都在一个链表中,那么意味着系统要从队头开始往后遍历然后找到合适位置插入该节点,那么遍历的代价是O(N),其次由于每一个节点物理内存不连续,每一个节点都通过指针来连接,那么意味着CPU还需要多次寻址访问内存,那么还会付出性能上的代价,所以这里内核的实现上不是将所有节点都放在一个链表中,而是按照优先级分成了多个链表,那么我们知道内核中存在多个消息队列,那么系统为了管理消息队列,那么采取的方式是先描述再组织,会为消息队列定义对应的结构体struct msg_queue,那么这里我们可以在结构体中查看到一个字段:
// 位置:include/linux/msg.h
struct msg_queue {
struct kern_ipc_perm q_perm; // ⭐ IPC权限控制块
// 时间戳
time64_t q_stime; // 最后发送时间
time64_t q_rtime; // 最后接收时间
time64_t q_ctime; // 最后修改时间
// 资源统计
unsigned long q_cbytes; // ⭐ 当前队列总字节数
unsigned long q_qnum; // ⭐ 当前消息数量
unsigned long q_qbytes; // ⭐ 队列最大字节限制
// 进程追踪
pid_t q_lspid; // ⭐ 最后发送进程PID
pid_t q_lrpid; // ⭐ 最后接收进程PID
// ⭐⭐⭐ 核心:优先级分桶链表
struct list_head q_messages[PRIO_BUCKETS];
// 高级功能
struct mq_attr attr; // POSIX扩展属性
struct user_struct *user; // 用户资源追踪
struct ns_common *ns; // 命名空间
};
那么就是struct list_head q_message[],那么该字段的本质是一个指针数组,一般长度为32,那么这里的指针数组的每一个元素是一个指针,指向的就是一个链表,那么指向的每一个链表就代表着优先级,那么该链表中的每一个节点的优先级都是相同的,而数组的索引也就对应这优先级的大小,数组索引越大,其对应的链表的优先级越高,那么最后一个位置的指针指向的链表的优先级是最高的,所以假设现在有一个进程发送了一个消息块,那么其mytype也就是优先级的大小是31,那么这里节点的插入就是只需要确定其在哪一个链表即可,确定完之后直接尾插,那么这里就采取取模运算31%32=31来定位指针数组的索引,那么确定是在下标为31的链表中,而这里数组中指向的每一个链表都是带有哨兵节点的带头双向循环链表,那么意味着我们不用遍历一遍链表找到尾结点,那么直接通过哨兵节点就可以找到尾结点,然后直接尾插即可,而该数组中的每一位位置的指针指向的就是链表的哨兵节点,那么这个模型相比于之前的所有节点存储在一个链表,那么它的时间复杂度是O(1),并且采取指针数组,那么也对CPU的缓存友好,可以将整个指针数组加载到缓存,然后再只需要访问一次内存,将链表该加载进来,那么消息的弹出就是会从后往前遍历数组,如果当前位置的链表为空就遍历之后的位置指向的链表,然后删除队头元素
其次注意的就是这里的优先级的问题,根据上文我们知道优先级是会进行取模运算确定数组的下标,那么这里可能存在这种情况,那么假设我们现在有一个mytype为1的消息和一个mytype为65的消息,那么从数值上来看,那65的优先级比61的优先级大,但是实际模上32之后,他们的优先级都是1,会被视作优先级相同的消息,所以这里我们就得注意在设置消息的优先级的时候,尽量不要设置太高,否则这里如果你定义65是紧急消息,1是正常消息,紧急消息需要先被接收弹出,但是根据上文的原理,实际上65的消息可能在正常消息也就是优先级为1的消息之后
而如果这里你的mytype的含义不是优先级,比如0的含义是给A进程接收,1是给B进程接收,这里你的mytype表示是类型,所以这里0和1按照你的理解优先级是一样的,不需要谁先谁后接收,但是对于内核来说,它不知道mytype的具体含义,内核还是统一将0和1视作优先级来处理
2.IDR树
我们之前已经介绍了共享内存,而现在有引入了消息队列,那么我们目前知道,系统中存在多种不同类型的共享资源,而每一种类型的共享资源又有多个,那么系统管理特定类型的共享资源比如共享内存或者消息队列,那么采取的方式就是先描述再组织的方式为每一个特定类型的共享资源定义结构体,但是操作系统又是如何管理整个不同类型的共享资源呢?
而这里我们可以发现不同ipc资源对应的结构体都有一个共性,不管是共享内存对应的结构体还是消息队列乃至后面的信号量对应的结构体,那么它们的结构体的第一个字段都是一个ipc_perm结构体,其中记录了相关的权限以及key值和所属组编号等,那么此时我们可以建立这样一个模型,那么就是线性的数组模型,那么操作系统可以创建一个数组,那么该数组是一个指针数组,其中里面存储了所以不同类型的ipc资源,那么数组中每一个元素都是一个指向ipc_perm结构体的指针,该ipc_perm结构体可以是属于不同ipc资源的结构体,那么每一个ipc资源的结构体中还有一个ipc类型的字段,不管是什么类型的ipc资源,那么该字段相对于起始位置的偏移量是固定的,而由于ipc_prem是结构体的首个成员变量,那么其指针指向ipc_perm的地址,就是该结构体的首地址,那么我们可以移动固定的偏移量,获取到ipc类型,那么接下来就可以将指针强制类型转化,就可以访问该ipc资源对应结构体的各个属性
那么在这个模型下,我们创建一个ipc资源,比如共享内存或者消息队列,我们知道内核会持有两个东西,分别是key值以及ipc资源类型,然后会遍历该数组找到空闲位置,如果找到就创建对应ipc类型的结构体,然后让数组的该位置的元素也就是指针指向ipc_perm,并且返回该数组的下标,所以我们之前的共享内存的shmid和消息队列的msgid就是这个数组下标
而查找ipc资源的时候,那么数组也会持有key值和ipc资源类型,遍历该数组,如果发现ipc_perm的key值匹配,再移动一定的偏移量看ipc类型是否匹配,如果两者都匹配,那么就返回该数组的下标即可,而之后比如像共享内存的shmat以及消息队列的msgsnd接口,那么都会用到下标,那么系统获取到下标就可以直接定位数组对应位置访问到对应的ipc资源。
那么该模型理论上肯定是没有问题的,但是唯一的缺点就是效率问题,因为当我们创建一个ipc资源的时候,我们有可能需要扫描整个数组,那么时间复杂度就是O(N),所以内核在此模型下进行了一个优化,那么就是采取的树的结构来实现,采取的是一个三层的64叉树,那么每一层的每个节点对应64个分支,那么最后一层的叶子节点就是存储实际有效内容也就是指向ipc_perm结构体的指针,而首层的根节点和中间层的节点,那么他们的节点的构造就是包含一个位图和一个长度为64的指针数组,那么位图则是64个比特位,每一个比特位对应一个分支,该比特位为1代表该分支有空闲位置,比特位为0代表该分支没有空闲位置,那么64个比特位对应8个字节,那么就可以用long类型的数来表示,那么长度为64的指针数组则是指向下一层的分支,并且这里我们不是把不同的ipc资源都存放到该树中,建立多个独立的IDR树用来存放对应类型的ipc资源,比如共享内存对应一个IDR树,然后消息队列也对应一个IDR树,这样就更提高了查找的效率相比于之前的混合存储,那么接下来我们再来看一下IDR节点对应的结构体的样子:
// 包含IDR节点的内核源码 (linux/lib/idr.c)
struct idr_layer {
int prefix; // 本节点管理的ID前缀
int layer; // 当前层深度(0=叶子)
unsigned long bitmap; // ⭐核心位图:64位比特位
struct idr_layer *slots[64];// ⭐指针数组:64个指针槽位
};
那么我们知道我们在访问某个共享资源的时候,比如对于共享内存来说,那么要进行挂接我们需要调用shmat接口,那么需要给内核传递一个shmid,而对于消息队列,我们要发一个消息,需要告诉内核msqid,那么这个标识符就对应了IDR树中叶子节点的一个编号,那么内核到时候会根据这个编号到对应的IDR树中去定位叶子节点,那么这里我们是从根节点逐层往下遍历知道最后一层的叶子节点,那么每一层的节点都有64个分支,那么每一个节点内部都会对应一个前缀,那么我们就可以根据这个前缀去匹配,那么一个IPC资源的标识符是36位,那么其中的前18位就是用来定位的,那么其中高6位就是对应根节点的指针数组的索引,那么利用位运算获取高6位,然后直接跳转到下一层的对应的节点,然后再解析紧挨着的低6位,获取中层节点的指针数组的索引,然后最后在紧挨着低6为获取最后叶子节点的指针数组的索引,其中对于叶子节点来说,那么它也有一个64个比特位的位图和长度为64的指针数组,那么这里的指针数组的每一个指针指向不是下一层的节点,而是ipc_perm结构体
ID二进制:0110 1000 1010 0011 1111 1001 1100 0010
分解:
根索引:[31:26] 011010 → 0x1A (26)
中索引:[25:20] 001010 → 0x0A (10)
叶索引:[19:14] 001111 → 0x0F (15)
`
结语
那么这就是消息对了通信的全部过程,那么至此我们就学习了4种通信方式,那么下一期我会更新linux的信号,那么我会持续更新,希望你能够多多关照,如果本文帮组到你的话,还请三连加关注,你的支持就是我创作最大的动力!