在《侦听socket为什么要设置成非阻塞的?》这篇文章中我们解决了 listenfd 为什么被建议设置成非阻塞的问题,现在我们将 listenfd 挂载到某个 loop 所属的 epollfd 上与 clientfd 统一处理就没疑问了。让我们来进一步讨论这一结构。
1. listenfd 单独使用一个 loop,clientfd 分配至其他 loop
这是在实际商业服务器中比较常用的一个结构,listenfd 单独挂载到一个线程的 Loop 的 epollfd 上(这个线程一般是主线程),为了表述方便,我们将这个线程称之为”主线程“,对应的 loop 称之为主 Loop;产生新的 clientfd 按一定的策略挂载到其他线程 Loop 的 epollfd 上,我们将这些线程称之为工作线程,对应的 Loop 称之为为工作 Loop 。
例如使用轮询策略(round robin),clientfd 均匀的给其他的工作线程,如下图所示:
轮询策略可以做一些优化,clientfd 挂到各个工作 Loop 上的后,由于连接断开从工作 Loop 上移除,所以各个工作 Loop 上 clientfd 可能数量不一样,甚至出现数量不严重失衡的极端情况,因此主 Loop 在分配新产生的 clientfd 时可以先查询一下各个 Loop 上当前实际的 clientfd 数量,把当前新产生的 clientfd 分配给有用 clientfd 最少的 Loop。如下图所示:
当然,你也可以根据一定的策略比重来分配 clientfd,例如假设现在有个工作线程(四个工作 Loop),其分配比重为 1 : 4 : 4 : 1,那么程序运行一段时间后,在没有断开连接的情况,这四个工作 Loop 上的 clientfd 的数量理论上应该比例也是 1 : 4 : 4 : 1。
2. listenfd 不单独使用一个 loop,所有 clientfd 按一定的策略分配给各个 loop
对于一些建立和断开连接不是高频操作的场景,实际没必要让 listenfd 单独使用一个线程,因为在这种场景下如果让 listenfd 单独使用一个 Loop,这个线程大多数情况可能处于空闲状态,而负责的 clientfd 的其他线程可能比较忙碌,例如像用户较多的即时通讯服务器、实时对战类型的游戏服务器。这样不仅浪费资源,同时效率也不高,所以这种场景下应该让 listenfd 所在的线程也参与 clientfd 读写事件的处理。
redis 6.0 引入多线程 IO 在多线程启用状态分配 clientfd 逻辑是主线程也参与分配 clientfd 并处理 clientfd 的读写事件的典型案例。
3. listenfd 和所有 clientfd 均使用一个 loop
这种是上述情形的特例,一般用于整个 Loop 都是高效的内存操作的情形,例如 redis-server 的 IO 线程,即所谓的单线程 IO。
在 《侵入式服务与非侵入式程序结构》这篇文章中,我们讨论了一个服务器程序的基本结构,从线程的维度来看,可以分为网路线程和业务处理线程,其中网络线程执行的逻辑一般比较固定,而业务线程执行的逻辑则随着业务的不同而千差万别。
在机器物理资源有限的情况下,我们假定某个服务线程数目也是有限的。为了合理分配线程资源,让程序性能最大化,我们需要找到程序的性能瓶颈在哪里,一般按照业务类型的不同,我们将服务器程序归为两类:
所谓 IO密集型指的是程序业务上没有复杂的计算或者耗时的业务逻辑处理,大多数情况下是频繁的网络收发操作,这类业务如 IM、交易系统中的行情推送服务、实时对战游戏的服务等,所谓计算密集型指的是程序业务逻辑中存在耗时的计算,这类服务如数据处理服务、调度服务等。
如果服务是 IO 密集型,那么我们需要将线程数目向网络通信组件上倾斜,反过来如果是计算密集型的服务,我们应该将线程数目向业务模块倾斜。举个例子,假设现在总线程数目是 10 个,那么对于 IO 密集型服务,我们的网络线程数目可以设置为大于 5,而业务线程数目小于 5;反过来,对于计算密集型服务,我们可以将网络线程数目设置为小于 5,业务线程数目大于 5,具体数目根据网络通信逻辑与业务处理逻辑在整个服务中的资源占用比例,谁占用资源率大,倾斜的线程数应该越多。