前言
线程池是多线程和并发编程的重要手段,本文尝试从操作系统、线程池实现原理和资源分配三个方面来分析,解释如何高效地使用线程池以及背后的逻辑。
操作系统知识
在介绍线程池之前,我们先来了解一下操作系统相关的知识。
1.时间片
我们的服务器大多是分时操作系统,这种操作系统将系统资源(特别是CPU)进行时间上的分割,每个时间段称为一个时间片,以时间片轮转的方式为多个任务提供服务。一个CPU同一时间只能执行一个任务,但是由于时间片快速轮转,给用户的感觉好像是在同时执行多个任务。
2.进程和线程
进程是操作系统进行资源分配的基本单位,线程是操作系统调度的最小单元。每个进程都有自己的地址空间,进程包含线程,一般一个进程拥有一条守护线程,多条普通线程。线程共享进程的全部系统资源,每条线程又拥有自己的调用栈、寄存器、本地存储。进程是一个“运行中的程序”,而线程是实际的运作单位。
3.调度
调度是操作系统对任务或者进程/线程进行资源分配的方法。多任务或者多进程、多线程在操作系统上同时运行时,只有分配了时间片和其他资源的任务、进程/线程才能真正被执行,其他的都处在排队等待状态。因此操作系统一般将任务和进程/线程分别放在任务队列和进程/线程队列中,在发生调度时,使用一定的算法从就绪队列中选出将要执行的任务或者进程/线程,为它分配资源(内存、CPU时间片等)。
发生调度的时机:1.正在运行的进程/线程运行完毕;2.运行中的进程/线程要求IO操作;3.某种操作(比如等待锁、线程的sleep操作等)使进程/线程阻塞;4.优先级更高的进程/线程进入就绪队列;5.分配给进程/线程的时间片用完;6.硬件中断。
当进行进程/线程调度时,将发生上下文切换。
4.上下文切换
上下文切换指操作系统在CPU上对进程或者线程进行切换。上下文切换的过程:1.挂起正在运行的进程/线程,将当前进程/线程在CPU寄存器中的状态保存到PCB/TCB(进程控制块/线程控制块)中,然后放到一个task_struct的数据结构中保存;2.在task_struct中找到下一个要执行进程/线程的PCB/TCB并将它恢复到CPU寄存器中;3.执行引擎跳转到程序计数器指向的位置(上次程序执行中断的位置)并恢复该进程/线程。
上下文切换需要保存和恢复寄存器和内存页表以及更新内核相关数据结构,需要消耗CPU时间;如果是跨核心切换,则需要付出更高的代价。时间片的大小一般是几十至几百毫秒,比如一个时间片是20ms,上下文切换需要开支5ms,就相当于一次CPU切换需要支付20%的性能损耗。
多线程
为提高系统吞吐量,充分利用资源,需要根据实际运行的程序特点,决定是否使用多线程,适当调整时间片大小,选择适当的调度算法等,让CPU、内存、IO等资源得到最大的利用。
1.线程数量
多线程编程,并不是线程越多越好,因为服务器的CPU是有限的,上下文切换需要开支额外的成本;另一个原因是线程需要占用资源,比如内存,无节制创建线程或者管理不善会造成资源浪费甚至内存溢出。我们这里看一下有哪些影响线程数量的因素。
2.数量限制
我们看下linux环境下查看进程和线程数量相关的限制和命令:
系统允许最大进程数:cat /proc/sys/kernel/pid_max
系统允许最大线程数:cat /proc/sys/kernel/threads-max
单用户允许最大线程数:ulimit -u
单进程允许最大线程数:cat /usr/include/bits/local_lim.h 的PTHREAD_THREADS_MAX
每个线程栈空间大小:ulimit -s
查看系统已用进程数:pstree -p|wc -l
查看一个进程中线程的数量:ps -Lf pid|wc -l
查看一个进程资源消耗情况:top -p pid
Java虚拟机中线程的限制:启动参数-Xss表示每条线程的栈大小。
一般系统允许的默认线程数会非常大,不同系统的默认值也不一样。以上数据都是可以修改的,可以根据所运行程序的特点,调整相应参数。需要注意的是32位操作系统能使用到的最大内存是3.2G,如果系统内存达到这个值时,将无法创建进程、线程。
线程池
对大量使用线程的系统,一般不会每次在使用到线程时才创建,使用完成后又销毁,这样效率和性能都很差。这种场景一般会使用线程池,使用线程池的好处有1.提高了响应速度,在有线程需求的时候,直接从线程池中获取线程立即执行;2.降低资源消耗,虽然线程创建在线程池中会占用一定的内存,但是它避免了频繁的创建、销毁工作。3.便于管理线程,手动创建和销毁线程,有不确定性,线程管理不善就会导致线程大量创建却没有释放资源,最后导致资源耗尽。ThreadPoolExecutor线程池概念模型,如下图:
线程池主要有工作线程集合、任务队列、拒绝策略构成,其中工作线程集合被划分成两部分,一部分叫核心线程池,一部分叫最大线程池。当线程池接到执行命令时,会做以下处理:1.先判断当前线程数是否达到核心线程数,如果没有,则创建一条工作线程运行当前任务,并将其加入核心线程数中; 2.如果当前线程数到达核心线程数,则将任务加入任务队列,由工作线程从队列中获取任务并运行;3.如果任务队列已经满了,则检查当前线程数是否达到最大线程数,如果没有,就创建一条新的工作线程运行当前任务并加入线程池中;4.如果当前线程数达到最大线程数,意味着线程池已经饱和,这时会执行拒绝策略。源码如下:
ThreadPoolExecutor.execute
ThreadPoolExecutor提供了4种拒绝策略:CallerRunsPolicy(在调用方线程运行任务)、AbortPolicy(终止并对外抛异常)、DiscardPolicy(丢弃新任务)、DiscardOldestPolicy(丢弃最老的任务)。使用时根据自身业务特点选用合适的拒绝策略,也可以实现RejectedExecutionHandler接口自定义策略。
在Executors类中提供了5种线程池类型,其中newFixedThreadPool、 newCachedThreadPool、newScheduledThreadPool、newSingleThreadExecutor都是基于ThreadPoolExecutor,一般推荐直接使用ThreadPoolExecutor 创建线程池,这样可以自己设置一些线程池资源参数,避免在不知不觉中导致线程资源耗尽;还有一种newWorkStealingPool线程池是JDK1.8后增加的,基于ForkJoinPool,它会创建足够多的线程来维持给定的并行级别,它通过工作窃取算法让空闲的CPU去执行本不属于它的任务以提高效率。
线程池的大小
为尽可能提高程序效率和资源利用率,线程池大小应被合理规划,避免过大或者过小。影响线程池大小的因素挺多的,任务的类型、硬件资源、软件资源、系统对线程数量的各种限制等都会影响线程的大小。
任务类型对线程池大小的影响:1.如果是明确的计算密集型任务,则一般使用T = N+1(考虑某个线程因为一些原因暂停了,能有一个额外的线程补上,虽然此时CPU并不一定空闲,但是对当前任务来说,即便增加一个线程到操作系统任务队列中排队,也是好的);2.如果是混合型任务,则采用T=N*U(1+W/C),假设CPU使用率无限接近100%,则T=N*(1+W/C),这时候影响最大线程数的是等待时间和计算时间的比例。
以上公式中变量含义:T表示最大线程数;N表示CPU数量;U表示CPU使用率,范围0~1;W表示任务等待时间;C表示任务计算时间。
硬件资源的影响:比如内存、CPU、网络带宽等,CPU一般由操作系统调度分配,不管是采用时间片轮转算法还是多级反馈队列算法,时间片都能较公平地摊分给所有线程(设置优先级的另说);内存、带宽等一般按照可用的总量除以每个任务大概的消耗量,就得到最大线程数。当然还要考虑线程本身的内存消耗。
软件资源的影响:比如数据库连接池资源,如果每条线程需要占用一条数据库连接池连接,则线程池大小受制于数据库连接池数量,因为如果超过了数据库连接池的数量,任务线程只能被挂起无法继续执行,设置太多反而浪费资源。
综上所述,合理设置线程大小,首先不同类型的任务(计算密集型、IO密集型)应使用不同的线程池,采用不同的计算方法得到最大线程数;其次考虑硬件资源能支撑多少线程,如果任务需要使用其他外部资源,应将外部资源的限制考虑进去;最后综合所有因素,以及操作系统对各种线程数量的限制,得到一个合理的线程池数量。
结束
在分时操作系统中,线程池的线程都处在繁忙状态,并不意味着它们正在被CPU执行,它们可能在操作系统的就绪队列中排队等待被调度。所以即便是计算密集型线程池设置了N(CPU核数)个线程,它也会在不断被调度、挂起。线程池线程的最优数量,往往是指这个线程池对资源的最大利用,其实即便某个线程池没有设置最优的线程数,也不意味着CPU就是空闲的,因为还有其他进程、线程在运行。