为什么并发越高,程序反而越慢
在进行性能测试时,我们可能会遇到一种反直觉现象:并发数从10提高到50,系统吞吐量明显增加;继续提高到200,吞吐量增长开始变慢;当并发数达到1000时,系统不但没有处理更多请求,响应时间反而急剧上升,甚至出现大量超时。
这说明并发并不是越高越好。并发只能让系统更加充分地使用资源,却不能凭空创造计算能力。当请求数量超过系统的处理能力后,新增请求只会进入队列、争抢资源并增加调度开销。
理解这一点,是正确配置线程池、连接池和服务限流策略的基础。
一、并发和并行并不是一回事
并发表示多个任务在同一段时间内推进,并不要求它们在同一时刻真正执行。
并行则表示多个任务在同一时刻分别由不同的计算资源执行。
假设一颗CPU只有一个核心,同时有三个任务需要运行。操作系统可以让它们轮流使用CPU:
任务A运行一段时间
任务B运行一段时间
任务C运行一段时间
再次运行任务A
从较长的时间尺度看,三个任务都在推进,因此它们是并发执行;但任意时刻只有一个任务真正占用CPU,所以并没有实现并行。
如果CPU拥有多个核心,不同任务才可能在同一时刻分别运行。
因此,创建1000个线程并不意味着有1000个任务可以同时计算。对于8核心CPU,在不考虑其他因素时,同一时刻通常仍然只有少量线程能够真正运行。
二、并发为什么能够提高性能
虽然CPU核心数量有限,但程序并不总是在使用CPU。
一个请求可能经历以下过程:
读取请求
→ 查询数据库
→ 等待数据库响应
→ 执行业务计算
→ 读取文件
→ 等待磁盘
→ 返回结果
当一个线程等待数据库、磁盘或其他外部资源时,CPU可能处于空闲状态。此时让另一个线程继续执行,就能提高资源利用率。
因此,并发对于输入输出密集型任务通常非常有效。它可以把某个任务的等待时间用于处理其他任务。
假设一个任务需要:
- 10毫秒进行CPU计算;
- 90毫秒等待数据库。
如果完全串行执行,一个任务需要100毫秒,一秒大约只能处理10个任务。
如果允许多个任务并发,当第一个任务等待数据库时,CPU可以处理其他任务。在资源允许的情况下,系统吞吐量可能获得明显提升。
这也是服务器通常需要并发处理请求的主要原因。
三、为什么继续增加并发会变慢
并发带来的收益存在上限。当关键资源已经接近饱和后,继续增加并发会产生额外成本。
1. CPU上下文切换
当可运行线程数量超过CPU核心数量时,操作系统需要频繁暂停一个线程,再恢复另一个线程。
切换过程中需要保存和恢复:
- CPU寄存器;
- 程序执行位置;
- 栈指针;
- 调度状态;
- 部分缓存相关信息。
上下文切换本身不处理业务,却会消耗CPU时间。
线程数量过多时,CPU可能把大量时间花在线程调度上,而不是执行真正的计算任务。
2. CPU缓存命中率下降
现代CPU与内存之间存在多级缓存。线程运行时,会把经常访问的数据加载到CPU缓存中。
当线程频繁切换,不同线程的数据会相互挤占缓存。原线程再次运行时,可能需要重新从速度较慢的内存读取数据。
因此,上下文切换的代价不只是保存和恢复寄存器,还可能破坏数据局部性,降低缓存命中率。
3. 锁竞争加剧
多个线程访问共享数据时,通常需要使用锁保证一致性:
synchronized (lock) {
updateSharedState();
}
如果只有少量线程,锁竞争可能并不明显;如果大量线程同时请求同一把锁,大部分线程只能等待。
此时增加线程并不会提高吞吐量,因为临界区在同一时刻仍然只能由一个线程执行。更多线程只会形成更长的等待队列。
严重时还可能出现“锁竞争风暴”:线程不断被唤醒、尝试获取锁、失败后再次休眠,大量计算资源消耗在无效竞争上。
4. 内存压力增加
每个线程都需要维护自己的栈和运行状态。线程越多,额外内存占用越大。
如果一个线程分配1MB栈空间,那么1000个线程可能需要预留接近1GB的虚拟地址空间。实际占用方式会因语言、系统和配置不同而变化,但线程绝不是没有成本的。
大量并发请求还可能同时创建:
- 请求对象;
- 临时缓冲区;
- 数据库查询结果;
- 序列化对象;
- 日志信息;
- 待发送的响应数据。
这些对象会增加垃圾回收压力。垃圾回收越频繁,业务线程能够使用的CPU时间就越少。
5. 下游资源已经达到上限
应用线程可以增加,但数据库连接数、磁盘吞吐量和外部接口容量不会自动增加。
假设应用创建了500个工作线程,但数据库连接池只有30个连接,那么最多只有30个线程能够同时访问数据库,其余线程仍然需要等待。
如果盲目扩大数据库连接池,数据库也可能因为并发查询过多而发生:
- CPU利用率过高;
- 磁盘输入输出饱和;
- 锁等待增加;
- 缓存命中率下降;
- 查询延迟上升。
性能问题不会因为扩大连接池而消失,它往往只是从应用层转移到了数据库层。
四、吞吐量和响应时间的关系
吞吐量表示系统单位时间内能够完成多少任务,响应时间表示一个任务从进入系统到处理完成需要多久。
在低负载阶段,增加并发通常可以提高吞吐量,而响应时间变化不大。
随着负载接近系统上限:
- CPU利用率逐渐升高;
- 数据库连接逐渐耗尽;
- 请求开始进入队列;
- 响应时间开始增加。
超过系统容量后,吞吐量可能不再增长,但响应时间会快速恶化。
例如,一个服务每秒最多稳定处理500个请求。如果每秒到达800个请求,那么每秒都会多出300个请求进入队列。只要这种情况持续,队列就会越来越长。
即使每个请求实际只需要几十毫秒处理,用户也可能需要等待数秒,因为大部分时间都消耗在排队上。
五、排队为什么会迅速放大延迟
系统中的资源利用率越接近百分之百,排队延迟通常越不稳定。
当CPU利用率为百分之五十时,偶尔到来的新任务通常能较快获得执行机会;当CPU长期接近百分之百时,任何额外请求都需要等待其他任务完成。
如果任务执行时间完全一致,队列还比较容易预测。但真实系统中的任务通常长短不同:
- 有些查询只需几毫秒;
- 有些查询需要扫描大量数据;
- 有些请求命中缓存;
- 有些请求需要访问慢速存储;
- 有些请求触发垃圾回收或重试。
一个特别慢的任务可能占用关键资源,使其后的大量短任务一起等待,这就是队头阻塞。
因此,系统平均响应时间看似正常时,少量请求仍可能出现很高的尾部延迟。
六、为什么平均响应时间可能骗人
假设有100个请求:
- 95个请求耗时100毫秒;
- 5个请求耗时5秒。
平均响应时间约为345毫秒。单看平均值,问题似乎不算严重;但实际上,每20个用户中就可能有一个需要等待5秒。
性能分析中通常还要关注分位数:
- P50:一半请求的响应时间不超过该值;
- P90:百分之九十的请求不超过该值;
- P95:百分之九十五的请求不超过该值;
- P99:百分之九十九的请求不超过该值。
高并发下,P99通常比平均值更早暴露排队、锁竞争和慢查询问题。
平均值适合描述整体水平,P95和P99则更接近最容易影响用户体验的慢请求。
七、线程池不是越大越好
线程池的目的不是创建尽可能多的线程,而是控制同时执行任务的数量。
线程池通常包含两个重要参数:
- 工作线程数;
- 任务队列容量。
如果线程数过小,CPU和外部资源可能无法被充分利用;如果线程数过大,则可能出现上下文切换、锁竞争和内存压力。
CPU密集型任务
CPU密集型任务的大部分时间都在进行计算,例如:
- 图像处理;
- 视频编码;
- 数据压缩;
- 加密计算;
- 模型推理;
- 大规模排序。
这类任务的线程数通常应接近CPU核心数。线程过多不会增加真正的并行能力,反而会增加调度成本。
输入输出密集型任务
输入输出密集型任务的大部分时间用于等待,例如:
- 数据库查询;
- 文件读写;
- 远程调用;
- 消息收发。
这类任务可以配置比CPU核心数更多的线程,因为部分线程等待时,其他线程仍可使用CPU。
可以用下面的关系作为初步估算:
线程数 ≈ CPU核心数 ×(1 + 等待时间 ÷ 计算时间)
例如,一个任务平均计算10毫秒、等待40毫秒,在8核心CPU上可以初步估算:
线程数 ≈ 8 ×(1 + 40 ÷ 10)= 40
这个公式只能提供起始值,最终参数仍然需要通过压力测试确定。
八、无界队列为什么危险
有些线程池使用没有容量限制的任务队列。当请求速度超过处理速度时,新任务会不断进入队列。
短时间看,系统似乎没有拒绝请求;实际上,问题只是被隐藏了。
随着队列增长:
- 请求等待时间越来越长;
- 队列对象占用越来越多内存;
- 用户可能早已放弃请求;
- 服务器仍在处理已经失去价值的任务;
- 最终可能发生内存不足。
假设任务处理能力是每秒500个,而每秒进入800个。即使队列能够容纳数百万个任务,也只是让系统晚一点崩溃,并没有解决容量不足的问题。
相比无界队列,一个有容量限制的队列通常更加安全。队列满后,系统应当采取明确策略:
- 拒绝新请求;
- 返回系统繁忙;
- 降低非核心功能质量;
- 延迟生产任务;
- 将任务转移到其他实例;
- 对不同优先级任务进行区分。
拒绝部分请求看起来不够友好,但它可以保护系统继续为其他用户提供服务。
九、超时和重试可能形成雪崩
当系统变慢时,客户端通常会触发超时并重新请求。如果重试策略设计不当,原本已经过载的系统会收到更多请求。
例如,正常流量为每秒500个请求。系统发生短暂抖动后,部分请求超时,每个客户端立即重试两次,那么实际到达量可能迅速增加到每秒1500个。
更多请求导致队列更长,队列更长又导致更多超时,最终形成恶性循环:
系统变慢
→ 请求超时
→ 客户端重试
→ 流量增加
→ 系统进一步变慢
合理的重试机制通常需要:
- 设置最大重试次数;
- 使用指数退避;
- 加入随机抖动;
- 只重试可安全重复的操作;
- 设置整体截止时间;
- 避免所有客户端同时重试。
服务端也应保证关键接口具有幂等性,防止重试造成重复扣款、重复创建订单等副作用。
十、协程和异步能解决所有问题吗
协程和异步输入输出可以减少大量线程带来的调度与内存开销,因此非常适合高并发、长等待的任务。
但它们并不能突破硬件和下游资源上限。
如果任务本身需要大量CPU计算,那么将线程改成协程并不会增加CPU核心数量。如果数据库每秒只能完成一定数量的查询,异步提交更多查询也只会让数据库队列更长。
异步技术主要解决的是“如何用更少的线程管理大量等待中的任务”,而不是“如何无限提高系统吞吐量”。
即使使用协程,仍然需要:
- 并发数量限制;
- 有界队列;
- 超时控制;
- 背压机制;
- 下游容量保护;
- 过载拒绝策略。
十一、什么是背压
背压是指下游处理不过来时,能够通知或限制上游继续发送任务。
假设生产者每秒产生1000个任务,而消费者每秒只能处理600个。如果没有背压,剩余任务只能不断堆积。
加入背压后,系统可以在队列接近上限时:
- 降低生产速度;
- 暂停读取新任务;
- 拒绝低优先级任务;
- 只保留最新数据;
- 对任务进行采样;
- 通知调用方稍后重试。
背压承认系统容量是有限的,并将这种限制明确传递给上游。它通常比依赖无限队列更加可靠。
十二、如何找到合适的并发数
合适的并发数不是根据经验直接猜出来的,而应通过逐步压力测试确定。
可以按照以下方式测试:
- 固定请求类型和数据规模;
- 从较低并发开始;
- 每轮逐步提高并发;
- 记录吞吐量、平均延迟、P95和P99;
- 同时记录CPU、内存、垃圾回收和队列长度;
- 观察数据库连接、磁盘和下游服务;
- 找到吞吐量不再明显增长的位置;
- 在拐点之前保留一定安全余量。
如果并发从100增加到150时,吞吐量只提高百分之二,但P99延迟增加了两倍,那么继续增加并发通常已经没有意义。
生产环境也不应长期运行在理论最大值附近。系统需要为流量波动、慢查询、垃圾回收和机器故障保留余量。
十三、性能优化应该优化什么
面对并发性能问题,单纯扩大线程池通常不是最佳方案。更有效的方向包括:
减少单个任务的处理时间
- 优化算法;
- 减少不必要的计算;
- 合并重复请求;
- 使用批处理;
- 优化数据库查询;
- 避免频繁创建大对象。
缩短关键资源占用时间
- 缩小锁的范围;
- 减少事务持续时间;
- 尽早释放数据库连接;
- 避免在持锁状态下进行慢速操作。
降低共享状态
- 使用不可变对象;
- 对数据进行分片;
- 使用线程本地数据;
- 将全局锁拆分为多把细粒度锁。
控制进入系统的工作量
- 限流;
- 有界队列;
- 请求优先级;
- 超时与取消;
- 熔断和降级;
- 背压。
扩展实际处理能力
- 增加CPU核心;
- 增加服务实例;
- 拆分热点数据;
- 扩展数据库或存储系统;
- 将任务分配到不同节点。
优化的关键不是让系统同时接收更多任务,而是让单位时间内完成更多有效任务。
结语
并发的价值在于隐藏等待时间并提高资源利用率,但它不能突破系统的实际容量。
当并发较低时,增加并发可以减少资源空闲,提高吞吐量;当关键资源接近饱和后,更多并发只会带来排队、上下文切换、锁竞争和内存压力。
因此,一个稳定的高并发系统并不是“来多少请求都接收”,而是能够清楚地知道自己的处理上限,并在接近上限时主动进行限流、排队、降级和拒绝。
并发优化真正要寻找的,不是最大的线程数,而是吞吐量、响应时间和系统稳定性之间最合适的平衡点。
- 点赞
- 收藏
- 关注作者
评论(0)