含泪播种的人一定能含笑收获。
有个朋友Hunter跟我聊,最近他参加腾讯的面试,在二面的时候被问到了关于线程池线程数目设置的一个问题。此处记录下这个问题的面试过程,以及后面关于此问题的理论方面的知识讲解。
面试官开场了:
线程池你用过吧,线程数是怎么设置的呢?
Hunter心想,这不难啊,曾经在《Java并发编程》一书中有看到过线程池中线程数目设置的讲述,于是张口就来:
线程数的设置需要考虑三方面的因素,服务器的配置、服务器资源的预算和任务自身的特性。具体来说就是服务器有多少个CPU,多少内存,IO支持的最大QPS是多少,任务主要执行的是计算、IO还是一些混合操作,任务中是否包含数据库连接等的稀缺资源。线程池的线程数设置主要取决于这些因素。
面试官追问来了:
那具体是怎么设置呢?
Hunter略一思忖,整理了下思路,娓娓道来:
假设机器有N个CPU,那么对于计算密集型的任务,应该设置线程数为N+1;对于IO密集型的任务,应该设置线程数为2N;对于同时有计算工作和IO工作的任务,应该考虑使用两个线程池,一个处理计算任务,一个处理IO任务,分别对两个线程池按照计算密集型和IO密集型来设置线程数。
面试官表情毫无变化,接着发问:
N+1和2N是怎么来的?
Hunter张口就来:
是个经验值。
面试官:
经验值吗?那为什么不是N+2或者N+3,而非得是N+1呢?
Hunter被驳得稍有点懵,脑子里努力在回想学习过的那些技术点,竟一时语塞。
看得出来面试官略有不满,于是提示道:
那假如在一个请求中,计算操作需要5ms,DB操作需要100ms,对于一台8个CPU的服务器,怎么设置线程数呢?
Hunter努力平复心情,紧接着最开始的思路,说到:
这是一个计算和IO混合型的任务,可以将其分解为两个线程池来处理。一个线程池处理计算操作,设置N+1=9个线程,一个线程处理IO操作,设置2N=16个线程。
面试官:
如果一个任务同时包含了一个计算操作和DB操作呢,不能拆分怎么设置?你能讲一下具体的计算过程吗?
Hunter略有点慌,心里不断给自己暗示:这个问题不难不难。然后不断回想看过的《Java并发编程实战》和《Java虚拟机并发编程》中关于线程池设置的章节,并试图将自己对这个问题的分析思路也表达出来。
首先这个任务整体上是一个IO密集型的任务。在处理一个请求的过程中,总共耗时100+5=105ms,而其中只有5ms是用于计算操作的,CPU利用率为5/(100+5)。使用线程池是为了尽量提高CPU的利用率,减少对CPU资源的浪费,假设以100%的CPU利用率来说,要达到100%的CPU利用率,对于一个CPU就要设置其利用率的倒数个数的线程数,也即1/(5/(100+5)),8个CPU的话就乘以8。那么算下来的话,就是……168,对,这个线程池要设置168个线程数。
面试官表情略有缓和,嘴角微微一笑:
如果实际的任务差异较大,不同任务实际的CPU操作耗时和IO操作耗时有所不同,那么怎么设置线程数呢?
经过刚才的分析过程,Hunter心里已经回忆起了这块的知识点,已然不慌了。
那对所有任务的CPU操作耗时和IO操作耗时求个平均值就好了。
Hunter心里渐渐恢复了自信,大脑的利用率瞬间提高好几十个百分点。
面试官轻轻“嗯”了一声,表示认可。
那如果现在这个IO操作是DB操作,而DB的QPS上限是1000,这个线程池又该设置为多大呢?
经过刚才的心理调整,对问题完整的分析过程,以及面试官的略微认可,Hunter已经知道如何去更好地回答面试官的问题了。
按比例来减少就可以了,按照之前的计算过程,可以计算出来当线程数设置为168的时候,DB操作的QPS为,168*(1000/(100+5))=1600,如果现在DB的QPS最大为1000,那么对应的,最大只能设置168*(1000/1600)=105个线程。
面试官这次是真的满意了,给这个回答给了一个正面的评价:
思路挺清晰的。那设置线程池的时候除了考虑这些,还需要考虑哪些内容呢?
Hunter此时已经完全找回自信了,不惧任何问题。
除了考虑任务CPU操作耗时、IO操作耗时之外,还需要服务器的内存资源、硬盘资源、网络带宽等等的。
面试官点点头,看起来Hunter已经获得了面试官的正式认可了。面试官告诉Hunter,表现不错,等接下来的面试安排吧。
Hunter内心异常激动,这真算是一次“死里逃生”的经历了。面试结束后,Hunter压抑兴奋,马上去找到《Java并发编程实战》和《Java虚拟机并发编程》两本书,翻到对应的章节,想确认下自己的回答。 果然,压力除了会造成紧张之外,也能提高大脑利用率。Hunter在调整状态后的回答完全正确。附上两本书中对线程池设置的理论。
在《Java并发编程实践》中,是这样来计算线程池的线程数目的:
在一个基准负载下,使用 几种不同大小的线程池运行你的应用程序,并观察CPU利用率的水平。 给定下列定义: Ncpu = CPU的数量 Ucpu = 目标CPU的使用率, 0 <= Ucpu <= 1 W/C = 等待时间与计算时间的比率 为保持处理器达到期望的使用率,最优的池的大小等于: Nthreads = Ncpu x Ucpu x (1 + W/C)
这种计算方式,我们需要知道上面定义的几个数值,才能计算出来线程池需要设置的线程数。其中,CPU数量是确定的,CPU使用率是目标值也是确定的,W/C也是可以通过基准程序测试得出的。
而在《Java虚拟机并发编程》中,则是这样来计算线程池的线程数目的:
线程数 = CPU可用核心数/(1 - 阻塞系数),其中阻塞系数的取值在0和1之间。 计算密集型任务的阻塞系数为0,而IO密集型任务的阻塞系数则接近1。一个完全阻塞的任务是注定要挂掉的,所以我们无须担心阻塞系数会达到1。
这种计算方式,我们需要知道CPU可用核心数和阻塞系数,才能计算出来线程池需要设置的线程数目。其中,CPU可用核心数是确定的,阻塞系数可以通过公式:阻塞系数=阻塞时间/(阻塞时间+计算时间),其实也就是上一种算法中的W/C的方式来计算,所以阻塞系数也是可以通过基准程序计算得出的。
那么我们再来看所谓的N+1与2N的经验值的来源。 计算密集型应用 以第一种计算方式来看,对于计算密集型应用,假定等待时间趋近于0,是的CPU利用率达到100%,那么线程数就是CPU核心数,那这个+1意义何在呢? 《Java并发编程实践》这么说:
计算密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作。
所以N+1确实是一个经验值。 IO密集型应用 同样以第一种方式来看,对于IO密集型应用,假定所有的操作时间几乎都是IO操作耗时,那么W/C的值就为1,那么对应的线程数确实为2N。