一个 C++ 信号/事件库 blinker 的开发笔记

本文是最近写的一个简单的 C++ 信号库 blinker.h 的开发笔记。

最原始的出发点是:

  1. 它要可以用在一个游戏或者机器人之类的 tick 循环中。
  2. 可以和我的另一个行为树的库 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.ba.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:

  1. 后端 buffer 主要记录生产者释放的信号内容,面向生产者 Emit
  2. 前端 buffer 供消费者观察和消费,面向消费者 Poll
  3. 在执行翻转 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 的翻转时机,可以立即进行、也可以在帧尾执行。

如果需要更细节一点的话,可以切分信号板,比如说分为两个板子:

  1. inputBoard 面向外部输入,可以在核心业务逻辑前翻转。
  2. internalBoard 面向内部生产的信号,可以在帧尾翻转,这样可保证一帧的完整性、信号也不会级联式地无休止地在一帧内激活。
while (...) {

    handleInputEvents();  // 转化外部输入到 signals
    inputBoard.Flip();  // 立即翻转外部信号板

    Update(); // 核心逻辑
    Draw(); // 渲染

    internalBoard.Flip(); // 翻转内部信号板,下一帧消费
}

最后,代码链接: https://github.com/hit9/blinker.h,欢迎给小星星。

(完)

本文原始链接地址: https://writings.sh/post/blinker

王超 ·
微信扫码赞赏
评论 首页 | 归档 | 算法 | 订阅