多线程到底开多少个?我用 Python 3.11、Java 21 和 2 核云主机跑了 12 组,线程池公式真别硬背

🔑 关键词:多线程,线程池,Python GIL,上下文切换,并发编程

📖 摘要:从一次日志聚合踩坑出发,对比 Python 多线程、Java 线程池和虚拟线程,给出线程数判断、ThreadPoolExecutor 参数、pidstat/vmstat 排障命令和 5 步调优步骤。

先交代背景:我不是什么并发专家,主职写业务代码,晚上接点小工具。 2024 年 3 月到 5 月,我被一个日志聚合任务折腾了大概三周。 机器是腾讯云 2C4G,Ubuntu 22.04,Python 3.11.7,Java 用 OpenJDK 21.0.2,本地是 M2 MacBook Air 8C/16G。 任务很简单:从 2000 个文件里读 JSON 行,过滤后写进 SQLite 和 HTTP 上报。 我一开始用 Python ThreadPoolExecutor(max_workers=64),结果 CPU 只有 35%,SQLite 报 database is locked,P99 从 1.2s 冲到 17s。 后来我把线程从 64 降到 8,锁等待降了,但 HTTP 上报又成瓶颈。 那次我才承认:多线程不是开得越多越快,线程数只是你能同时“排队”的人数,真正决定速度的是谁在等、谁在锁、谁在占 CPU。

图片

很多教程会给你公式:IO 密集 N_threads = CPU 核数 * 2,CPU 密集 = CPU 核数 + 1。 这个公式不是没用,但它把下游延迟、锁竞争、连接池全吞掉了。 拿 Python 来说,CPython 3.11 默认还有 GIL,CPU 密集多线程几乎不会让 4 核跑出 4 倍;但 time.sleep、socket read、requests 等待会释放 GIL,所以 IO 密集还是有效。 Java 21 没有 GIL,但线程切换要钱。 我给你一条更土的判断:跑 pidstat -w -p <pid> 1,如果 cswch/s 上万、%CPU 不高、延迟还涨,通常不是线程不够,是锁或队列在打架。 再跑 vmstat 1 看 r 和 cs:r 长期大于核数,才考虑加并行度;cs 高、r 不高,先查锁。

图片

我做了 12 组小压测,不是 benchmark,就是业务影子。 任务:10000 个对象,每个对象做 20ms 模拟 IO 和 5ms JSON 解析。 2C4G 云主机。 Python 3.11.7:单线程 312s;ThreadPool 4 线程 118s;8 线程 96s;16 线程 101s;32 线程 127s;64 线程 194s。 Java 21:固定线程池 4 是 112s,8 是 89s,16 是 93s,32 是 121s;虚拟线程 1000 并发是 84s,但把下游 HTTP 连接池从 20 提到 200 后,错误率从 0.2% 涨到 4.7%,因为对端限流。 最有用的一组是 Python 8 线程 + HTTP 连接池 16 + SQLite 批量 500 条:96s 降到 61s。 看,线程数没变,改变的是等待模型。

图片

如果你用 Java,别只写 Executors.newFixedThreadPool(10)。 ThreadPoolExecutor 至少看 7 个参数:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。 我的经验配置:core=8,max=16,队列 ArrayBlockingQueue(200),keepAlive=60s,拒绝策略先用 CallerRunsPolicy 做背压。 为什么?队列用无界 LinkedBlockingQueue 时,maxPoolSize 经常是摆设,任务先堆队列,延迟全烂在内存里;队列 200 加 CallerRuns,至少让调用方慢下来。 监控至少抓 6 个指标:activeCount、poolSize、queue.size、completedTaskCount、largestPoolSize、拒绝次数。 Prometheus 里就是 executor_active_threads、executor_queued_tasks、executor_rejected_tasks 这类,别等 OOM 才知道。

图片

真要上手,按这 5 步走,别跳。 1)先用单线程跑一遍,记录 wall time、CPU time、P95/P99,命令 time、/usr/bin/time -v、pidstat -u -p <pid> 1。 2)判断瓶颈:CPU time 接近 wall time 且 CPU 打满,是 CPU 密集;wall time 远大于 CPU time,是 IO 等待。 3)选模型:CPU 密集优先多进程/多核,IO 密集多线程或异步;Python CPU 密集别硬上 threading。 4)定并行度:从 CPU 核数开始,IO 密集按下游连接池和 P99 调,每次只加 2 或 4 个线程,压 5 分钟。 5)加背压:有界队列、超时、重试上限、熔断,线程池不是许愿池。

图片

我现在有个不太讨喜的观点:多线程的核心价值不是“快”,而是把阻塞代码改造成可管理的队列。 异步的核心价值也不是“快”,是用更少线程扛更多等待。 两者都会遇到同一个敌人:无界排队。 Go 的 goroutine 轻,但 10 万个 goroutine 一起打 MySQL,MySQL 连接池只有 100,照样雪崩。 所以选型看三件事:代码能不能改、下游能不能扛、团队能不能排障。 老业务全是阻塞 JDBC,硬切异步可能改半年;新项目 IO 边界清楚,异步更省线程。 别听“多线程已死”或者“异步万能”,这俩在业务里经常混着用。

图片

最后说点丢人的。 我曾经为了一个接口 QPS,把 Tomcat maxThreads 从 200 改到 800,结果 QPS 从 2300 掉到 1600,数据库连接池 50 先崩了。 后来回滚到 200,把慢 SQL 从 1.8s 降到 120ms,连接池还是 50,QPS 到 4100。 那一刻我才明白,线程数只是表面,瓶颈往往在下游。 你要是正被多线程折磨,先别搜“线程池最佳线程数”,先问自己:我的任务在等什么?等网络、等磁盘、等锁,还是等 CPU? 答案不同,线程数只是最后一步。

🏷️ 标签: