你可能有过这种体验:CPU 看起来很闲,任务却慢得像在散步。
这时很多人第一反应是"多开点并发"。结果吞吐没上去,反而出现锁等待、上下文切换飙升、最后汇总卡死。
这篇文章只做一件事:帮你用一套可落地的模型,判断什么时候该用并行、该用哪种并行、以及要为此付出什么同步成本。
先建立心智模型:并行不是免费午餐
同步成本换并行度 的意思是:你把一个大任务拆开并行执行,换来更高吞吐;但为了"让结果正确地合起来",你必须支付同步开销。
- 术语:
同步成本指锁、队列、屏障、合并、调度带来的额外时间。 - 生活类比:食堂多开窗口(并行)确实更快,但大家都在同一个打菜口抢勺子(同步),速度又掉下来了。
- 小案例:图片批处理从单线程改成 8 线程后,计算阶段变快,但最终写同一个输出文件时互斥锁变成瓶颈。
一个实用估算式:
text
总耗时 ≈ 纯计算耗时/并行度 + 同步成本 + 合并成本 + 调度成本
当后面三项涨得太快时,加并行就不再赚钱。
text
输入任务
-> 拆分(fan-out)
-> 并行执行(线程/协程/分片节点)
-> 同步点(锁、队列、屏障)
-> 结果汇总(fan-in/reduce)
-> 输出
这张流程图说明:真正决定上限的,往往不是"并行执行"本身,而是同步点和汇总点,所以先画出你的同步点再谈提速。
四种常见手段:该上谁、别上谁
1) 多线程 fan-in/fan-out
- 术语:把任务拆给多个线程并行跑,最后再汇总。
- 生活类比:一个项目分给多个同事同时干,最后开会合稿。
- 小案例:本地 10000 张图片缩放,按文件块 fan-out 到线程池,fan-in 收集结果。
适合:CPU 密集、单机多核、共享内存访问频繁。
风险:锁竞争和线程切换,线程开太多像早高峰抢电梯。
2) 协程并发
- 术语:在单线程或少量线程里,用协程在 I/O 等待时切换执行。
- 生活类比:一个前台在"等客户签字"间隙处理下一位,不傻等。
- 小案例:批量请求外部 API,协程把等待网络返回的时间利用起来。
适合:I/O 密集(网络、磁盘、RPC)。
风险:CPU 密集任务放进协程不会自动变快,反而可能阻塞事件循环。
3) 分片并行
- 术语:按 key、时间段、范围把数据切块,每块独立处理。
- 生活类比:把仓库按区域分给不同小组,各组互不打扰。
- 小案例:订单按
user_id % 32分片,每片独立聚合后再汇总。
适合:数据量大、可天然切分、跨机器扩展。
风险:数据倾斜(某些分片特别热)导致"有人忙死,有人摸鱼"。
4) MapReduce
- 术语:
Map并行处理局部,Reduce合并同类结果。 - 生活类比:先各班统计投票(Map),再全年级汇总(Reduce)。
- 小案例:日志词频统计,Map 输出
(word, count),Reduce 求和。
适合:批量离线任务、可重试、可扩展的分布式处理。
风险:Reduce 阶段、Shuffle 网络开销和小文件问题。
| 手段 | 最适场景 | 优势 | 主要成本 | 首要防坑动作 |
|---|---|---|---|---|
| 多线程 fan-out/fan-in | 单机 CPU 密集任务 | 吃满多核,延迟低 | 锁竞争、上下文切换 | 控制线程数接近核心数,减少共享写 |
| 协程并发 | 高 I/O 等待任务 | 资源占用低,连接数高 | 事件循环阻塞、回调链复杂 | CPU 任务移出协程主循环 |
| 分片并行 | 大数据批量处理 | 可水平扩展 | 分片不均、跨片汇总 | 先做分片热度评估和重分片策略 |
| MapReduce | 离线统计、ETL | 容错好,扩展强 | Shuffle 与 Reduce 成本 | 先看中间数据量,再调分区与合并 |
这张对比表的意义是"先按场景做第一轮筛选",下一步请先圈定你的主瓶颈是 CPU、I/O 还是数据规模,再选技术手段。
三个开关:你的任务到底适不适合并行
先做一个 30 秒判断:
任务可拆分:拆开后子任务是否基本独立,副作用可控?CPU 多核可用:你的瓶颈真在计算,且机器有可用核心?批量处理:任务量够大,足以摊薄拆分和合并成本?
| 条件 | 是 | 否 |
|---|---|---|
| 可拆分且依赖弱 | 进入并行方案评估 | 先重构任务边界 |
| CPU 密集且多核充足 | 优先多线程或分片 | 优先协程或单线程优化 |
| 批量规模大 | 可以考虑 MapReduce | 可能并行不偿失 |
这个决策矩阵告诉你:并行优化前先做"可拆分性审计",如果第一行过不去,先别急着加线程。
三笔必须算清的代价
锁竞争
多个执行单元同时抢同一资源时会排队。
信号:锁等待时间高、CPU 不高但吞吐上不去。
动作:减少共享写、用分段锁/无锁结构、把"全局计数器"改成"局部累加后合并"。
上下文切换
线程或协程切来切去要付调度成本。
信号:系统态开销升高、任务数越多越慢。
动作:限制并发上限、批处理小任务、避免创建过细粒度任务。
合并阶段成本(fan-in/reduce)
前面跑得再快,最后汇总点堵住也白搭。
信号:前段执行结束很快,尾部收敛时间很长。
动作:树形归约(分层合并)、提前局部聚合、减少跨节点数据传输。
可复现演练:1000 万条日志词频统计
目标:把单机慢任务改造成可扩展并行任务,并验证"同步成本是否可接受"。
步骤:
- 按
hash(word) % 16分片,保证同一个词尽量落在同片。 - 每片本地计数(Map),写入局部
Counter,避免全局锁。 - 分层合并 16 个
Counter(Reduce),而不是一次性全量合并。 - 记录四个指标:总耗时、锁等待、上下文切换、合并耗时占比。
- 把分片数从 16 调到 8/32 做 A/B,对比是否出现数据倾斜和合并退化。
python
# pseudo-code
chunks = shard(log_lines, 16) # fan-out / sharding
partials = parallel_map(count_words, chunks) # local map
result = tree_reduce(merge_counter, partials) # fan-in / reduce
print(metrics(total, lock_wait, ctx_switch, reduce_ratio))
这套流程的关键不是"跑得并行",而是"把共享写改成局部写,再低成本合并",下一步请先把 reduce_ratio 压到可接受范围(如 <20%)。
结尾:并行优化的正确目标
并行度不是 KPI,单位成本下的稳定吞吐 才是。
线程、协程、分片、MapReduce 都是工具,别把扳手当信仰。
你可以直接按这 5 条执行:
检查任务是否真可拆分,先画同步点。测量锁等待、上下文切换、合并耗时三项指标。选择与瓶颈匹配的并行手段(CPU 选线程,I/O 选协程,数据规模选分片/MapReduce)。测试不同并行度和分片数,找收益拐点,不盲目拉满。验证优化后稳定性和尾延迟,避免"平均快了、抖动更大"。
做到这一步,你就不是"开并发碰运气",而是在做可解释、可复现、可迭代的并行优化。