第 16 章 · 并发总览:并发与并行、进程线程协程、四种运行时模型
解决什么问题:程序要同时等好几件事、或同时算好几件事时,怎么安排,才能让等待的时间不白白流走、多个 CPU 核不闲着。
四种语言的态度:Python 和 JS/TS 以单线程事件循环加协程为主,用多核要另开进程或 Worker;C# 把 Task 放到线程池上;Go 由运行时把 goroutine 调度到系统线程上。
程序要抓取三个页面 a、b、c。真实的抓取大部分时间在等网络回应,这里用 150 毫秒的延迟代替。先一个接一个地抓:抓完 a 再抓 b,抓完 b 再抓 c;再把三个同时发出去,一起等。两种方式各计一次时,结果按名字排好再打印,最后比较:并发耗时不到顺序耗时的 70%,就算“明显更快”。四种语言各用自己最惯用的写法:Python 的 asyncio.gather,JS/TS 的 Promise.all,C# 的 Task.WhenAll,Go 的 goroutine 加 sync.WaitGroup。
一个程序里同时有好几件事在进行,可能出于两种需要。一种是等:发出三个网络请求后,CPU 只是干等回应,与其等完一个再发下一个,不如三个一起发、一起等。另一种是算:一张大图要压缩,机器有八个核,只用一个核就浪费了七个。前一种叫 I/O 密集(I/O-bound),瓶颈在外部设备;后一种叫 CPU 密集(CPU-bound),瓶颈在计算本身。
并发(concurrency)指程序在结构上同时处理多件事:几件事的进度交错推进,任何时刻都可能有几件“进行到一半”。并行(parallelism)指物理上同时执行多件事,需要多个核。只有一个核也能并发,靠的是轮流做;并行则一定是多个核在同一时刻各干各的。等网络这种事,并发就够了,因为等待本身不占 CPU;算图这种事,必须并行才会变快。
看上下两栏完成同样的四段工作:上栏一个核轮流推进 A 和 B,两件事都“在进行”,但同一时刻只有一件在跑;下栏两个核各管一件,总时间减半。
能同时推进的执行单位有三层。进程(process)是操作系统分配资源的单位,有自己独立的内存空间,一个进程崩溃不影响别的进程,进程间交换数据要经过管道、套接字这类通道。线程(thread)是操作系统调度的单位,同一进程的线程共享内存,切换由内核完成,每条线程预留 MB 级的栈。协程(coroutine)是用户态的轻量任务,由语言运行时而不是内核调度,切换只是保存和恢复几个寄存器或一个对象的状态,开销小到可以开成千上万个。
谁来决定什么时候换下一个任务,分两种。抢占式调度(preemptive scheduling)由调度者强行打断:操作系统用时钟中断让线程轮流上 CPU,正在运行的线程不用配合。协作式调度(cooperative scheduling)要任务自己让出:协程走到 await 这样的位置才交出控制权,一个不肯让出的任务会让同一线程上的其他任务全部饿着。抢占式更公平,但共享数据随时可能被打断改到一半,必须加锁;协作式切换点明确,代价是一段长计算会卡住所有人。
四种语言在这些概念上搭出了四种不同的运行时模型(runtime model),这是本章的核心,也是第四部分后面各章差异的根源。
看每一栏最终落在什么上:Python 的多线程被 GIL 收成同一时刻一条,asyncio 只有一条线程;不开 Worker 时,Node 只有主线程执行 JS;C# 的 Task 分摊到多条线程池线程;Go 的大量 G 经过 P 分到多条 M 上。
Python 怎么做
Section titled “Python 怎么做”Python 的 async/await 是原生语法,事件循环由标准库 asyncio 提供;系统线程用标准库 threading,多进程用 multiprocessing 和 concurrent.futures。等网络这类 I/O 并发,惯用写法是 asyncio。
import asyncioimport time
async def fetch_page(name: str) -> str: await asyncio.sleep(0.15) # 模拟网络等待:让出事件循环,不占线程 return name
async def main() -> None: names = ["a", "b", "c"]
start = time.perf_counter() sequential = [await fetch_page(n) for n in names] # 一个等完再等下一个 sequential_time = time.perf_counter() - start
start = time.perf_counter() concurrent = await asyncio.gather(*(fetch_page(n) for n in names)) # 三个一起等 concurrent_time = time.perf_counter() - start
print("顺序抓取:" + " ".join(sorted(sequential))) print("并发抓取:" + " ".join(sorted(concurrent))) faster = concurrent_time < sequential_time * 0.7 print("并发明显更快:" + ("是" if faster else "否"))
asyncio.run(main())
# 输出:# 顺序抓取:a b c# 并发抓取:a b c# 并发明显更快:是运行:python fetch.py。asyncio.run 创建事件循环(event loop)并运行 main。调用 fetch_page("a") 并不执行函数体,只得到一个协程对象;顺序版在列表推导式里逐个 await,等完 a 才创建 b。gather 把三个协程包装成任务(Task)一起交给事件循环,三段 150 毫秒的等待重叠在一起,总耗时约 150 毫秒,顺序版约 450 毫秒。
底层怎么实现。 Python 的协程沿用了生成器的暂停机制:执行到 await 一个未完成的对象时,协程把控制权交回事件循环,自己的局部变量和执行位置留在帧对象里。事件循环是一条线程上的循环:取出就绪的任务运行到下一个 await,再问操作系统哪些 I/O 已就绪(Linux 上用 epoll,Windows 上默认的事件循环用 IOCP),asyncio.sleep 则登记一个定时器。全在一条线程里,只是并发,不是并行。
threading 的线程是真正的系统线程,但 CPython 有一把全局解释器锁(Global Interpreter Lock,GIL):线程必须持有它才能执行 Python 字节码。线程做 I/O 或 time.sleep 时会释放 GIL,所以多线程能让等待重叠;纯 Python 的计算则只能轮流做,解释器每隔 5 毫秒(sys.getswitchinterval())请求持锁线程让出。3.13 起有可选的无 GIL 构建(free-threaded build,PEP 703,当时是实验性的,可执行文件名如 python3.13t);3.14 起它按 PEP 779 转为官方支持,但默认安装的仍是带 GIL 的版本,无 GIL 版本的单线程代码约慢 5% 到 10%。在标准构建下想让 CPU 密集代码用上多核,办法是多进程:每个进程有自己的解释器和自己的 GIL,代价是数据要用 pickle 序列化后在进程间复制。
- 在
async函数里调用阻塞函数:把await asyncio.sleep(0.15)换成time.sleep(0.15),整个事件循环在这一行停住,三个任务只能一个个过,本例就会打印“否”。同步的 HTTP 库、文件读写同理。 - 以为多开线程能让纯 Python 计算变快:标准构建下四条线程算阶乘和一条线程差不多,还多了争抢 GIL 的开销。
- 在无 GIL 构建里导入没有声明支持无 GIL 的 C 扩展,解释器会重新启用 GIL 并发出警告,程序又回到同一时刻一条线程。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JS 的 async/await 和 Promise 是语言原生的;事件循环由宿主提供(Node 用 libuv,浏览器有自己的实现)。真正的并行要用 Worker:Node 的 node:worker_threads,浏览器的 Web Worker,它们属于运行时 API,不属于语言本身。
import { setTimeout as sleep } from "node:timers/promises";
async function fetchPage(name: string): Promise<string> { await sleep(150); // 模拟网络等待:登记一个定时器后让出主线程 return name;}
const names = ["a", "b", "c"];
let start = performance.now();const sequential: string[] = [];for (const n of names) sequential.push(await fetchPage(n)); // 一个等完再等下一个const sequentialTime = performance.now() - start;
start = performance.now();const concurrent = await Promise.all(names.map(fetchPage)); // 三个一起等const concurrentTime = performance.now() - start;
console.log("顺序抓取:" + [...sequential].sort().join(" "));console.log("并发抓取:" + [...concurrent].sort().join(" "));const faster = concurrentTime < sequentialTime * 0.7;console.log("并发明显更快:" + (faster ? "是" : "否"));
// 输出:// 顺序抓取:a b c// 并发抓取:a b c// 并发明显更快:是运行:tsx fetch.ts。和 Python 不同,JS 的 async 函数一调用就开始执行,直到第一个 await 才返回一个 Promise;所以 names.map(fetchPage) 已经把三次等待同时发了出去,Promise.all 只是等它们全部完成,并按传入的顺序给出结果。顶层的 await 只能在 ES 模块里用。forEach 不等异步回调、Promise.all 遇到拒绝就立刻拒绝,这两个坑第 19 章细讲。
底层怎么实现。 执行 JS 代码的只有一条主线程。事件循环一轮轮转:取一个宏任务(macrotask,定时器到期、I/O 完成的回调)执行到底,然后清空微任务队列(microtask queue,Promise 的回调都在这里),再进入下一轮。await 一个未完成的 Promise 时,引擎保存当前函数的状态并返回,等 Promise 完成,再作为微任务从原处恢复。
I/O 本身不在主线程上做。网络 I/O 交给操作系统的多路复用机制(epoll、kqueue、IOCP),完成后通知事件循环;文件系统操作、dns.lookup、部分 crypto 和 zlib 函数则交给 libuv 的线程池,默认 4 条线程,可以用环境变量 UV_THREADPOOL_SIZE 调整(上限 1024)。这些线程只做 I/O 和 C++ 层的计算,从不执行 JS。要让 JS 计算用上第二个核,得开 Worker:每个 Worker 有自己的 V8 实例和事件循环,和主线程默认不共享内存,postMessage 发出的数据按结构化克隆(structured clone)复制一份;只有 SharedArrayBuffer 能真正共享,配合 Atomics 使用。
- 一段长计算会卡住一切:主线程花 2 秒解析一个巨大的 JSON 时,定时器不触发,已经到达的网络回应也没人处理,服务器上所有请求一起变慢。
- 同时读几十个文件,多出的在 4 条线程的池里排队;
dns.lookup也用这个池,DNS 一慢,读文件跟着变慢。
C# 怎么做
Section titled “C# 怎么做”C# 的 async/await 是原生语法,Task 和任务并行库(Task Parallel Library,TPL)在标准库里,Thread 也可以直接用。I/O 并发和 CPU 并行用的是同一套 Task,区别只在任务里是在等还是在算。
using System;using System.Collections.Generic;using System.Diagnostics;using System.Linq;using System.Threading.Tasks;
string[] names = ["a", "b", "c"];
var watch = Stopwatch.StartNew();var sequential = new List<string>();foreach (var n in names) sequential.Add(await FetchPage(n)); // 一个等完再等下一个var sequentialTime = watch.Elapsed;
watch.Restart();string[] concurrent = await Task.WhenAll(names.Select(FetchPage)); // 三个一起等var concurrentTime = watch.Elapsed;
Console.WriteLine("顺序抓取:" + string.Join(" ", sequential.Order()));Console.WriteLine("并发抓取:" + string.Join(" ", concurrent.Order()));var faster = concurrentTime < sequentialTime * 0.7;Console.WriteLine("并发明显更快:" + (faster ? "是" : "否"));
static async Task<string> FetchPage(string name){ await Task.Delay(150); // 模拟网络等待:交给计时器,不占线程 return name;}
// 输出:// 顺序抓取:a b c// 并发抓取:a b c// 并发明显更快:是运行:dotnet run fetch.cs。和 JS 一样,FetchPage 一调用就开始执行,返回的是已在运行的“热”任务;Task.WhenAll 等它们全部完成,结果数组按传入顺序排列。顶层语句里可以直接 await,编译器自动生成 async 的 Main。
底层怎么实现。 编译器把 async 方法改写成一个状态机(state machine):每个 await 是一个状态,遇到未完成的任务时登记一个延续(continuation),然后返回。任务完成后,延续被排进线程池的队列,由某条空闲的线程池线程接着执行。控制台程序没有同步上下文(SynchronizationContext),所以 await 之后的代码可能换了一条线程;WPF、WinForms 这类 UI 程序则会把延续送回 UI 线程。
线程池给每条工作线程配一个本地队列,另有一个全局队列;线程池线程上新建的任务进本地队列,自己的队列空了就从别的线程那里窃取(work stealing)。最少线程数默认等于逻辑核数,任务阻塞导致排队时,运行时用爬山算法(hill climbing)等策略逐步加线程。这些都是真正的系统线程,所以 Task.Run 或 Parallel.For 里的计算天然分到多个核上。
- 用
.Result或.Wait()同步等任务:等待期间占着一条线程池线程。请求一多,线程池被这种等待占满,运行时只能逐步补线程,延迟随之飙升,这叫线程池饥饿(thread pool starvation);在 UI 程序里还可能死锁。 - 用
Task.Run(() => FetchPage(n))包一层 I/O 不会更快,只是多占一条线程去启动它。 await前后可能不在同一条线程上:依赖线程本地存储([ThreadStatic])的状态会丢;几个任务共享的数据随时可能被别的线程同时修改,要加锁,第 17 章细讲。
Go 怎么做
Section titled “Go 怎么做”Go 的并发是语言原生的:go 关键字启动 goroutine,chan 在 goroutine 之间传值,标准库 sync 提供 WaitGroup 和锁。Go 没有 async/await,任何普通函数都可以放进 goroutine 里,用阻塞的写法照常写。
package main
import ( "fmt" "slices" "strings" "sync" "time")
func fetchPage(name string) string { time.Sleep(150 * time.Millisecond) // 模拟网络等待:只挂起这个 goroutine return name}
func main() { names := []string{"a", "b", "c"}
start := time.Now() var sequential []string for _, n := range names { sequential = append(sequential, fetchPage(n)) // 一个等完再等下一个 } sequentialTime := time.Since(start)
start = time.Now() concurrent := make([]string, len(names)) var wg sync.WaitGroup for i, n := range names { wg.Add(1) go func() { // 三个一起等 defer wg.Done() concurrent[i] = fetchPage(n) // 每个 goroutine 只写自己那一格 }() } wg.Wait() concurrentTime := time.Since(start)
slices.Sort(sequential) slices.Sort(concurrent) fmt.Println("顺序抓取:" + strings.Join(sequential, " ")) fmt.Println("并发抓取:" + strings.Join(concurrent, " ")) faster := "否" if concurrentTime < sequentialTime*7/10 { faster = "是" } fmt.Println("并发明显更快:" + faster)}
// 输出:// 顺序抓取:a b c// 并发抓取:a b c// 并发明显更快:是运行:go run fetch.go。fetchPage 是普通函数,顺序版和并发版调用的是同一个;区别只在调用前有没有 go。WaitGroup 是一个计数器:启动前 Add(1),完成时 Done(),Wait() 阻塞到计数归零;漏了它,main 返回时没跑完的 goroutine 会被丢弃(第 19 章)。三个 goroutine 各写切片里不同的下标,互不冲突,不需要锁。Go 1.22 起每轮循环的 i、n 是新变量,闭包直接用它们是安全的。
底层怎么实现。 运行时把大量 goroutine 以 M:N 方式调度到少量系统线程上,有三种角色:G 是 goroutine;M(machine)是系统线程;P(processor)是运行 Go 代码的许可,带一个本地运行队列。M 必须绑定一个 P 才能执行 G,P 的个数由 GOMAXPROCS 决定,所以同一时刻最多有 GOMAXPROCS 个 G 在并行执行。GOMAXPROCS 默认等于可用的逻辑 CPU 数;Go 1.25 起,Linux 上若进程所在 cgroup 的 CPU 带宽限额(容器的 CPU limit)更低,就取这个限额,运行时还会在核数或限额变化时定期更新它,手动设置过 GOMAXPROCS 则不再自动调整。
看 M2 怎样找下一个 G:本地队列空了,先查全局队列,再问网络轮询器(netpoller)有没有等到数据的 G,都没有就从别的 P 那里偷走一半;下方 M3 卡在系统调用里,它的 P 被监控线程 sysmon 拿走交给另一条线程。
goroutine 在通道(channel)、锁或 time.Sleep 上阻塞时,只是 G 被挂起,M 立刻去执行别的 G;网络读写在运行时内部变成非阻塞调用,由 netpoller 统一等待,所以写法是阻塞的,底下却和事件循环一样不占线程。只有真正阻塞线程的系统调用(如部分文件操作)会连 M 一起卡住,这时 P 被交给别的 M,必要时新建线程。goroutine 的初始栈只有几 KB,不够时运行时把它复制到更大的栈上。调度以协作为主,函数调用处会检查是否该让出;Go 1.14 起又加了基于信号的异步抢占,一个 G 连续运行约 10 毫秒就会被打断,死循环不再能霸占一个 P。
- 多个 goroutine 不加保护地写同一个 map:运行时检测到并发写时直接报
fatal error: concurrent map writes,进程退出,recover也接不住。 - goroutine 阻塞在一个永远没人读的通道上,就永远不会结束,它的栈和引用的对象都回收不了;这种泄漏不报错,只表现为内存慢慢上涨。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生 async/await,标准库 asyncio、threading、multiprocessing |
原生 async/await,Worker 属于运行时 API |
原生 async/await,标准库 TPL |
原生 go、chan |
| 实现机制 | 单线程事件循环 + 协程;系统线程受 GIL 约束 | 单线程事件循环 + libuv 线程池 | 状态机 + 线程池上的 Task | M:N 调度(G、M、P) |
| 能否用多核 | 标准构建靠多进程;无 GIL 构建可用多线程 | 只能靠 Worker | 能 | 能 |
| I/O 并发的惯用写法 | asyncio.gather |
Promise.all |
Task.WhenAll |
goroutine + WaitGroup |
| CPU 并行的惯用写法 | ProcessPoolExecutor |
Worker |
Parallel.For、Task.Run |
goroutine(同上) |
| 调度方式 | 协程协作式;线程由系统抢占,GIL 每 5 毫秒请求切换 | 协作式,一个回调跑完才轮到下一个 | 线程由系统抢占;Task 在线程池队列里排队、窃取 | 运行时调度,协作点加异步抢占 |
| 一个并发任务的大致开销 | 协程:堆上一个对象;进程:一整个解释器 | Promise:堆上一个对象;Worker:一整个 V8 实例 | Task:堆上一个对象;线程:MB 级栈预留 | goroutine:几 KB 起步的栈 |
差别最大的是“能否用多核”一行:同样一段 CPU 密集代码,C# 和 Go 用平常的并发写法就能分到多个核,Python 和 JS/TS 则要换成进程或 Worker,数据也要复制过去。
CPython 的 GIL 来自引用计数(reference counting)。每个对象带一个计数,每次赋值、传参都在增减它;1990 年代初 Python 加入线程支持时,要让这些计数和解释器内部的数据结构在多线程下不出错,最简单的办法就是一把大锁。这把锁让单线程程序不必为细粒度加锁付代价,C 扩展作者也可以假定同一时刻只有自己在动 Python 对象。代价在多核普及后才显出来:线程只能让等待重叠,算不快。无 GIL 构建要改成线程安全的引用计数、给内部结构配细粒度的锁,还要所有 C 扩展重新声明自己线程安全,所以用了 3.13、3.14 两个版本分阶段推进,仍未成为默认。
JavaScript 生在 1995 年的浏览器里,任务是响应点击、修改页面。页面的 DOM 结构经不起两条线程同时改,单线程加事件回调既简单又不会有数据竞争(data race)。2009 年 Node.js 把同样的模型搬到服务器:用非阻塞 I/O 加一条线程同时服务成千上万个连接,比每连接一条线程省得多。后来加入的 Worker 坚持不共享内存,正是为了保住“同一时刻只有一段 JS 在动这些数据”的前提。
C# 和 .NET 生于 2000 年前后的 Windows 世界,直接以操作系统线程为基础,线程池从 .NET 1.0 起就有。.NET 4(2010 年)加入 Task 和 TPL,把“一件要做的事”和“哪条线程来做”分开;C# 5(2012 年)加入 async/await,由编译器生成状态机,让等待不再占住线程。底下始终是可以并行的线程池,所以 I/O 并发和 CPU 并行是同一套写法。Go 2009 年在 Google 诞生,目标是写服务端程序,设计者沿用 Hoare 1978 年提出的通信顺序进程(Communicating Sequential Processes,CSP)思想,把轻量任务和通道做进语言,让运行时负责把它们映射到线程上。
这些出身带来几条直接的后果。第一,同一段 CPU 密集代码,C# 和 Go 开几个任务就能吃满多核;Python 标准构建和 JS 要换成进程或 Worker,参数和结果都要序列化复制,大数据的复制成本可能抵掉并行的收益。第二,有 async/await 的三种语言里,异步会沿调用链向上传染:一个函数改成异步,调用它的函数也得改,同步代码和异步代码要分开写;Go 没有这种区分,同一个函数既能直接调用也能放进 goroutine。第三,单线程事件循环里没有两段代码真正同时运行,共享变量不会被改到一半;C# 和 Go 的任务真的并行,共享数据必须加锁或改用通道传递,否则就是数据竞争。
旁注:别的语言
Section titled “旁注:别的语言”Erlang(以及跑在同一虚拟机上的 Elixir)的进程比 goroutine 走得更远。它们极轻量,一个进程初始只占几 KB,一台机器上开几十万个很平常;进程之间完全不共享内存,唯一的交流方式是发消息,消息被复制进对方的邮箱。虚拟机给每个核配一个调度器,按“规约次数”(reductions,大致是函数调用的次数)计数,用完就换下一个进程,所以是抢占式的。因为不共享,一个进程崩溃不会弄坏别人的数据,“让它崩溃,由监督者重启”成了 Erlang 处理错误的标准做法。
Pid = spawn(fun() -> receive {From, N} -> From ! N * 2 end end),Pid ! {self(), 21},receive Result -> io:format("~p~n", [Result]) end. % 打印 42- 先分清是 I/O 密集还是 CPU 密集。等网络、等磁盘,用各语言的异步写法或 goroutine;要多核算,Python 用
ProcessPoolExecutor,JS/TS 用 Worker,C# 用Parallel.For或Task.Run,Go 照常开 goroutine。 - 在 Python 和 JS/TS 的事件循环里,任何一步都不要阻塞:不调用同步的网络和文件函数,长计算拆小或移到进程、Worker 里去。
- C# 里从头到尾
await,不用.Result、.Wait()去等;Go 里每启动一个 goroutine,都想好它怎样结束、谁在等它。 - 若主要工作是大量 CPU 计算又离不开共享内存,Python 标准构建和 JS 都不顺手,这时考虑把这部分换成 C#、Go 或别的能直接用多线程的语言来写。
用两种语言同时计算 1 到 4 的阶乘:每个数的阶乘是一个独立的任务,四个任务同时进行,全部完成后按编号打印 1! = 1 到 4! = 24 四行。用 Python 和 Go 各写一遍。
提示:阶乘是 CPU 计算,Python 里用线程还是进程才能真正同时算?用进程时,入口代码要放在什么判断里,为什么?Go 里四个 goroutine 的结果存到哪里,才能既不需要锁、又能按编号打印?
- 并发是结构上同时处理多件事,一个核轮流做也算;并行是物理上同时执行,需要多个核。等待为主的 I/O 密集任务靠并发就能变快,CPU 密集任务必须并行。
- 进程隔离内存、开销最大;线程共享内存、由系统抢占调度;协程由运行时在用户态调度,多为协作式,开销最小。
- Python 是单线程事件循环加协程,系统线程受 GIL 约束(3.14 起无 GIL 构建获官方支持但非默认),多核靠多进程;JS 是单线程事件循环,I/O 交给操作系统和 libuv 线程池,多核靠 Worker。
- C# 把
async方法编译成状态机,延续跑在线程池上,天然多核;Go 用 G、M、P 把 goroutine 调度到GOMAXPROCS条线程上,阻塞写法底下是非阻塞的调度。 - 差异来自出身:引用计数需要 GIL,浏览器需要单线程,C# 建在线程池上,Go 为服务端并发而生;后果是同一段 CPU 密集代码能否直接吃满多核各不相同。