Post

HaloOS vbslite初探

HaloOS vbslite初探

本文主要介绍HaloOS通信中间件vbslite,代码版本tag_V1.0.0_20250721,运行在Ubuntu 20.04上。

1. 代码规范

如果要打造一个良性的开源社区,方便大家协作,规范性是十分必要的,这其中包含:代码风格、bug提交规范,代码入库规范、测试规范、文档规范、评审机制等。

在阅读代码期间发现不少规范性的问题,不知道是vbslite缺少统一的规范,还是有规范但执行不到位,下面举几个例子(部分问题已反馈给作者):

  • vbslitespace/examples/local_test/app_test.c中变量未对齐: 变量未对齐

  • vbslitespace/mvbs/posix_aux/src/loop.c操作符前后是否加空格风格不一致: 操作符前后是否加空格风格不一致

  • vbslitespace/mvbs/src/adapter/posix/src/adapter_socket.c中tab和space混用: tab和space混用

  • 下面这个提交,提交说明形同虚设,不点进去看代码都不知道改了什么: 从提交说明看不出改了什么

  • 下面这个提交说是修改文档,结果一个文档没改,改了大量的代码提交说明严重不符

2. 线程模型

通信中间件的线程模型特别重要,比如api是否线程安全、回调函数从哪个线程执行,掌握了这些信息,有助于减少因为使用不当导致的bug,比如该加锁的地方没加锁。

根据官方文档,基于vbslite开发的程序一般是下面这种结构:

  • 创建loop
  • 阻塞在loop上循环等待事件
  • 处理socket/定时事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main(int argc, char* argv[]) {
    // 创建loop
    struct mvbs_event_loop* loop = mvbs_event_loop_create(MVBS_APP_LOOP_PERIOD_MS);

    while (true) {
        // 等待事件
        uint32_t event = mvbs_event_loop_wait(loop);

        if (event & MVBS_EV_TIMER) {
            // 处理定时任务
        }

        if (event & MVBS_EV_RECV) {
            // 处理socket事件
        }
    }
}

使用vbsliste框架会额外引入两个线程:

  • socket io线程,处理socket收发事件
  • 定时器线程:严格来讲算不上定时器,只是定时唤醒用户线程

线程模型

函数mvbs_event_loop_create会创建上面提到的两个框架线程:

  • 定时器线程处理函数timer_event_handle是while循环加sleep实现的,每隔一段时间(用户设置的阈值)通过mvbs_event_send唤醒用户线程
  • socket线程通过 socket_recv_loop –> adapter_socket_monitor_loop监听socket事件,底层实现是epoll,epoll唤醒后,首先调用各socket对应的handler处理socket事件,这些handler都是框架注册的,比如收网络数据、接收新连接,等这些io操作处理好了,再通过mvbs_event_send唤醒用户线程
  • mvbs_event_send唤醒用户线程是通过pthread_cond_t条件变量实现的,main函数中用户线程调用mvbs_event_loop_wait就是阻塞在mvbs_event_wait
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// mvbs/posix_aux/src/loop.c
// 函数mvbs_event_loop_create代码片段,有删减

struct mvbs_event_loop* mvbs_event_loop_create(uint32_t peroid_ms) {
    struct mvbs_event_loop* e;

    // socket线程/定时器线程唤醒用户线程的事件通道
    e->ev = mvbs_event_create();
    e->peroid_ms = peroid_ms;

    // 创建定时器线程
    pthread_create(&e->timer_thrd, &attr, timer_event_handle, e);
    pthread_detach(e->timer_thrd);

    adapter_socket_init();

    // 创建socket线程
    pthread_create(&e->sock_thrd, &attr, socket_recv_loop, e);
    pthread_detach(e->sock_thrd);

    return e;
}

定时器线程和socket线程是不是可以合并,通过调整epoll的timeout来实现定时功能?

3. 数据收发流程

接下来以官方的rpc_test为例,看下数据收发流程。
基于dds的pub/sub数据收发流程类似,只是序列化/反序列化、Transport部分有差异,本文不再展开,后面看情况是否单写一篇。

整体数据收发流程如下图所示(点击查看高清大图),红色虚线左侧是client进程,右侧是server进程:

rpc数据收发流程

我们重点关注以下几点:

  • socket线程收到数据后,是先放在FIFO队列的,用户线程收到MVBS_EV_TIMER事件后消费数据(不知道为啥没监听MVBS_EV_RECV)。以server端为例,socket线程在第6步通过adapter_socket_tcp_read接收数据后,放到FIFO队列,用户线程在第9步通过rpc_server_recv_loop调用rpc_connection_recv,从FIFO中取数据。因为FIFO的生产者和消费者在不同的线程,所以要有锁保护。
  • rpc调用的超时机制:client端轮询实现,如果server长时间未响应,或者由于FIFO溢出导致没收到reply,add_cb会收到RPC_ERRNO_TIMEOUT
  • server如何知道调用的是哪个rpc函数:client端在发送请求的时候带上模块名和函数名,服务端收到后根据模块名和函数名匹配回调函数,如下面代码所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
      // client端生成代码:rpc_test_client/gen/rpc_test/calculatorRpcClient.c
    
      // client发送前在函数MVBS_calculator_add中序列化了模块名和rpc函数名
    
      /*  step4: serialize the interface name */
      if (mcdr_serialize_string(&stream, "MVBS_calculator") == false)
          goto MCDR_FAIL;
    
      /*  step5: serialize the operation name */
      if (mcdr_serialize_string(&stream, "add") == false)
          goto MCDR_FAIL;
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
      // server端生成代码:rpc_test_client/gen/rpc_test/calculatorRpcServer.c
    
      // server收到请求后匹配interface和operation决定调用哪个rpc函数
    
      static struct rpc_srv_handler srv_tab[] = {
          {
              .interface = "MVBS_calculator",
              .operation = "add",
              .handle = MVBS_calculator_add_handle,
              .svc_cb = MVBS_calculator_add_svc,
              .stream = 0,
              .active = 0,
              .sn = 0,
          },
      };
    
  • 上面rpc_srv_handler中的MVBS_calculator_add_svc强弱符号机制实现了类似c++中的Overriding功能,生成代码rpc_test_client/gen/rpc_test/calculatorRpcServer.c中的函数MVBS_calculator_add_svc是空壳,被添加了__attribute__((weak))属性,是弱符号,用户代码examples/rpc_test/server.c中的MVBS_calculator_add_svc是强符号,在编译的时候,最终链接的是用户代码中的强符号。

  • client收到reply后,如何知道调用哪个callback:client在每次rpc调用前都通过rpc_client_alloc_sn分配了序号并附加在请求报文中,server在reply中回传该序号,client通过这个序号查找对应的callback。

现在rpc仅支持tcp,并且仅支持异步调用(基于上述线程模型分析,vbslite现在的框架是无法支持同步调用的)。

4. 内存使用情况

4.1 拷贝次数

上述收发过程对应的内存拷贝情况如下:

  • 收发双方必要的序列化、反序列化
  • socket通信用户态与内核态之间的拷贝(图中的1、2、5、6四处)
  • 进出FIFO的内存拷贝(图中的3、4、7、8四处)

rpc内存拷贝次数

上述这些拷贝,在RPC通信中都是中规中矩的,在本机IPC通信中通过其他方法存在优化的可能性(需要综合考虑系统调用的开销,比如数据量较大时使用memfd)。官方提到的零拷贝在vbslite版本中暂未看到,可能vbspro配合理想自己的vcos有这个特性,后面有时间再研究下。

4.2 内存预分配

在vbslite中,内存都是预分配的。比如上面的例子rpc_test,在app_init中就通过mvbs_mm_init预分配了内存。这里的预分配并不是调用系统函数malloc分配一大块内存,而是通过全局静态变量的方式占用了一大块内存,在程序加载的时候内存就分配好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// mvbs_mm_init中预分配的内存

// mvbs/src/adapter/posix/src/mvbs_adapter_base.c

#define MVBS_HEAP_SIZE_L	(256 * 1024)
static uint8_t mvbs_heap_5[MVBS_HEAP_SIZE_L];

static struct mem_region mvbs_heap_region[] = {
    {
        .mr_start = mvbs_heap_5,
        .mr_size = MVBS_HEAP_SIZE_L,
    },
    {
        .mr_start = NULL,
        .mr_size = 0,
    }
};

预分配的内存通过mvbs_mm_region_register交给vbslite自己的内存管理模块,后续动态内存的申请、释放调用mvbs_mallocmvbs_free等函数就行了。前面提到的FIFO队列,也是从这里申请的内存。

这样做的好处是:

  • 避免了调用系统内存管理函数导致的时延不确定性,这点对于实时操作系统很重要
  • 踩内存检测机制
    • 为每块内存打了标记,free的时候检测被释放内存块是否被踩
    • 提供了mvbs_mm_check_guard函数,可以遍历整个预分配内存区,检测是否有内存块被踩。这种机制对开发同学定位问题很有用,之前我在定位一个踩内存问题时也用过类似方式
  • 便于统计内存使用情况

mvbs_malloc、mvbs_free并不是线程安全的,需避免多线程并发使用。

5. 总体观感

从技术层面看,vbslite如果跑在其他系统上进行商业化使用,happy path问题不大,unhappy path则还有很多工作要做。

不过vbslite毕竟刚开源,希望后面会越来越完善,成为行业的福祉。

This post is licensed under CC BY 4.0 by the author.