线程,以及 Python / C++ 里的进程与线程
前置:进程(fork/exec、syscall 边界)、fd。
实验代码在 code/,下面的输出都是在本机(Ubuntu 26.04、16 核、Python 3.14.4、g++ 15.2)实测的:
cd local/system/code python3 share.py # 线程 vs 进程:内存共不共享 python3 gil.py # GIL:CPU 密集任务用线程还是进程 g++ -std=c++20 -pthread threads.cpp -o threads && ./threads # 数据竞争 vs atomic g++ -std=c++20 -pthread forkthread.cpp -o forkthread && strace -f -e trace=clone,clone3 ./forkthread
1. 先把进程拆开:资源容器 + 执行流
前面把进程当成一个整体。现在把它拆成两部分,线程就自然出来了。看 xv6 的 struct proc(kernel/proc.h):
| 字段 | 是什么 | 属于哪一半 |
|---|---|---|
pagetable、sz |
地址空间:这个进程能看到的内存(代码、全局变量、堆、栈) | 资源 |
ofile[NOFILE] |
fd 表 | 资源 |
cwd |
当前目录 | 资源 |
pid、parent、xstate |
身份、父子关系、退出状态 | 资源 |
trapframe |
进入内核时保存的用户态寄存器(PC、SP、a0…) | 执行流 |
kstack、context |
内核栈,以及在内核里被切换走时保存的寄存器 | 执行流 |
state、chan |
正在跑 / 可以跑 / 在睡(等什么) | 执行流 |
- 资源容器:拥有什么。内存、打开的文件、当前目录。
- 执行流:正在做什么。一组寄存器(尤其是 PC 指向下一条指令,SP 指向自己的栈),加上一个栈。
xv6 里两者是 1:1 绑定的:一个进程 = 一份资源 + 一条执行流。线程就是把这个绑定拆开:一份资源,多条执行流。
2. 执行流到底是什么:CPU 只认寄存器
CPU 并不知道"进程"是什么,它只知道:PC 指向哪条指令,就执行哪条;SP 指向哪里,栈就在哪里。所以"让另一个程序上 CPU"本质上就是:
- 把当前这组寄存器存到内存里;
- 把另一组寄存器从内存里装回来。
xv6 里干这件事的就是 kernel/swtch.S。它只做 28 条 sd/ld:保存和恢复 struct context 里的 ra、sp、s0~s11。ret 之后 CPU 就在另一个执行流的栈上、从另一个地方继续跑了。
调度循环(kernel/proc.c:429 的 scheduler):每个 CPU 不停地扫进程表,找到 RUNNABLE 的进程就 swtch(&c->context, &p->context)(proc.c:453)切过去。进程要让出 CPU 时调用 sched(),切回调度器(proc.c:495)。
谁来逼进程让出 CPU?时钟中断。 每个 tick(约 0.1 s)来一次中断,usertrap 里看到是时钟中断就调用 yield()(kernel/trap.c:86):把自己设为 RUNNABLE,切回调度器。这就是抢占式时间片:程序就算写了死循环,也会被定期打断。
状态机(enum procstate):
UNUSED ─allocproc─▶ USED ─fork 完成─▶ RUNNABLE ◀──────────────┐
│ ▲ │
scheduler │ │ yield(时钟中断) │ wakeup
▼ │ │
RUNNING ──sleep(等 I/O、等子进程、pause)──▶ SLEEPING
│
exit
▼
ZOMBIE ──父进程 wait 收尸──▶ UNUSED
sleep 10 期间进程处于 SLEEPING,完全不占 CPU;忙等循环则一直是 RUNNING/RUNNABLE,白白烧 CPU。这就是 util lab 里 sleep 必须用 pause 的原因。
细节:xv6 的每个进程其实有两条"执行流",一条在用户态(寄存器存在
trapframe),一条在内核态(用kstack和context)。进程进内核时从前者转到后者。它们不会同时跑,所以仍然算"一个进程一个线程"。
3. 线程:共享资源,各自执行
同一个进程里的多个线程:
| 共享(属于进程) | 每个线程私有 |
|---|---|
| 地址空间:代码、全局变量、堆 | 寄存器(PC、SP …) |
| fd 表 | 栈(局部变量都在这里) |
| 当前目录 | 线程局部变量(thread_local) |
| PID | 线程 ID(TID)、调度状态 |
┌──────────── 进程(一份资源)────────────┐
│ 地址空间:代码 | 全局变量 x | 堆 │
│ fd 表:0 1 2 3 cwd:/home/... │
│ │
│ 线程 A 线程 B 线程 C │
│ PC, SP PC, SP PC, SP │
│ 栈 A 栈 B 栈 C │
└──────────────────────────────────────────┘
后果都来自"共享地址空间":
- 通信几乎零成本:直接读写同一个变量就行,不用管道、不用拷贝。
- 数据竞争:两个线程同时改同一个变量,会互相覆盖(见 §6 的实验)。必须加锁或用 atomic。
- 没有隔离:一个线程越界写坏了内存,或者段错误,整个进程一起死。进程之间则互不影响。
- 创建和切换更便宜:不用复制或切换页表,切换时 TLB 和缓存也更"热"。
xv6 不支持多线程:没有类似 clone 的系统调用,一个进程永远只有一个执行流。课程往年有个 thread lab:在用户态自己写一个 swtch,实现"用户级线程",内核完全不知道它们的存在(Makefile 里还留着 LAB=thread 的 uthread 规则)。
4. Linux:进程和线程都是 task,区别只在"共享多少"
Linux 内核里没有两种东西,进程和线程都是一个 task_struct。创建时用 clone 的 flag 指定跟父亲共享哪些资源。实测(strace 看 forkthread.cpp):
创建线程:clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|...})
fork :clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD)
| flag | 含义 | 线程 | fork |
|---|---|---|---|
CLONE_VM |
共享地址空间 | ✅ | ❌ 复制(写时复制,cow lab 会实现) |
CLONE_FILES |
共享 fd 表 | ✅ | ❌ 复制 fd 表(但指向同一批 struct file,跟 xv6 一样) |
CLONE_FS |
共享当前目录等 | ✅ | ❌ |
CLONE_THREAD |
同一个线程组:getpid() 相同 |
✅ | ❌ 新 PID |
所以 "进程 vs 线程"不是两个物种,而是一个旋钮:共享得越多越像线程,复制得越多越像进程。 Linux 的"PID"其实是线程组 ID,每个线程有自己的 TID(单线程进程的 TID == PID)。ls /proc/<pid>/task/ 可以看到一个进程里的所有线程。
threads.cpp 的输出也印证了这一点:
main : pid=378401 tid=378401
thread: pid=378401 tid=378402 ← 同一个 PID,不同的 TID
5. 进程还是线程:怎么选
| 多进程 | 多线程 | |
|---|---|---|
| 隔离 | 强:一个崩了不影响别的 | 无:一个崩了全部死 |
| 通信 | 要走管道 / socket / 共享内存,数据要序列化或拷贝 | 直接读写共享变量 |
| 正确性风险 | 低:默认什么都不共享 | 高:数据竞争、死锁 |
| 创建 / 切换开销 | 较大(页表、TLB) | 较小 |
| 典型用法 | shell 跑命令、浏览器每个标签页、HFT 里网关和策略分开 | 一个服务内部的并行计算、I/O 线程 + 工作线程 |
6. 上升到 C++
std::thread 就是一个真正的内核线程:std::thread → pthread_create → clone3(CLONE_VM|...),跟内核线程一对一。
数据竞争实验(threads.cpp:4 个线程各加 100 万次):
plain = 3948972 (expected 4000000) ← 普通 long,每次结果都不一样
safe = 4000000 ← std::atomic<long>
原因:plain++ 在机器层面是三步:读到寄存器、加一、写回去。两个线程可能同时读到 100,各自加一,都写回 101,于是丢了一次。std::atomic 的 ++ 编译成一条不可分割的原子指令(RISC-V 上是 amoadd,x86 上是 lock xadd)。注意:在 C++ 内存模型里,数据竞争是未定义行为(UB),不只是"数字不对",编译器可以假设它不会发生,并据此做优化。
C++ 的同步工具和它们在内核里的对应:
| C++ | 做什么 | 底下 |
|---|---|---|
std::atomic<T> |
单个变量的无锁原子操作 | CPU 原子指令,不进内核 |
std::mutex |
互斥 | Linux 上是 futex:没人抢时纯用户态原子操作;抢不到才进内核睡觉(就像 xv6 进程进入 SLEEPING) |
std::condition_variable |
等条件成立 | futex 睡眠 / 唤醒,相当于 xv6 的 sleep/wakeup |
thread_local |
每个线程一份的变量 | 存在线程私有的 TLS 区(CLONE_SETTLS) |
std::jthread(C++20) |
析构时自动 join,支持 stop_token |
同上 |
C++ 里的进程:标准库没有进程 API。要用就直接调 POSIX 的 fork / execvp / waitpid / posix_spawn,跟 xv6 里写的一模一样;或者用 Boost.Process 之类的库。
多线程程序里 fork 的大坑:fork 只复制调用 fork 的那一个线程。如果别的线程当时正持有一把锁(比如 malloc 内部的锁),子进程里这把锁就永远不会被释放,子进程一调 malloc 就死锁。规则是:多线程程序 fork 之后,子进程只能调用"异步信号安全"的函数,并且应该尽快 exec。这也是 posix_spawn 存在的原因之一。
HFT 视角:
- 线程绑核(
pthread_setaffinity_np/taskset),配合isolcpus把核心留给热路径,避免被调度器挪来挪去。 - 忙轮询而不是睡眠:睡眠意味着走一遍 SLEEPING → RUNNABLE → 调度器 →
swtch,醒来时缓存也凉了。热路径宁可烧一个核也要避免上下文切换。 - 热路径不用 mutex:单生产者单消费者用无锁环形队列(atomic + 内存序)。
- 用多进程做隔离:网关、策略、风控分成不同进程,通过共享内存(
mmap)通信,兼顾隔离和速度。
7. 上升到 Python
threading.Thread 也是真正的内核线程(share.py 实测):
main: pid=378356 tid=378356
thread:
in worker: pid=378356 tid=378357 x=100 ← 同 PID、不同 TID
back in main: x=100 ← 共享内存:主线程看到了修改
process:
in worker: pid=378360 tid=378360 x=100 ← 新 PID
back in main: x=0 ← 不共享:子进程改的是自己的拷贝
start method: forkserver
GIL enabled: True
但是有 GIL(全局解释器锁):CPython 解释器同一时刻只允许一个线程执行 Python 字节码。所以 CPU 密集的任务,多线程不会变快(gil.py:数 8000 万个数):
1 thread : 2.07s
4 threads: 1.99s ← 4 个线程轮流拿 GIL,总时间几乎不变
4 procs : 0.86s ← 4 个进程 = 4 个解释器 = 4 把 GIL,真并行(含进程启动开销)
- I/O 密集任务用线程没问题:线程阻塞在
read/recv这类系统调用时会释放 GIL,别的线程可以继续跑。 - GIL 不等于线程安全:
x += 1是好几条字节码,线程可能在它们之间被切换走,所以共享数据照样要用threading.Lock。 - free-threaded 构建:3.13 起有可选的无 GIL 构建(
python3.13t),3.14 起官方支持,但默认构建仍然带 GIL(本机sys._is_gil_enabled()为True)。
multiprocessing:用进程绕开 GIL,代价是不共享内存
- 每个子进程有自己的解释器和 GIL,才能真正并行。
- 参数和返回值要 pickle 序列化后经管道传送(就是 fd 那一套),不能直接共享对象。上面
x=0就是证据。 - 三种启动方式:
fork:直接 fork 当前进程。快,但如果父进程有多个线程,就会撞上 §6 里的那个坑。spawn:启动一个全新的 Python 进程(fork + exec),重新导入主模块。forkserver:先启动一个干净的单线程服务进程,以后由它来 fork。
- Python 3.14 起 Linux 默认改成
forkserver(本机实测确认)。后果:子进程会重新导入主模块。我第一次运行share.py时没写if __name__ == "__main__":,结果子进程导入主模块时又去启动子进程,直接报RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase。所以用 multiprocessing 必须写 main guard。
其他对应:
subprocess.run([...])= fork + exec + wait,跟 xv6 的 sh、find -exec 是同一个模式。Python 的 C 层在 Linux 上会尽量用vfork这类更轻的方式来实现。os.fork()、os.execvp()、os.pipe()、os.dup2()、os.wait():就是那些系统调用本身。concurrent.futures:ThreadPoolExecutor(适合 I/O)和ProcessPoolExecutor(适合 CPU)是同一套接口。asyncio:单线程,在用户态自己切换协程(遇到await主动让出)。本质上就是 thread lab 里的"用户级线程"加一个事件循环,内核完全看不到这些切换。C++20 的协程(co_await)也是同一个思路。
8. 一张表:同一个概念在四层里的样子
| 要做的事 | xv6 | Linux 系统调用 | C++ | Python |
|---|---|---|---|---|
| 新进程(复制自己) | fork |
clone(不带共享 flag) |
fork()(POSIX) |
os.fork()、multiprocessing.Process |
| 运行另一个程序 | exec |
execve |
execvp、posix_spawn |
subprocess.run |
| 新线程 | 不支持 | clone3(CLONE_VM|CLONE_THREAD…) |
std::thread |
threading.Thread |
| 等它结束 | wait |
wait4;等线程用 futex |
waitpid;t.join() |
p.join() / os.wait();t.join() |
| 互斥 | 内核里的 spinlock / sleeplock | futex | std::mutex、std::atomic |
threading.Lock(GIL 不能代替它) |
| 用户态切换,内核不知道 | (thread lab 的 uthread) | — | C++20 协程 | asyncio |
一句话:进程 = 资源 + 执行流;线程 = 共享资源的多条执行流;Linux 用一个 clone 旋钮统一两者。C++ 把内核线程几乎原样交给你,所以你要自己负责同步;Python 在上面加了 GIL 和 pickle,所以 CPU 并行要用进程,I/O 并发用线程或 asyncio。