Hang Zhengyang

线程,以及 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"本质上就是:

  1. 把当前这组寄存器存到内存里;
  2. 把另一组寄存器从内存里装回来。

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。