OpenHarmony轻量系统服务管理|进程间通信数据结构及过程详解
前言
之前介绍了鸿蒙业务模型中的三大概念以及简单的注册过程,相信读者已经对鸿蒙的业务逻辑有了一定的了解。简单的来说,就是将多个子功能注册到服务中,再把服务注册到全局系统功能管理器(Samgr)中。这样,一个服务包含零个或多个功能,而功能又绑定了对外接口,然后我们可以向暴露的接口发送消息,等服务执行特定的处理后再将响应消息发送回来。这是最简单最直观的一次交互过程,但是要完成这样的交互,鸿蒙的底层还需要做许多基础工作,比如服务的初始化、功能的初始化、消息传输对象的构建等等。所以,在本文中我将会为读者介绍一次简单的交互过程中所涉及的数据结构及通信过程。
服务间通信
之前已经介绍了Service、Feature和IUnknown以及它们的实例对象ServiceImpl、FeatureImpl和SamgrLiteImpl。在这部分将会对消息通信过程中重要的结构体进行分析,掌握这些重要结构就可以理解鸿蒙的通信机制和交互过程。
Vector
Vector说是鸿蒙系统中最重要的结构也不为过,它是鸿蒙开发的一种简化版的容器,适用于数据量较小且需要动态扩展的C语言开发场景。它的底层实现是封装了一块数据缓冲区,使用max、top和free三个字段来维护缓冲区中的数据。并定义了两个函数指针成员,一个用于获取vector中数据的键值,另一个用于比较键值,由调用者在创建vector时指定。它的结构定义如下:
1 typedef struct SimpleVector { 2 int16 max; //可存储的最大数据记录数,即vector的容量。 3 int16 top; //已使用的数据记录数。 4 int16 free; //已释放的数据记录数。 5 void **data; //数据缓冲区,指向一块内存空间 6 //函数指针,指向将数据元素转换为键值的函数 7 VECTOR_Key key; 8 /** 9 * 函数指针,指向比较调用者提供的两个键值的函数 10 * 1 表示key1大于key2 11 * 0 表示key1等于key2 12 * -1 表示key1小于key2 13 */ 14 VECTOR_Compare compare; 15 } Vector;
Vector图示如下,仅画出部分字段。
消息队列
消息队列在linux系统中常用来辅助消息的传输,可以用作进程间通信,也可以用在线程间。而在鸿蒙系统中实现了一个无锁队列(后面还是有锁,可能是比较细粒度吧)主要是用于线程间通信,在进程间的通信是采用的共享内存的方式。队列是通过MQueueId字段来标识并使用的,它存储的是队列所占内存的首地址,所以它是不可以用于进程间通信的,因为不同的进程有不同的地址空间,当前进程MQueueId所标识的队列地址在其他进程中是无效的。无锁队列的结构定义如下:
1 struct LockFreeQueue { 2 uint32 write; //消息入队时写入的起始位置 3 uint32 read; //消息出队时读取的起始位置 4 uint32 itemSize; //每个元素的大小 5 uint32 totalSize; //总字节大小 6 uint8 buffer[0]; //数据缓冲区,这里起一个占位的作用,空间大小由调用者使用malloc()决定 7 };
鸿蒙中的消息队列的存储空间是通过malloc()函数申请的,它所占用的空间布局如下图,仅画出部分字段。size是每个元素占用的字节数,count为元素个数。
消息对象
消息队列作为线程间通信的重要结构,消息体的设计也极为巧妙。消息可以分为请求消息和响应消息,服务端接收到请求消息后会调用消息处理函数进行处理,然后将响应信息发送给请求者。请求消息是通过Request封装的,响应消息是通过Response封装的,它们的结构定义如下:
1 //请求消息结构体,用于承载请求数据 2 struct Request { 3 int16 msgId; //消息ID,标识当前消息的操作类型 4 int16 len; //标识data指向的缓冲区的长度 5 void *data; //指向一块缓冲区,保存请求发送的数据 6 /* 7 如果请求中传输的数据比较小,那么可以通过msgValue这个字段进行传输。 8 就不需要调用malloc()函数为data字段申请内存,这样可以提高消息发送的效率。 9 */ 10 uint32 msgValue; //消息值,也可以用于保存小数据 11 };
1 //响应消息结构体,用于承载响应数据 2 struct Response { 3 void *data; //指向一块缓冲区,保存响应的数据 4 int16 len; //标识data指向的缓冲区的长度 5 };
介绍完请求消息结构体和响应消息结构体后,接下来就引出消息队列中真正传输的对象Exchange。它封装了Request和Response这两个重要的结构,并且用一个Identity类型的字段来标识目标服务和功能的地址。只要将消息放入目标服务和功能绑定的消息队列中即可完成消息的传输。Exchange和Identity结构体定义如下:
1 //消息通信时,消息队列中的元素对象 2 struct Exchange { 3 /* 4 exchange发往的目的服务或功能的地址 5 当客户端向服务端发送请求时,Identity是服务端的服务和功能的地址。 6 */ 7 Identity id; //目标服务或特性的标识 8 Request request; //请求消息体 9 Response response; //响应消息体 10 short type; //exchange对象类型,包括MSG_EXIT退出,MSG_ACK确认等 11 Handler handler; //异步响应或回调函数,用于消息的响应处理 12 uint32 *sharedRef; //用于共享请求和响应以节省内存 13 };
1 //用于标识服务和功能的地址信息 2 struct Identity { 3 int16 serviceId; //服务ID,即服务注册时,在samgr的vector中的下标 4 int16 featureId; //功能ID,即功能注册时,在服务的vector中的下标 5 MQueueId queueId; //服务和功能绑定的消息队列标识,本质上是队列的内存首地址 6 };
Exchange
鸿蒙系统消息机制中的消息体设计比较巧妙,那么Exchange消息体的设计巧妙在哪里呢?相对与CPU计算速度,IO操作速度是十分缓慢的,并且计算机的内存是有限的并且比磁盘昂贵的多。在分析鸿蒙源代码的时候,发现它的消息传输机制的设计上就考虑到了这一点,目前所发现的有两处优点:
1.消息传输的对象是exchange结构的数据,在它的内部包含了三个可用做数据传输的字段,它们分别是Request中的data、Request中的msgValue以及Response中的data。
对于Request中的msgValue,前面的注释中简单的介绍了它的作用,即当传输的数据小于无符号32位整数值时,我们可以避免使用malloc()函数申请数据缓冲区,而采用msgValue来传输数据。申请动态内存是很耗时的,并且频繁申请小空间会产生大量的碎片。这样一来就可以提高小消息的处理效率。每次发送消息时都会传输Request和Response,那么是不是就有两个数据指针data可以使用了呢!只需要在消息的接收上做相应的区分,发一次消息就可以传输两个缓冲区地址。
2.为了进一步节约内存空间,exchange中还有一个sharedRef字段,它的作用就是用来记录当前的exchange对象被引用的次数。
以广播服务为例,当广播一条消息时,一个exchange对象就可能会被发送到多个消息队列中,由于数据是保存在堆中,通过data指针使用,所以传输的数据不需要拷贝到各个消息队列中,每一个消息队列只是保存data指针即可。想象一下,如果传输的数据很大,而数据又是保存在类似数组的结构中,那么同一份数据就会被拷贝多次,造成内存的浪费。所以通过sharedRef,我们可以知道data指针被引用了多少次,当引用数为0的时候就可以释放它指向的内存。
Exchange图示如下,只画出部分字段:
Taskpool
上面已经介绍了vector(常用于服务和功能等的注册)、消息队列和消息对象,为了能够更清晰的展示一次交互过程,加深读者的理解,在这里我们接着分析任务池(Taskpool)在交互过程中扮演的角色。在服务实例(ServiceImpl)和系统功能管理实例(SamgrLiteImpl)中都有这个字段。略有不同的是服务实例中只指向一个任务池。而系统功能管理实例指向的是一个数组,数组的每一个元素又指向一个任务池。
为了更好的解读鸿蒙系统业务逻辑的交互过程,我在这里做一个场景假设。 假设有两个服务,在这里一个作为客户端服务,另一个作为服务端服务。客户端向服务端发送请求,而服务端处理完请求消息后向客户端发送响应。将它们之间的消息通信称之为一次交互。 现在要实现它们之间的通信,而前面已经分析过服务实例、功能实例、消息队列以及消息对象,那么我们现在缺的就是推动消息的发送和接收。在鸿蒙系统中使用的就是任务池这种机制,它的底层维护了一组线程(可以是一个也可以是多个),负责消息队列中消息的发送和接收。每一个服务实例都会绑定一个任务池,而任务池又关联了一个消息队列,任务池中的线程负责从消息队列中读取消息并处理。服务实例只有绑定任务池后才会真正工作起来。
任务池的结构定义如下:
1 typedef struct TaskPool TaskPool; 2 struct TaskPool { 3 MQueueId queueId; //消息队列ID,即队列的首地址 4 uint16 stackSize; //栈大小,用于配置线程的栈 5 uint8 priority; //任务的优先级,用于配置线程的优先级 6 uint8 size; //任务池的大小,维护的线程数 7 uint8 top; //标识tasks中的线程ID的个数 8 int8 ref; //引用数,引用数为0时释放任务池 9 ThreadId tasks[0]; //记录任务池下属的线程ID 10 };
任务池图示如下:
小结
这里分析一下同一进程中不同服务间(线程间)的通信过程,先客户端产生请求数据并查询目标服务和功能的地址(Identity),然后从地址中拿到目标服务所绑定的消息队列ID(即消息队列首地址),将请求数据封装到exchange对象中,并修改它Identity字段中消息队列ID,改为当前客户端所绑定的消息队列ID,然后放入目标消息队列中。那么为什么要将exchange对象Identity的消息队列ID改为客户端的呢?因为只有这样,服务端在处理完这条消息时才知道应该把响应信息发送到哪个消息队列中。
服务端处理完消息队列中接收的请求数据后,将响应信息填充到这个exchange对象中,并发送到Identity字段记录的消息队列ID(客户端绑定的消息队列)中。至此,线程间的交互就已完成。下面我们再聊一聊进程间的重要数据结构及通信过程。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库