Skip to content

约 7 分钟阅读

线程池开多大:排队、利用率与 Little 定律 ​

前四篇讲的是执行者本身:谁在干活、在等还是在算、多了会怎样、怎样不互相踩踏。这一篇回到订单服务最常被争论的配置:线程池开多大。有人按 CPU 核数配,有人直接写 200,大促前又改成 800。这一篇用一个实验和一条简单的公式,把线程数、请求速率和响应时间联系起来,再解释为什么 CPU 才用到七八成,响应时间就开始明显上涨。读完后,你应该能根据实测数据算出线程池的大小,并知道服务的利用率该留多少余量。

先分清三个指标 ​

讨论之前,先把三个常被混用的指标分开:

指标含义订单详情接口的例子
吞吐量单位时间内完成的请求数。按查询计就是 QPS(每秒查询数),按事务计常写成 TPS每秒完成 500 个请求
响应时间一个请求从到达到返回经过的时间,等于排队时间加处理时间处理 42 毫秒,排队 0 毫秒
并发数(在途数)某一时刻已经到达、还没返回的请求数,包括正在处理的和正在排队的平均约 21 个

吞吐量说的是“一秒做完多少”,并发数说的是“同时手上有多少”,两者不是一回事。说“这个服务能扛 1 万并发”时,要问清楚说的是同时在途的 1 万个请求,还是每秒 1 万个请求。

实验:每秒 500 个请求,线程池从 8 开到 256 ​

下面的实验模拟订单详情接口:每个请求先等待 40 毫秒(查库和调用下游),再计算一个 4 MiB 数据的 SHA-256 摘要作为签名(占 CPU,hashlib 计算时会释放 GIL,所以多个线程能并行)。请求按每秒 500 个的固定节奏到达,共 1500 个,不管前面的请求有没有处理完;线程池大小从 8 逐步加到 256。容器限制为 2 个 CPU。保存为 pool_size.py:

python
import hashlib, time
from concurrent.futures import ThreadPoolExecutor

BLOB = b"x" * (4 << 20)
RATE, N = 500, 1500                      # 每秒到达 500 个请求,共 3 秒

def handle(arrived):
    time.sleep(0.040)                    # 查库、调下游:等待 40 ms
    c0 = time.thread_time()
    hashlib.sha256(BLOB).hexdigest()     # 计算签名:占 CPU,hashlib 计算时会释放 GIL
    return time.perf_counter() - arrived, time.thread_time() - c0

def pct(xs, p):
    xs = sorted(xs)
    return xs[max(0, -(-len(xs) * p // 100) - 1)]

for size in (8, 16, 24, 32, 64, 256):
    futures = []
    with ThreadPoolExecutor(size) as ex:
        t0 = time.perf_counter()
        for i in range(N):               # 按固定节奏到达,不管前面的有没有处理完
            delay = t0 + i / RATE - time.perf_counter()
            if delay > 0:
                time.sleep(delay)
            futures.append(ex.submit(handle, time.perf_counter()))
        res = [f.result() for f in futures]
        wall = time.perf_counter() - t0
    lat = [r[0] * 1000 for r in res]
    cpu = sum(r[1] for r in res) / N * 1000
    inflight = sum(r[0] for r in res) / wall          # 平均同时在系统里的请求数
    print(f"线程池 {size:3d}:完成 {N / wall:4.0f} 个/秒,平均 {sum(lat) / N:5.0f} ms,P50 {pct(lat, 50):5.0f} ms,"
          f"P99 {pct(lat, 99):5.0f} ms,平均在途 {inflight:5.1f} 个,计算 {cpu:3.1f} ms/个")
bash
docker run --rm --cpuset-cpus 0-1 -v "$PWD":/lab -w /lab capacity-lab python pool_size.py

实际输出:

text
线程池   8:完成  178 个/秒,平均  2655 ms,P50  2610 ms,P99  5346 ms,平均在途 473.2 个,计算 2.4 ms/个
线程池  16:完成  371 个/秒,平均   519 ms,P50   506 ms,P99  1041 ms,平均在途 192.8 个,计算 1.7 ms/个
线程池  24:完成  493 个/秒,平均    43 ms,P50    42 ms,P99    59 ms,平均在途  21.2 个,计算 1.7 ms/个
线程池  32:完成  493 个/秒,平均    42 ms,P50    42 ms,P99    48 ms,平均在途  20.7 个,计算 1.6 ms/个
线程池  64:完成  493 个/秒,平均    42 ms,P50    42 ms,P99    50 ms,平均在途  20.6 个,计算 1.5 ms/个
线程池 256:完成  493 个/秒,平均    42 ms,P50    42 ms,P99    43 ms,平均在途  20.6 个,计算 1.5 ms/个

(P50、P99 是百分位数,下一篇专门解释;这里可以先理解为“一半请求快于这个值”和“99% 的请求快于这个值”。)

线程池只有 8 个或 16 个时,每秒只能完成 178 或 371 个请求,跟不上每秒 500 个的到达速度,多出来的请求在队列里越积越多,平均响应时间达到 0.5 秒到 2.6 秒,而且实验跑得越久,这个数字会越大。线程池到 24 个时,吞吐量追上了到达速度,响应时间降到处理时间本身的 42 毫秒。再往上加到 256 个,结果几乎没有变化。实验跑了两次,结论一致;第二次线程池 64 时出现过一次 P99 143 毫秒的波动,来自宿主机的干扰。

Little 定律:在途数 = 到达速率 × 响应时间 ​

注意输出最后的“平均在途”一栏。线程池足够时,它稳定在 20.6 个左右,这个数可以直接算出来:

text
平均在途数 = 到达速率 × 平均响应时间
          = 500 个/秒 × 0.042 秒 = 21 个

这就是 Little 定律,排队论里最通用的一条关系。它几乎不需要任何前提:不管请求按什么规律到达、处理时间怎么分布,只要系统处于稳定状态(进来多少就出去多少),长期平均的在途数就等于到达速率乘以平均停留时间。

线程池的排队模型与 Little 定律

用它算线程池的大小就很直接了。每个在处理中的请求都占着一个线程,所以线程数至少要等于“正在处理”的请求数:500 个/秒 × 0.0415 秒(处理时间,不含排队)≈ 21。实验里 24 个线程刚好够用,16 个就不够。反过来,8 个线程最多能撑住 8 ÷ 0.0415 ≈ 193 个/秒,实测 178 个/秒,和公式吻合。

另一个经常见到的公式从 CPU 的角度出发:

text
线程数 ≈ CPU 核数 × (1 + 等待时间 ÷ 计算时间) = 2 × (1 + 40 ÷ 1.5) ≈ 55

它算的是另一件事:要让 2 个核都忙满,需要多少线程同时在跑,因为每个线程只有 1.5/41.5 的时间在用 CPU。这是线程数的上限参考,超过它,多出来的线程只能等 CPU。实验里每秒 500 个请求只需要 0.75 个核的计算量,CPU 远没有用满,所以线程从 24 加到 256 都没有坏处。如果到达速率继续涨到每秒 1300 个以上,两个核会被签名计算占满,再多的线程也只会排队等 CPU,就回到了第 03 篇的情形。

所以确定线程池大小的步骤是:先测出每个请求的处理时间和其中的计算时间,用 Little 定律算出目标流量需要多少线程,再用 CPU 公式确认不会超过 CPU 的承载能力,最后留出余量。还要考虑线程背后的资源:每个线程都可能持有一条数据库连接,800 个线程配 50 条连接的连接池,750 个线程只会在连接池上排队,还可能把数据库压垮。

利用率越高,排队越长 ​

实验里线程池为 24 时,线程的平均利用率是 21 ÷ 24 ≈ 88%,响应时间却几乎没有排队。这是因为实验的请求是匀速到达的,处理时间也几乎固定,前一个刚走,后一个才来。真实的流量是随机的:有时一秒内连续来了一串,有时又空一会儿;处理时间也有长有短。随机性会让队列在某些时刻突然变长。

下面用一个模拟来看这种影响:只有 1 个工作线程,请求随机到达,处理时间也随机,平均 10 毫秒;改变到达速率,让线程的利用率从 50% 升到 99%,每种情况模拟 20 万个请求。这里没有真的执行任何请求,只是按时间轴推算每个请求的排队和完成时刻,是模拟结果。保存为 queue_sim.py:

python
import random

random.seed(7)
SERVICE, N = 10.0, 200_000           # 平均处理 10 ms,模拟 20 万个请求

def pct(xs, p):
    xs = sorted(xs)
    return xs[max(0, -(-len(xs) * p // 100) - 1)]

for rho in (0.5, 0.7, 0.8, 0.9, 0.95, 0.99):
    arrive = free = 0.0
    lat = []
    for _ in range(N):
        arrive += random.expovariate(rho / SERVICE)
        start = max(arrive, free)            # 线程忙就排队
        free = start + random.expovariate(1 / SERVICE)
        lat.append(free - arrive)
    print(f"利用率 {rho:4.0%}:平均响应 {sum(lat) / N:6.0f} ms(公式 {SERVICE / (1 - rho):5.0f}),"
          f"P50 {pct(lat, 50):5.0f},P99 {pct(lat, 99):6.0f} ms")
bash
docker run --rm -v "$PWD":/lab -w /lab capacity-lab python queue_sim.py

模拟输出:

text
利用率  50%:平均响应     20 ms(公式    20),P50    14,P99     93 ms
利用率  70%:平均响应     33 ms(公式    33),P50    23,P99    156 ms
利用率  80%:平均响应     51 ms(公式    50),P50    35,P99    239 ms
利用率  90%:平均响应    103 ms(公式   100),P50    69,P99    512 ms
利用率  95%:平均响应    207 ms(公式   200),P50   144,P99    892 ms
利用率  99%:平均响应   1518 ms(公式  1000),P50   884,P99   4967 ms

利用率与平均响应时间的关系曲线

处理时间始终是平均 10 毫秒,但利用率 50% 时平均响应是 20 毫秒,80% 时 50 毫秒,90% 时 100 毫秒,95% 时 200 毫秒。括号里的公式是排队论中单服务台随机到达模型的结果:

text
平均响应时间 = 平均处理时间 ÷ (1 − 利用率)

利用率每往 100% 靠近一步,分母就小一截,响应时间成倍增长。从 50% 到 80%,利用率只提高了 30 个百分点,响应时间翻了 2.5 倍;从 90% 到 99%,响应时间翻了十倍以上。99% 这一行的模拟值和公式偏差较大,是因为接近满载时队列的波动极大,20 万个请求还不足以让平均值稳定下来,这本身也说明了接近满载的系统有多不可预测。

把模拟换成真实的线程池,用 time.sleep 按同样的随机时长“处理”请求,每种利用率跑 4000 个请求,得到的趋势相同:利用率约 53% 时平均响应 24–25 毫秒,约 85% 时 60 毫秒左右,超过 95% 后达到几百毫秒到一秒多。真实运行里 sleep 总会多睡一点,实际利用率比设定值高几个百分点,样本也少,所以数字不如模拟整齐。

工作线程多于一个时,曲线前段更平缓,因为一个线程忙时,别的线程可以接手;但形状相同,越接近满载,排队时间越是急剧上升。这就是 CPU 或线程池利用率在七八成时,响应时间就开始明显变差的原因,也是容量规划通常要求在峰值时留出 30% 以上余量的依据。对响应时间敏感的服务,余量要留得更多。

线程池满了以后怎么办 ​

到达速率持续超过处理能力时,无论线程池多大、队列多长,排队都会无限增长,就像实验里 8 个线程的情形:请求在队列里等了几秒,客户端早已超时放弃,服务器却还在处理这些已经没人要的请求。所以线程池的队列必须有上限,满了以后要尽快拒绝新请求,或者让上游降低发送速度,同时给每个请求设置超时。这些做法叫限流和背压,展开讨论见 连接之外:吞吐、扇出与背压 和 并发控制:限流、背压、重试。

在服务器上观察线程池 ​

线程池的健康状况,至少要看这几个指标,大部分框架都能直接导出:

指标说明什么
活跃线程数 / 最大线程数线程池的利用率,长期高于 80% 就要关注
队列长度排队的请求数,持续增长说明处理能力不足
任务排队时间请求在队列里等了多久,比队列长度更直接地反映用户感受
被拒绝的任务数队列满了以后拒绝了多少请求
每秒完成数吞吐量,和到达速率比较

以 Java 为例,ThreadPoolExecutor 的 getActiveCount()、getQueue().size()、getCompletedTaskCount() 分别对应前三项;Tomcat 的 maxThreads 是工作线程上限(默认 200),acceptCount 是线程都忙时还能排队的连接数。数据库连接池同样要看活跃连接数和等待获取连接的线程数,因为它往往是线程池背后真正的瓶颈。

用 Little 定律还可以交叉验证监控数据:平均在途数应该约等于 QPS 乘以平均响应时间。如果监控上的在途数明显偏大,说明有请求卡住了没有返回,或者统计口径不一致。

这一篇反复用到平均响应时间,但最后一组模拟里已经能看到,P99 比平均值大好几倍。平均值好看,用户却说慢,问题往往出在这里。最后一篇:06 平均值会骗人:P50、P95、P99 与长尾。

别急,先让缓存热一下。