« May 2020 | Main | July 2020 »

June 16, 2020

skynet 并发模型的一点改进思路

skynet 的内核是一个多线程的消息分发器。每个服务有一个消息队列,任何服务都可以向其它任意服务的消息队列投递消息,而每个服务只可以读自己的消息队列,并处理其中的消息。

目前的工作原理是,在任意消息队列不为空的那一刻,将该消息队列关联的服务对象放在一个全局队列中。框架启动固定数量的工作线程,每个工作线程分头从全局队列中获取一个服务对象,并从关联的消息队列中获取若干条消息,顺序调用服务设置的回调函数。如果处理完后消息队列仍不为空,则将服务对象重新放回全局队列。

这样,就完成了尽量多(远超过工作线程数量)的并发服务的调度问题。

我这些年一直在考虑这个模型可否有改进之处。能不能设计得更简单,却还能在简化设计的基础上进一步提高并发性。同时,还可以更好的处理消息队列过载问题。

想要提高并发处理能力,主要是减少 CPU 在锁上空转浪费的计算力。

目前的设计中,全局队列是一个单点,所有工作线程都可能并发读写这个全局队列。为了减少锁碰撞的概率,我已经做了不少的优化,比如为不同的工作线程配置了不同的策略,有的会一次尽可能多的处理单个服务中的消息;有的在处理完一个服务中的单条消息后,就立刻切换到下一个服务。这样,每个工作线程去获取锁的频率就不太相同,同时,任务繁重的服务也得以尽量在同一个工作线程多做一些事情,而不必频繁进出全局队列。

但这个优化并没有从根本上改进设计。

另一个问题是,每个服务的消息队列是多写一读模式。只有唯一的一个读取者,也就是关联服务;却有众多潜在的写入者。比如 log 服务,所有其它服务都可能向它写入消息。它的队列的锁的碰撞概率就很高。

那么,有什么改进的空间呢?


我设想,以上的消息队列均可简化为一读一写,即只有严格意义上的一个写入者和一个读取者。

我们可以设置一个足够大的固定长度的全局消息队列,当服务 A 向服务 B 发送消息时,它是将消息投递到这个全局消息队列中。因为我们的工作线程数量是固定的,这个全局消息队列的内部实现就可以按工作线程的固定数量分解成同样数量的子结构。每个工作线程都只写入关联的子结构,这样就只存在唯一的写入者。而全局消息队列当然只分配一个独立的工作线程去读取和转发。这个转发线程就是整个全局消息队列的唯一读取者,同时、它还是所有服务私有消息队列的唯一写入者。

虽然比旧设计多了一步转发工作,但转发的只是数据指针,且业务非常简单,设置未必会占据满一个 cpu 核心。它或许会增加一些消息投递的延迟,但一定能在整体上增加 cpu 的利用率。一个并发队列的实现只有唯一的读者和写者时,首尾指针都不再需要锁就可以实现了。

这个转发线程,同样还可以承担工作线程的调度工作,统一把任务分发给工作线程上去。这样也同时避免了碰撞。


再说说过载问题。

因为旧设计中,每个服务的消息队列是无限长的,除非发生 oom ,否则投递消息总能成功。这简化了业务层的实现,不用考虑消息投递阻塞的例外处理。但同时也带来了麻烦。如果业务层不妥善处理,消息队列的过载极易产生雪崩效应。现在的设计中,只提供了少许基础设置来判断消息阻塞是否发生(可以获取自身的消息队列长度),依赖服务自身想办法解决问题。

我想,如果做了上面的消息转发,以及工作线程统一调度的改造后,我们可以更好的帮助业务层解决消息过载,尽可能地避免雪崩效应。

当我们发现任何服务的消息队列太长,那么就可以暂停所有向这个服务消息队列的投递行为。也就是,不再从全局消息队列转发消息到服务消息队列,而是暂存起来。(在实现细节上,可以在消息发生过载时服务队列加一个递增的版本号,每次转发都校验这个版本号,决定消息是直接转发还是暂存)暂存队列是转发服务的私有数据结构,没有并发的问题。

同时,因为我们是统一调度,所以还可以提高过载服务的处理优先级。专门安排一个工作线程处理它(如果多个服务过载,也可以分别安排到不同的工作线程上)。工作线程只要在处理服务消息,那么必定会逐步消化服务的消息队列。当情况缓解,再将暂存数据转发过去。

如此,每个服务的消息队列都可以实现成定长结构,进一步简化实现。


btw, 工作线程的任务调度本身也可以通过消息队列通讯来完成。每个工作线程有一对自己和调度线程通讯的消息队列,用来处理调度任务。调度线程平常只需要轮流将服务对象 id 发送到每个工作线程,工作线程每完成一个任务都把确认信息发回。如果发现完成了一步任务后,自己的工作队列还很长,也可以取出任务而不执行,直接发回去,方便调度线程将其分配给别的工作线程。

June 10, 2020

内存块对象的 Lua 封装

最近给 bgfx 的 lua binding 做了一点改进,因为修改了原有的 api 语义,所以需要做一点记录。

对于 3d 库来说,API 涉及大量的内存块的操作。创建 Buffer ,贴图,shader ,都需要输入一个数据块。大多数数据块是只读的,少部分是需要回写的。对于只读数据块,封装层可以用 lua string 替代,可写的用 userdata 。

bgfx 自己抽象了一个叫做 Memory 的结构,用来统一描述这类内存块对象。按 bgfx 的定义,Memory 的构造由用户决定,而释放通常由 bgfx 管理,而非调用者。

即,用户负责构造出 Memory 对象,将数据拷贝进去,然后再传递给 bgfx 的 api 后就可以撒手不管了。但是,如果你构造出 Memory 对象不传递给 bgfx 则会造成内存泄漏(因为没有任何直接释放它的方法);也不可以将一个 Memory 对象使用多次(传递给 bgfx 多次),因为一旦传给 bgfx ,就失去了对象的控制权。

这个用法,在 C/C++ 层面是非常好用的,但对于 lua binding 来说,很让人苦恼。因为很难安全的封装。

如果你将 Memory 封装成一个 Lua 的 userdata ,当用户构造出来后却因为种种原因没有使用(可能是发生了 error ,阻断了正常的执行流程),你没有办法消化掉它(因为没有直接释放的 api )。如果用户构造出一个对象,却无法多次使用,也会造成使用上的困扰。

Memory 的构造会多一次内存拷贝,可能也是个浪费。bgfx 提供了用引用来创建 Memory 的方法,但你必须自己保证数据的生命期。这里有两种方案:

  1. 保证引用的数据至少能活过 2 个 frame 。
  2. 提供一个用来释放内存的 callback 。

如果我们用方案 1 ,就需要自己将所有传入 bgfx 的 lua 内存对象暂时引用起来,每隔 2 frame 解除引用;如果用方案 2 , 需要考虑 bgfx 是一个多线程库,而跨线程操作 lua 的 vm 对象需要考虑线程安全问题。

基于这些难点,我一开始做 bgfx 的 binding 时,没有抽象出一个 memory 对象,而是在所有相关 api 处都直接处理输入参数。根据参数是 table / string / userdata 来临时创建出 Memory 对象,传给 bgfx 了事。不把细节暴露出去。

但随着日益开发,我们需要越来越多样的数据构造方法,这使得维护一组 api 变得负担颇大。有些复杂的构造方法(例如传入指针加偏移量等多个信息)参数过多,让相关 api 的参数的复杂性也变得不可接受。所以我还是决定抽象出 lua 层面的 memory 对象,一劳永逸的解决这个问题。

先说结果:

我给 binding 库增加了 bgfx.memory_buffer 这个新 api ,可以用 4 种方法创建出内存块。

  1. 用一个描述数据布局的字符串和一个 table 数组来创建。布局字符串可以描述每个数据段的数据类型(浮点/不同字长的整数),table 数组则是每个字段的数值。
  2. 用一个字符串,以及可选的起始位置及长度来创建一个不可写的内存块。
  3. 用 lightuserdata (指针) 以及可选的长度、数据关联对象来创建。这里的数据关联对象指,让框架帮你引用住这个对象,防止指针引用的内存失效。
  4. 直接指定一个 size ,创建一个可写的内存块。

大部分过去的 api 依旧兼容,但少部分 api ,例如 bgfx.create_vertex_buffer 就必须传入这样一个内存块对象,而不能像过去那样传入 table 。具体使用上的变化可以参考 example 。


我是如何实现这个东西的呢?

首先,这个 lua 内存对象并非对 bgfx::Memory 的直接封装。它在构造出来后,并非 bgfx 的 Memory 对象。所以即使你构造出来不用,也可以被安全的回收。

一旦它被 bgfx 的 api 调用,那么就会用数据引用的形式临时创建出一个 Memory 对象,传递给 bgfx 。并且递增了一个内存引用。当 bgfx 不再使用它后(在两个 frame 之内就会释放)回调函数会递减这个引用。

这个对象的 __gc 方法会检查引用计数。只有是 0 的时候才会安全的释放。如果引用计数不为 0 ,那么会让这个对象多活一小段时间。

怎样做到让一个进入 __gc 方法的对象多活一段时间?我使用的方法是在 __gc 方法内临时创建出一个 userdata ,并把自身挂载在其 uservalue 上。这个临时的 userdata 的 __gc 会再次检查引用计数,如果下次还是未能到 0 ,就继续这个过程。

June 05, 2020

层次结构和状态继承

在 blog 上,我写过好几篇关于场景管理模块的树结构的文章。这些也是我这两年在做游戏引擎中对象管理的思考历程。

通常游戏引擎中会把可渲染对象以树结构储存,这是场景管理模块最常见的作法。顺便说一句,GUI 界面也是用类似的方式。但是,我始终认为,从 gameplay 的层面上来看,游戏逻辑需要关注的对象并不需要用层次结构的方式管理。因为,空间结构上的层次很可能发生变化,从而引起关注的对象的层次路径变化。我们最终关注的那些东西不变,但它们在空间中的位置却会经常改变。

我一直在思考的问题是:为什么一定要用树结构组织可渲染对象?树结构到底带来了什么好处?

最直接的好处是,减少矩阵运算的次数。因为,渲染层最终需要对象在整个世界中的位置,而每个被渲染的部件本身却是逐级组合起来的(为了减少数据重复,我们不能因为一个部件换了个位置,就复制一次),部件只会记录相对整体的一个局部空间变换。如果我们平坦的保存没有可渲染部件,势必在计算它最终被渲染到屏幕时的世界矩阵的时候,需要连乘一长串局部矩阵。而组织成树结构,以一定的次序计算,可以大大减少最终矩阵乘法的数量。

但这一点好处,我认为还没有触及本质。表达空间位置的矩阵,仅仅是可渲染对象的一个属性而已。

层次结构的本质是让属性可以用继承的方式优化储存,并方便批量修改。对于每种属性,会定义一种对应的继承方法。

对于空间矩阵,如果一个对象没有自己的局部矩阵,那么它就继承了父亲的矩阵,如果有,继承的方式就是做 一次矩阵的乘法。修改根节点的空间矩阵,等价于修改了连同它的所有子孙的在空间中的位置。

其实,还有很多属性也需要继承。继承可以避免把相同的属性值复制到相关节点上,也方便了一组对象一起修改。

例如材质,当我们由于种种原因,将一个网格拆分成多个时(可能是因为网格顶点数量过多,也可能是因为它们的贴图不同),多个子网格的材质其实是基本一致的。

还有可使距离,用来控制摄象机距离多远的时候,就不再渲染该组对象。同一个物件,放在室内的时候可视距离比较近,放在室外的时候可视距离较远。

还有一类属性,可能简单到只是一个布尔量,但需要方便的成组改变。我能举出的例子有很多,下面只列出常见的几个:

  • 可见标记:我们有时需要临时关闭一组对象的显示,但又不希望删掉这些对象。
  • 投射阴影:让标记了的对象投射在阴影图上。
  • 接收阴影:在渲染的时候,考虑阴影图的影响。
  • 点选标记:被聚焦的时候,采用一种特殊的着色器高亮显示。
  • 水中倒影:需要绘制在水面的倒影中。

我觉得在实现的时候可以合并这类布尔量的标记,一起处理比较好。

用一个 64bit 整数保存 32 组状态。高 32bit 表示该状态是自己设置(1) 还是从父亲继承下来(0) 。低 32bit 表示每个状态(0/1) 。

这样,读取第 n 个状态可以简单的用 (state >> n) & 1

当我们需要从父亲继承状态时,可以使用:

local mask = state >> 32
state = ( parent.state & (mask~0xffffffff) | (state & mask) | (mask << 32)