并发与并行:Rob Pike 那句话到底在说什么
「并发是同时应对很多事,并行是同时做很多事」—— 这句话被引用了一万遍,通常在面试的第三分钟。但很少有人说清它的后果:并发是你写代码的方式,并行是代码跑起来时的一个属性。同一份并发代码,在单核上跑就是纯并发(不并行),在八核上跑就顺带并行了 —— 而你一行都不用改。理解这一点,你就理解了为什么 Go 敢把 GOMAXPROCS 做成一个可以随便调的旋钮。
先把定义摆正
并发(concurrency):把程序组织成多个可以独立推进的任务。它是一种代码结构 —— 一种「把问题拆开」的方式。它不要求这些任务真的同时执行。
并行(parallelism):多个任务在同一物理时刻真的在执行。它是一种运行时属性 —— 要求你有多个执行单元(多核、多机、GPU、SIMD)。
关键:并发是你写的,并行是它跑的。
一份并发的代码,可以并行执行(如果有多核),也可以不并行(单核上交替执行)。它自己不在乎。
Rob Pike 在 2012 年那个著名的演讲(《Concurrency is not Parallelism》)里用了一个搬书的比喻。我把它改成更直白的版本:
| 并发 | 并行 | |
|---|---|---|
| 是什么 | 一种设计:把工作拆成能独立推进的部分 | 一种执行:真的有多双手在同时干 |
| 需要多核吗 | 不需要 | 必须 |
| 解决什么 | 结构问题:怎么在等待的时候不闲着 / 怎么同时应对很多请求 | 吞吐问题:怎么把 CPU 密集的活干得更快 |
| 典型手段 | 协程、goroutine、事件循环、Actor | 多线程 + 多核、SIMD、GPU、多机 |
| 一句话 | 「别站在那儿等」 | 「多雇几个人」 |
| 没有它会怎样 | 一个慢请求拖死整个服务 | 你的 CPU 有 15 个核在闲着 |
为什么这个区分有实际后果
三个具体的、会改变你决策的推论:
推论一:并发不需要多核,所以它在单核上也有意义
JavaScript 是最好的证据:它永远是单线程的,永远不并行(Web Worker 除外)。但它是高度并发的 —— 一个 Node.js 进程可以同时处理几万个连接。
因为并发解决的不是「算得快」,是「等的时候别闲着」。这跟你有几个核,一点关系都没有。
推论二:并行不需要并发结构(而且常常不需要)
// 这是「并行」,但几乎没有「并发结构」可言:
// 8 个线程各算一块矩阵,互不通信,算完汇总。
val chunks = matrix.split(8)
val results = chunks.parallelStream() // 或者 runBlocking { chunks.map { async { compute(it) } }.awaitAll() }
.map { compute(it) }
.toList()
这里没有等待、没有交互、没有协调,只是「把活分给八个人」。这叫数据并行(data parallelism)—— 它是并行的最简单形态,也是最不需要并发思维的一种。
所以:并行的难度,跟并发的难度不是一回事。数据并行很简单(分块、算、合并),而并发协调(多个任务互相等待、共享状态、传播取消)才是真正难的那部分 —— 也是本书剩下 21 章的内容。
推论三:GOMAXPROCS=1 之后,你的 Go 程序还是并发的
func main() {
runtime.GOMAXPROCS(1) // 只允许 1 个 P → 同一时刻只有 1 个 goroutine 在真的跑
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() { // Go 1.25 新增的 wg.Go,省掉 Add/Done
resp, _ := http.Get(urls[i]) // 阻塞?不,netpoller 会把 G park 掉
process(resp)
})
}
wg.Wait()
}
// 1000 个 goroutine 全都正常工作,1000 个 HTTP 请求真的重叠在飞。
// 只是:任何一个时刻,只有一个 goroutine 在「用 CPU 算东西」。
// 并发:满分。并行:1。
GOMAXPROCS 是并行度的闸门,不是并发度的闸门。
把它调成 1,你的 goroutine 一个都不会少,channel 一个都不会坏,IO 一样在重叠。你只是不再利用多核了。
这个旋钮之所以能存在,正是因为「并发」和「并行」是分离的。如果 Go 的并发是靠「一个 goroutine 一个线程」实现的,这个旋钮根本不可能存在。
(顺带一个实用的坑:Go 1.25 之前,容器里的 Go 程序会读到宿主机的核数 —— 你的 pod 只有 0.5 核的 quota,但 GOMAXPROCS 是 96,于是 runtime 疯狂建线程、疯狂被 cgroup 限流。Go 1.25 起,runtime 会自动读 cgroup 的 CPU limit。在那之前,标准做法是引入 uber-go/automaxprocs。)
Amdahl 定律:并行的天花板
既然并行要靠多核,那多买几个核不就完了?
算笔账。Gene Amdahl 在 1967 年给出的公式:
加速比 = 1 / ( (1 - p) + p/n ) p = 程序中「可以并行」的部分所占的比例 n = 核数 当 n → ∞: 加速比 → 1 / (1 - p) ← 天花板,跟核数无关
拖一下滑块,感受这条曲线怎么被按住:
Amdahl 假设:并行部分能完美地切成 n 份,切分和合并零成本,核之间零干扰。
现实里这三条全是假的。Neil Gunther 的通用可扩展性定律(USL)补上了两项现实开销:
- 争用(contention):核越多,抢同一把锁的人越多。这一项让曲线变平。
- 一致性(coherency):核越多,缓存行在核之间来回同步的开销越大(第 6 章的 MESI)。这一项是 n² 级别的 —— 它让曲线掉头向下。
后果:几乎每个并行系统都有一个「最优核数」,超过它,加核只会更慢。
这就是为什么你会看到那种诡异的现象:把线程池从 32 调到 64,QPS 反而掉了。你以为是你的代码有问题 —— 不,是物理定律在收账。
「串行部分」藏在哪
Amdahl 定律里那个致命的 (1-p),在你的代码里通常长这样:
| 串行部分 | 藏在哪 | 怎么办 |
|---|---|---|
| 那把大锁 | 一个全局的 synchronized / sync.Mutex,所有线程都要排队过 | 减小临界区、分段锁(ConcurrentHashMap 的思路)、无锁结构(第 10 章) |
| 单点的下游 | 一个数据库、一个 Redis、一个第三方 API。你并发了,它没有。 | 这是最常见也最容易被忽视的一种。加线程只会让更多人排同一个队。 |
| 汇总/归并 | map-reduce 的 reduce 那一步 | 树形归并(log n 层而不是 n 步) |
| 初始化 / 收尾 | 加载配置、建连接池、写日志、刷盘 | 通常不值得优化,但值得测量 —— 因为它就是那个天花板 |
| 内存带宽 | 16 个核在抢同一条内存总线 | 这是硬件的墙。改数据布局(第 6 章),或者认命。 |
你并行的不是瓶颈。
典型场景:接口慢,一查发现 90% 的时间在等数据库。于是你把业务逻辑改成了多线程并行 —— 而业务逻辑本来只占 10%。
按 Amdahl:p = 0.1,哪怕 n = ∞,加速比也只有 1 / 0.9 ≈ 1.11×。你忙了一个月,快了 11%。
永远先 profile,再优化。「觉得哪里慢」的准确率,历史证明大约是掷硬币的水平。
并发的三种「形状」
最后,把「并发」这个抽象概念落到你真正会写的三种代码结构上:
① 任务并发(task concurrency)—— 最常见
// 三件不相关的事,同时开始,一起等回来
coroutineScope {
val user = async { api.getUser(id) } // 100ms
val orders = async { api.getOrders(id) } // 150ms
val coupons = async { api.getCoupons(id) } // 80ms
render(user.await(), orders.await(), coupons.await())
}
// 串行:100 + 150 + 80 = 330ms
// 并发:max(100, 150, 80) = 150ms
// ← 注意:这里一个核都没多用。纯并发,零并行。
这是你 90% 的时候会用到的形状。它跟多核毫无关系 —— 它只是「三个等待重叠在一起」。
② 数据并行(data parallelism)—— 需要多核
// 100 万张图片,8 个 worker 分着处理
var wg sync.WaitGroup
ch := make(chan Image, 64) // 有界 channel = 背压(第 21 章)
for i := 0; i < runtime.NumCPU(); i++ { // worker 数 = 核数
wg.Go(func() {
for img := range ch {
process(img) // 纯 CPU 活
}
})
}
for _, img := range images { ch <- img }
close(ch)
wg.Wait()
这才是真的需要多核的场景。worker 数应该 ≈ 核数,不是「越多越好」。
③ 流水线(pipeline)—— 两者兼有
// 每一级一个 goroutine,用 channel 串起来
gen := generate(nums) // 阶段 1:读文件
sq := square(gen) // 阶段 2:计算
out := format(sq) // 阶段 3:格式化
for r := range out { fmt.Println(r) }
// 三个阶段「同时」在跑不同的数据 —— 像工厂流水线。
// 每级之间的 channel 天然提供背压:下游慢了,上游自动被顶住。
这是 Go 的招牌形状,也是 Kotlin Flow 的核心思想(第 15、21 章)。
1. 用上面那个 Amdahl demo,把 p 拖到 50%。看看 64 个核能给你几倍加速。(答案:不到 2 倍。)这一张图能挡掉很多不切实际的性能预期。
2. 找出你系统里那个「串行部分」。它多半不在你的代码里 —— 它是那个所有请求都要过的数据库/缓存/网关。
3. 把你项目里的 worker 数 / 线程池大小翻出来。问:这个数字是怎么来的?如果答案是「抄的」或者「觉得差不多」—— 那它多半是错的。CPU 池 ≈ 核数,IO 池按 Little 定律估。
下一章:现在往下钻一层。「一个线程照看十万个连接」这件事,究竟是怎么做到的?答案不在任何一门语言里 —— 它在内核里,而且只有一个系统调用。