【操作系统百科】线程模型:1:1 / N:1 / M:N 与虚拟线程
“用线程还是用协程”是工程上的经典争吵,但它其实是同一个问题的三种答案:调度归谁管?
- 1:1(内核线程):每个用户线程绑一个内核 task,调度归内核
- N:1(绿线程):所有用户线程共用一个内核 task,调度全在用户态
- M:N(混合):M 个用户线程映射到 N 个内核 task,两层调度
- “虚拟线程”(Loom、Go):形式上是 M:N 但内置运行时,让阻塞 syscall 不成为陷阱
本文讲每种模型的由来、优缺点、失败案例,以及现代运行时如何回避 M:N 的历史坑。
一、背景:为什么要线程
OS 已经有进程了,为什么还要线程?
- 共享地址空间 → 零拷贝通信
- 更小的 context switch 开销
- 自然映射到多核:一个进程利用多核需要多个控制流
- 编程模型上比”共享 mmap + 消息队列”简洁
但”共享地址空间 + 并发”也是 bug 温床——race、死锁、内存模型、cache bouncing 都在这里发生。H 子系列专门讨论同步原语。
二、四种模型一览
flowchart TB
subgraph OneToOne[1:1 内核线程]
U1[用户线程] --- K1[kernel task]
U2[用户线程] --- K2[kernel task]
U3[用户线程] --- K3[kernel task]
end
subgraph NToOne[N:1 绿线程]
G1[用户线程 g] --- R[用户态调度器]
G2[用户线程 g] --- R
G3[用户线程 g] --- R
R --- KA[kernel task]
end
subgraph MToN[M:N 混合]
M1[用户线程] --- RT[runtime 调度器]
M2[用户线程] --- RT
M3[用户线程] --- RT
RT --- KX[kernel task]
RT --- KY[kernel task]
end
subgraph Virtual[虚拟线程 / Loom / Go]
V1[goroutine / vthread] --- VRT[运行时<br/>netpoll + parkunpark]
V2[goroutine / vthread] --- VRT
V3[goroutine / vthread] --- VRT
VRT --- KP1[kernel task]
VRT --- KP2[kernel task]
end
四种模型的差别不在”图好不好看”,在阻塞 syscall 时发生什么:
- 1:1:一个线程阻塞 = 一个 task 阻塞,互不影响;内核负责调另一个 task
- N:1:一个线程阻塞 = 所有线程阻塞(灾难);历史上 N:1 全靠应用自己包装 I/O 为非阻塞才能避免
- M:N:一个线程阻塞 = 一个内核 task 阻塞;其他线程能在剩下的 task 上跑;看起来美好,但内核看不见”等的是什么”,不能帮你 migrate
- 虚拟线程:运行时捕获阻塞 syscall,把任务暂停 + 把 task 还给调度器,等 I/O 就绪再恢复
三、Linux 的 1:1 历程:LinuxThreads → NPTL
3.1 LinuxThreads(1996)
最早的 Linux pthread 实现。简单的做法:每个 pthread 就是一个 clone 出来的独立进程(共享地址空间),tid 独立,signal 需要特殊处理。
问题:
- 所有线程显示为独立进程,
ps看到几十个 “java” - signal 语义与 POSIX 不符(信号发给”进程组”但实际发给一个 task)
getpid()在线程间返回不同值- 线程取消(pthread_cancel)复杂
- 管理线程做 pid 分配,成为瓶颈
3.2 NPTL(Native POSIX Thread Library,2003)
glibc 2.3.2 引入,Ingo Molnar + Ulrich Drepper 主导。核心是:
- 内核支持
CLONE_THREAD:同一 tgid 下多个 tid,getpid()返回 tgid 一致 - futex 原语让用户态锁只在有争用时陷入内核
- TLS(Thread-Local Storage)通过 GS 段(x86)/ TPIDR_EL0(arm64)
- 没有管理线程,内核直接调度
NPTL 的 1:1 模型让 Linux 跑数万线程成为可能。Java HotSpot 在 Linux 上是 1:1,直到 Loom。
四、M:N 的败北:Solaris、FreeBSD 的教训
M:N 听起来很美:用户态调度精细控制,内核负责多核利用。Solaris(1990s)的 LWP 模型、早期 FreeBSD 的 KSE、早期 Windows NT 的 fiber 都尝试过。
它们全失败了,原因高度一致:
- 阻塞 syscall
的决策权在内核。用户态调度器不知道一次
read()会阻塞多久,也没法在阻塞时”弹回”用户态。内核只能做到”让这个 task 睡着”。 - 用户态调度器复杂度爆炸。优先级、signal
递送、
fork语义、GDB 调试都要重写。 - 性能反而不好。1:1 + 内核调度器的每一次优化(CFS、NUMA-aware balance)M:N 都要重造。
Solaris 最终在 Solaris 8 Patch 替换为”基于 LWP 的 1:1”。FreeBSD 的 KSE 在 7.0 被 libthr(1:1)取代。Windows 的 fiber 沦为 niche 特性。
结论是 2000 年代的教训:OS 级的 M:N 是死路;必须有运行时配合才可能做好。
五、Go 的 GPM:M:N 再度成功的关键
Go 的 goroutine 是 M:N 的复活:
- G(goroutine):逻辑任务单位,2KB 初始栈
- M(machine):OS 线程,默认一个 CPU 一个
- P(processor):调度上下文,GOMAXPROCS 个
flowchart LR
G1[goroutine] -.->|runnable queue| P1[P]
G2[goroutine] -.->|runnable queue| P1
G3[goroutine] -.->|runnable queue| P2[P]
P1 --- M1[kernel thread M]
P2 --- M2[kernel thread M]
M1 --> K1[(CPU)]
M2 --> K2[(CPU)]
NP[netpoller<br/>epoll/kqueue] -. 就绪 G .-> P1
NP -. 就绪 G .-> P2
syscall[阻塞 syscall] -.hand off M.-> P1
Go 为什么能成?三件事:
5.1 netpoller:所有 I/O 走非阻塞
Go runtime 把所有 socket 设为 O_NONBLOCK,包装在内置
netpoller(epoll/kqueue/IOCP)里。用户代码看
conn.Read() 是阻塞语义,但实际上:
- runtime 调
read,如果EAGAIN,park 当前 goroutine - 把 fd 加入 netpoller
- 调度其他 goroutine
- netpoller 报告就绪,唤醒 goroutine
这消除了 90% 的”阻塞 syscall 问题”。
5.2 阻塞 syscall 的 hand-off
对不能走 netpoller 的 syscall(比如
fsync、第三方 cgo 调用),Go runtime 把当前 M
和 P 解绑:
- M 保留在阻塞 syscall 上(它在 kernel 里)
- P 被转给其他 M(可能是新开的)
- 阻塞完后 M 尝试拿回 P;拿不到就把 goroutine 放队列,M 进休眠
这让”一个 goroutine 阻塞 syscall”只占一个 M,其他 goroutine 仍能跑。
5.3 goroutine 栈是小而能长的
每个 goroutine 初始 2KB 栈,栈底有 guard 页;越界时触发 growth(整块复制)。这让百万个 goroutine 的总内存成本可控(~2GB/million)。
Go 能成功的关键是 2010 年代才成熟的基础设施:epoll 普及、NPTL + futex 成熟、CPU 廉价多核、语言内生 GC。Solaris 时代这些都不齐。
六、Java 的虚拟线程:Loom
Java 21(2023 LTS)正式引入 virtual threads(JEP 444),由 Project Loom 孵化。
设计思路与 Go 类似:
- virtual thread 跑在 carrier thread(= 平台线程)上
- 阻塞操作(sleep、IO、lock)时 virtual thread 被”unmount”,carrier 去跑其他
- 恢复时 mount 到任意 carrier
关键不同:
- JVM 有自己的协议栈表示(Continuation),比 Go 的 “整栈” 更轻
- 所有 JDK 阻塞 API 都改造:I/O 走 NIO 的
async、
synchronized会 pinning(临时不 unmount)、LockSupport.park原生支持 - 和 ForkJoinPool 的 scheduler 整合
pinning 问题:如果 virtual thread 在
synchronized 块里阻塞,它不会 unmount(carrier
被占用)。Java 23+ 改进,让 synchronized 也能
unmount。用户代码应避免 synchronized 包住阻塞调用,改用
ReentrantLock。
Loom 解决了 Java 长期 “百万连接要百万线程还是要写 NIO callback” 的痛点,让”每请求一个线程” 的编程模型在高并发下可行。
七、Erlang / BEAM:N:M 的另一种
Erlang 的 process(实际是绿色进程)从 1990 年代就是 M:N:
- 每个 BEAM 调度器绑定一个内核线程
- 每个 Erlang process 有自己栈 + 独立堆(no GC pause 跨进程)
- 消息传递是唯一通信手段(邮箱)
- GC 是 per-process,微秒级
- 抢占基于 reduction counting(执行多少指令就 yield)
Erlang 成功的秘诀是没有共享内存——消息传递让调度没有 lock;每 process 独立 GC 让停顿微秒级;reduction-based preemption 让没有 syscall 的纯计算也能被抢占。
这是 Erlang 一开始就把自己定位在 telecom / 高可用领域,生态小但极致。对通用工作负载(需要大内存共享、第三方 C 库)Erlang 不合适。
八、async/await:语言级的”用户态线程”
Rust、C#、Python、JavaScript 的 async/await 不是传统意义的线程,而是编译时把函数拆成状态机:
async fn handle(conn: TcpStream) -> Result<()> {
let n = conn.read(&mut buf).await?; // await 点 = 状态机切片
conn.write(&buf[..n]).await?;
Ok(())
}编译器把每个 .await 翻译成
“暂停、记录状态、返回;恢复时从记录的状态继续”。没有独立栈,状态在堆上的
Future 对象里。
优点:
- 无 stack 开销:每个 async task 只有 state 大小的内存
- 零开销抽象(Rust 目标):关闭 runtime 后单 future 和手写状态机等价
- 类型系统保证:Rust 的
Sendbound 让编译器阻止不安全的跨线程 await
缺点:
- 函数染色(coloring):async 函数只能被 async 调用;同步世界和异步世界割裂
- trait async 难:Rust 2023 才稳定 async trait,std 仍在完善
- 调试难:调用栈在 Future 对象里,panic backtrace 不直观
- 阻塞 syscall 仍是陷阱:async runtime 不劫持 syscall,程序员要用 runtime 提供的 I/O API
tokio、async-std(Rust)、libuv(node.js)、asyncio(Python)是 async 运行时的代表。它们内部都基于 epoll/kqueue/IOCP。
九、“每个请求一个线程” vs “非阻塞 callback”
过去 20 年 Java/C# 被逼进”反应式”(Reactor、RxJava、Netty)或 CompletableFuture 的 callback hell,是因为 1:1 线程太贵。Loom、Go、Rust async 的目标都是把 callback 重新变成顺序代码,但保留非阻塞的性能。
这是个重大的编程模型回归。未来几年的应用层设计会在”用平台线程(简单)” vs “用虚拟线程/goroutine(高并发)” 之间找平衡。
十、如何选
- CPU-bound、并发 < 几千:1:1 内核线程。简单、工具完善(gdb、perf、strace)、无 runtime 额外开销
- I/O-bound、并发 >> 千:虚拟线程(Loom)、goroutine(Go)、async runtime(Rust/Python)
- 网络巨量连接 + 消息传递:Erlang / Elixir 仍是最佳
- 需要和 C 库紧密互操作:避开 M:N,走 1:1,因为 C 库会调任意 syscall
- 面向硬件的低延迟:1:1 + CPU pinning + busy wait,M:N 的调度点不可控
对 Linux 调度器(C 子系列)来说,它看到的永远是内核 task。runtime 层的 G、虚拟线程、async task 对它不可见。这就是为什么 runtime 设计要非常小心”和内核调度器合作”——过度抢占会和 CFS 冲突,不抢占又让响应恶化。
下一篇 B-11 深入 task_struct——Linux
内核里”一个 task 到底是什么”的完整解剖。
参考文献
- Drepper, U., Molnar, I. “The Native POSIX Thread Library for Linux.” 2005
- Cox, R. Go runtime notes; golang.org/src/runtime documentation
- Cesarini, F., Thompson, S. Erlang Programming, 2009
- JEP 444: Virtual Threads; JEP 491: Synchronize Virtual Threads without Pinning
- Aaron Turon. “Zero-Cost Abstractions” (Rust async design notes)
- Bovet, D., Cesati, M. Understanding the Linux Kernel, Chapter 3 “Processes”
- McKenney, P. “Is Parallel Programming Hard, And, If So, What Can You Do About It?” §5
- Pike, R. “Concurrency is not Parallelism.” talk
工具
ps -eLf/ps -eT—— 按 tid 查看线程cat /proc/<tid>/status—— per-thread 状态perf sched—— 调度事件- Go
GODEBUG=schedtrace=1000,scheddetail=1—— 调度器内部 - Java
-Djdk.tracePinnedThreads=full—— Loom pinning 检测 - Rust
tokio-console—— async 任务监控
上一篇:进程与 fork/exec 的历史包袱 下一篇:task_struct 解剖
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【操作系统百科】futex
glibc pthread_mutex 背后是 futex——用户态快路径无 syscall,竞争时才进内核。本文讲 FUTEX_WAIT/WAKE、PI futex、robust futex、futex2、requeue、安全补丁与工程陷阱。
【操作系统百科】内存回收
Linux 内存回收是 VM 最复杂的子系统之一。本文讲 active/inactive LRU、kswapd 与 direct reclaim、watermark 三线、swappiness 的真实含义、MGLRU 改造、memcg 回收与 PSI。
【操作系统百科】交换
swap 还值得开吗?本文讲 swap area 基础、swap cache、zram 压缩内存、zswap 前端压缩池、swappiness 的真实含义、容器里的 swap 策略,以及为什么现代 Android 全靠 zram 不靠磁盘。
【操作系统百科】Slab/SLUB 分配器
buddy 只管页粒度(4K+),内核大多数对象只有几十到几百字节。slab/SLUB 在 buddy 之上做对象级缓存。本文讲 slab 历史、SLUB 接手、SLOB 退场、kmem_cache、per-CPU cache、KASAN 集成。