Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← 系统与高性能 / Systems & Performance

xv6 源码学习 / xv6 Source Learning

1. xv6 源码学习专题 / xv6 Source Learning

2. xv6 学习路线:从 C 指针到操作系统源码 / xv6 Learning Roadmap

3. 读 xv6 必备的 C 语言和指针基础 / C And Pointers For xv6

4. 读 xv6 必备的 RISC-V 基础 / RISC-V Basics For xv6

5. 如何阅读 xv6 源码 / How To Read xv6 Source

6. xv6 启动与内核入口 / xv6 Boot And Kernel Entry

7. xv6 内存布局与页表 / xv6 Memory Layout And Page Tables

8. xv6 trap、中断与系统调用 / xv6 Traps Interrupts And System Calls

9. xv6 进程与 struct proc / xv6 Process And struct proc

10. xv6 上下文切换与调度器 / xv6 Context Switch And Scheduler

11. xv6 sleep/wakeup、wait 与 exit / xv6 Sleep Wakeup Wait And Exit

12. xv6 锁与并发 / xv6 Locks And Concurrency

13. xv6 文件描述符与 inode / xv6 File Descriptor And Inode

14. xv6 文件系统与日志 / xv6 File System And Log

15. xv6 设备驱动、console、UART 与磁盘 / xv6 Device Driver Console UART And Disk

16. xv6 用户程序与 shell / xv6 User Programs And Shell

17. 用 QEMU/GDB 调试 xv6 / Debugging xv6 With QEMU And GDB

18. 从 xv6 过渡到 Linux 0.11 / Bridge From xv6 To Linux 0.11

19. xv6 专题总结与实践项目 / xv6 Summary And Practice Projects

本页目录

xv6 锁与并发 / xv6 Locks And Concurrency ​

1. 这篇文章解决什么问题 ​

xv6 是多核内核。

多个 hart 可以同时执行内核代码。

如果它们同时访问共享数据,就可能出错。

典型共享对象:

text
proc table
free page list
file table
inode cache
buffer cache
pipe buffer
console buffer
1
2
3
4
5
6
7

锁的作用不是让代码看起来更严肃。

锁保护共享对象的不变量。

本篇重点:

  • 什么是竞态。
  • spinlock 解决什么问题。
  • sleeplock 解决什么问题。
  • 为什么有些锁要关闭中断。
  • sleep/wakeup 为什么必须配合锁。
  • 如何思考死锁。

1.1 本文基础词小注释 ​

并发 concurrency:并发表示多条执行路径在时间上交错推进。它不一定要求同一瞬间真的同时执行。单核上可以靠中断和切换形成并发,多核上则可能真的同时执行。

共享数据 shared data:被多个 CPU、多个进程路径、或者中断处理路径共同访问的数据。比如进程表、空闲页链表、buffer cache。只要共享,就要问:谁能改?什么时候改?改到一半会不会被别人看见?

竞态 race condition:程序结果取决于“谁先执行、谁后执行”的微妙时序,而且这种时序没有被锁或其他同步机制约束。竞态常常不是每次都出现,所以很难调试。

text
------- CPU 0 -------+     +------- CPU 1 -------+
read old value       |     | read old value       |
compute new value    |     | compute new value    |
write back           |     | write back           |
          \          |     |          /
           +---- lost update ----+
1
2
3
4
5
6

锁 lock:锁是一种同步工具。拿到锁的路径可以进入临界区,其他路径必须等待。锁的目的不是“让代码串行得更慢”,而是保护共享对象在修改过程中不被别人破坏。

临界区 critical section:从拿锁到放锁之间的代码区域。这个区域通常会读写共享数据。临界区应尽量短,但不能短到破坏正确性。

不变量 invariant:对象必须一直满足的正确条件。例如“一个空闲物理页只能在 free list 里出现一次”。锁通常保护的不是某一行代码,而是这个条件。

死锁 deadlock:多个执行路径互相等待对方释放资源,导致谁也走不下去。最常见原因是锁顺序不一致。

2. 什么是共享不变量 ​

不变量是某个对象必须一直满足的正确条件。

例如进程状态:

text
one process cannot be RUNNING on two CPUs at same time
1

例如 free list:

text
each free physical page appears exactly once in free list
1

例如 file ref:

text
file object can be reused only when ref == 0
1

锁保护这些条件。

text
shared object
  |
  v
lock
  |
  v
temporarily modify fields safely
  |
  v
restore invariant before unlock
1
2
3
4
5
6
7
8
9
10

所以读锁时,不要只问“为什么加锁”。

要问:

text
which invariant is protected?
1

3. spinlock ​

自旋锁用于短临界区。

拿不到锁时,CPU 会循环等待。

text
try acquire
  |
  +-- success -> enter critical section
  |
  +-- fail -> keep spinning
1
2
3
4
5

xv6 spinlock 相关文件:

text
kernel/spinlock.h
kernel/spinlock.c
1
2

典型 API:

text
initlock
acquire
release
holding
1
2
3
4

适合保护:

  • 进程状态。
  • free page list。
  • file table。
  • buffer cache metadata。

不适合长期持有。

因为自旋时 CPU 没有做有用工作。

4. sleeplock ​

sleeplock 允许等待者睡眠。

适合可能等待较久的资源。

相关文件:

text
kernel/sleeplock.h
kernel/sleeplock.c
1
2

典型 API:

text
initsleeplock
acquiresleep
releasesleep
holdingsleep
1
2
3
4

常见用途:

inode 内容保护。

为什么 inode 适合 sleeplock?

因为文件系统操作可能涉及磁盘 I/O。

等待磁盘时不应该一直自旋浪费 CPU。

text
short metadata update
  |
  v
spinlock

long operation may sleep
  |
  v
sleeplock
1
2
3
4
5
6
7
8
9

5. 为什么 spinlock 会关中断 ​

xv6 获取 spinlock 时会配合 push_off()。

释放时配合 pop_off()。

它们管理当前 CPU 的中断开关嵌套。

为什么?

避免同一 CPU 上中断处理程序再次尝试获取同一把锁,造成死锁。

错误情况:

text
kernel code acquires lock L
  |
  v
interrupt arrives on same CPU
  |
  v
interrupt handler tries acquire L
  |
  v
deadlock
1
2
3
4
5
6
7
8
9
10

关中断可以避免这类本地中断重入。

注意:

关中断不是为了阻止其他 CPU。

其他 CPU 仍然可能运行。

阻止其他 CPU 同时进入临界区的是锁本身。

text
spinlock
  |
  +-- protects against other CPUs

push_off
  |
  +-- protects against interrupt reentry on same CPU
1
2
3
4
5
6
7

6. proc lock ​

每个进程有 p->lock。

它保护进程状态。

text
p->lock protects
  |
  +-- state
  +-- chan
  +-- killed
  +-- xstate
  +-- scheduling transitions
1
2
3
4
5
6
7

调度器使用它。

sleep/wakeup 使用它。

exit/wait 使用它。

状态变化通常要持锁。

text
acquire p->lock
  |
  v
change p->state
  |
  v
release p->lock
1
2
3
4
5
6
7

没有锁,两个 CPU 可能同时把同一进程状态改坏。

7. kalloc lock ​

物理页分配器维护 free list。

free list 是共享结构。

text
free list
  |
  +-- page A -> page B -> page C
1
2
3

kalloc() 和 kfree() 都会修改它。

所以需要锁。

text
kalloc
  |
  +-- acquire lock
  +-- remove page from free list
  +-- release lock

kfree
  |
  +-- acquire lock
  +-- add page to free list
  +-- release lock
1
2
3
4
5
6
7
8
9
10
11

不变量:

text
free pages form a valid list
no allocated page remains on free list
no free page appears twice
1
2
3

8. file table lock ​

全局文件表也需要锁。

文件对象有引用计数。

text
struct file
  |
  +-- ref
1
2
3

filealloc() 找空闲槽位。

filedup() 增加引用。

fileclose() 减少引用。

这些都不能并发乱改。

text
file ref invariant
  |
  +-- ref > 0 means in use
  +-- ref == 0 means reusable
1
2
3
4

如果两个 CPU 同时操作 ref,可能提前释放或重复释放。

所以要锁保护。

9. buffer cache lock ​

buffer cache 既有全局元数据,又有每个 buffer 的内容锁。

简化:

text
buffer cache metadata
  |
  +-- spinlock

individual buffer content
  |
  +-- sleeplock
1
2
3
4
5
6
7

为什么两层?

查找缓存块、维护 LRU 链表是短操作。

适合 spinlock。

读写某个块内容可能涉及磁盘等待。

适合 sleeplock。

text
find buffer
  |
  v
spinlock protected metadata
  |
  v
lock buffer content
  |
  v
release metadata lock
1
2
3
4
5
6
7
8
9
10

这是锁粒度设计的例子。

10. sleep/wakeup 中的锁 ​

sleep/wakeup 最容易出现 lost wakeup。

锁用于保护条件检查和睡眠状态转换。

text
acquire condition lock
  |
  v
while condition not true
  |
  v
sleep(chan, lock)
1
2
3
4
5
6
7

sleep 内部会:

text
acquire p->lock
release external lock
set p->chan
set p->state = SLEEPING
sched
...
reacquire external lock before return
1
2
3
4
5
6
7

这个过程复杂,是为了不丢唤醒。

不要把 sleep 看成普通阻塞函数。

它是调度、锁、状态机三者配合。

11. 死锁 ​

死锁是多个执行流互相等待。

典型:

text
CPU 0 holds A, waits B
CPU 1 holds B, waits A
1
2

图:

text
CPU 0 ----holds----> Lock A
CPU 0 ----waits----> Lock B

CPU 1 ----holds----> Lock B
CPU 1 ----waits----> Lock A
1
2
3
4
5

避免死锁的方法:

  • 固定锁顺序。
  • 不在持有不该持有的锁时 sleep。
  • 缩短临界区。
  • 区分 spinlock 和 sleeplock。
  • 明确每把锁保护什么。

xv6 代码简单,但锁顺序仍然要注意。

12. 读锁代码的方法 ​

读到锁时按这个模板。

text
lock name:

protected object:

protected fields:

invariant:

who acquires it:

can code sleep while holding it:

lock ordering:
1
2
3
4
5
6
7
8
9
10
11
12
13

例如:

text
lock name:
  p->lock

protected object:
  struct proc

protected fields:
  state, chan, killed, xstate

invariant:
  scheduler and wakeup see consistent process state
1
2
3
4
5
6
7
8
9
10
11

这比只写“这里加锁保证线程安全”好得多。

13. 锁粒度:一把大锁还是多把小锁 ​

锁粒度描述一把锁保护的数据范围有多大。

粗粒度锁保护很多数据。

细粒度锁保护较小对象。

text
coarse-grained lock
  |
  +-- simpler
  +-- less parallelism
  +-- easier to reason initially

fine-grained locks
  |
  +-- more parallelism
  +-- more complex
  +-- higher deadlock risk
1
2
3
4
5
6
7
8
9
10
11

xv6 同时使用两种思路。

进程是每个 struct proc 一把锁。

这比整个进程表一把锁更细。

物理页分配器有一把锁保护 free list。

文件表有一把锁保护全局文件表。

buffer cache 既有全局锁,又有每个 buffer 的 sleeplock。

text
proc table
  |
  +-- proc[0].lock
  +-- proc[1].lock
  +-- proc[2].lock

buffer cache
  |
  +-- global metadata lock
  +-- buf[0].sleeplock
  +-- buf[1].sleeplock
1
2
3
4
5
6
7
8
9
10
11

读代码时要问:

这把锁保护一个对象,还是一组对象?

如果保护一组对象,是否会限制并发?

如果拆成多把锁,是否会增加锁顺序问题?

14. 原子操作和锁的关系 ​

自旋锁底层通常需要原子操作。

原子操作保证某个读改写过程不可被其他 CPU 插入。

例如获取锁时,需要从“未锁定”变成“已锁定”。

text
CPU 0 tries acquire
CPU 1 tries acquire
  |
  v
atomic operation ensures only one succeeds
1
2
3
4
5

如果没有原子操作,两个 CPU 可能同时认为自己拿到锁。

text
CPU 0 sees locked = 0
CPU 1 sees locked = 0
CPU 0 sets locked = 1
CPU 1 sets locked = 1

both enter critical section
1
2
3
4
5
6

锁是更高层语义。

原子操作是实现锁的硬件基础之一。

初学 xv6 不需要深入所有内存模型细节。

但要知道:

自旋锁不是普通变量判断。

它依赖硬件提供的原子性。

15. 内存可见性:为什么 release 不只是清零 ​

释放锁不只是把 locked 设回 0。

它还要保证临界区内的写入对后续拿锁者可见。

抽象模型:

text
CPU 0 inside lock
  |
  +-- writes shared data
  |
  v
release lock
  |
  v
CPU 1 acquire lock
  |
  v
must see CPU 0's shared data writes
1
2
3
4
5
6
7
8
9
10
11
12

如果没有合适的内存顺序,CPU 或编译器可能重排访问。

这会让另一个 CPU 看到奇怪状态。

xv6 的锁实现会使用原子和内存同步机制。

读源码时先把目标记住:

text
acquire
  |
  +-- after acquire, protected data can be read consistently

release
  |
  +-- before release, protected writes become visible
1
2
3
4
5
6
7

这就是锁的“可见性”作用。

它不只是互斥。

16. 中断上下文和普通内核上下文 ​

内核代码可能在不同上下文运行。

text
process kernel context
  |
  +-- executing syscall
  +-- can sleep in some paths

interrupt context
  |
  +-- handling device/timer interrupt
  +-- must be careful
1
2
3
4
5
6
7
8
9

如果普通内核代码持有某把锁时被中断打断,而中断处理也要拿同一把锁,就会死锁。

这就是 spinlock 结合关中断的原因之一。

text
same CPU:

normal kernel code holds lock L
  |
  v
interrupt arrives
  |
  v
interrupt handler wants L
  |
  v
deadlock
1
2
3
4
5
6
7
8
9
10
11
12

所以分析锁时,要问:

这把锁会不会在中断处理里使用?

持有这把锁时是否需要禁止本 CPU 中断?

17. sleep 时不能随便持有 spinlock ​

睡眠意味着当前进程让出 CPU。

如果睡眠时持有不该持有的 spinlock,其他 CPU 或进程可能永远等不到锁。

text
process holds spinlock L
  |
  v
process sleeps
  |
  v
other code needs L to wake it
  |
  v
deadlock
1
2
3
4
5
6
7
8
9
10

xv6 的 sleep(chan, lock) 有明确规则。

它会释放传入的外部锁,并在醒来后重新获取。

但这并不意味着可以随便在任何锁下睡眠。

你必须知道:

  • 当前持有什么锁。
  • sleep 会释放哪把锁。
  • 是否还持有其他锁。
  • wakeup 路径是否需要这些锁。

这也是为什么锁分析必须和调用路径一起看。

18. 本篇源码阅读清单 ​

按这个顺序读锁相关源码:

text
spinlock.h
  |
  +-- struct spinlock

spinlock.c
  |
  +-- initlock
  +-- acquire
  +-- release
  +-- push_off
  +-- pop_off

sleeplock.h
  |
  +-- struct sleeplock

sleeplock.c
  |
  +-- acquiresleep
  +-- releasesleep

proc.c
  |
  +-- sleep
  +-- wakeup
  +-- scheduler

bio.c / fs.c / file.c / kalloc.c
  |
  +-- concrete lock users
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

每读一把锁,写下:

text
lock:
protected fields:
critical section:
can sleep:
interrupt concern:
lock order:
1
2
3
4
5
6

这会让锁从“看不见的魔法”变成可分析对象。

19. 常见误解 ​

误解一:锁保护代码。

更准确地说,锁保护数据和不变量。

误解二:关中断能阻止其他 CPU。

不能。

关中断只影响当前 CPU。

误解三:spinlock 和 sleeplock 可以随便替换。

不能。

spinlock 适合短临界区。

sleeplock 适合可能睡眠的长等待。

误解四:拿锁越多越安全。

不是。

锁过多可能死锁,也可能降低并发。

误解五:sleep 只是暂停。

在 xv6 中,sleep 和锁、调度、wakeup 紧密相关。

20. 小实验:观察 kalloc 锁 ​

在 kalloc() 和 kfree() 周围加少量打印。

观察:

text
kalloc acquire
kalloc got page
kfree return page
1
2
3

不要在高频路径长期保留。

目标是理解 free list 修改必须受锁保护。

21. 小实验:构造 lost wakeup 思考实验 ​

不建议真的破坏源码。

可以画时序图。

text
reader checks condition
writer changes condition
writer wakeup
reader sleeps
1
2
3
4

然后解释为什么正确的 sleep(chan, lock) 避免它。

这是理解并发比跑代码更重要的一类实验。

22. 本篇总结 ​

xv6 锁学习的核心不是 API。

而是共享不变量。

text
shared object
  |
  v
invariant
  |
  v
lock
  |
  v
safe state transition
1
2
3
4
5
6
7
8
9
10

下一篇进入:

text
12-file-descriptor-and-inode.md
1

文件系统会把进程资源、引用计数、锁和 inode 对象连接起来。

最后更新于:

Pager
上一篇11. xv6 sleep/wakeup、wait 与 exit / xv6 Sleep Wakeup Wait And Exit
下一篇13. xv6 文件描述符与 inode / xv6 File Descriptor And Inode

持续记录,持续成长

Copyright © Tidenflow