本文是最近写的一个简单的 C++ 信号库 blinker.h 的开发笔记。
最原始的出发点是:
- 它要可以用在一个游戏或者机器人之类的
tick
循环中。 - 可以和我的另一个行为树的库 bt.cc 搭配使用,以达到「事件驱动」、优化行为树性能的可能。
命名来源于 Python 世界中一个叫做 blinker 的信号库[1]。
用法示例:
// 生产
signal->Emit("some data");
// 消费
connection->Poll([&](const blinker::SignalId id, std::any data)) {
std::cout << std::any_cast<int>(data) << std::endl;
};
和大多数的事件库的作用一样,主要是用来解耦生产者和消费者,也就是说: 生产者不关心谁来消费、消费者也不关心谁在生产、都是管好自己。
那么对于这个非常小的库来说,特别之处有二。
前缀匹配订阅 ¶
消费者可以筛选一批信号来订阅。
比如说,采用通配符是一种方式,具体地,我采用的是 前缀匹配,比如订阅 a.*
,则会订阅到信号 a.b
和 a.c
等。
首先,信号、订阅者 都会依附于一个信号板 board
,然后每个信号有一个名字,一个板子上的所有信号按名字来组织成前缀树。
// 创建信号板
blinker::Board board;
// 注册信号
auto ax = board.NewSignal("a.x");
auto ay = board.NewSignal("a.y");
auto bz = board.NewSignal("b.z");
// 订阅信号
auto connection = board.Connect("a.*");
比如说下面的图中,代码中的三个信号组成了一颗前缀树,当我们订阅 a.*
时,会订阅到 a
开头的两个信号。
这样,方便对信号按前缀做分类整理,订阅者可以轻松地订阅一批感兴趣的信号。
在信号名字上,比如说一个任务的信号可以整理成下面的名字:
task.started
task.pending
task.paused
task.done
task.failed
task.errored
虽然我们是用前缀树来匹配信号的,但是这种匹配只发生在预先的绑定阶段,在信号生产和消费的运行时阶段,分发是按基于 bitset
的签名来匹配的,不会存在性能问题。
首先,每个信号在一个信号板中会有一个唯一的数字 ID,是自增的 1~N
。
class Signal {
private:
std::string name;
const SignalId id;
};
前缀树中的每个节点,都会存储以自身为根的子树的签名 signature
。
这个签名的意思是说,第 n
位是 true
,表示匹配到 ID 为 n
的信号。
template <std::size_t N>
using Signature = std::bitset<N>;
每个订阅 connection
也都有一个签名,标识自己匹配了哪些信号。
template <size_t N>
class Connection {
private:
const Signature<N> signature; // 订阅的所有信号的签名
};
在和信号板绑定时,会在前缀树上找到匹配的所有子树,然后取「或」,生成这个签名:
std::unique_ptr<Connection<N>> Connect(
const std::vector<std::string_view>& patterns) {
Signature<N> signature;
for (const auto& pattern : patterns)
// 前缀树 Match 函数返回一个模式串匹配的所有子树的签名的或结果
signature |= tree.Match(pattern);
return std::make_unique<Connection<N>>(signature, this);
}
这样在运行时分发信号时,只需要把激活的信号 和 自身的签名取交集,即可以知道哪些信号的回调函数需要执行了:
buffer.fired & connection.signature
双 buffer 设计 ¶
双 buffer 设计和渲染中的概念是类似的。
因为要用在 tick
循环中,其大部分逻辑都是约束在一个一般叫做 Update()
的主函数之中来执行的。 所以 事件的消费不应立即执行,而是要在一个地方 “兜” 一下, 然后在当前帧、或者下一帧再触发执行。这样才可以把逻辑收纳在一个 tick
循环之中。
每个信号板包括两块 buffer:
- 后端 buffer 主要记录生产者释放的信号内容,面向生产者
Emit
。 - 前端 buffer 供消费者观察和消费,面向消费者
Poll
。 在执行翻转
Flip
操作后,先清空前端、然后和后端交换指针。void Flip(void) { frontend->Clear(); std::swap(frontend, backend); }
每块 buffer 的内部结构是一样的,都包括一个签名,记录激活的信号:
template <size_t N = DefaultNSignal>
class Buffer {
private:
Signature<N> fired; // 哪些信号激活了?
std::any d[N]; // 相关的数据
};
这样,每个 connection
去主动检查当前的前端 buffer 中是否存在感兴趣的激活的信号,通过 bitset 取交集即可,很快的:
int Poll(const Signature<N>& signature, Callback cb, SignalId maxId) {
auto match = signature & fired;
for (int i = 1; i < maxId; i++)
if (match[i]) cb(i, d[i]);
return match.count();
}
至于两块 buffer 的翻转时机,可以立即进行、也可以在帧尾执行。
如果需要更细节一点的话,可以切分信号板,比如说分为两个板子:
inputBoard
面向外部输入,可以在核心业务逻辑前翻转。internalBoard
面向内部生产的信号,可以在帧尾翻转,这样可保证一帧的完整性、信号也不会级联式地无休止地在一帧内激活。
while (...) {
handleInputEvents(); // 转化外部输入到 signals
inputBoard.Flip(); // 立即翻转外部信号板
Update(); // 核心逻辑
Draw(); // 渲染
internalBoard.Flip(); // 翻转内部信号板,下一帧消费
}
最后,代码链接: https://github.com/hit9/blinker.h,欢迎给小星星。
(完)
本文原始链接地址: https://writings.sh/post/blinker