学习资源
- Rust 语言圣经(中文版):底层探秘: Future 执行与任务调度
- RFC 2394: async/await
- RFC 2592: futures
- Asynchronous Programming in Rust
进程、线程与协程的异同
进程、线程和协程是三种不同粒度的执行单元,它们在资源隔离、调度方式和并发模型上有本质区别。
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | 用户态程序(运行时/应用) |
| 资源拥有 | 独立地址空间(页表、文件描述符等) | 共享进程地址空间,拥有独立栈和寄存器 | 共享进程/线程地址空间,仅拥有独立栈 |
| 创建/切换开销 | 高(需切换页表、刷新 TLB) | 中(需内核介入、保存/恢复寄存器) | 极低(纯用户态,仅保存少量寄存器) |
| 栈大小 | 独立地址空间 | ~2 MB(虚拟,按需分配) | ~数 KB(初始,可动态增长) |
| 通信方式 | IPC(管道、共享内存、socket 等) | 共享内存(需同步原语) | 共享内存(通常无需锁,协作式) |
| 是否可并行 | 是(多核) | 是(多核) | 否(单线程内为协作式并发) |
| 能否利用多核 | 能 | 能 | 依赖运行时调度到多线程上 |
| 隔离性 | 强(独立地址空间,互不影响) | 中(共享地址空间,一个崩溃会影响整个进程) | 弱(共享进程/线程状态) |
核心区别
-
调度层级不同:进程和线程由操作系统内核调度(抢占式),协程由用户态程序调度(协作式)。协程主动
yield或await时让出执行权,而非被时钟中断强制切换。 -
上下文切换开销:进程切换需要切换页表、刷新 TLB,开销最大。线程切换需要内核态/用户态切换,保存/恢复寄存器,开销中等。协程切换在用户态完成,仅需保存少量寄存器和栈指针,开销可低至数十纳秒。
-
并发模型:进程是操作系统级别的资源隔离单位,适合强隔离场景。线程是 CPU 调度的基本单位,适合中等并发。协程是编程语言层面的控制流抽象,适合 I/O 密集型高并发场景。
-
栈管理:进程和线程的栈由内核管理,虚拟地址空间预留较大(线程默认 2 MB)。协程的栈由运行时管理,初始分配极小(如 Rust 的 async block 栈仅数 KB),可在堆上动态增长,极适合创建大量并发任务。
如何选择
- 进程:需要强隔离(如不同用户的程序、浏览器标签页)、独立的安全边界
- 线程:需要利用多核并行计算、CPU 密集型任务、中等等并发量
- 协程:I/O 密集型高并发(Web 服务器、爬虫、消息队列)、需要大量轻量级任务
Rust 中三种模型均可直接使用:
std::process::Command(进程)、std::thread(线程)、async/await+ Tokio(协程)。本任务中的三个爬虫程序正是这三种模型的具体实现。
实践任务
编写爬虫程序,依据高校名称和官方网站列表中的网站链接,从对应网站上下载首页的纯文本内容,保存在本地当前目录下,文件命名为学校的中文名称。
分别使用三种并发模型实现:
- 基于进程的爬虫程序(串行)
- 基于线程的爬虫程序(每请求一线程)
- 基于协程的爬虫程序(async/await)
对比三种模型的性能特征:延时分布、吞吐率和内存开销。
代码实现位于 labs/crawler/,包含三种实现的完整 Rust 源码。
三种爬虫模型的性能对比分析
测试环境
- 项目:
web crawler— Rust 爬虫,分别基于进程、线程、协程实现 - 数据集:34 所中国高校官网首页
- HTTP 客户端:
reqwest,超时 2 秒,重定向限制 5 次 - 运行时:线程模型使用
std::thread::scope,协程模型使用 Tokio +futures::join_all - 编译:
cargo build --release(Release 模式) - 操作系统:Linux x86_64
1. 总耗时(吞吐率)
| 模型 | 总耗时 | 相对比例 |
|---|---|---|
| 进程(串行) | 20~23 s | ≈ 1×(基准) |
| 线程(并行) | ~2.2 s | ≈ 10× |
| 协程(异步) | ~2.0 s | ≈ 11× |
结论:进程模型逐请求串行阻塞等待,总耗时 ≈ 所有请求延迟之和。线程和协程模型并发执行所有请求,总耗时 ≈ 最慢单个请求的延迟,吞吐率提升约一个数量级。
2. 延时分布
由于线程和协程模型并发执行、不串行等待每个请求完成,当前版本的代码无法像进程模型那样逐个测量延迟。
进程模型的延时分布(两次运行):
| 指标 | 第一次 | 第二次 | 说明 |
|---|---|---|---|
| P50 | 0.59 s | 0.65 s | 半数请求 0.6 s 左右完成 |
| P90 | 0.95 s | 1.44 s | 90% 请求在 1.4 s 内 |
| P99 | 1.32 s | 3.32 s | 尾部延迟波动较大,受网络状况影响 |
| 总耗时 | 20.23 s | 27.30 s | 累加所有延迟的结果 |
总耗时(2027 s)远大于 P99(1.33.3 s),直观地展示了串行模型的瓶颈——即使大部分请求很快,总时间也是所有延迟之和。
线程和协程模型也可增加逐个记录的延迟统计,当前版本做了简化。
3. 内存开销
| 模型 | 最大 RSS(常驻内存) | 相对比例 |
|---|---|---|
| 进程(串行) | ~7.8 MB | ≈ 1×(基准) |
| 线程(并行) | ~13.4 MB | ≈ 1.7× |
| 协程(异步) | ~13.7 MB | ≈ 1.8× |
- 进程模型内存最小:串行执行,任何时候最多只有一个 HTTP 响应驻留,无需并发调度结构。
- 线程模型内存稍大:每线程拥有独立栈空间(默认 2 MB 虚拟,常驻按需分配)。
- 协程模型与线程模型在此规模下接近:Tokio 运行时自身包含线程池和 I/O 驱动,但在本测试的 34 并发量下差异不明显。协程的栈极轻量(初始仅数 KB),在更大并发规模下优势会显著体现。
4. CPU 与上下文切换
| 模型 | User CPU | System CPU | CPU 利用率 | 自愿上下文切换 |
|---|---|---|---|---|
| 进程 | 0.13 s | 0.13 s | 1% | 3831 |
| 线程 | 0.07 s | 0.12 s | 9% | 2582 |
| 协程 | 0.05 s | 0.10 s | 8% | 1982 |
- CPU 利用率普遍很低:三个模型均为 I/O 密集型,CPU 大部分时间等待网络响应。
- 上下文切换:进程模型循环读写阻塞,每次 I/O 等待都触发调度,自愿上下文切换最多。线程和协程模型总耗时短,上下文切换反而更少。
- 协程 User CPU 最低(0.05 s),体现了异步 I/O 的最少调度开销。
5. 综合对比
| 维度 | 进程(串行) | 线程(并行) | 协程(异步) |
|---|---|---|---|
| 总耗时(34 个请求) | ~23 s | ~2.2 s | ~2.0 s |
| 吞吐率 | ≈ 1.5 req/s | ≈ 15 req/s | ≈ 17 req/s |
| 内存开销 | 7.8 MB | 13.4 MB | 13.7 MB |
| 代码复杂度 | 低(直观易懂) | 中(Arc + 闭包) | 中(async/await) |
| 实现机制 | 串行阻塞 I/O | blocking 线程池 | 非阻塞 epoll + 协程 |
| 适用场景 | 请求量小、调试 | 中等并发、通用场景 | 高并发、I/O 密集型 |
6. 关键发现
-
并发 vs 串行是最大差异:进程模型串行执行,总耗时 = 所有请求延迟之和。线程和协程并发执行,总耗时 ≈ 最慢请求的延迟。这是性能差异的根本来源。
-
线程 vs 协程在此规模下性能几乎相同:对于 34 个并发请求,操作系统线程 + blocking I/O 与 Tokio 异步 I/O 差距可忽略,内存开销也相近。
-
协程的真正优势在更大规模时显现:当并发量达到数千时,线程模型会因栈内存(每线程约 2 MB 虚拟空间)和上下文切换开销开始吃力,协程的轻量级特性(每协程仅数 KB)将展现明显的伸缩性优势。