1、线程模型基础:进程与线程的区别、用户态与内核态线程、1:1/N:1/M:N模型对比
各位同学,咱们今天聊聊线程模型。说实话,这玩意儿是网络编程的根基。你调优调得再花哨,底层模型选错了,全白搭。我在项目里见过太多人上来就怼几千个线程,结果系统直接跪了——嗯,这就是没搞懂线程模型该有的样子。
1.1 进程与线程:别再傻傻分不清
先问个问题:进程和线程到底差在哪?
进程,说白了就是一个程序运行的独立实体。它有自己独立的内存空间、文件句柄、环境变量。你打开一个 Chrome 浏览器,那就是一个进程。再打开一个,又是一个进程。两个进程之间,谁也碰不到谁的数据。
线程呢?它是进程里的一个执行流。同一个进程里的线程,共享进程的内存空间和资源。我习惯这么理解:进程是工厂,线程是工厂里的工人。工厂之间是隔离的,但同一个工厂里的工人可以共用工具和材料。
核心区别一句话:进程是资源分配的最小单位,线程是 CPU 调度的最小单位。
具体差异,我整理了个表格,你一看就明白:
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 内存空间 | 独立,互不干扰 | 共享进程内存 |
| 创建开销 | 大(分配独立地址空间) | 小(复用进程资源) |
| 切换成本 | 高(TLB 刷新、页表切换) | 低(上下文轻量) |
| 通信方式 | IPC(管道、消息队列、共享内存) | 直接读写共享变量 |
| 健壮性 | 一个进程挂了不影响其他进程 | 一个线程挂了可能导致整个进程崩溃 |
我在项目中遇到过一件事:有个同事用多进程做高并发服务,结果每来一个请求就 fork 一次,系统内存直接被打满。后来改成线程池,同样的机器扛住了 10 倍流量。你想想看,这就是创建开销的差距。
1.2 用户态线程 vs 内核态线程
好,接下来聊个稍微绕一点的概念——线程到底跑在哪个层级?
内核态线程,是由操作系统内核管理的线程。内核知道它的存在,负责它的创建、调度、销毁。每次线程切换,都要经过内核,涉及系统调用,成本不低。
用户态线程,是用户程序自己管理的线程。内核根本不知道它的存在。用户态线程的切换,完全在用户空间完成,不需要陷入内核。说白了,就是你自己在用户空间模拟了一套线程调度器。
我的经验:用户态线程切换极快(纳秒级),但一旦发生阻塞(比如 I/O 操作),整个进程都会被阻塞,因为内核只认进程,不认你里面的用户态线程。这是它的致命伤。
两者的对比,我习惯用这张表:
| 特性 | 用户态线程 | 内核态线程 |
|---|---|---|
| 管理方 | 用户程序(运行时库) | 操作系统内核 |
| 切换开销 | 极低(几条指令) | 较高(系统调用 + 上下文切换) |
| 阻塞影响 | 阻塞整个进程 | 只阻塞当前线程 |
| 多核利用 | 无法直接利用多核 | 天然支持多核并行 |
| 创建数量 | 可以成千上万 | 受系统资源限制(通常几千) |
我曾经在一个老项目里看到,有人用用户态线程做了个协程库,单机开了 10 万个协程,跑得飞起。但一遇到网络 I/O 阻塞,整个进程就卡死了。后来我建议换成内核线程 + 非阻塞 I/O,问题才解决。嗯,这里要注意:选型一定要看你的 I/O 模型。
1.3 三种线程模型:1:1、N:1、M:N
现在我们把用户态线程和内核态线程组合起来,就得到了三种经典的线程模型。这是今天最核心的内容,我建议你仔细看。
1:1 模型(一个用户线程对应一个内核线程)
这是 Linux 上 pthread 的默认实现。每个用户线程都绑定一个内核线程。优点是简单、稳定,一个线程阻塞不影响其他线程。缺点是线程多了,内核调度压力大。
适用场景:线程数量可控(几百以内),需要充分利用多核 CPU,对稳定性要求高。
Java 的 Thread 类,底层就是 1:1 模型。你 new 一个 Thread,操作系统就给你创建一个内核线程。我刚开始做 Java 后端时,动不动就 new 几千个线程,结果 CPU 全花在线程切换上了,业务吞吐量惨不忍睹。
N:1 模型(多个用户线程映射到一个内核线程)
所有用户线程都跑在同一个内核线程上。切换在用户态完成,速度极快。但问题也很明显:一个用户线程阻塞,所有线程都跟着阻塞。而且无法利用多核。
避坑指南:我曾经在一个嵌入式项目里用过 N:1 模型,因为硬件资源有限。结果一个网络请求超时,整个系统的所有「线程」都卡住了。从那以后,但凡涉及 I/O 操作,我绝不推荐 N:1 模型。
M:N 模型(多个用户线程映射到多个内核线程)
这是最灵活的模型。M 个用户线程可以调度到 N 个内核线程上执行(M > N)。它结合了 1:1 和 N:1 的优点:用户线程可以很多,切换快,又能利用多核,一个线程阻塞也不会拖垮全部。
但代价是实现极其复杂。你需要自己实现用户态调度器,处理负载均衡、线程迁移、阻塞唤醒等一堆问题。Go 语言的 GMP 模型就是 M:N 的典型代表。
三种模型的对比,我画了张图,你一看就懂:
三种模型的详细对比,我再用表格给你梳理一遍:
| 特性 | 1:1 模型 | N:1 模型 | M:N 模型 |
|---|---|---|---|
| 典型代表 | Linux pthread、Java Thread | 早期协程库(如 GNU Pth) | Go goroutine、Erlang 进程 |
| 并发能力 | 中等(受内核线程数限制) | 高(用户态线程可开很多) | 极高(灵活调度) |
| 多核利用 | ✅ 是 | ❌ 否 | ✅ 是 |
| 阻塞影响 | 只阻塞当前线程 | 阻塞所有线程 | 只阻塞当前用户线程 |
| 实现复杂度 | 低(内核帮你搞定) | 中(需要用户态调度器) | 高(调度器 + 负载均衡) |
| 上下文切换成本 | 高(内核态切换) | 极低(用户态切换) | 低(大部分在用户态) |
我的建议:如果你在做网络服务,优先考虑 M:N 模型或者 1:1 模型配合非阻塞 I/O。N:1 模型除非你确定没有阻塞操作,否则别碰。我在 Go 社区看到很多项目用 goroutine 处理百万级连接,这就是 M:N 模型的威力。