多线程没用?我把线程数调到64反而更慢,后来只剩4个线程反而稳定

🔑 关键词:多线程, 线程池, GIL, goroutine, 并发

📖 摘要:一次线程池调优的真实记录,核心线程数从8加到64反而更慢,固定4线程反而稳定;对比 Java 线程池、Go goroutine、Python GIL,线程数不该看并发该看隔离,队列和超时才是关键。

最近我在排查一个消息推送服务,8核16G的虚拟机,压测的时候CPU只用了30%,但接口P99从800ms一路涨到2.3s。所有人第一反应是线程池太小。我按流程把核心线程数从8加到32,再到64,结果P99不降反升:32线程时P99变成1.6s,到64线程直接飙到2.8s,而 /proc/loadavg 依然很低。用 pidstat -w 一看,每秒上下文切换从1.9万次变成7.6万次,系统大量时间耗在换栈和调度队列上,任务本身并没有多跑。后来我把 @Async 注解全部拆掉,改成固定4个线程去消费一个无界任务内存队列,结果P99稳定在900ms左右。这一下让我对多线程的认知发生了改变。

图片

很多人喜欢拿多线程并行和异步单线程做对比,但没有一个模型是免费的。Java 线程栈默认在 Linux 上通常是1M,Windows上1M(和编译器相关),JVM 还要额外的元数据,开500个线程不一定会崩,但堆外内存已经吃了不少,切换成本更是吓人。Go 的 goroutine 看着是轻量线程,初始栈只有2K,但调度器为了在系统线程上抢占,依然有额外成本。Netty 这类的事件循环避免了线程切换,却要求你不许在IO线程里做阻塞操作,一旦有人偷偷写了 Thread.sleep,整个事件循环就卡死。我见过最尴尬的调优是,一个系统几乎全是 Redis 和数据库操作,阻塞系数极高,理论线程数 = CPU核心 / (1-阻塞系数) 算出来几百个,但由于 socket 超时和连接池排队,400个线程反而让数据库连接被占光,redis 客户端的 netty 线程也成了瓶颈。

图片

我的观点可能和主流不太一样:多线程真正该解决的不是并行,而是隔离。当一个系统里出现了不稳定的下游,你用了太多线程做并发,等于让所有任务在同一个共享池里抢资源,一次大批量超时就能把线程池全部占满,后续正常请求全部排队。反过来,如果把执行单元个数限定成很少的几路,每一路都像一个独立的疏散通道,某一路堵死了,其他几路还能继续处理。我现在的处理是这么调:给线程按业务语义分组,比如支付回调线程池只有2个线程,再做有界队列,最大容量是200;宁可拒绝任务让调用方看到503,也不要让线程池消化到堆溢出。也许吞吐峰值不会更高,但P99和可用性稳定很多,这一点在真实环境里比绝对值重要。

图片

最后想吐槽 Java 的 ThreadPoolExecutor 那个默认队列,谁用谁踩坑。官方文档写得很清楚,如果用的是无界 LinkedBlockingQueue,那么 maxPoolSize 其实没有意义,因为线程永远只会有 core 个,多余任务全部进队列。很多人把 core 设4,max设100,看监控线程数从没超过4,还以为是自己代码不对;其实是队列无限吸收任务。真到队列堆满的那天,GC 已经先崩了。我线上一定要把 allowCoreThreadTimeOut(true) 打开,配合 SynchronousQueue 或者有界 ArrayBlockingQueue,线程数才会按负载增长。另外,CallerRunsPolicy 看着会降低吞吐,但至少把压力传回给调用方,比悄悄吞掉任务强。

图片

再提到 Python,Python 的多线程是被骂得最冤的。GIL 只让同一时刻一个线程执行 Python 字节码,但在网络和磁盘IO等待期间,线程会让出GIL,所以做并发IO没问题。真正不该用的是 CPU 密集的 Python 线程;这时候 multiprocessing 或者用 C 扩展才靠谱。我早期看网上说 Python 多线程是垃圾,就一竿子打翻,后来自己做了一次把3000个URL下载的测试,线程版本比单线程快了三倍。所以别拿 GIL 当不去理解工具的借口。多线程的关键从来不是线程有几个,而是你对阻塞、队列、超时的控制到不到位。用得好,哪怕只有4个线程也很强大。

图片

如果你想搜多线程、线程池、GIL或者goroutine,你会发现网上很极端:有的说线程越多越好,有的说线程是垃圾,全都用异步。其实没有哪一种是银弹。我唯一改变的做法是:先想故障域在哪,再算线程数;先把队列设成有界,再谈并发;先设置超时和拒绝策略,再去压测P99。你可能不需要64个线程,你只需要让4个线程别互相牵连,然后站在旁边看它们什么时候会死。

图片

🏷️ 标签: