思考 cora 的多核支持(后续)

2025-12-28

前一篇里面,由于有一个很强的限制,每个 GC 是跟线程绑定。这个限制太强了,导致最终思考推导出来多核模型只能是线程/VM/协程 1:1:N 这样的形态,即一个线程中运行一个 VM,在这个 VM 里面可以有许多协程。勉强能利用到多核,但是很难做工作流窃取实现负载均衡。

GC 跟线程的绑定,带来了一些 C 和 cora 交互上的便利性,因为 C 栈也是受 GC 保护的,在 C 里面写扩展的时候不用担心对象会被 GC。但是锁死了并发模型的上限,所以之前的方案我觉得“还是没想好”。GC 跟线程绑定是不是必须的?其实也不是。如果实现得更优雅,是可以做到 GC 只跟 VM 绑定的,每个 VM 一个 GC,每个 VM 独立的内存管理,这样就可以不受限启动多少 VM,每个 VM 在哪个线程上运行。于是我做了一个重要的优化,把 GC 跟线程绑定的限制给干掉了,让当前的 GC 只跟 VM 绑定。具体怎么做的没有单独写篇博客,但是这个限制去掉之后,现在又可以重新再思考 cora 的多核支持了。

现在的 cora,跟 lua 是类似的,所以这里描述的多核模型在 lua 或者其它 VM 上面也适用。

由 M 个线程驱动 N 个 VM

cora VM 的实例数量不受限制,每个 VM 都有自己的 GC 和内存管理,每个 VM 就是一个独立的资源单元,很容易访问自己的内存,但是不能直接访问其它 VM 的内存。可以由单个线程来运行全部的 VM,也可以由多个线程来运行全部的 VM,也就是线程池可以看作是底层的物理资源。最好是设置成线程数跟核数相同,这样就是每核对应一个线程 (TPC)。

是否可以直接拿 VM 当协程使用呢? VM 的执行是可以随时停下来,然后切换,然后恢复的。尽管 cora 设计足够轻量了,我还是感觉 VM 的资源粒度偏重。每个 VM 会有自己的堆,栈,GC,全局变量和环境,导入的模块,等等等。创建 VM 要时间,各个内部组件的初始化也会需要时间,启动全新的 cora VM 然后加载模块,还是会有耗时。每个 GC 对象的内存,还是会比协程要重一些。而 cora 的 VM 本身能支持协程了,一个 VM 里面可以跑许许多多的协程。所以我决定还是协程承担原有的角色。

只是说会有一个限制:单个 VM 内启动的协程,只能并行,不能并发。引入多 VM 应该是扩展并发的能力,而原有的协程设计继续保护,在 VM 内部,协程并行。不同 VM 之间,多核并发。

每个 VM 内部可执行多个 coroutine

每个 VM 内部可执行多个 coroutine,每个 coroutine 都有自己的栈和局部变量,但是共享全局变量和环境。coroutine 可以在 VM 内部切换。但是,coroutine 不能跨 VM 之间切换。也就是说,在这个 VM 中运行的 coroutine,不能够移动到其它 VM。

可以这么想:首先,我们有一个像 nodejs 一类的 runtime,它是单线程,非阻塞 io,可以有许多协程。如果需要执行一些重 CPU 的任务怎么办?单线程里面如果计算过重,会阻塞其它协程的运行。于是我们可以起一个计算 worker 去做其它事情,等它结果完全再唤醒相应的协程。这个模式会遇到什么问题?这是一个非负载均衡的 runtime,缺乏一个整体的调度。也不好说需要起多少 thread 来承担计算 worker,nodejs runtime 线程跟外部线程是割裂的。只对 IO 类型的场景比较友好,但是对重计算,或者计算 IO 混合负载的时候,就不再友好了。

所以我们不要再起线程啦,我们起 VM 吧。让 M 个线程去驱动 N 个 VM,负载均衡的问题就可以在这一层的调度去解决,VM 运行超过一段时间之后,就需要挂起再切换另一个 VM 获取计算资源。再然后,我们的 VM 也不要搞特殊了,不区分主 VM 和计算 VM,它们都是一个普普通通的 VM,每个里面可以起很多 coroutine。所有的 VM,都可以这么做,大伙都是平等的。

CML

coroutine 很好做到 IO 非阻塞。而多 VM 则可以做好多核利用。

假设现在我们可以

(spawn (lambda () (do something))) ;; 启动一个 coroutine

也可以

(spawn-cora (lambda () (do something))) ;; 启动一个新的 VM

还差什么?需要有机制来让不同的 VM, 不同的 coroutine 之间可以通信。actor 那种每个实例绑定一个 mailbox 的模式我不太喜欢,我希望这一层是解耦的,即可以随便创建 mailbox,也可以创建 VM,只有去读写 mailbox 的时候才有可能触发当前执行实单元的 yield。CML 模型是我比较喜欢的。同一个 VM 内部,coroutine 之间是可以自由传递信息的,而在不同 VM 之间,内存隔离,使得我需要两种数据结构来实现通信:

  1. mailbox:用于跨 VM 的传递消息
  2. channel:用于在同 VM 内部的 coroutine 之间传递消息

mailbox 既不绑定 VM 也不绑定 coroutine,只要能获得某个 mailbox 的 coroutine 都可以读写该 mailbox。

mailbox 和 channel 对用户的使用层面没太大的区别,只是在实现层面的差异。读写 channel 阻塞,会使当前的 coroutine 挂到 channel 的等待队列中,VM 会取自己的就绪队列中的下一下 coroutine 执行。恢复过程则是,如果 channel 条件满足了,就把上面的 coroutine 从 channel 的等待队列中移除,然后加入到 VM 的就绪队列中。对 mailbox 的处理则会复杂一些,因为 channel 不用考虑线程安全问题,而 mailbox 这边则可能会不同的 VM 在不同的线程上,访问同一个 mailbox。

mailbox 的 wakeup 过程

假设当前在 VM 的 coroutine 读写一个 mailbox,触发 coroutine 挂起,我们不能将整个 VM 都挂起,因为 VM 里面可能还有其它的 coroutine 可以执行。我们也不能直接把挂起的 coroutine,放到 mailbox 的等待列队里面,因为这样会这样不是线程安全的,一个 VM 中的 coroutine 并不能够被其它 VM 去访问。

所以 mailbox 的等待队列应该用一个什么样的数据结构呢?应该使用引用,实际的数据 owner 还是 VM。只有 VM 自己可以访问属于它的数据,而 VM 之外是不能访问的。

struct WakeUp {
  VM *vm;
  Coroutine *co;
}

每个 VM 内部,都会有一个 coroutine 的 ready queue。优化性能可以拆成两个,一个是不支持并发的普通队列,由 VM 的本地的 coroutine 使用;另一个是并发安全的队列,考虑多线程访问的场景使用。然而 mailbox 有消息可用之后,需要唤醒 mailbox 中的等待的 wakeup,就是把 wakeup->co 放回到 wakeup->vm 的线程安全的那个 ready 队列。通知方所在线程做到这一步就完全了,剩下的工作,由 VM 自己去调度,如果 VM 本身正在线程池的执行中,它取到队列就会执行。而如果 VM 没获取到线程,则等 VM 被唤醒的时候,再去执行。

poller 的设计

epoll 事件循环是必然会引入的。这里有个细微的地方是,应该每个 VM 一个 poller,还是每个 thread 一个,或者每个 NUMA 节点一个,还是全局只使用一个 poller?

初看的时候我以为应该设计成每个 VM 一个 poller,因为每个 VM 都几乎是相互独立的,一个 VM 通知另一个 VM,也可以借用 poller,如果我要一个 VM 中的 coroutine 给另一个 VM 中的 coroutine 发消息,可以通过 eventfd 来创建一个 fd,然后通过 poller 监听这个 fd,当 fd 可读时,就说明有消息了,然后唤醒 coroutine。但是这样设计是错误的,太重了!发送消息就需要系统调用嵌入到内核,由内核去通知,然后再唤醒另一个 VM... 事实上轻量的通知就应该用前面的 wakup 机制,走一个线程安全的队列就行。只有真正的 io 或者系统的事件,才应该走更重的 poller 的接口。

每个 VM 一个 poller 的做法,还有一个错误的地方是,它把回调处理跟通知机制耦合了。其实 poller 应该只做到事件通知,而收到通知之后,更重的具体事件回调处理,应该可以解耦合,这样就不会把 poller 的处理变慢,导致 block 更后面的消息。假设做成每个线程带一个 poller 呢?我们的 VM 是可能在不同的线程间移动的,这样就会一个 VM 在某个 poller 上注册的,但是挂起了,再到其它的线程上唤醒,再涉及到不同的 poller。

正确的做法,poller 可以当一个独立的组件去设计,先不要管由谁主来运行它,先思考它应该做啥?实际上它应该做的就是持有 Wakup 引用,然后事件 ready 后把 co 放到 VM 的线程安全的 ready 队列,它的工作就完成了。这是足够的轻量以至于全局一个 poller 应该就满足条件了,这样还可以简化一些更复杂的逻辑,避免每个线程一个 poller 之后 VM 在不同的 poller 间挂起和唤醒的麻烦。

最终形态

最终形态应该是这样子:

  • 可以只运行一个 VM,然后 VM 里面许多多的 coroutines,只需要一个 thread 去驱动 (并行,不并发)
  • 可以运行多个 VM,每个 VM 里面运行一个或者许多的 coroutines,只由一个 thread 去驱动 (并行,不并发)
  • 运行 M 个 VM,由 N 个 thread 去驱动 (VM 并发,同一 VM 内的 coroutine 并行)

只有一个 thread 的时候,可以直接让这个 thread 也同时执行 scheduler 和 poller 的逻辑,scheduler 就是不停地从队列中取出一个 VM,运行一个会儿,然后队 VM 放回队列。而 poller 就是当就绪队列是空着的,去 poll 一下。有多个 thread 的时候,调度还是 thread pool 中每个 thread 去就绪队列中抢 VM 来执行,执行一段时间片还放回到队列。而 poller 这边,可以考虑单独一个 thread 来执行 poller 的逻辑。至于更复杂的调度的逻辑,就是后面再去考虑了。

解耦的地方是,多个 VM 不需要关心它是会被一个 thread 还是多个 thread 来驱动,反正 VM 的队列会被运行的。发送消息方,不需要去关注唤醒的逻辑,反正丢个 Waker 挂到 mailbox 上面,后面就自然会有人去处理了。然后 poller 这边,不用想着唤醒之后回调咋执行,只负责消息传达,把 coroutine 丢到相应队列就行了。

cora