文档目录

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 密集的上限是核数
数据量 数据量大时数据库更慢,能容忍的并发更低

所以要定期重测——尤其是在数据量增长或下游变更之后。


六、本节小结

  1. 并行有上限:过了某个点就变成负收益(切换成本 + 下游过载 + 竞争)。
  2. IO 密集用协程但要限制并发:无限制的并发等于对下游的 DDoS。
  3. CPU 密集的并行度 ≈ 核数;混合任务要分开调度器。
  4. 串行链路的并行化:只有互相独立的调用才能并行,收益从「和」变成「最大值」。
  5. 四个反效果:上下文切换、下游被打垮、共享状态竞争、木桶效应。
  6. 避免共享比调优并行更重要:分片 / 线程封闭 / 不可变对象。
  7. 并行度调优的方法:逐步提升,找吞吐拐点与延迟恶化点,选在交点之前。

七、自测

  1. 你把某个接口的并发从 8 提到 32,QPS 从 12000 涨到 12500(+4%),但 P99 从 80 ms 涨到 300 ms。请评价这个改动。
  2. 一个接口串行调用 4 个独立的下游(各 50 ms,总 200 ms)。请说出并行化后的预期耗时,以及需要检查的两件事。
  3. 为什么「加线程可能让系统更慢」?请说出至少三个具体机制。
  1. 应该回滚(或降到更低的并发)。理由:① 收益极小——QPS 只涨了 4%(可能还在噪声范围内,需要用第 5 章的方法验证);② 代价巨大——P99 从 80 ms 涨到 300 ms(恶化 275%),这直接影响用户体验和 SLO;③ 这个现象本身就说明超过了最优并行度:QPS 不再增长但延迟大幅上升,典型的「超过拐点」信号;④ 潜在的额外风险:32 并发可能让下游(数据库/其他服务)压力增大,把瓶颈转移出去。正确的做法:回到并发 8(或测试 12、16 找最优点),用「QPS 拐点 + P99 可接受」两个条件共同决定。
  2. 预期耗时约 50–60 ms(取最慢的那个 + 少量调度开销),而不是 200 ms——提升约 3.3 倍。需要检查的两件事:① 这 4 个调用是否真的互相独立(没有依赖关系)——如果一个调用需要另一个的返回值,就不能并行(第 3 节的例子:fetchOrders(user.region) 依赖 user);② 下游能否承受 4 倍的瞬时并发——并行会把并发从 1 提高到 4,如果下游是数据库或有速率限制的服务,可能触发限流或过载;应该用 Semaphore 限制对每个下游的并发。还要注意第三点:并行化后,优化单个下游的收益会下降——因为总耗时取决于最慢的那个(木桶效应),优化非最慢的毫无意义。
  3. 三个机制:① 上下文切换成本——线程数超过核数后,操作系统必须频繁轮转线程,每次切换 1–5 微秒,而且会导致 CPU 缓存失效(新线程要重新加载工作集);当切换频率高到一定程度,花在切换上的 CPU 时间超过并行收益(第 4 章 4.5 节实测:nvcswch/s 从 3000 涨到 45000 时 QPS 下降)。② 共享资源的竞争加剧——更多线程争抢同一把锁或同一个连接池,锁等待时间非线性增长(第 0 章 0.5 节的排队论);而且竞争会消耗 sys CPU。③ 把压力转移到下游——应用并发提高意味着对数据库/下游的并发请求数提高,可能导致下游锁竞争加剧、IO 队列变长,结果是所有查询都变慢。④ 缓存与内存压力(第四个):更多线程意味着更大的工作集、更多的临时对象、可能触发更多 GC。