在 skynet 中,服务之间并行运行,而每个服务自身的业务都是串行的。一个服务由开发者自行切分成多个时间片,每个时间片串行运行在不同的工作线程上。最常见的做法是在每个服务中运行一个 Lua 虚拟机,用 coroutine 切分时间片,这样从编写代码的角度看,任务是连续的。
这个设计的目的是,让开发者轻松享受到多核带来的并发性能优势,同时减轻编写多线程程序带来的心智负担。
用过 skynet 的应该都碰到过:当我们在服务中不小心调用了一个长时间运行而不返回的 C 函数,会独占一个工作线程。同时,这个被阻塞的服务也无法处理新的消息。一旦这种情况发生,看似是无解的。我们通常认为,是设计问题导致了这种情况发生。skynet 的框架在监测到这种情况发生时,会输出 maybe in an endless loop
。
如果是 Lua 函数产生的死循环,可以通过发送 signal 打断正在运行运行的 Lua 虚拟机,但如果是陷入 C 函数中,只能事后追查 bug 了。
那么,如果我原本就预期一段 C 代码会运行很长时间,有没有可能从底层支持以非阻塞方式运行这段代码呢?即,在这段代码运行期间,该服务还可以接收并处理新的消息?
在很长时间里,我认为在保证前面说的严格约束条件下,无法实现这个特性。这个约束就是,skynet 的服务必须以串行方式运行。但最近,我发现其实用一点巧妙的方式,还是有可能做到的。但我们需要重新审视约束条件。
我们约束了 skynet 的单个服务以串行方式运行,指的是,所有对服务 context 的操作都是串行的。如果是一个 Lua 服务,这个 context 应包括 Lua VM 。但是,如果一个需要长期运行的 C 函数并不需要访问 context (包括 Lua 虚拟机),而实现者自己能保证函数自身没有竞态问题,那么,在运行这段代码的同时,让另一个工作线程继续处理同一个服务,其实是满足条件的。
假设让 skynet 提供两个函数:skynet_yield()
和 skynet_resume()
。
当我们调用 yield 时,通知框架结束当前服务的时间片。这时,该服务的工作线程阻塞在服务的回调函数上,但我们依然可以关闭时间片。同时,框架可以额外启动一个备用的线程,补充临时减少的处理能力。这个服务被放回调度队列中,运行其它工作线程处理它的后续消息(即,可以继续调用服务的处理函数)。
等长期任务执行完毕,它并没有离开同一个工作线程的同一个 C 调用栈,但这时调用 resume ,框架则去检查当前服务是否正在被其它服务处理。如果有,等其它处理线程处理完毕后,不要归还服务进调度队列,由 resume 调用者继续后续的流程。
这样,我们通过 yield/resume api 拥有了在不离开当前工作线程而临时切分时间片的能力。只要实现者自己保障 yield 和 resume 之间的线程安全问题就够了。
从 Lua 的角度看,如果预期一个 C 函数调用可能是长期的,那么就在这个 C 函数中加入 yield 和 resume ,隔开耗时的部分,并保证被隔出的部分不会访问 Lua 虚拟机即可。
对这种特制过的 C 函数的调用,使用上看起来就是远程调用了一个系统服务,让出了 Lua 虚拟机并等待回应。但这个系统服务实际上是在当前的 C 调用栈上执行的。
这种略显诡异的方法,其实我在 ltask 中就实现过 。
接下来如果我给 skynet 增加这个特性,看起来可以做到之前难以完成的任务。
比如说,网络线程其实可以实现为一个常规服务,而不必像现在这样放在 skynet 的内核中。目前这样做是因为网络处理部分会阻塞在 epoll 的 wait api 上。当等待新的网络消息期间,它无法正常处理 skynet 的内部消息。
一旦有这样的特性,我们只需要把 wait 夹在 yield 和 resume 之间就可以了。
封装一些现成的自带阻塞 api 的 C 库也会更容易:我们可以直接接入官方的 db driver ,而不必把它们的 io 部分换成 skynet 的专有 api 。