xv6 sleep/wakeup、wait 与 exit / xv6 Sleep Wakeup Wait And Exit
1. 这篇文章解决什么问题
进程不总是在运行。
它可能等待输入。
可能等待子进程退出。
可能等待磁盘 I/O。
也可能已经退出,但父进程还没回收。
xv6 用几组机制处理这些情况:
sleep / wakeup
|
+-- wait for an event
exit
|
+-- terminate current process
wait
|
+-- parent waits and reaps child
kill
|
+-- mark process as killed and wake if needed这篇文章聚焦:
chan是什么。- 为什么睡眠要和锁配合。
- lost wakeup 是什么。
exit()为什么产生 ZOMBIE。wait()如何回收子进程。- 父进程退出后子进程怎么办。
1.1 本文基础词小注释
sleep:这里不是让 CPU 睡觉,而是让“当前进程”暂停运行。进程进入 SLEEPING 状态后,调度器不会选它,直到某个事件把它唤醒。
wakeup:唤醒不是立刻运行。wakeup(chan) 通常只是把睡在某个 chan 上的进程改成 RUNNABLE,表示它以后可以被调度器选中。
----------+ wakeup(chan) +----------+
|SLEEPING | ---------------------> |RUNNABLE |
+----------+ +----------+
| |
| not chosen by scheduler | may be chosen laterchan:chan 是 channel 的缩写,但 xv6 里它不是 Go 语言那种通道。它本质上是一个“等待原因的标记”,通常用一个地址当标记。睡眠者说“我在等这个地址代表的事件”,唤醒者也用同一个地址去找人。
lost wakeup:丢失唤醒。意思是事件已经发生了,但等待者还没正确进入睡眠状态,结果唤醒信号错过了它,之后它可能永远睡下去。xv6 用锁把“检查条件”和“进入睡眠”连起来,避免这个问题。
wait:父进程等待子进程退出,并回收子进程留下的内核资源。这里的 wait 不只是等待,还包含“收尸式回收”,所以它会处理 ZOMBIE 子进程。
exit:当前进程结束自己。进程调用 exit() 后,不能直接把自己的所有东西立刻清掉,因为父进程还需要通过 wait() 看到它的退出状态。
ZOMBIE:不是正在运行的进程,也不是完全不存在的进程。它已经退出,但还留着一点记录,等父进程 wait() 来回收。
2. 为什么需要 sleep
如果进程等待事件时一直占用 CPU,会浪费资源。
例如管道为空。
读进程没有数据可读。
它应该睡眠。
pipe empty
|
v
reader cannot proceed
|
v
reader sleeps
|
v
writer writes data
|
v
writer wakes reader睡眠不是结束。
睡眠表示进程暂时不可运行。
状态:
RUNNING
|
| sleep
v
SLEEPING
|
| wakeup
v
RUNNABLE调度器不会选择 SLEEPING 进程。
只有变回 RUNNABLE,它才可能再次运行。
3. chan:等待通道
chan 是进程正在等待的通道。
struct proc
|
+-- void *chan它通常是某个内核对象地址。
可以理解成等待事件的标签。
process p
|
+-- state = SLEEPING
+-- chan = X
wakeup(X)
|
+-- find sleeping processes with chan == X
+-- set them RUNNABLE图:
chan = pipe_read_addr
|
+-- reader A sleeps here
+-- reader B sleeps here
wakeup(pipe_read_addr)
|
+-- wake A
+-- wake Bchan 是 void *,说明它不关心具体类型。
它只用于比较。
这是内核中常见的轻量等待标识方式。
4. sleep(chan, lock) 为什么需要 lock
sleep 不只接收通道。
它还接收锁。
sleep(chan, lock)这个锁非常重要。
它保护“检查条件”和“进入睡眠”之间的原子性。
否则会出现 lost wakeup。
错误时序:
reader checks pipe empty
|
| before reader sleeps
v
writer writes data and wakeup
|
v
reader now goes to sleep
|
v
no one wakes it again这就是 lost wakeup。
正确做法需要锁保护条件。
acquire lock
|
v
check condition
|
v
sleep atomically releases lock and sleeps
|
v
wakeup later can see sleeping state所以 sleep/wakeup 的难点不是 C 语法。
而是并发时序。
5. wakeup(chan) 做什么
wakeup(chan) 扫描进程表。
找到睡在同一个通道上的进程。
把它们改成 RUNNABLE。
wakeup(chan)
|
v
for p in proc table
|
+-- if p->state == SLEEPING && p->chan == chan
|
v
p->state = RUNNABLE它不是立即运行进程。
它只是让进程有资格被调度。
SLEEPING
|
| wakeup
v
RUNNABLE
|
| scheduler later
v
RUNNING这点很重要。
唤醒不是切换。
唤醒只是改变状态。
6. pipe 中的 sleep/wakeup
管道是理解 sleep/wakeup 的好例子。
读空管道:
pipe empty
|
v
reader sleeps写管道:
writer adds data
|
v
wakeup readers管道满时写进程也可能睡眠。
pipe full
|
v
writer sleeps
reader consumes data
|
v
wakeup writers图:
pipe buffer
|
+-- readers wait for data
|
+-- writers wait for space同一个机制支持不同等待事件。
关键在于使用不同 chan。
7. exit():进程如何结束
进程退出不是简单删除。
exit(status) 会:
close open files
|
v
release cwd
|
v
reparent children
|
v
wake parent
|
v
set xstate
|
v
state = ZOMBIE
|
v
sched()为什么变成 ZOMBIE?
因为父进程可能要读取退出状态。
如果内核立刻把进程记录清空,父进程就无法 wait() 得到结果。
child exits
|
v
ZOMBIE record remains
|
v
parent wait reads status
|
v
freeprocZOMBIE 是退出但未回收的进程记录。
不是还在运行的进程。
8. wait():父进程如何回收
wait() 查找当前进程的子进程。
如果找到 ZOMBIE 子进程,就回收。
如果有子进程但还没退出,就睡眠。
wait()
|
v
scan proc table
|
+-- child ZOMBIE?
| |
| +-- copy xstate
| +-- freeproc
| +-- return pid
|
+-- children exist but none zombie?
|
+-- sleep waiting父进程等待的是子进程。
不是任意进程。
关系:
child->parent == current processexit() 会唤醒父进程。
这样父进程如果正在 wait() 睡眠,就能继续扫描并回收子进程。
9. reparent:父进程先退出怎么办
如果父进程先退出,而子进程还活着,子进程会变成孤儿。
xv6 会把它们重新挂到 init。
parent exits
|
v
children still alive
|
v
reparent to init
|
v
init will wait and reap为什么需要 init?
因为每个退出子进程都需要有人回收。
init 作为根进程,负责收养孤儿。
图:
before
parent
|
+-- child
after parent exits
init
|
+-- child这保证 ZOMBIE 最终有人处理。
10. kill() 和睡眠进程
kill(pid) 通常不会直接强行删除进程。
它设置 killed 标志。
如果目标进程正在睡眠,会把它唤醒。
kill(pid)
|
v
find process
|
v
p->killed = 1
|
+-- if SLEEPING, set RUNNABLE为什么要唤醒?
因为睡眠进程如果永远睡着,就没有机会检查 killed。
唤醒后,它返回到内核路径,检查标志,然后退出。
SLEEPING killed process
|
v
wakeup
|
v
RUNNABLE
|
v
runs and notices killed
|
v
exit这体现了 xv6 的协作式终止。
11. wait/exit 的锁关系
wait/exit 涉及父子关系和进程表扫描。
所以锁很重要。
关键不变量:
child state and parent relation must be observed consistently如果没有锁,可能出现:
- 父进程错过子进程退出。
- 子进程状态被同时修改。
- reparent 和 wait 竞态。
简化关系:
exit child
|
+-- set xstate
+-- set ZOMBIE
+-- wake parent
wait parent
|
+-- scan children
+-- find ZOMBIE
+-- free childwait 和 exit 必须配合,不能各干各的。
12. sleep/wakeup 与调度器
sleep 最终会调用 sched()。
这让当前进程切回调度器。
sleep
|
+-- p->chan = chan
+-- p->state = SLEEPING
+-- sched()wakeup 不直接切换。
wakeup
|
+-- p->state = RUNNABLE调度器之后选择它。
完整图:
process waits
|
v
sleep -> SLEEPING -> scheduler
|
v
event happens
|
v
wakeup -> RUNNABLE
|
v
scheduler -> RUNNING这解释了 sleep/wakeup 和 scheduler 的边界。
13. 常见误解
误解一:sleep 会睡固定时间。
不一定。
xv6 的 sleep(chan, lock) 是等待事件通道。
不是普通定时睡眠 API。
误解二:wakeup 立刻运行进程。
不是。
它只是改成 RUNNABLE。
误解三:ZOMBIE 很可怕,说明进程还在运行。
不是。
ZOMBIE 是退出后等待父进程回收的记录。
误解四:exit 可以直接释放 proc。
不能。
父进程还需要 wait 读取退出状态。
误解五:kill 立即销毁进程。
不是。
它设置 killed,并唤醒睡眠进程。
误解六:chan 是数据队列。
不是。
chan 是等待事件的标识。
14. lost wakeup 的完整时序
lost wakeup 是 sleep/wakeup 最重要的陷阱。
它不是“忘记调用 wakeup”这么简单。
它通常发生在检查条件和真正睡眠之间。
错误时序可以画得更细。
Reader CPU Writer CPU
---------- ----------
acquire pipe lock
check buffer empty
decide to sleep
acquire pipe lock blocked
release pipe lock
acquire pipe lock
write data
wakeup(reader chan)
release pipe lock
now reader sets SLEEPING
sched()
result:
reader sleeps after wakeup already happened这个时序的问题是:
reader 还没把自己标记为 SLEEPING,writer 就已经完成 wakeup。
之后 reader 再睡下去,就没人叫醒它。
正确的 sleep 设计要把这些动作变成安全顺序:
hold condition lock
|
v
sleep obtains p->lock before releasing condition lock
|
v
process state and chan become visible safely
|
v
wakeup cannot miss the sleeping process所以 sleep(chan, lock) 的参数不是多余设计。
它是为了把外部条件锁和进程状态锁衔接起来。
15. wait 为什么也会 sleep
wait() 不只是扫描一次。
如果当前进程有子进程,但它们都还没退出,父进程应该睡眠。
否则父进程会一直循环扫描进程表,浪费 CPU。
parent calls wait
|
v
has children?
|
+-- no -> return -1
|
+-- yes
|
v
any ZOMBIE?
|
+-- yes -> reap
|
+-- no -> sleep子进程退出时,会唤醒父进程。
child exit
|
v
state = ZOMBIE
|
v
wakeup(parent)父进程醒来后,不是直接相信某个子进程已经退出。
它会重新扫描。
这是一种常见模式:
while condition not satisfied:
sleep
after wakeup:
recheck condition为什么醒来要重新检查?
因为 wakeup 只说明“可能有变化”。
不保证你要的条件一定成立。
16. exit 为什么不能返回
普通函数执行完会 return。
但 exit() 不会返回到用户程序。
进程已经决定结束。
所以 exit() 最终会进入调度路径。
process calls exit
|
v
cleanup resources
|
v
state = ZOMBIE
|
v
sched()
|
v
never returns to user mode这和 yield() 不同。
yield() 是:
RUNNING -> RUNNABLE
later may run againexit() 是:
RUNNING -> ZOMBIE
will not run user code again所以 exit() 后如果还能回到用户程序,就是严重错误。
ZOMBIE 只保留给父进程回收的信息。
17. wait/exit/reparent 的三方关系
wait/exit 不是两方关系。
还要考虑 init。
三方图:
parent process
|
+-- waits for child
child process
|
+-- exits and becomes ZOMBIE
init process
|
+-- adopts orphan children
+-- eventually waits them如果父进程活着:
child exit
|
v
parent wait
|
v
child reaped如果父进程先退出:
parent exit
|
v
child reparented to init
|
v
child exits
|
v
init wait这样保证每个退出子进程最终有人收尸。
否则 ZOMBIE 会永远留在进程表里。
18. 本篇源码阅读清单
建议按这个顺序读:
proc.h
|
+-- state
+-- chan
+-- killed
+-- xstate
+-- parent
proc.c
|
+-- sleep
+-- wakeup
+-- exit
+-- wait
+-- kill
+-- reparent
pipe.c
|
+-- pipe read/write sleep/wakeup case每个函数问:
what condition is being waited for?
what lock protects that condition?
what chan is used?
who calls wakeup?
what state transition happens?这套问题能覆盖大部分 sleep/wakeup 场景。
19. 小实验:观察 wait/exit
写用户程序:
parent
|
+-- fork child
|
+-- child exits with status
|
+-- parent waits内核打印:
exit: pid=child status=...
wait: parent=... found zombie=...
freeproc: pid=...观察:
- child 何时变 ZOMBIE。
- parent 何时回收。
- 回收后槽位是否 UNUSED。
20. 小实验:观察 pipe sleep/wakeup
写一个管道程序。
让 reader 先读,writer 稍后写。
观察:
reader sleep on pipe
writer writes data
writer wakeup reader
reader becomes RUNNABLE打印要少。
只在 sleep/wakeup 关键点打印。
21. 本篇总结
sleep/wakeup 解决等待事件。
wait/exit 解决父子进程退出和回收。
核心图:
RUNNING
|
+-- sleep ------------> SLEEPING
| |
| | wakeup
| v
| RUNNABLE
|
+-- exit --------------> ZOMBIE
|
| wait
v
UNUSED下一篇进入:
11-locks-and-concurrency.md因为 sleep/wakeup、wait/exit 的正确性都依赖锁。