跳转到内容

第 17 章 · 多线程:共享内存、竞态与锁

解决什么问题:几条线程同时读写同一块内存时,怎样让结果不随执行的先后而变。

四种语言的态度:Python、C# 和 Go 的线程默认共享内存,靠锁和原子操作保护;JS/TS 默认不共享,只有 SharedArrayBuffer 加 Atomics 才能在 Worker 之间共享一块内存。

4 个线程各给同一个计数器加 100000 次,正确结果是 400000。不加任何保护时,结果几乎总是更少(Python 标准构建例外,见 Python 一节),而且每次不同:本机 Go 连跑三次得到 222790、228045、224728,C# 得到 175275、197441、212644。结果不确定的代码不能当示例,所以下面每种语言演示两种正确做法:先给计数器加锁;再让每个线程在自己的变量里累加,最后由主线程合并。JS/TS 没有共享内存的线程,用 4 个 Worker 代替。

同一进程里的线程共享堆内存:一个线程改了某个对象,别的线程立刻就能读到,不用复制,也不用打包发送。这种做法叫共享内存并发(shared-memory concurrency),优点是交换数据几乎没有成本。代价在第 16 章已经点过:线程由操作系统抢占调度,任何一条指令之后都可能被换下,别的线程接着去改同一块数据。

count += 1 看起来是一步,实际是三步:从内存读出旧值、加一、把新值写回。这叫读改写(read-modify-write),三步之间可以插进别的线程。

一次加法怎样丢失

看两条线程读到的都是 5:T1 写回 6 之后,T2 又写回 6,把 T1 的结果盖掉了,两次加一只算了一次。4 个线程加 40 万次,这样的丢失发生成千上万次,最后的数就少了一大截。

两个术语要分清。数据竞争(data race)是一个精确的底层定义:两个线程访问同一个内存位置,至少一个是写,而两次访问之间没有任何同步。竞态条件(race condition)范围更大,指程序的结果取决于线程执行的先后。本章的计数器两者都有。但没有数据竞争也可能有竞态:每次读写余额都用原子操作,“先查余额够不够、再扣款”这两步之间仍可能被另一个线程插进来扣掉同一笔钱。所以光把每次访问变成原子的还不够,需要整体不可分的那几步要一起保护。

修正的办法有三种,下图把它们并排放在一起。

三种做法

上栏是互斥锁(mutex,mutual exclusion):同一时刻只有拿到锁的线程能进入被保护的代码,这段代码叫临界区(critical section),四个线程在这里排队,等于串行执行。中栏是原子操作(atomic operation):CPU 提供一条不可打断的读改写指令(x86 上是带 lock 前缀的 xadd),只能保护单个变量的单步操作,但不用排队等锁。下栏干脆不共享:每个线程在局部变量里累加,全部结束后再把几个结果加起来,中间没有任何争用。三者的适用面依次变窄,开销也依次变小。

锁还解决另一个问题:可见性(visibility)。编译器和 CPU 会重排指令,每个核也有自己的缓存,一个线程写下的值,另一个线程不一定马上看得到,看到的先后也可能和写的先后不同。内存模型(memory model)规定一次写入什么时候保证被另一次读取看到,核心是先行发生(happens-before)关系:释放一把锁先行发生于之后再拿到同一把锁,线程的最后一步先行发生于别人等到它结束(join)。Java、C++(只用默认的顺序一致原子操作时)、Go、JS 的内存模型都承诺:没有数据竞争的程序,表现就像所有线程的操作按某个顺序依次执行。所以用锁或原子操作保护好共享访问,可见性也一并解决了。至于死锁(deadlock):两个线程各拿着一把锁、又都在等对方那把,就会永远等下去,预防办法是所有线程按同一顺序拿锁。

Python 的线程和锁来自标准库 threading:Thread 是真正的系统线程,Lock 是互斥锁。同一进程里的对象天然被所有线程共享。标准库没有原子整数这种类型,要保护计数只能用锁,或者让每个线程各算各的。

import threading
from collections.abc import Callable
THREADS = 4
ADDS = 100_000
def run_all(work: Callable[[int], None]) -> None:
threads = [threading.Thread(target=work, args=(i,)) for i in range(THREADS)]
for t in threads:
t.start()
for t in threads:
t.join() # 等全部线程结束再读结果
count = 0
lock = threading.Lock()
def add_with_lock(_: int) -> None:
global count
for _ in range(ADDS):
with lock: # 读、加一、写回都在锁里,别的线程插不进来
count += 1
partial = [0] * THREADS
def add_locally(i: int) -> None:
local = 0
for _ in range(ADDS):
local += 1 # 只动自己的局部变量
partial[i] = local # 每个线程只写自己那一格
run_all(add_with_lock)
print(f"加锁后计数:{count}")
run_all(add_locally)
print(f"各线程分别累加后合并:{sum(partial)}")
# 输出:
# 加锁后计数:400000
# 各线程分别累加后合并:400000

运行:python threads.py。with lock: 进入时拿锁、离开时放锁,中途抛异常也会放。join() 除了等线程结束,还保证线程里写下的值对主线程可见。第二种做法里,每个线程只写 partial 中自己那一格,不需要锁。

有 GIL 并不意味着 count += 1 是原子的。用 dis 看 3.12 和 3.13 编译出的字节码,它是 LOAD_GLOBAL、LOAD_CONST、BINARY_OP、STORE_GLOBAL 四条,GIL 只保证每条字节码执行时不被打断,线程切换可以发生在两条之间。本机 3.12 和 3.13 的标准构建上,去掉锁的 4 × 10 万次 count += 1 每次都得到 400000,这是因为解释器只在循环回跳、进入函数这类检查点才响应切换请求,从读到写的这几条字节码之间没有检查点,循环的检查点在写回之后的 JUMP_BACKWARD 上。只要读和写之间夹一次函数调用,写成 g[0] = g[0] + one(),4 × 100 万次就只剩 136 万到 256 万。这是实现细节带来的侥幸,语言没有任何保证。

底层怎么实现。 threading.Thread 在 Linux 上对应一条 pthread,在 Windows 上对应一条 Windows 线程。Lock 用操作系统提供的同步原语实现,线程在 acquire 上等待时会释放 GIL,所以等锁的线程不会挡住别人。在标准构建里,同一时刻只有持有 GIL 的线程在执行字节码,多线程算不快,但读改写仍会被切开。3.13 起的无 GIL 构建(3.14 起官方支持,但不是默认)里,几条线程真正同时在多个核上执行字节码,读和写之间被插入的机会大得多。为了不让解释器本身崩溃,list、dict 这些内置类型在内部给每个对象加了锁,单次 append 是安全的,但“读出、计算、写回”这样跨越多个操作的步骤,仍然要你自己加锁。

  • 以为有 GIL 就不用加锁:GIL 保护的是解释器自己的数据结构,不保护你的多步操作。if key not in cache: cache[key] = load(key) 两个线程可能同时通过检查,load 被调用两次。
  • 在标准构建上测试从不出错的代码,换到无 GIL 构建就开始丢数:原来靠检查点侥幸躲过的切换,现在成了真正的并行。
  • 直接调用 lock.acquire() 而不配 try/finally:中间抛出异常,锁永远不释放,其他线程在 acquire 上一直等下去。

JS 的普通代码没有共享内存的线程:一个 Node 进程里执行 JS 的只有主线程。要并行得用 node:worker_threads 的 Worker,而 Worker 之间默认什么都不共享。唯一的例外是 ES2017 加入的 SharedArrayBuffer:它的字节可以同时被几个 Worker 看到,配合 Atomics 上的原子操作使用。标准库没有现成的互斥锁,要用 Atomics 自己拼一个。

import { Worker, isMainThread, workerData } from "node:worker_threads";
const THREADS = 4;
const ADDS = 100_000;
const LOCK = 0; // 共享数组第 0 格当锁:0 空闲,1 占用
const COUNT = 1; // 第 1 格是共享计数器,第 2 格起每个 Worker 一格
if (isMainThread) {
const shared = new Int32Array(new SharedArrayBuffer(4 * (2 + THREADS)));
const workers = Array.from({ length: THREADS }, (_, id) =>
new Worker(new URL(import.meta.url), { workerData: { id, shared } })); // 再加载本文件
await Promise.all(workers.map((w) => new Promise((done) => w.on("exit", done))));
console.log(`加锁后计数:${shared[COUNT]}`);
const merged = shared.slice(2).reduce((a, b) => a + b, 0);
console.log(`各线程分别累加后合并:${merged}`);
} else {
const { id, shared } = workerData as { id: number; shared: Int32Array };
for (let k = 0; k < ADDS; k++) {
while (Atomics.compareExchange(shared, LOCK, 0, 1) !== 0); // 自旋,直到把 0 换成 1
shared[COUNT]++; // 读、加一、写回都在锁里
Atomics.store(shared, LOCK, 0); // 放锁
}
let local = 0;
for (let k = 0; k < ADDS; k++) local++; // 只动自己的局部变量
shared[2 + id] = local; // 每个 Worker 只写自己那一格
}
// 输出:
// 加锁后计数:400000
// 各线程分别累加后合并:400000

运行:tsx threads.ts。这里用了一个单文件的写法:new Worker(new URL(import.meta.url)) 让 Worker 把本文件再加载一遍,isMainThread 区分自己是主线程还是 Worker,各走一个分支。Worker 默认继承主线程的启动参数 execArgv,tsx 注册加载器的参数也在其中,所以 Worker 同样能加载 .ts 文件(本机 Node 22 加 tsx 4 实测)。另一种写法是给 Worker 传 eval: true 和一段代码字符串,但字符串里的代码不经过类型检查。为了少起一轮 Worker,两种做法放在同一批 Worker 里先后完成。

锁是一个自旋锁(spinlock):Atomics.compareExchange 原子地检查第 0 格是不是 0,是就换成 1 并返回旧值 0,表示拿到了锁;不是就一直重试。放锁用 Atomics.store 写回 0。临界区里的 shared[COUNT]++ 是普通读写,靠前后两个原子操作保证顺序和可见性;主线程等到各 Worker 的 exit 事件后才读结果,那时 Worker 线程已经结束,普通写入也都看得到。只为一个计数器,Atomics.add(shared, COUNT, 1) 更简单也更快;这里选自旋锁,是为了和另外三种语言的“加锁”对应,而且锁能保护多步操作,Atomics.add 只能做一次加法。自旋锁的代价是等锁时一直占着 CPU 空转,只适合像这里这样极短的临界区;要等得久,应当用 Atomics.wait 让线程睡下、Atomics.notify 唤醒。

底层怎么实现。 每个 Worker 是一条系统线程,有自己的 V8 实例、堆和事件循环,普通对象只存在于某一个堆里,postMessage 传过去的是结构化克隆出的副本。SharedArrayBuffer 的底层存储则是一块被几个 V8 实例同时引用的内存,所以只能存放字节,对象和字符串必须自己编码成数字才放得进去。Atomics 的操作由 JIT 编译成 CPU 的原子指令,x64 上是带 lock 前缀的 cmpxchg 和 xadd。ECMAScript 规范从 ES2017 起为共享内存定义了内存模型,原子操作之间按顺序一致的方式排序,并在线程之间建立同步关系。

  • 浏览器里 SharedArrayBuffer 需要跨源隔离(cross-origin isolation):页面要带 Cross-Origin-Opener-Policy: same-origin 和 Cross-Origin-Embedder-Policy: require-corp(或 credentialless)两个响应头,否则 SharedArrayBuffer 根本不存在,因为它能被做成 Spectre 攻击所需的高精度计时器。
  • 以为 shared[COUNT]++ 在共享数组上会自动变成原子的:它和别的语言一样是读改写三步,去掉锁就会丢数。
  • 浏览器主线程上调用 Atomics.wait 会抛出 TypeError,因为主线程不允许阻塞;在主线程上自旋等锁又会让页面卡住,所以等锁的代码只能放在 Worker 里。

C# 的线程直接对应系统线程,同一进程的对象默认共享。lock 是语言原生的语句,标准库还提供 Interlocked 原子操作,以及 SemaphoreSlim、ReaderWriterLockSlim、并发集合等工具。用 Thread 或 Task.Run 都能开出并行执行的线程,这里用 Thread,和别的语言一一对应。

using System;
using System.Linq;
using System.Threading;
const int Threads = 4, Adds = 100_000;
int count = 0;
Lock gate = new(); // .NET 9 起的专用锁类型;更早的版本用 new object()
RunAll(_ =>
{
for (int k = 0; k < Adds; k++)
{
lock (gate) { count++; } // 读、加一、写回都在锁里
}
});
Console.WriteLine($"加锁后计数:{count}");
int[] partial = new int[Threads];
RunAll(i =>
{
int local = 0;
for (int k = 0; k < Adds; k++) local++; // 只动自己的局部变量
partial[i] = local; // 每个线程只写自己那一格
});
Console.WriteLine($"各线程分别累加后合并:{partial.Sum()}");
static void RunAll(Action<int> work)
{
var threads = Enumerable.Range(0, Threads).Select(i => new Thread(() => work(i))).ToList();
threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join()); // 等全部线程结束再读结果
}
// 输出:
// 加锁后计数:400000
// 各线程分别累加后合并:400000

运行:dotnet run threads.cs。第一个 lambda 捕获了局部变量 count 和 gate,编译器把它们提升为一个隐藏类的字段,所有线程操作的是同一个 count,所以需要锁。lock 语句对任意引用类型都能用,传统写法是锁一个专门的 new object()。.NET 9 加入了 System.Threading.Lock 类型,C# 13 起 lock 遇到这个类型的对象,就改用它自己的 EnterScope 方法,而不再走 Monitor。

底层怎么实现。 对普通对象,lock (x) { … } 被编译成 Monitor.Enter(x) 加一个 try/finally,在 finally 里 Monitor.Exit(x)。每个引用类型对象的对象头里有一个字,没有争用时,拿锁只是用一次比较交换把当前线程记进这个字;发生争用,等待的线程先自旋一会儿,还拿不到就把锁升级成同步块(sync block),在内核事件上睡眠。Lock 是专门的锁对象,不借用对象头。Interlocked.Increment 由 JIT 编译成 x64 的 lock xadd 一条指令。进锁和出锁都带内存屏障,一个线程在锁里写的值,下一个拿到锁的线程一定看得见。

  • 锁 this 或字符串:this 是公开的对象,外部代码也可能锁它;相同内容的字符串字面量在整个进程里是同一个对象,两段不相干的代码 lock ("cache") 会互相等待,甚至死锁。应该锁一个私有的专用对象。
  • 在 lock 里 await:编译直接报 CS1996”无法在 lock 语句体中等待”。原因是 Monitor 记录的是哪条线程拿了锁,必须由同一条线程释放,而 await 之后的代码可能换到另一条线程上。需要在异步代码里互斥,用 SemaphoreSlim 的 WaitAsync。
  • 把 Lock 对象当成 object 传来传去再去 lock:编译器给出警告 CS9216,指出这会使用“可能意外的基于监视器的锁定”。此时锁的是这个对象的对象头,和别处用 EnterScope 拿的锁不是同一把,起不到互斥作用。

goroutine 之间默认共享内存,闭包捕获的变量、指针指向的数据都是共享的。标准库 sync 提供 Mutex 和 RWMutex,sync/atomic 提供原子操作,Go 1.19 起还有 atomic.Int64 这类原子类型。Go 同时有一句口号:“不要通过共享内存来通信,而要通过通信来共享内存”,指的是用通道在 goroutine 之间传递数据,第 20 章细讲。

package main
import (
"fmt"
"sync"
)
const threads, adds = 4, 100_000
func runAll(work func(i int)) {
var wg sync.WaitGroup
for i := range threads {
wg.Add(1)
go func() {
defer wg.Done()
work(i)
}()
}
wg.Wait() // 等全部 goroutine 结束再读结果
}
func main() {
var mu sync.Mutex
count := 0
runAll(func(int) {
for range adds {
mu.Lock()
count++ // 读、加一、写回都在锁里
mu.Unlock()
}
})
fmt.Printf("加锁后计数:%d\n", count)
partial := make([]int, threads)
runAll(func(i int) {
local := 0
for range adds {
local++ // 只动自己的局部变量
}
partial[i] = local // 每个 goroutine 只写自己那一格
})
total := 0
for _, p := range partial {
total += p
}
fmt.Printf("各线程分别累加后合并:%d\n", total)
}
// 输出:
// 加锁后计数:400000
// 各线程分别累加后合并:400000

运行:go run threads.go。sync.Mutex 的零值就是一把没锁上的锁,声明即可用。这里临界区只有一行,直接成对写 Lock 和 Unlock;临界区是一个函数时,惯用写法是拿锁后紧跟 defer mu.Unlock()。wg.Wait() 返回时,所有 goroutine 的写入对主 goroutine 都可见,这是 Go 内存模型对 WaitGroup 的保证。

Go 自带竞态检测器(race detector):go run -race 或 go test -race 会在运行时记录每次内存访问,发现数据竞争就打印两处冲突访问的调用栈。它基于 ThreadSanitizer,官方文档给出的开销是内存增加 5 到 10 倍、时间增加 2 到 20 倍,所以一般只在测试里开。除 macOS 外它都需要 cgo,也就是要有 C 编译器,Windows 默认没有;本机没有 gcc,go run -race 直接报 go: -race requires cgo; enable cgo by setting CGO_ENABLED=1。

底层怎么实现。 Mutex 是一个状态字加一个信号量。没有争用时,Lock 只是一次比较交换;有争用时先自旋几轮,仍拿不到就通过运行时的信号量把当前 goroutine 挂起,只挂起这个 G,它所在的系统线程转去执行别的 goroutine。Go 1.9 起加入了饥饿模式:一个 goroutine 等锁超过 1 毫秒,锁就直接交给排队最久的那个,防止新来的 goroutine 一直插队。atomic.AddInt64 在 amd64 上编译成 LOCK XADDQ 一条指令。Go 的内存模型文档在 2022 年随 Go 1.19 修订,承诺没有数据竞争的程序表现为顺序一致;有数据竞争时,读到切片、接口这类多字长的值可能是新旧拼在一起的半个值,后果可能是内存损坏。

  • 复制含有 Mutex 的结构体:副本里是另一把锁,两边各锁各的,等于没锁;在锁着的时候复制,副本还会一直处于锁定状态。go vet 会报 passes lock by value: … contains sync.Mutex,所以这类结构体要用指针传递。
  • 忘了 Unlock:某条提前 return 的路径没放锁,之后所有 Lock 都会永远等下去;如果所有 goroutine 都因此卡住,运行时报 fatal error: all goroutines are asleep - deadlock! 退出。拿锁后紧跟 defer mu.Unlock() 就不会漏。
  • 以为 -race 没报错就没有竞争:检测器只看这次运行实际执行的路径,测试覆盖不到的并发路径照样可能有竞争。
维度 Python JS/TS C# Go
支持程度 标准库 threading 运行时 API:Worker 加 SharedArrayBuffer 原生 lock,标准库 Thread、Interlocked 原生 go,标准库 sync、sync/atomic
实现机制 系统线程 + GIL 每个 Worker 一条系统线程和一个 V8 实例 系统线程、线程池 + Monitor goroutine 调度到系统线程 + 运行时信号量
默认能否共享内存 能 不能,只有 SharedArrayBuffer 共享 能 能
互斥锁 threading.Lock 没有,用 Atomics 自己写 lock、System.Threading.Lock sync.Mutex
原子操作 没有原子整数 Atomics.add 等 Interlocked.Increment 等 atomic.Int64 等
竞态检测工具 没有标准工具 没有标准工具 没有内置工具 go run -race
能否并行执行 标准构建不能,无 GIL 构建能 能,在 Worker 之间 能 能

差异最大的是“默认能否共享内存”一行:三种语言默认什么都共享、要你主动保护,JS 默认什么都不共享、要你主动开一块共享区。

共享内存的线程是操作系统给的原语:pthread 和 Windows 的线程 API 都提供线程、互斥量和原子指令,四种语言的差别在于把它们暴露到什么程度。C# 从 1.0 起就有 lock 语句和 Monitor,直接把系统线程交给程序员,再在上面加线程池和 Task。Python 在 1990 年代加入线程时同时引入了 GIL,线程同样共享所有对象,但 GIL 让很多竞争在实际运行中很少显现,掩盖了本该加锁的地方;无 GIL 构建把这些问题重新暴露了出来。

JS 走了相反的路。浏览器里的页面结构经不起两条线程同时改,让脚本单线程运行既简单又没有数据竞争;后来的 Worker 也坚持隔离,只能复制消息。SharedArrayBuffer 在 2017 年进入标准,是为了让 WebAssembly 和高性能计算能用多线程,但 2018 年就因 Spectre 被浏览器关闭,之后只在跨源隔离的页面里恢复。它只能存字节,是有意把共享限制在一个很窄的口子里。

Go 处在中间:goroutine 之间可以共享内存,sync 包也提供锁,但语言把通道做进语法,口号也鼓励用消息传递。设计者清楚共享内存容易出错,所以随工具链附带竞态检测器。

这些选择带来几条后果。调试上,数据竞争的结果每次不同,常常只在高负载时出现、加一行日志又消失;Go 有现成的检测器,C# 和 Python 主要靠代码审查和压力测试。性能上,锁在争用时把并行变成排队,原子操作便宜得多,各自累加再合并最快;JS 要把数据编码成字节才能共享,Python 标准构建加锁也换不来多核加速。多核利用上,C# 和 Go 开几条线程就能吃满多核,Python 要无 GIL 构建,JS 要 Worker 加共享内存。

Rust 用所有权和 Send、Sync 两个标记特质(trait)在编译期排除数据竞争:一个值同一时刻要么只有一个可变引用,要么只有若干只读引用,编译器还检查跨线程传递的类型是否实现了 Send、Sync。两个线程同时修改同一个整数,编译直接报 E0499(同一个值被可变借用了不止一次)。要共享可变数据,就把数据放进 Mutex 里,不拿锁根本访问不到里面的值:

let count = Mutex::new(0);
thread::scope(|s| {
for _ in 0..4 { s.spawn(|| *count.lock().unwrap() += 1); }
});

编译器能排除的只是数据竞争;“先查后扣”这种竞态条件和死锁,Rust 程序照样可能写出来。

  • 首选不共享:能拆开的工作就拆开,各算各的再合并;需要持续交换数据时用消息传递(第 20 章),把共享留给确实绕不开的地方。
  • 必须共享时,单个计数器或标志用原子操作;要一起保持正确的几步操作用锁,临界区尽量短,不在锁里做 I/O,C# 不在锁里 await。
  • 拿锁一律用结构化写法:Python 的 with、C# 的 lock、Go 的 defer mu.Unlock(),JS 自己写的锁也放进 try/finally,保证任何路径都会放锁。
  • 需要同时拿几把锁时,所有线程按同一顺序拿,避免死锁;Go 项目在测试里开 -race(除 macOS 外要有 C 编译器),打算支持无 GIL 构建的 Python 代码要在无 GIL 构建上跑一遍测试。

把本章加锁示例里的锁去掉,就得到一段有数据竞争的计数代码:4 个线程各给共享计数器加 100000 次。用 C# 和 Go 各写一遍,不用锁,改用原子操作让结果正确,打印 计数:400000。

提示:C# 的原子加一在哪个类里,参数为什么要带 ref?Go 里可以用 atomic.AddInt64 配一个 int64 变量,也可以用 Go 1.19 起的 atomic.Int64 类型,哪一种更不容易被别人绕过原子操作直接读写?

  • count += 1 是读、加一、写回三步,几条线程交错执行时会互相覆盖,结果偏小而且每次不同。
  • 数据竞争是没有同步的并发读写同一位置;竞态条件是结果依赖执行先后,用原子操作消除了前者也不一定消除后者。
  • 三种修正:锁让临界区串行,原子操作用一条硬件指令完成读改写,各自累加后合并则根本不共享;锁和原子操作同时解决可见性问题。
  • Python 的 GIL 不让 += 变成原子操作,无 GIL 构建下竞争更容易出现;JS 默认不共享,只有 SharedArrayBuffer 加 Atomics;C# 用 lock(.NET 9 起可锁 Lock 对象)和 Interlocked;Go 用 sync.Mutex、sync/atomic 和 -race。
  • 差异来自出身:系统线程本身就共享内存,C#、Python、Go 把它交给程序员,JS 为了浏览器安全默认隔离;后果体现在调试难度、性能和能否直接用多核上。