线程扩展需要考虑的问题

线程扩展要考虑的问题

简介

如今越来越多的后台系统采用了单线程的设计,原因多是因为单线程的设计简单轻量,而且更安全(无多线程竞争)。
而其性能保证则通过多进程的方式来完成,多进程由于互相隔离,也是稳定性的保证。
比如nginx默认工作方式就是单线程多进程;其他的案例比如node.js的集群;比如redis的集群。
通常这种方式能很好的工作,然而有些时候你还是会面临项目向多线程转变的时候,比如项目的技术栈从node.js转向Go,Go又是天生为并发而生的;
比如项目在演进过程中出现了CPU密集型的业务,单线程会严重影响性能;比如多进程架构扩展时遇到了IP等资源不足(比如业务需要为每个进程绑定一个公网IP)的限制。
那么当你在处理这种架构转变的时候,需要考虑哪些问题呢?

线程模型

对于多线程的架构,采用什么线程模型是首要解决的问题。

netty的模型

我们直接拿大名鼎鼎的netty的模型来看:
netty thread model

可以看到,首先由单独的线程来做accept操作,这点没有什么疑问,为了响应迅速,单独拿线程来做accept是没有什么问题的。
另一个是工作线程组,也就是一个线程池,这个线程组会接收accept线程accept之后的socketchannel,也就是socket的fd,来负责这个fd的IO操作。
netty的这个线程组很特殊,这个线程组不光要负责IO操作,还要负责跑具体的业务,也就是说read->handle->write都在一个线程上完成。
所以这里有个问题是IO类操作(read/write)和业务操作(handle)会互相等待,由于IO相对业务较快,比较容易发生的是IO会被业务给阻塞。
netty解决这个问题的方法是,首先程序员要自己清楚这个线程不能跑轻量的任务,否则会阻塞IO以及其他socketchannel的业务。
其次,对于CPU密集型的非轻量的操作,要自己拆分,然后把拆分后的任务扔到这同一个线程组中执行。
为什么我说netty是这么建议的呢,因为首先netty并无其他线程池存在,并且这个线程组存在接口来注册非IO的任务。

IO分离

但是如果你不是用的netty,你也不是非要像netty这么设计。
为了防止IO和业务互相阻塞,我们可以设计成两个线程组:
two pool thread model

可以看到,由一个线程来accept,accept之后,socket被放到network线程组。
network线程组的每个线程包含一个epoll/selector, network线程组触发的事件会被抛到work线程组,work线程组的IO写操作也会被抛回network线程组。
这样IO和业务是跑在不同的线程中的,不会发生互相阻塞的事情。

上面这两个线程模型基本可以涵盖大部分的服务器后端的线程架构。

线程安全

多线程的架构避免不了要面对这个问题,我们也要面对,而且是优先要考虑这个问题,因为线程安全严重的会导致程序崩溃,轻的也会影响业务的准确性,最少他也能影响系统的性能。
我们处理线程安全的策略有3个。

暴力面对

正所谓真的勇士,敢于直面惨淡的人生,敢于正视淋漓的献血。
这种策略是为每一个非线程安全的地方加上锁保护,每一个STL容器的遍历,每一个数据库的访问,每一个计数器的增减,我们都加上锁,管你是多少个线程我都不怕,管你把这个任务扔到了哪个线程上运行也都没事。
这种方法其实挺好的,只不过我没有办法保证你这么做的时候不会出问题,比如死锁,或者锁加的不好,导致等待时间长,影响性能。
只要你合理的加锁,这种方式是可以的。

按功能拆分

虽然线程池中有很多线程,但是我们可以给同一类功能的操作放到同一个线程中,比如专门取一个线程作为数据库线程,所以数据库访问的任务都会被定向到这个线程中,因此就是串行操作不会有线程安全的问题。
这个方案的思路就是把线程池中的线程拆分成几个部分,所有可能引起线程安全问题的都单独建立一个线程,把并行的场景转化成串行的场景。
如图:
split by function thread model

按业务拆分

有的服务器会把一些状态类信息放到数据库中(redis/Cassandra)保存,做到无状态。也有的会把一些状态信息放到内存中,这可能是出于效率的考虑,或者是出于系统整体的架构不引入第三方服务。
对于把会话等状态信息放在内存中的系统,我们可以按照足以避免竞争的业务单元来拆分系统,把一个业务单元的业务都跑到同一个线程上去。
比如确保同一个会话的所有业务运行到单个线程,而这个线程可以承担多个业务单元的运行。如图:
split by business thread model

负载均衡

要很好地发挥出多个CPU的性能, 必须保证分配到各个CPU 上的任务有一个很好的负载平衡。否则一些CPU 在运行, 而另一些CPU 却处于空闲, 而实际的效率是以任务较重的CPU 为准, 从而造成性能的瓶颈, 无法发挥出多核CPU 的优势。
要实现一个好的负载平衡通常有两种方案,一种是静态负载平衡, 采用人工干预的方法预先将任务分割成多个可并行执行的部分, 尽量保证分割的各个部分可以均衡地运行在不同的核上;
另外一种是动态负载平衡, 在程序的运行过程中进行任务的分配达到负载平衡的目的。

动态拆分和静态拆分

负载均衡的方法还与线程安全的策略有很大关系,比如线程安全采用的是暴力面对,那么负载均衡很简单了,用动态分配的方式,把请求轮询得抛给线程池的线程就可以了。
如果采用的是按照功能拆分,那么就是采用的静态的分配方法,分配后如果发现某个线程特别的重,那么需要合理地拆分这个线程的任务,比如如果访问数据库的线程太重,那么可以把访问A业务和访问B业务的请求拆分到不同的线程,这样既不影响线程安全又可以负载均衡。
同样的如果某个线程空闲则可以进行线程的合并。

如果采用的是按照业务来分,比如按照会话来拆分,由于会话之间的体量并不相同,因此不光不能静态分配,就连动态分配也有难度,比如轮询得分配,能确保分配的数量一致,但是每个会话的体量不同,实际的负荷也是不同的。
对于这种情况,需要采用动态计算的方式,比如最小CPU的方法:
每个线程定时计算自己的CPU,然后在会话初次创建时,选择一个最小CPU的线程来创建,随后该会话的业务都跑到该线程上去。并且保持CPU的定时更新。
或者采用最小连接数:
每个线程统计处于自身线程上的socket连接个数,然后在会话初次创建时,选择一个最少socket的线程来创建,随后该会话的业务都跑到该线程上去。

大任务的拆分

这里还是再次强调一次netty中的场景,我们前面说到过,netty中

  1. 不能跑繁重的任务,因为netty的线程业务和网络IO跑一起的,业务太繁忙,会引起IO网络响应缓慢,影响网络的吞吐。
  2. 如果你必须要跑繁重的任务,那么要学会拆分,把大任务拆分成小任务,并扔回线程池中运行。netty的线程池是接受非IO任务的。

拆分的图示:
split light task thread model