skynet 并发模型的一点改进思路
skynet 的内核是一个多线程的消息分发器。每个服务有一个消息队列,任何服务都可以向其它任意服务的消息队列投递消息,而每个服务只可以读自己的消息队列,并处理其中的消息。
目前的工作原理是,在任意消息队列不为空的那一刻,将该消息队列关联的服务对象放在一个全局队列中。框架启动固定数量的工作线程,每个工作线程分头从全局队列中获取一个服务对象,并从关联的消息队列中获取若干条消息,顺序调用服务设置的回调函数。如果处理完后消息队列仍不为空,则将服务对象重新放回全局队列。
这样,就完成了尽量多(远超过工作线程数量)的并发服务的调度问题。
我这些年一直在考虑这个模型可否有改进之处。能不能设计得更简单,却还能在简化设计的基础上进一步提高并发性。同时,还可以更好的处理消息队列过载问题。
想要提高并发处理能力,主要是减少 CPU 在锁上空转浪费的计算力。
目前的设计中,全局队列是一个单点,所有工作线程都可能并发读写这个全局队列。为了减少锁碰撞的概率,我已经做了不少的优化,比如为不同的工作线程配置了不同的策略,有的会一次尽可能多的处理单个服务中的消息;有的在处理完一个服务中的单条消息后,就立刻切换到下一个服务。这样,每个工作线程去获取锁的频率就不太相同,同时,任务繁重的服务也得以尽量在同一个工作线程多做一些事情,而不必频繁进出全局队列。
但这个优化并没有从根本上改进设计。
另一个问题是,每个服务的消息队列是多写一读模式。只有唯一的一个读取者,也就是关联服务;却有众多潜在的写入者。比如 log 服务,所有其它服务都可能向它写入消息。它的队列的锁的碰撞概率就很高。
那么,有什么改进的空间呢?
我设想,以上的消息队列均可简化为一读一写,即只有严格意义上的一个写入者和一个读取者。
我们可以设置一个足够大的固定长度的全局消息队列,当服务 A 向服务 B 发送消息时,它是将消息投递到这个全局消息队列中。因为我们的工作线程数量是固定的,这个全局消息队列的内部实现就可以按工作线程的固定数量分解成同样数量的子结构。每个工作线程都只写入关联的子结构,这样就只存在唯一的写入者。而全局消息队列当然只分配一个独立的工作线程去读取和转发。这个转发线程就是整个全局消息队列的唯一读取者,同时、它还是所有服务私有消息队列的唯一写入者。
虽然比旧设计多了一步转发工作,但转发的只是数据指针,且业务非常简单,设置未必会占据满一个 cpu 核心。它或许会增加一些消息投递的延迟,但一定能在整体上增加 cpu 的利用率。一个并发队列的实现只有唯一的读者和写者时,首尾指针都不再需要锁就可以实现了。
这个转发线程,同样还可以承担工作线程的调度工作,统一把任务分发给工作线程上去。这样也同时避免了碰撞。
再说说过载问题。
因为旧设计中,每个服务的消息队列是无限长的,除非发生 oom ,否则投递消息总能成功。这简化了业务层的实现,不用考虑消息投递阻塞的例外处理。但同时也带来了麻烦。如果业务层不妥善处理,消息队列的过载极易产生雪崩效应。现在的设计中,只提供了少许基础设置来判断消息阻塞是否发生(可以获取自身的消息队列长度),依赖服务自身想办法解决问题。
我想,如果做了上面的消息转发,以及工作线程统一调度的改造后,我们可以更好的帮助业务层解决消息过载,尽可能地避免雪崩效应。
当我们发现任何服务的消息队列太长,那么就可以暂停所有向这个服务消息队列的投递行为。也就是,不再从全局消息队列转发消息到服务消息队列,而是暂存起来。(在实现细节上,可以在消息发生过载时服务队列加一个递增的版本号,每次转发都校验这个版本号,决定消息是直接转发还是暂存)暂存队列是转发服务的私有数据结构,没有并发的问题。
同时,因为我们是统一调度,所以还可以提高过载服务的处理优先级。专门安排一个工作线程处理它(如果多个服务过载,也可以分别安排到不同的工作线程上)。工作线程只要在处理服务消息,那么必定会逐步消化服务的消息队列。当情况缓解,再将暂存数据转发过去。
如此,每个服务的消息队列都可以实现成定长结构,进一步简化实现。
btw, 工作线程的任务调度本身也可以通过消息队列通讯来完成。每个工作线程有一对自己和调度线程通讯的消息队列,用来处理调度任务。调度线程平常只需要轮流将服务对象 id 发送到每个工作线程,工作线程每完成一个任务都把确认信息发回。如果发现完成了一步任务后,自己的工作队列还很长,也可以取出任务而不执行,直接发回去,方便调度线程将其分配给别的工作线程。