并发编程(三):互斥锁——语言层的原子性、可见性与有序性

继续使用 counter++,理解互斥锁如何提供原子性、可见性和有序性,并比较 Java synchronized、Go sync.Mutex 与 CPython threading.Lock 的语义差异。

中文发布于 更新于 2026/10/03约 3 分钟读完
并发编程(三):互斥锁——语言层的原子性、可见性与有序性

目录


0. 从上一篇继续

这一篇直接从语言内存模型层的规则讨论 Mutex。


1. 三种语言的锁规则分别保证什么?

1.1 Java:synchronized 与 Monitor

Java 的 synchronized 锁定对象关联的 Monitor。JLS §17.1 规定:

“Only one thread at a time may hold a lock on a monitor.”

这条规则对应 Atomicity。同一时刻只有一个线程能持有 Monitor,所以只有它能进入临界区,其他使用同一 Monitor 的线程只能等待。

JLS §17.4.5 还规定:

“An unlock on a monitor happens-before every subsequent lock on that monitor.”

这条规则同时对应 Visibility 和 Ordering。

对于 Visibility,A 的写入发生在 unlock 之前,B 的读取发生在后续 lock 之后。通过 happens-before 的传递性,A 在临界区中的写入对 B 可见。

对于 Ordering,A 内部的写入顺序、unlock → lock 和 B 内部的读取顺序被连成一条 happens-before 链。因此,B 不能看到 ready = true、counter = 0 这种违反该顺序的结果。

还是用上一篇的两个例子来看。

Atomicity

上一篇中,两个线程并发执行:

Thread A                    Thread B

count++                     count++

可能同时读到旧值 0,最终得到 count = 1。

现在把同一个 count++ 放进同一个 Monitor 保护的临界区:

synchronized (lock) {
    count++;
}

执行过程会变成:

初始:count = 0

Thread A                         Thread B

获得 lock
                                 尝试获得 lock
                                 等待
读取 count -> 0
计算 count + 1 -> 1
写回 count -> 1
释放 lock
                                 获得 lock
                                 读取 count -> 1
                                 计算 count + 1 -> 2
                                 写回 count -> 2
                                 释放 lock

最终:

count = 2

这里不是 count++ 这条普通语句本身变成了原子指令,而是同一个 Monitor 把整个临界区串行化了。

Visibility / Ordering

上一篇的另一个例子是:

Thread A                    Thread B

counter = 1
ready = true                read ready == true
                                  │
                                  ▼
                             read counter

现在让两个线程通过同一个 Monitor 访问这组状态:

// Thread A
synchronized (lock) {
    counter = 1;
    ready = true;
}

// Thread B
synchronized (lock) {
    if (ready) {
        System.out.println(counter);
    }
}

如果 Thread B 在 Thread A 释放 lock 之后获得同一个 lock,就有:

counter = 1
ready = true
    │
    ▼
unlock
    │
    │ happens-before
    ▼
lock
    │
    ▼
read ready == true
read counter == 1

因此,当 B 在这次加锁后看到 ready == true 时,它也会看到前面的 counter = 1,不会出现:

ready == true
counter == 0

如果 B 比 A 更早获得锁,它当然可能先看到 ready == false;这并不违反上述规则。

1.2 Go:sync.Mutex

Go 的 sync.Mutex 不绑定某个 Goroutine,也不提供可重入语义。sync.Mutex.Lock 规定:

“If the lock is already in use, the calling goroutine blocks until the mutex is available.”

这条规则对应 Atomicity。一个 Goroutine 持有 Mutex 时,其他 Goroutine 会等待,不能进入受同一 Mutex 保护的临界区。

The Go Memory Model - Locks 还规定:

“For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() is synchronized before call m of l.Lock() returns.”

这条规则同时对应 Visibility 和 Ordering。

对于 Visibility,A 的写入 sequenced-before Unlock,Unlock 又 synchronized-before B 的 Lock 返回,B 的读取发生在 Lock 返回之后。这些关系组成 happens-before,因此 A 的写入对 B 可见。

对于 Ordering,同一条 happens-before 链把 A 内部的写入顺序、Unlock → Lock 和 B 内部的读取顺序连起来。因此,B 同样不能看到 ready = true、counter = 0。

还是同样的两个例子。

Atomicity

把上一篇的 count++ 放进同一个 sync.Mutex 保护的临界区:

mu.Lock()
count++
mu.Unlock()

两个 Goroutine 并发执行时:

初始:count = 0

Goroutine A                      Goroutine B

Lock()
                                 Lock()
                                 等待
读取 count -> 0
计算 count + 1 -> 1
写回 count -> 1
Unlock()
                                 Lock() 返回
                                 读取 count -> 1
                                 计算 count + 1 -> 2
                                 写回 count -> 2
                                 Unlock()

最终仍然是:

count = 2

同样,原子性来自 Mutex 对临界区的串行化,而不是普通的 count++ 本身。

Visibility / Ordering

再看同一个 counter / ready 例子:

// Goroutine A
mu.Lock()
counter = 1
ready = true
mu.Unlock()

// Goroutine B
mu.Lock()
if ready {
    fmt.Println(counter)
}
mu.Unlock()

如果 B 的 Lock() 在 A 的 Unlock() 之后返回:

counter = 1
ready = true
    │
    ▼
Unlock()
    │
    │ synchronized-before
    ▼
Lock() returns
    │
    ▼
read ready == true
read counter == 1

因此,当 B 看到 ready == true 时,也会看到 counter == 1,不会出现 ready = true、counter = 0。

1.3 CPython:threading.Lock

Python 官方的 threading.Lock 文档规定:

“A primitive lock is a synchronization primitive that is not owned by a particular thread when locked.”

“All methods are executed atomically.”

第二条规则对应 Atomicity。Lock 已经被获得时,其他线程的 acquire() 会等待;因此同一时刻只有一个线程能进入受同一把 Lock 保护的临界区。

对于 Visibility 和 Ordering,Python 和 Java、Go 不同:Python 没有定义一条正式的 release → acquire happens-before 规则,因此不能像 Java、Go 那样直接从语言级 Memory Model 推导。

同样把上一篇的例子放进 threading.Lock。

Atomicity

with lock:
    count += 1

两个线程使用同一把 Lock 时:

初始:count = 0

Thread A                         Thread B

acquire()
                                 acquire()
                                 等待
读取 count -> 0
计算 count + 1 -> 1
写回 count -> 1
release()
                                 acquire() 返回
                                 读取 count -> 1
                                 计算 count + 1 -> 2
                                 写回 count -> 2
                                 release()

最终:

count = 2

Visibility / Ordering

共享的 counter / ready 也通过同一把 Lock 访问:

# Thread A
with lock:
    counter = 1
    ready = True

# Thread B
with lock:
    if ready:
        print(counter)

在 CPython 中,同一把 Lock 会把这两个临界区串行化:A 先写入 counter = 1、ready = True 并释放 Lock,B 再获得同一把 Lock 后读取这组状态。

因此在这个例子里,如果 B 读到 ready == True,对应的 counter 也已经是 1。

至于 CPython 在实现层如何把 release → acquire 落实成跨临界区的可见性和顺序边界,下一篇再继续下钻。


2. 下一篇:互斥锁是怎么实现的?

这一篇停在语言层:从 Atomicity、Visibility 和 Ordering 三个方面,看一把 Mutex 向程序员提供什么保证。

下一篇会把这些语言层保证转换成实现层真正需要解决的问题,分别沿着 Java synchronized、Go sync.Mutex 和 CPython threading.Lock,从 Runtime 一直看到 CPU。

讨论

使用 GitHub 账号参与讨论,评论会保存在 GitHub Issues 中。在 GitHub 查看