前言
上一篇介绍了鸿蒙业务模型中的三大概念以及简单的注册过程,相信读者已经对鸿蒙的业务逻辑有了一定的了解。简单的来说,就是将多个子功能注册到服务中,再把服务注册到全局系统功能管理器(Samgr)中。这样,一个服务包含零个或多个功能,而功能又绑定了对外接口,然后我们可以向暴露的接口发送消息,等服务执行特定的处理后再将响应消息发送回来。这是最简单最直观的一次交互过程,但是要完成这样的交互,鸿蒙的底层还需要做许多基础工作,比如服务的初始化、功能的初始化、消息传输对象的构建等等。所以,在本文中我将会为读者介绍一次简单的交互过程中所涉及的数据结构及通信过程,以帮助读者更好的理解鸿蒙的业务模型,起到抛砖引玉的作用。
进程内通信详解
上一篇文章已经介绍了Service、Feature和IUnknown以及它们的实例对象ServiceImpl、FeatureImpl和SamgrLiteImpl。在这部分将会对消息通信过程中重要的结构体进行分析,掌握这些重要结构就可以理解鸿蒙的通信机制和交互过程。
Vector
Vector说是鸿蒙系统中最重要的结构也不为过,它是鸿蒙开发的一种简化版的容器,适用于数据量较小且需要动态扩展的C语言开发场景。它的底层实现是封装了一块数据缓冲区,使用max、top和free三个字段来维护缓冲区中的数据。并定义了两个函数指针成员,一个用于获取vector中数据的键值,另一个用于比较键值,由调用者在创建vector时指定。它的结构定义如下:
typedef struct SimpleVector {
int16 max; //可存储的最大数据记录数,即vector的容量。
int16 top; //已使用的数据记录数。
int16 free; //已释放的数据记录数。
void **data; //数据缓冲区,指向一块内存空间
//函数指针,指向将数据元素转换为键值的函数
VECTOR_Key key;
/**
* 函数指针,指向比较调用者提供的两个键值的函数
* 1 表示key1大于key2
* 0 表示key1等于key2
* -1 表示key1小于key2
*/
VECTOR_Compare compare;
} Vector;
Vector部分更详细的数据和函数分析 看这里 。
消息队列
消息队列在linux系统中常用来辅助消息的传输,可以用作进程间通信,也可以用在线程间。而在鸿蒙系统中实现了一个无锁队列主要是用于线程间通信,在进程间的通信是采用的共享内存的方式。在针对鸿蒙代码的分析中,我们发现队列是通过MQueueId字段来标识并使用的,它存储的是队列所占内存的首地址,所以它是不可以用于进程间通信的,因为不同的进程有不同的地址空间,当前进程MQueueId所标识的队列地址在其他进程中是无效的。无锁队列的结构定义如下:
struct LockFreeQueue {
uint32 write; //消息入队时写入的起始位置
uint32 read; //消息出队时读取的起始位置
uint32 itemSize; //每个元素的大小
uint32 totalSize; //总字节大小
uint8 buffer[0]; //数据缓冲区,这里起一个占位的作用,空间大小由调用者使用malloc()决定
};
鸿蒙中的消息队列的存储空间是通过malloc()函数申请的,它所占用的空间布局如下图,仅画出部分字段。size是每个元素占用的字节数,count为元素个数。
消息对象
刚刚对消息队列进行了分析,那么肯定有读者会好奇消息队列中传输的消息体是什么样子。消息队列作为线程间通信的重要结构,消息体的设计也极为巧妙。消息可以分为请求消息和响应消息,服务端接收到请求消息后会调用消息处理函数进行处理,然后将响应信息发送给请求者。请求消息是通过Request封装的,响应消息是通过Response封装的,它们的结构定义如下:
//请求消息结构体,用于承载请求数据
struct Request {
int16 msgId; //消息ID,标识当前消息的操作类型
int16 len; //标识data指向的缓冲区的长度
void *data; //指向一块缓冲区,保存请求发送的数据
/*
如果请求中传输的数据比较小,那么可以通过msgValue这个字段进行传输。
就不需要调用malloc()函数为data字段申请内存,这样可以提高消息发送的效率。
*/
uint32 msgValue; //消息值,也可以用于保存小数据
};
//响应消息结构体,用于承载响应数据
struct Response {
void *data; //指向一块缓冲区,保存响应的数据
int16 len; //标识data指向的缓冲区的长度
};
介绍完请求消息结构体和响应消息结构体后,接下来就引出消息队列中真正传输的对象Exchange。它封装了Request和Response这两个重要的结构,并且用一个Identity类型的字段来标识目标服务和功能的地址。只要将消息放入目标服务和功能绑定的消息队列中即可完成消息的传输。Exchange和Identity结构体定义如下:
//消息通信时,消息队列中的元素对象
struct Exchange {
/*
exchange发往的目的服务或功能的地址
当客户端向服务端发送请求时,Identity是服务端的服务和功能的地址。
*/
Identity id; //目标服务或特性的标识
Request request; //请求消息体
Response response; //响应消息体
short type; //exchange对象类型,包括MSG_EXIT退出,MSG_ACK确认等
Handler handler; //异步响应或回调函数,用于消息的响应处理
uint32 *sharedRef; //用于共享请求和响应以节省内存
};
//用于标识服务和功能的地址信息
struct Identity {
int16 serviceId; //服务ID,即服务注册时,在samgr的vector中的下标
int16 featureId; //功能ID,即功能注册时,在服务的vector中的下标
MQueueId queueId; //服务和功能绑定的消息队列标识,本质上是队列的内存首地址
};
Exchange
上一篇说到鸿蒙系统消息机制中的消息体设计比较巧妙,那么Exchange消息体的设计巧妙在哪里呢?我们都知道计算机完成一项工作,最快的是CPU计算,而最慢的是I/O操作。尤其是将数据从磁盘拷贝到内存会导致程序阻塞,降低运行效率。并且计算机的内存是有限的并且比磁盘昂贵的多。我们在分析鸿蒙源代码的时候,发现它的消息传输机制的设计上就考虑到了这一点,目前所发现的有两处优点。 第一,消息传输的对象是exchange结构的数据,在它的内部包含了三个可用做数据传输的字段,它们分别是Request中的data、Request中的msgValue以及Response中的data。 对于Request中的msgValue,前面的注释中简单的介绍了它的作用,即当传输的数据小于无符号32位整数值时,我们可以避免使用malloc()函数申请数据缓冲区,而采用msgValue来传输数据。申请动态内存是很耗时的,并且频繁申请小空间会产生大量的碎片。这样一来就可以提高小消息的处理效率。不知道读者有没有发现,我们每次发送消息时都会传输Request和Response,那么我们是不是就有两个数据指针data可以使用了呢!只需要我们在消息的接收上做相应的区分,发一次消息就可以传输两个缓冲区地址。 第二,为了进一步节约内存空间,exchange中还有一个sharedRef字段,它的作用就是用来记录当前的exchange对象被引用的次数。 以广播服务为例,当广播一条消息时,一个exchange对象就可能会被发送到多个消息队列中,由于数据是保存在堆中,通过data指针使用,所以传输的数据不需要拷贝到各个消息队列中,每一个消息队列只是保存data指针即可。想象一下,如果传输的数据很大,而数据又是保存在类似数组的结构中,那么同一份数据就会被拷贝多次,造成内存的浪费。所以通过sharedRef,我们可以知道data指针被引用了多少次,当引用数为0的时候就可以释放它指向的内存。 exchange是消息传输过程中的信息载体,sharedref指向的值记录了动态缓冲区被引用的次数。当引用数==0时,释放所有的动态缓冲区,当!=0时,不做任何操作。通过引用数来维护动态缓冲区的共享,可以节约内存。 Exchange图示如下,只画出部分字段。
Taskpool
上面已经介绍了vector(常用于服务和功能等的注册)、消息队列和消息对象,为了能够更清晰的展示一次交互过程,加深读者的理解,在这里我们接着分析任务池(Taskpool)在交互过程中扮演的角色。在服务实例(ServiceImpl)和系统功能管理实例(SamgrLiteImpl)中都有这个字段。略有不同的是服务实例中只指向一个任务池。而系统功能管理实例指向的是一个数组,数组的每一个元素又指向一个任务池。 为了更好的解读鸿蒙系统业务逻辑的交互过程,我在这里做一个场景假设。 假设有两个服务,在这里一个作为客户端服务,另一个作为服务端服务。客户端向服务端发送请求,而服务端处理完请求消息后向客户端发送响应。将它们之间的消息通信称之为一次交互。 现在要实现它们之间的通信,而前面已经分析过服务实例、功能实例、消息队列以及消息对象,那么我们现在缺的就是推动消息的发送和接收。在鸿蒙系统中使用的就是任务池这种机制,它的底层维护了一组线程(可以是一个也可以是多个),负责消息队列中消息的发送和接收。每一个服务实例都会绑定一个任务池,而任务池又关联了一个消息队列,任务池中的线程负责从消息队列中读取消息并处理。服务实例只有绑定任务池后才会真正工作起来。 任务池的结构定义如下:
typedef struct TaskPool TaskPool;
struct TaskPool {
MQueueId queueId; //消息队列ID,即队列的首地址
uint16 stackSize; //栈大小,用于配置线程的栈
uint8 priority; //任务的优先级,用于配置线程的优先级
uint8 size; //任务池的大小,维护的线程数
uint8 top; //标识tasks中的线程ID的个数
int8 ref; //引用数,引用数为0时释放任务池
ThreadId tasks[0]; //记录任务池下属的线程ID
};
任务池图示如下。
进程内部通信系列小结
这里分析一下同一进程中不同服务间(线程间)的通信过程。 首先客户端产生请求数据并查询目标服务和功能的地址(Identity),然后从地址中拿到目标服务所绑定的消息队列ID(即消息队列首地址),将请求数据封装到exchange对象中,并修改它Identity字段中消息队列ID,改为当前客户端所绑定的消息队列ID,然后放入目标消息队列中。那么为什么要将exchange对象Identity的消息队列ID改为客户端的呢?因为只有这样,服务端在处理完这条消息时才知道应该把响应信息发送到哪个消息队列中。 服务端处理完消息队列中接收的请求数据后,将响应信息填充到这个exchange对象中,并发送到Identity字段记录的消息队列ID(客户端绑定的消息队列)中。至此,线程间的交互就已完成。下面我们再聊一聊进程间的重要数据结构及通信过程。
跨进程通信详解
Endpoint
从上一篇文章中我们分析了在同一进程内不同服务间采用的通信机制是消息队列。然而,在不同进程间服务的通信机制并不是鸿蒙系统设计的消息队列,而是采用了共享内存。这是因为同一进程内的各个服务的地址空间是共用的,所以消息队列的首地址一旦分配就是唯一的。而不同进程间的各个服务的地址空间是独立的,消息队列就不再适用。并且共享内存是进程间通信效率最高的,它减少了数据的拷贝次数。 鸿蒙系统中进程间通信的核心就是endpoint,也可以称为通信端点,它是当前进程与其他进程通信的进出口,所有进程间通信的交互都要经过它。每一个endpoint都有一个SvcIdentity字段来唯一标识当前进程的通信地址。当本进程的endpoint知道目标进程的endpoint地址后,就可以向它发送消息,完成进程间的交互。那么在多进程的环境下,我们如何知道目的进程的endpoint地址呢? 这就需要有一个知道所有endpoint通信地址的管理器来帮助我们发现地址。在鸿蒙的代码中指定了一个固定的SvcIdentity地址,作为公开的通信地址,我们把绑定这个地址的endpoint称为主endpoint或知名endpoint。所有的endpoint都要向这个主endpoint注册自己的通信地址。当本进程的endpoint需要向目的进程发送消息时,就可以向主endpoint查询目的进程的通信地址,有了地址以后我们就可以进行通信啦。它的结构定义如下:
//当前进程和其他进程间通信的通信端点
struct Endpoint {
const char *name; //端点名称
IpcContext *context; //ipc上下文
//作为当前进程中服务和功能与其他进程间通信的桥梁,充当查找指定服务时的路由功能
Vector routers; //routers中保存的是router对象
ThreadId boss; //主线程,用于接收其他进程发出的消息
uint32 deadId;
int running; //标识endpoint的启用状态
SvcIdentity identity; //endpoint的身份标识,作为当前进程对外暴露的通信地址
RegisterEndpoint registerEP;//指向注册通信端点函数的指针
TokenBucket bucket; //令牌桶,作为消息接收和处理的流控机制
};
SvcIdentity、PidHandle、Router
刚刚为大家介绍了endpoint的作用,并且提到了SvcIdentity,那么我们就趁热打铁介绍一下SvcIdentity是什么以及它的作用。先贴上它的结构定义:
//作为进程间的通信地址
typedef struct {
uint32_t handle; //当endpoint注册后,主endpoint会为它生成一个全局唯一的handle标识
uint32_t token; //标识服务和功能在路由表中的表项下标
uint32_t cookie; //暂未看到使用
#ifdef __LINUX__
IpcContext* ipcContext; //在linux下才有这个字段,进程通信的上下文
#endif
} SvcIdentity;
在针对代码的分析中,我发现SvcIdentity主要有两个作用,第一个就是通过handle字段唯一标识进程的通信地址。那么,Handle是如何产生的呢?这里不谈主endpoint的初始化、注册和启动过程,我们直接切入主题。 这里还是先做一个场景假设。1号进程创建并初始化了主endpoint。2号进程刚启动。 首先,2号进程先创建和初始化一个endpoint,称为IPC Client。然后向主endpoint发送注册消息,内核会在消息中填充2号进程的进程号(pid)、线程号(tid)和用户号(uid)。 最终,主endpoint从共享内存中读取到这条消息,会根据线程号tid产生唯一的handle标识,然后将pid、uid和handle保存到pidhandle中,然后将handle作为响应消息发送给2号线程。 自此2号进程的endpoint就成功注册,并得到了唯一的handle值,它作为当前进程的全局唯一标识。通过这个handle值可以唯一定位到一个进程。现在我们已经可以定位到指定进程了,但是鸿蒙系统业务的执行是通过服务来完成的,所以我们还需要知道如何定位到进程中的服务和功能。这就是SvcIdentity的第二个作用,通过token字段定位进程内的服务和功能。那么,token值是如何产生的呢?这就涉及到进程内服务和功能的注册了。 在客户端进程中有一个全局变量g_remoteRegister,它维护了当前进程对外的endpoint。而endpoint中有一个vector类型的字段,名为routers。它维护了一系列router对象,每一个router对象都一一对应一个服务和功能。所以也可以称它为路由表项,通过它可以唯一定位指定的服务和功能。将服务和功能到endpoint的routers中作为一个路由表项,而它的下标就是SvcIdentity的token值。 以下是pidhandle和router的结构体定义:
//用于标识endpoint和进程的关系
struct PidHandle {
pid_t pid; //进程ID
uid_t uid; //用户ID
uint32 handle; //向主endpoint注册后得到的唯一标识
uint32 deadId;
};
//路由表项,在进程间通信时,充当服务发现的路由功能
typedef struct Router
{
//这个字段用于在路由表中查找指定路由项时的key值
SaName saName; //标识服务名称和功能名称
//通过这个字段就可以定位到进程内部指定的服务、功能和消息队列
Identity identity; //标识服务名称、功能名称和消息队列
IServerProxy *proxy; //进程间通信的服务端代理接口
PolicyTrans *policy; //访问策略,做权限控制
uint32 policyNum; //访问策略的总数
} Router;
SAstore
上面介绍了进程间通信的主要数据结构,也提到有一个主endpoint负责管理和维护全局的endpoint地址信息。在这部分就为大家介绍一下系统功能存储的数据结构(SAstore),定义如下:
//系统功能的存储结构
struct SAStore {
int saSize; //维护的featureNode节点个数
//root中维护了一组服务的信息,而服务下面还链接着一系列的功能
ListNode *root; //链表的根,挂着服务结点
int16 mapSize; //记录maps所指向的连续内存空间的个数,每个大小为sizeof(PidHandle)
int16 mapTop; //记录maps中存储的元素个数
//maps中从小到大维护着一系列进程id和handle的对应关系
PidHandle *maps; //指向一块有序的连续内存空间,按照PidHandle中的pid从小到大排列。
};
虽然看着它的字段并不多,但是它却是进程间通信最基础的信息存储部分,各个服务的交互都需要依赖于它。它的root字段指向一个双向链表,链表的每一个结点都是一个服务信息。而在服务信息中还包含了一个由功能信息组成的双向链表。handle值保存在服务信息结点中,token值保存在功能信息结点中。这就完成了服务和功能以及SvcIdentity信息的存储。对于进程和handle的对应关系存放在maps中。它的图示如下:
跨进程通信小结
这部分针对鸿蒙系统的进程间通信所涉及的数据结构和过程进行了分析和总结,进程间通信过程的分析在SvcIdentity部分。