幕 I · 正名CH 04进度 04/25

并发与并行:Rob Pike 那句话到底在说什么

并发是同时应对很多事,并行是同时做很多事」—— 这句话被引用了一万遍,通常在面试的第三分钟。但很少有人说清它的后果:并发是你写代码的方式,并行是代码跑起来时的一个属性。同一份并发代码,在单核上跑就是纯并发(不并行),在八核上跑就顺带并行了 —— 而你一行都不用改。理解这一点,你就理解了为什么 Go 敢把 GOMAXPROCS 做成一个可以随便调的旋钮。

结构 vs 执行Rob PikeAmdahl 定律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 的全部含义

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 定律还是乐观的

Amdahl 假设:并行部分能完美地切成 n 份,切分和合并零成本,核之间零干扰

现实里这三条全是假的。Neil Gunther 的通用可扩展性定律(USL)补上了两项现实开销:

  • 争用(contention):核越多,抢同一把锁的人越多。这一项让曲线变平
  • 一致性(coherency):核越多,缓存行在核之间来回同步的开销越大(第 6 章的 MESI)。这一项是 级别的 —— 它让曲线掉头向下

后果:几乎每个并行系统都有一个「最优核数」,超过它,加核只会更慢。

这就是为什么你会看到那种诡异的现象:把线程池从 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 定律估。

下一章:现在往下钻一层。「一个线程照看十万个连接」这件事,究竟是怎么做到的?答案不在任何一门语言里 —— 它在内核里,而且只有一个系统调用。