关于 skynet 调度器的一点想法(续)
这篇是继续上次的一些想法。
我最近想重新做一个新的基于消息管道的 N:M 多任务调度器。主要用在我们的客户端上。算是解决在使用 skynet 多年来总结出来的一些问题。
首先,我想改变 skynet 服务直接将待发消息投递到对方消息队列的做法。发出消息会让服务挂起,等待投递。
然后,我设计了一个叫做调度器的模块,用于把带发消息投递到对应的接收管道中。调度器是发送消息的消费者,接收管道的唯一生产者;而每个服务是它自己的发送消息的唯一生产者,接收管道的唯一消费者。这样没有竞争,当消息队列是定长的时候,也无需使用锁(但引入了消息队列满的新状态)。
同时,也遵循 skynet 的约定,send 消息是不会引起服务重入的。所以,因为发送消息而挂起的服务,调度器会尽快处理,给出结果,延续服务的运行。中间不会插入针对这个服务的别的事务。消息投递结果可能是成功,也可能是对方不存在,或对方队列阻塞。如果发送发送阻塞,逻辑层可以决定是重试还是丢弃。
这个方案是实现的重点是这个调度器。我想改变 skynet 现在每个工作线程去抢任务的方案。由(唯一的)调度器去分派任务。这样可以避免全局队列的锁,还可以让服务尽量在同一物理线程上工作。
实现细节我想了不少时间,方案主要是受最近 100+ 小时的异星工厂游戏时间的启发。
我原本想给每个工作线程安排一个工作队列,由调度器根据任务原本所在的工作线程,以及工作负荷均分的原则将任务投递进去。这些工作队列理论上只有工作线程本身单一消费者,和调度器这个唯一生产者。也可以简单实现。
但这里无法解决极端情况:我们无法预知未执行的任务到底需要多长时间。一旦一个任务花了超长的时间,就会导致所属工作线程上的工作队列中的任务全部收到延迟影响。如果再让调度器去检测病态任务,并收回已经分配任务重新分配的话,前面的简单性就不复存在。
所以,我不给工作线程安排工作队列,而只提供一个待处理任务的 slot (也可以视为一个长度为 1 的队列)。用原子指令操作。
调度器每轮的职责只是把待分配任务填到所有空的 slot 中就够了。而工作线程取出自己所属的非空 slot 就将其置空并执行任务。
这里有一个技巧。我们不必用一个独立线程去运行调度器。而把调度器视为一个模块,任何工作线程都可以运行,但运行前,需要先用 cas 指令抢到运行调度的权力。同一时刻,只有一个工作线程可以运行调度器。
如果工作线程在当前任务执行完毕后,发现所属的 slot 中没有下一份任务。那么它就尝试申请调度器的运行权力,如果获得调度权限,优先给自己分配下一个任务,然后把待做任务分到其它的 slot 中。如果没有待做任务,还可以把已经分配到其它 slot 上的任务收回,分给自己。
这样,就保证了只要有一个工作线程未休息,那么待做任务就至少有人会去做。
当工作线程无事可做,又抢不到调度权限时,它可以休息,但会对工作线程总数做原子的计数。如果它发现自己是最后一个准备去休息的工作线程了,那么它就负责把所有工作线程唤醒。这是为了避免边界情况导致有任务没做完但所有工作线程都休眠了。
只有在运行调度器的工作线程发现完全没有任何任务可以分配时,这个时候才真的全体休息,直到有外部消息(网络消息或时钟消息)进来,重新唤醒工作线程。