土法炼钢兴趣小组的算法知识备份

【操作系统百科】线程模型:1:1、N:1、M:N 与虚拟线程

文章导航

分类入口
os
标签入口
#thread#nptl#futex#green-thread#m-n#goroutine#virtual-thread#async-await

目录

【操作系统百科】线程模型:1:1 / N:1 / M:N 与虚拟线程

“用线程还是用协程”是工程上的经典争吵,但它其实是同一个问题的三种答案:调度归谁管?

本文讲每种模型的由来、优缺点、失败案例,以及现代运行时如何回避 M:N 的历史坑。

一、背景:为什么要线程

OS 已经有进程了,为什么还要线程?

但”共享地址空间 + 并发”也是 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 时发生什么

三、Linux 的 1:1 历程:LinuxThreads → NPTL

3.1 LinuxThreads(1996)

最早的 Linux pthread 实现。简单的做法:每个 pthread 就是一个 clone 出来的独立进程(共享地址空间),tid 独立,signal 需要特殊处理。

问题:

3.2 NPTL(Native POSIX Thread Library,2003)

glibc 2.3.2 引入,Ingo Molnar + Ulrich Drepper 主导。核心是:

NPTL 的 1:1 模型让 Linux 跑数万线程成为可能。Java HotSpot 在 Linux 上是 1:1,直到 Loom。

四、M:N 的败北:Solaris、FreeBSD 的教训

M:N 听起来很美:用户态调度精细控制,内核负责多核利用。Solaris(1990s)的 LWP 模型、早期 FreeBSD 的 KSE、早期 Windows NT 的 fiber 都尝试过。

它们全失败了,原因高度一致:

  1. 阻塞 syscall 的决策权在内核。用户态调度器不知道一次 read() 会阻塞多久,也没法在阻塞时”弹回”用户态。内核只能做到”让这个 task 睡着”。
  2. 用户态调度器复杂度爆炸。优先级、signal 递送、fork 语义、GDB 调试都要重写。
  3. 性能反而不好。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 的复活:

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() 是阻塞语义,但实际上:

  1. runtime 调 read,如果 EAGAIN,park 当前 goroutine
  2. 把 fd 加入 netpoller
  3. 调度其他 goroutine
  4. netpoller 报告就绪,唤醒 goroutine

这消除了 90% 的”阻塞 syscall 问题”。

5.2 阻塞 syscall 的 hand-off

对不能走 netpoller 的 syscall(比如 fsync、第三方 cgo 调用),Go runtime 把当前 M 和 P 解绑:

这让”一个 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 类似:

关键不同:

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:

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 对象里。

优点:

缺点:

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(高并发)” 之间找平衡。

十、如何选

  1. CPU-bound、并发 < 几千:1:1 内核线程。简单、工具完善(gdb、perf、strace)、无 runtime 额外开销
  2. I/O-bound、并发 >> 千:虚拟线程(Loom)、goroutine(Go)、async runtime(Rust/Python)
  3. 网络巨量连接 + 消息传递:Erlang / Elixir 仍是最佳
  4. 需要和 C 库紧密互操作:避开 M:N,走 1:1,因为 C 库会调任意 syscall
  5. 面向硬件的低延迟:1:1 + CPU pinning + busy wait,M:N 的调度点不可控

对 Linux 调度器(C 子系列)来说,它看到的永远是内核 task。runtime 层的 G、虚拟线程、async task 对它不可见。这就是为什么 runtime 设计要非常小心”和内核调度器合作”——过度抢占会和 CFS 冲突,不抢占又让响应恶化。

下一篇 B-11 深入 task_struct——Linux 内核里”一个 task 到底是什么”的完整解剖。


参考文献

工具


上一篇进程与 fork/exec 的历史包袱 下一篇task_struct 解剖

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-31 · os

【操作系统百科】futex

glibc pthread_mutex 背后是 futex——用户态快路径无 syscall,竞争时才进内核。本文讲 FUTEX_WAIT/WAKE、PI futex、robust futex、futex2、requeue、安全补丁与工程陷阱。

2026-04-27 · os

【操作系统百科】内存回收

Linux 内存回收是 VM 最复杂的子系统之一。本文讲 active/inactive LRU、kswapd 与 direct reclaim、watermark 三线、swappiness 的真实含义、MGLRU 改造、memcg 回收与 PSI。

2026-04-28 · os

【操作系统百科】交换

swap 还值得开吗?本文讲 swap area 基础、swap cache、zram 压缩内存、zswap 前端压缩池、swappiness 的真实含义、容器里的 swap 策略,以及为什么现代 Android 全靠 zram 不靠磁盘。

2026-05-03 · os

【操作系统百科】Slab/SLUB 分配器

buddy 只管页粒度(4K+),内核大多数对象只有几十到几百字节。slab/SLUB 在 buddy 之上做对象级缓存。本文讲 slab 历史、SLUB 接手、SLOB 退场、kmem_cache、per-CPU cache、KASAN 集成。


By .