代码中的锁机制是如何实现的

Kaku Lv4

前言

在并发编程中,锁是我们最常用的同步原语之一。无论是 Go 语言的 sync.Mutex,还是 Java 的 synchronized,亦或是 C++ 的 std::mutex,它们的核心目标都是一样的:保护临界区,防止竞态条件。

但你有没有想过,锁本身是如何实现的?它凭什么能保证”同一时刻只有一个线程进入临界区”?这个问题的答案,需要我们从计算机的竞态条件说起,一路深入到硬件层面。

竞态条件:并发的原罪

什么是竞态条件?

竞态条件(Race Condition)是指多个线程或进程同时访问和修改共享数据时,最终结果依赖于它们执行的相对时序。简单来说,就是程序的正确性取决于”运气”。

来看一个经典的例子——计数器递增:

1
2
3
4
5
var counter int

func increment() {
counter++ // 这一行代码,实际上包含三个操作
}

counter++ 看似是一个简单的操作,但在 CPU 层面,它实际上被分解为三个步骤:

  1. Load:从内存中读取 counter 的值到寄存器
  2. Add:在寄存器中将值加 1
  3. Store:将寄存器中的新值写回内存

如果只有一个线程执行,这没有任何问题。但当多个线程并发执行时,问题就来了:

sequenceDiagram
    participant 线程A
    participant 内存
    participant 线程B
    Note over 内存: counter = 0
    线程A->>内存: Load (读取到 0)
    线程A->>线程A: Add (0 + 1 = 1)
    线程B->>内存: Load (读取到 0,A还没写回)
    线程B->>线程B: Add (0 + 1 = 1)
    线程A->>内存: Store (写入 1)
    线程B->>内存: Store (写入 1)
    Note over 内存: counter = 1 (期望是 2)

两个线程各执行了一次递增,但最终结果却是 1 而不是 2。这就是竞态条件的典型表现。

竞态条件的本质

竞态条件的本质是操作的非原子性。一个逻辑上应该”不可分割”的操作,在物理上被拆分成了多个步骤,而其他线程可能在这些步骤之间”插队”执行。

要解决这个问题,我们需要一种机制,能够保证某些操作原子地执行——要么全部完成,要么全部不执行,中间状态不会被其他线程观察到。

锁:软件层面的解决方案

锁的基本原理

锁(Lock)是一种同步原语,它的核心思想很简单:在进入临界区之前加锁,离开临界区之后解锁。同一时刻,只有一个线程能够持有锁,其他试图获取锁的线程会被阻塞或自旋等待。

1
2
3
4
5
6
7
8
var mu sync.Mutex
var counter int

func increment() {
mu.Lock() // 加锁
counter++ // 临界区
mu.Unlock() // 解锁
}

但问题是:锁本身是如何实现的?如果锁的实现依赖于某种”原子操作”,那我们岂不是陷入了”用原子操作实现锁,用锁实现原子操作”的循环?

锁的实现层次

答案是:锁的实现依赖于硬件提供的原子指令。我们可以把并发控制的实现分为三个层次:

层次实现方式示例
应用层使用高级同步原语sync.Mutexsync.WaitGroup
操作系统层系统调用、futexpthread_mutex_lockfutex()
硬件层原子指令、内存屏障LOCK CMPXCHGLOCK XADD

锁的实现,本质上是将硬件提供的原子指令封装成易用的 API。要理解锁,我们必须先理解硬件做了哪些努力。

硬件的承诺:原子指令

为什么需要硬件支持?

在单核 CPU 时代,实现原子性相对简单:禁用中断即可。当一个线程进入临界区时,禁用 CPU 中断,这样就不会发生线程切换,直到离开临界区再恢复中断。

但在多核 CPU 时代,这种方法彻底失效了。即使禁用了某个核心的中断,其他核心仍然可以同时访问共享内存。我们需要一种机制,能够跨核心保证内存操作的原子性。

这就是 CPU 原子指令的用武之地。

常见的原子指令

现代 CPU 架构(x86、ARM、RISC-V 等)都提供了一系列原子指令。以 x86 架构为例:

1. LOCK 前缀

LOCK 是 x86 指令集中的一个前缀,它可以加在某些指令前面,使该指令变成原子的。例如:

1
2
LOCK ADD [counter], 1    ; 原子地将 counter 加 1
LOCK XCHG eax, [value] ; 原子地交换 eax 和 value 的值

LOCK 前缀的工作原理是:在执行指令期间,锁定总线(或缓存行),防止其他核心访问同一内存地址。

2. CMPXCHG(比较并交换)

这是最重要的原子指令之一,也是实现 CAS(Compare-And-Swap)操作的基础:

1
2
3
; CMPXCHG dest, src
; 如果 EAX == dest,则 dest = src;否则 EAX = dest
LOCK CMPXCHG [counter], 1

CAS 操作的伪代码:

1
2
3
4
5
6
7
8
func CAS(addr *int32, old, new int32) bool {
// 这是一个原子操作,由硬件保证
if *addr == old {
*addr = new
return true
}
return false
}

CAS 是构建无锁数据结构的基石,Go 语言的 atomic.CompareAndSwapInt32 就是基于它实现的。

3. XADD(交换并相加)

1
2
3
; XADD dest, reg
; temp = dest; dest = dest + reg; reg = temp
LOCK XADD [counter], eax

这条指令原子地将 eax 的值加到 counter 上,并返回 counter 的旧值。Go 语言的 atomic.AddInt32 就是基于它实现的。

4. XCHG(交换)

1
2
3
; XCHG dest, src
; 交换 dest 和 src 的值
LOCK XCHG [lock], 1

XCHG 指令天生的就是原子的(不需要 LOCK 前缀),它常用于实现自旋锁的”尝试获取锁”操作。

缓存一致性协议

你可能会问:LOCK 前缀”锁定总线”的开销不是很大吗?是的,在早期的 x86 处理器中,LOCK 确实会锁定整个总线,阻止其他核心访问任何内存。

但现代 CPU 引入了缓存一致性协议(如 MESI 协议),大大降低了原子操作的开销。MESI 协议的核心思想是:

  • 每个缓存行(Cache Line)有四种状态:Modified、Exclusive、Shared、Invalid
  • 当一个核心要原子地修改某个缓存行时,它会让其他核心的同一缓存行失效
  • 其他核心在访问该缓存行时,必须从修改者的缓存中获取最新值

这样,LOCK 前缀只需要锁定缓存行,而不需要锁定整个总线,大大提高了并发性能。

graph TD
    subgraph "核心 0"
        A[缓存行: M状态]
    end
    subgraph "核心 1"
        B[缓存行: I状态]
    end
    subgraph "核心 2"
        C[缓存行: I状态]
    end
    subgraph "内存"
        D[数据]
    end
    A -->|独占修改| D
    B -.->|需要读取时
从核心0获取| A C -.->|需要读取时
从核心0获取| A

从原子指令到锁

有了硬件提供的原子指令,我们就可以实现锁了。下面是一个简化版的自旋锁实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type SpinLock struct {
flag int32
}

func (sl *SpinLock) Lock() {
// 自旋等待,直到成功将 flag 从 0 设置为 1
for !atomic.CompareAndSwapInt32(&sl.flag, 0, 1) {
// 忙等待(自旋)
runtime.Gosched() // 让出 CPU,避免忙等待浪费资源
}
}

func (sl *SpinLock) Unlock() {
atomic.StoreInt32(&sl.flag, 0)
}

这个实现的核心逻辑:

  1. Lock:使用 CAS 操作,尝试将 flag 从 0 改为 1。如果成功,说明获取到了锁;如果失败(说明锁已被其他线程持有),则自旋重试。
  2. Unlock:直接将 flag 设为 0,释放锁。

自旋锁的问题

自旋锁虽然简单,但有一个致命问题:忙等待。当锁被持有时,等待的线程会一直循环检查,白白消耗 CPU 资源。

为了解决这个问题,操作系统提供了更高级的锁实现,如互斥锁(Mutex)。互斥锁在获取不到锁时,会让线程进入睡眠状态,而不是忙等待。当锁被释放时,操作系统会唤醒等待的线程。

在 Linux 中,这通常通过 futex(Fast Userspace Mutex)系统调用实现:

1
2
3
// futex 的基本用法
futex(addr, FUTEX_WAIT, expected_value, timeout) // 等待,如果值等于 expected_value 则睡眠
futex(addr, FUTEX_WAKE, num_wake) // 唤醒 num_wake 个等待的线程

Go 语言的 sync.Mutex 就是基于这种机制实现的。它在低竞争时使用自旋(快速路径),在高竞争时切换到基于 futex 的睡眠等待(慢速路径)。

锁与原子变量的关系

原子变量:更轻量的选择

原子变量(Atomic Variable)是另一种并发控制机制。与锁不同,原子变量直接利用硬件原子指令,不需要操作系统介入,因此开销更小。

Go 语言的 sync/atomic 包提供了原子操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var counter int64

// 原子加法
atomic.AddInt64(&counter, 1)

// 原子读取
value := atomic.LoadInt64(&counter)

// 原子写入
atomic.StoreInt64(&counter, 42)

// CAS
if atomic.CompareAndSwapInt64(&counter, 42, 100) {
// 更新成功
}

锁 vs 原子变量

特性原子变量
实现层级操作系统 API(如 futex)硬件指令
开销较高(可能涉及系统调用、上下文切换)极低(通常只需几条指令)
可扩展性竞争激烈时性能下降随 CPU 核心数线性扩展
适用场景复杂临界区、需要等待/通知机制简单变量更新、无锁数据结构
易用性简单直观需要仔细处理 ABA 问题等

它们的关系

锁和原子变量并不是对立的,而是互补的:

  1. 原子变量是锁的基础:锁的实现依赖于原子指令(如 CAS)。没有原子指令,就无法实现锁。
  2. 锁是原子变量的封装:锁将原子指令封装成更易用的 API,提供了更高级的抽象(如等待队列、公平性保证等)。
  3. 选择取决于场景
    • 如果只是简单的计数器、标志位,原子变量更高效
    • 如果需要保护复杂的临界区,或者需要等待/通知机制,锁更合适
graph TB
    subgraph "硬件层"
        A1["原子指令
CMPXCHG, XADD, XCHG"] end subgraph "操作系统层" B1["futex"] B2["原子变量
atomic.Add, atomic.CAS"] end subgraph "应用层" C1["互斥锁
sync.Mutex"] C2["读写锁
sync.RWMutex"] C3["条件变量
sync.Cond"] end A1 --> B1 A1 --> B2 B1 --> C1 B1 --> C2 B1 --> C3

内存屏障:被忽视的细节

除了原子指令,硬件还提供了另一个重要工具:内存屏障(Memory Barrier)。

为什么需要内存屏障?

现代 CPU 和编译器为了优化性能,可能会重排序指令的执行顺序。例如:

1
2
3
4
5
6
7
8
// 线程 A
data = 42 // 普通写入
ready = true // 普通写入

// 线程 B
if ready { // 普通读取
fmt.Println(data) // 可能读到 0,而不是 42
}

即使线程 A 先写 data 再写 ready,CPU 也可能先将 ready 的值写入内存。这样线程 B 看到 ready == true 时,data 可能还没更新。

内存屏障的作用

内存屏障告诉 CPU 和编译器:”这条指令之前的内存操作,必须在这条指令之后的操作之前完成”。

Go 语言的原子操作函数(如 atomic.StoreInt32)内部已经包含了必要的内存屏障,这也是为什么原子操作不仅能保证原子性,还能保证内存顺序。

总结

从竞态条件到锁,从原子指令到内存屏障,并发控制的核心原理其实并不复杂:

  1. 竞态条件源于操作的非原子性
  2. 硬件提供原子指令(如 CAS、XADD),保证单条指令的原子性
  3. 锁基于原子指令实现,提供了更高级的同步抽象
  4. 原子变量直接暴露硬件能力,适合简单场景
  5. 内存屏障保证操作的顺序性,防止指令重排序

理解这些底层原理,不仅能帮助我们更好地使用锁和原子变量,还能在遇到并发问题时更快地定位原因。

下次当你写下 mu.Lock() 时,不妨想想背后发生了什么:一次 CAS 操作、可能的系统调用、缓存行的锁定与失效……这些看似简单的 API,背后是硬件和操作系统几十年的演进与优化。

参考与延伸阅读

如果你对 Go 底层的并发机制感兴趣,或者有其他并发编程的问题,欢迎在评论区留言讨论。

  • 标题: 代码中的锁机制是如何实现的
  • 作者: Kaku
  • 创建于 : 2026-04-01 12:00:00
  • 更新于 : 2026-04-06 14:53:48
  • 链接: https://www.kakunet.top/2026/04/01/代码中的锁机制是如何实现的/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论