7.3 并发与并行:加资源不等于变快
上一节:7.2 减少工作 | 下一节:7.4 缓存 配套代码:07-optimization-and-validation/03-concurrency-and-parallelism
一句话结论
并行化的收益有上限,而且过了某个点就变成负的。 原因是:并行需要共享资源(CPU、数据库、下游),而共享资源会排队。所以「加线程/协程」不是免费的加速——它常常只是把瓶颈从应用层转移到下游。
一、用「超市收银台」理解并行的上限
超市有 8 个收银台(= CPU 核数)。顾客排队结账。
| 做法 | 结果 |
|---|---|
| 开 4 个台 | 队伍较长 |
| 开 8 个台 | 最优(与核数匹配) |
| 开 16 个台 | 收银员互相挤,通道拥堵 → 反而更慢 |
| 开 100 个台 | 完全没有空间,无法工作 |
关键洞察:收银员多了,但超市的物理空间、仓库补货能力没变——瓶颈转移了。
后端完全一样:
加了 2 倍线程 → 应用能并发处理 2 倍请求
但数据库只有那么多连接和处理能力
→ 请求全部堵在数据库前面
→ 整体更慢(因为锁竞争 + 上下文切换 + 数据库过载)
二、并行的三种正确用法
❶ 对 IO 密集操作:用协程,但要限制并发
// ❌ 无限制:10 万个协程全打到下游
suspend fun fetchAll(ids: List<Long>) = coroutineScope {
ids.map { async { httpGet(it) } }.awaitAll()
}
// ✅ 限制并发(保护下游,也保护自己)
private val limiter = Semaphore(64)
suspend fun fetchAll(ids: List<Long>) = coroutineScope {
ids.map { id -> async { limiter.withPermit { httpGet(id) } } }.awaitAll()
}
// ✅ 或按批次(更可控)
suspend fun fetchAllBatched(ids: List<Long>, batch: Int = 64) =
ids.chunked(batch).flatMap { chunk ->
coroutineScope { chunk.map { async { httpGet(it) } }.awaitAll() }
}
为什么要限制:你是下游的客户端,你的并发就是下游的负载。 无限制的并发 = 对下游的 DDoS。
❷ 对 CPU 密集操作:并行度 ≈ CPU 核数
// ❌ 在 IO 调度器上做 CPU 密集任务(占满 IO 线程,影响其他 IO)
withContext(Dispatchers.IO) { heavyCompute() }
// ✅ 用 Default(并行度 = 核数)
withContext(Dispatchers.Default) { heavyCompute() }
// ✅✅ 如果 CPU 密集任务会和其他请求争抢,用受限的专用调度器
private val cpuDispatcher = Dispatchers.Default.limitedParallelism(2)
withContext(cpuDispatcher) { heavyCompute() }
经验值:
| 任务类型 | 并行度 | 理由 |
|---|---|---|
| CPU 密集 | ≈ CPU 核数 | 超过核数只增加切换开销 |
| IO 密集(无外部限制) | 核数 × 2 ~ 4 | 有等待时间可重叠 |
| IO 密集(有限制下游) | 按下游承载能力 | Little’s Law 反推 |
| 混合 | 分开调度器 | 防止互相影响 |
❸ 对串行链路:安全的并行化
// ❌ 串行:3 个独立调用依次执行(总耗时 = 30+40+20 = 90ms)
val user = fetchUser(id)
val orders = fetchOrders(id)
val coupons = fetchCoupons(id)
// ✅ 并行:总耗时 = max(30, 40, 20) = 40ms
val (user, orders, coupons) = coroutineScope {
val u = async { fetchUser(id) }
val o = async { fetchOrders(id) }
val c = async { fetchCoupons(id) }
Triple(u.await(), o.await(), c.await())
}
收益:从「和」变成「最大值」——在有依赖的链路上不能这么做:
// ❌ 有依赖:不能并行(orders 需要 user 的某些字段)
val user = fetchUser(id)
val orders = fetchOrders(user.region)
判断方法:画出依赖图。只有互相独立的调用才能并行。
三、并行的四个「反效果」
❶ 上下文切换成本
线程数远超核数 → nvcswch/s 飙升 → 切换成本超过并行收益
(第 4 章 4.5 节的实测:从 3000 涨到 45000 时 QPS 反而下降)
❷ 下游被打垮(最常见)
应用加了 3 倍线程 → 3 倍的并发打到数据库
→ 数据库锁竞争加剧、IO 队列变长
→ 所有查询都变慢
→ 整体 P99 恶化
这就是「把瓶颈转移给下游」——你以为解决了问题,其实是把它推给了别人(而那个人可能是你自己的数据库)。
❸ 共享状态的竞争
// ❌ 并行更新共享计数器
val counter = AtomicLong()
ids.map { async { counter.incrementAndGet() } } // 竞争激烈
// ✅ 分片累加,最后合并
val shards = LongArray(cpuCount)
ids.forEachIndexed { i, _ -> shards[i % cpuCount]++ } // 无竞争
val total = shards.sum()
❹ 并行度不一致导致的「木桶效应」
链路:A(50ms) → [B(30ms) ∥ C(200ms)] → D(20ms)
总耗时 = 50 + 200 + 20 = 270ms
↑ 最慢的那个决定
→ 优化 B(30 → 15ms)完全没用;要优化 C
这就是为什么并行链路要用 span 树看关键路径(第 6.2 节)——优化非关键路径上的环节是白费力气。
四、避免共享:比并行的调优更重要
在第 2 章 2.6 节讲过:JVM 上「不共享」优于「聪明地共享」。三种手段:
| 手段 | 做法 | 适用 |
|---|---|---|
| 分片 | 按 key 路由到独立资源 | 计数器、缓存、连接池 |
| 线程封闭 | 每个线程/协程独立状态 | 缓冲区、临时对象 |
| 不可变对象 | 只读共享,无需同步 | 配置、常量、DTO |
// ❌ 共享可变状态 + 锁
class CounterBad {
private var count = 0L
@Synchronized fun inc() { count++ }
}
// ✅ 分片(无锁,高并发下快一个数量级)
class CounterGood(shards: Int = 16) {
private val slots = LongArray(shards)
fun inc(key: Int) { slots[key % shards]++ }
fun total() = slots.sum()
}
// ✅✅ 或用现成的分片结构
private val counter = LongAdder()
fun inc() = counter.increment()
五、并行度调优的实操方法
步骤
① 先测「不并行」的基线
② 逐步提升并行度(1 → 2 → 4 → 8 → 16 …)
③ 每级测:吞吐(QPS)与延迟(P50/P99)
④ 找「吞吐拐点」和「延迟恶化点」
⑤ 选在两者的交点之前
判据
| 现象 | 结论 |
|---|---|
| QPS 随并行度线性增长 | 还有空间(继续加) |
| QPS 增长变缓 | 接近拐点 |
| QPS 不再增长但延迟大幅上升 | ⚠️ 超过最优点了,应该降回来 |
| QPS 下降 | ❌ 已经过载(竞争/切换成本占主导) |
一个容易被忽略的点
「最优并行度」不是固定的——它取决于:
| 因素 | 影响 |
|---|---|
| 下游的承载能力 | 下游强则能容忍更高并发 |
| 单次操作的耗时 | 耗时长则需要更高并发才能填满(Little’s Law) |
| CPU 密集 vs IO 密集 | CPU 密集的上限是核数 |
| 数据量 | 数据量大时数据库更慢,能容忍的并发更低 |
所以要定期重测——尤其是在数据量增长或下游变更之后。
六、本节小结
- 并行有上限:过了某个点就变成负收益(切换成本 + 下游过载 + 竞争)。
- IO 密集用协程但要限制并发:无限制的并发等于对下游的 DDoS。
- CPU 密集的并行度 ≈ 核数;混合任务要分开调度器。
- 串行链路的并行化:只有互相独立的调用才能并行,收益从「和」变成「最大值」。
- 四个反效果:上下文切换、下游被打垮、共享状态竞争、木桶效应。
- 避免共享比调优并行更重要:分片 / 线程封闭 / 不可变对象。
- 并行度调优的方法:逐步提升,找吞吐拐点与延迟恶化点,选在交点之前。
七、自测
- 你把某个接口的并发从 8 提到 32,QPS 从 12000 涨到 12500(+4%),但 P99 从 80 ms 涨到 300 ms。请评价这个改动。
- 一个接口串行调用 4 个独立的下游(各 50 ms,总 200 ms)。请说出并行化后的预期耗时,以及需要检查的两件事。
- 为什么「加线程可能让系统更慢」?请说出至少三个具体机制。
- 应该回滚(或降到更低的并发)。理由:① 收益极小——QPS 只涨了 4%(可能还在噪声范围内,需要用第 5 章的方法验证);② 代价巨大——P99 从 80 ms 涨到 300 ms(恶化 275%),这直接影响用户体验和 SLO;③ 这个现象本身就说明超过了最优并行度:QPS 不再增长但延迟大幅上升,典型的「超过拐点」信号;④ 潜在的额外风险:32 并发可能让下游(数据库/其他服务)压力增大,把瓶颈转移出去。正确的做法:回到并发 8(或测试 12、16 找最优点),用「QPS 拐点 + P99 可接受」两个条件共同决定。
- 预期耗时约 50–60 ms(取最慢的那个 + 少量调度开销),而不是 200 ms——提升约 3.3 倍。需要检查的两件事:① 这 4 个调用是否真的互相独立(没有依赖关系)——如果一个调用需要另一个的返回值,就不能并行(第 3 节的例子:
fetchOrders(user.region)依赖 user);② 下游能否承受 4 倍的瞬时并发——并行会把并发从 1 提高到 4,如果下游是数据库或有速率限制的服务,可能触发限流或过载;应该用 Semaphore 限制对每个下游的并发。还要注意第三点:并行化后,优化单个下游的收益会下降——因为总耗时取决于最慢的那个(木桶效应),优化非最慢的毫无意义。 - 三个机制:① 上下文切换成本——线程数超过核数后,操作系统必须频繁轮转线程,每次切换 1–5 微秒,而且会导致 CPU 缓存失效(新线程要重新加载工作集);当切换频率高到一定程度,花在切换上的 CPU 时间超过并行收益(第 4 章 4.5 节实测:
nvcswch/s从 3000 涨到 45000 时 QPS 下降)。② 共享资源的竞争加剧——更多线程争抢同一把锁或同一个连接池,锁等待时间非线性增长(第 0 章 0.5 节的排队论);而且竞争会消耗sysCPU。③ 把压力转移到下游——应用并发提高意味着对数据库/下游的并发请求数提高,可能导致下游锁竞争加剧、IO 队列变长,结果是所有查询都变慢。④ 缓存与内存压力(第四个):更多线程意味着更大的工作集、更多的临时对象、可能触发更多 GC。