« 关于虚拟文件系统的一些新想法 | 返回首页 | 重构 ltask 的任务调度器 »

以非阻塞方式执行一个函数

在 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 。

Comments

"但是,如果一个需要长期运行的 C 函数并不需要访问 context (包括 Lua 虚拟机),而实现者自己能保证函数自身没有竞态问题,那么,在运行这段代码的同时,让另一个工作线程继续处理同一个服务,其实是满足条件的。"
这个和
“这个设计的目的是,让开发者轻松享受到多核带来的并发性能优势,同时减轻编写多线程程序带来的心智负担。”
其实是冲突的,一个使用者需要考虑代码是否有race condition了,如果开发者自己都能充分考虑到这个问题,也就不会使用异步串行避免race condition了。

就不提供可能导致卡顿/阻塞的接口。所有返回时间超过限定(比如30毫秒)的c接口一律报错,强制走异步模式🤔

“比如说,网络线程其实可以实现为一个常规服务,而不必像现在这样放在 skynet 的内核中。目前这样做是因为网络处理部分会阻塞在 epoll 的 wait api 上。当等待新的网络消息期间,它无法正常处理 skynet 的内部消息。”


其实许多调度器的实现是等待在 epoll wait 上面的, 使用一个单独的自定义消息队列 + 单独的 socketfd, 来触发自定义事件,这样就可以做到IO和自定义事件统一调度。

云大有空可以看看Rust,写惯了c/c++,再看Rust,只能说很多设计精妙,完全解决各类痛点问题。

Rust中异步编程处理这种情况有两种方式:
1、新起一个独占线程运行长时间任务,后续其他任务不使用该线程调度,直到该线程任务结束。
2、复用当前线程,但是会驱逐该线程上的其他task。

Post a comment

非这个主题相关的留言请到:留言本