编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

  • 程序编程原理

    • README
    • 序卷方法论

    • 数据的本质

    • 运行时模型

    • 并发的设计

      • README
      • 1.线程前世今生探索
      • 2.并发上下文切换原理
      • 3.线程通信设计思想
      • 4.线程异常设计原理
      • 5.多线程并发经典案例
      • 6.并发Bug源头由来
      • 7.并发编程设计思想
      • 8.并发编程安全设计
      • 9.锁核心设计和思想
      • 10.理解CAS设计由来
      • 11.异步和同步的设计
      • 12.单线程模型的思想
      • 13.协程核心设计思想
        • 00.真实事故引入
          • 0.1 一次线程池打满CPU闲置雪崩
          • 0.2 一段Java 21看似未改的革命
          • 0.3 灵魂三问
          • 0.4 本篇的探索路径
          • 0.5 为什么这个问题值得讲透
        • 01.为什么需要协程:线程与回调的困境
          • 1.1 线程模型的三大天花板
          • 1.2 异步回调的"反人类"本质
          • 1.3 协程要解决的根本矛盾
          • 1.4 协程史前史:60年的等待
        • 02.协程的本质:可挂起的函数
          • 2.1 从"子程序"到"协程"的飞跃
          • 2.2 挂起与恢复的机器视角
          • 2.3 与线程、回调的关键区别
          • 2.4 协程是语法糖还是机器特性
        • 03.两大流派:有栈 vs 无栈
          • 3.1 有栈协程Stackful:Goroutine设计
          • 3.2 无栈协程Stackless:async/await
          • 3.3 两派对比
          • 3.4 为什么现代语言都转向无栈
        • 04.协程实现三件套:挂起点/状态机/调度器
          • 4.1 挂起点(Suspend Point)
          • 4.2 状态机:CPS 变换
          • 4.3 调度器:谁决定何时恢复
        • 05.跨语言对比与设计哲学
          • 5.1 Kotlin协程:状态机+Continuation
          • 5.2 Go goroutine:有栈协程+GMP
          • 5.3 Rust async/await:零成本抽象
          • 5.4 JavaScript:Generator到async/await
          • 5.5 Python asyncio
          • 5.6 Java虚拟线程Project Loom
          • 5.7 跨语言横评
        • 06.协程的经典陷阱
          • 6.1 陷阱一:async函数写阻塞调用
          • 6.2 陷阱二:忘await让协程消失
          • 6.3 陷阱三:协程泄漏
          • 6.4 陷阱四:上下文丢失ThreadLocal失效
          • 6.5 陷阱五:细粒度切换反而慢
          • 6.6 陷阱六:异常吞没
          • 6.7 陷阱七:CPU密集任务错用Dispatcher
        • 07.共性抽象与未来演进
          • 7.1 去掉语法糖后的共同骨架
          • 7.2 协程的面向对象时刻结构化并发
          • 7.3 历史的回望:50 年的循环
          • 7.4 未来:协程会怎么演进
        • 08.一句话总结
          • 8.1 三层认知阶梯
          • 8.2 协程选型决策树
          • 8.3 七字真言
          • 8.4 与下篇的承接
        • 🔗 延伸阅读
      • 14.Actor与CSP并发模型
      • 15.线程池的设计思想
      • 16.线程池设计核心原理
      • 17.线程池使用技巧
      • 18.结构化并发设计思想
    • 内存的真相

    • 交互和系统

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 程序编程原理
  • 并发的设计
杨充
2026-04-11
目录

13.协程核心设计思想

# 3.13 协程核心设计思想

📍 本篇位置:第 3 卷 · 并发之道 · 第 13 篇(范式篇核心) 🎯 核心矛盾:线程太重(万级就崩)vs 回调太反人类(嵌套到地狱)—— 50 年来工程界一直在两个极端之间摇摆,能不能既要线程的"看起来同步",又要事件驱动的"百万并发"? 🧭 设计灵魂:协程不是"轻量级线程"——它是对"函数只能从头到尾执行"这条铁律的根本性打破。函数能在中间暂停、把状态存起来、之后再恢复——这个能力一旦获得,整个并发世界为之改写 🌐 跨语言覆盖:Go goroutine(有栈)· Kotlin suspend(无栈)· Rust async fn(无栈+Future)· Python asyncio · JavaScript generator/async · C# Task(鼻祖)· Java Virtual Thread(Project Loom) 🔗 延伸阅读:← 3.11 异步和同步的设计 · ← 3.12 单线程模型的思想 · → 3.14 Actor 与 CSP 并发模型 · → 3.18 结构化并发设计思想 · → 2.4 函数调用栈与栈帧设计


上一篇我们看到了"单线程模型 + 事件循环"如何应对高并发——但写出来的代码满是 then().then().then()、Promise 套 Promise,异步逻辑被打散成无数回调。本篇要回答一个工程师追问 50 年的问题:能不能既保留事件驱动的高并发能力,又让代码"看起来像同步的样子"?

答案是——协程。本篇从一个"线程池打满"的真实事故切入,揭开协程的本质:一个能在中间暂停、保存状态、之后恢复的函数。

# 目录介绍

  • 00.真实事故引入
  • 01.为什么需要协程:线程与回调的困境
  • 02.协程的本质:可挂起的函数
  • 03.两大流派:有栈 vs 无栈
  • 04.协程实现三件套:挂起点/状态机/调度器
  • 05.跨语言对比与设计哲学
  • 06.协程的经典陷阱
  • 07.共性抽象与未来演进
  • 08.一句话总结

# 00.真实事故引入

# 0.1 一次线程池打满CPU闲置雪崩

我维护过一个 Spring Boot 微服务,处理订单查询。架构很经典:

HTTP 请求 → Tomcat 线程池(200) → 调用下游 5 个微服务(HTTP)→ 聚合返回
1

某次大促当晚 22:00,监控告警:

22:00:00  QPS 1000,正常
22:05:00  QPS 上升到 3000,正常
22:10:00  P99 延迟从 200ms 飙到 5 秒
22:11:00  线程池满,新请求被拒绝
22:12:00  服务"假死"——CPU 使用率只有 15%!
22:15:00  上游网关熔断,业务受损
1
2
3
4
5
6

最诡异的现象——线程池满了,CPU 却闲着。这违反直觉:通常线程池满意味着 CPU 忙不过来。

排查发现,Tomcat 200 个线程全部阻塞在下游 HTTP 调用的 read() 上:

@RestController
public class OrderController {
    @GetMapping("/order/{id}")
    public OrderDetail getOrder(@PathVariable String id) {
        OrderInfo info = orderService.get(id);            // 100ms
        UserInfo user = userService.get(info.userId);     // 100ms
        ProductInfo product = productService.get(...);    // 100ms
        AddressInfo addr = addressService.get(...);       // 100ms
        ShippingInfo ship = shippingService.get(...);     // 100ms
        return aggregate(info, user, product, addr, ship);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

每个请求要顺序调用 5 个下游 → 总耗时 500ms。这 500ms 里:

CPU 实际工作时间:~5ms(解析、序列化、聚合)
网络等待时间:    ~495ms(5 × 99ms 的 socket read 阻塞)

→ Tomcat 线程 99% 时间在干等 IO
→ 200 个线程同时干等 → 物理 CPU 完全闲置
→ 但新请求来了没线程接(线程池满)→ 雪崩
1
2
3
4
5
6

根因:阻塞 IO + 线程模型 = 资源使用极度浪费。每个连接占一个线程,但 99% 时间这个线程在睡觉。

修复方案:改用 WebFlux + Reactor。但写出来的代码长这样:

return orderService.get(id)
    .flatMap(info -> userService.get(info.userId)
        .flatMap(user -> productService.get(...)
            .flatMap(product -> addressService.get(...)
                .flatMap(addr -> shippingService.get(...)
                    .map(ship -> aggregate(info, user, product, addr, ship))))));
1
2
3
4
5
6

性能上去了——同样 200 线程能扛 50 倍流量。但代码可读性崩塌——5 层嵌套,调试困难,异常处理混乱。

这就是 50 年来并发界的死循环:

线程模型:代码好读,但扩展性差(万级就崩)
事件驱动:扩展性强(百万),但代码难读(回调地狱)
1
2

直到协程出现——它说"我两个都要":

// Kotlin 协程:看起来同步,本质异步
suspend fun getOrder(id: String): OrderDetail {
    val info = orderService.get(id)
    val user = userService.get(info.userId)
    val product = productService.get(...)
    val addr = addressService.get(...)
    val ship = shippingService.get(...)
    return aggregate(info, user, product, addr, ship)
}
1
2
3
4
5
6
7
8
9

这段代码看起来和原始阻塞代码一模一样——但每个 await 不阻塞线程,单线程能扛百万并发。这就是协程的魔力。

# 0.2 一段Java 21看似未改的革命

2023 年 Java 21 发布的最重磅特性是虚拟线程(Virtual Thread / Project Loom):

// 老代码(线程模型)
ExecutorService executor = Executors.newFixedThreadPool(200);
executor.submit(() -> handleRequest(req));

// 新代码(虚拟线程)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> handleRequest(req));    // 看起来一模一样!
1
2
3
4
5
6
7

只改了一个 API——但同样的硬件,从扛 200 并发变成扛 100 万并发。

这怎么做到的? 答案就是协程——只是 Java 把它"伪装"成线程。

# 0.3 灵魂三问

这两个真实场景让我反复追问三个问题:

  1. 协程到底是个什么东西?为什么"它说能解决一切"——既要同步代码,又要百万并发?这不是矛盾的吗? —— 协程的物理本质是什么?
  2. 协程是 1958 年 Conway 发明的——为什么直到 2017 年(Kotlin)/ 2023 年(Java)才大爆发?中间这 60 年发生了什么? —— 是哪些条件改变了?
  3. Go 选择"有栈协程"(goroutine),Kotlin/Rust/Python 选择"无栈协程"(async/await)—— 这两种选择哲学上有什么根本差别? —— 它们各自适合什么场景?

如果你能回答这三个问题,你就理解了为什么 2020 年代被称为"协程的时代"。

# 0.4 本篇的探索路径

flowchart LR
    A[问题: 线程 vs 回调] --> B[协程的本质<br/>可挂起的函数]
    B --> C[两大流派]
    C --> C1[有栈协程<br/>Go]
    C --> C2[无栈协程<br/>Kotlin/Rust/JS]
    C1 --> D[实现三件套]
    C2 --> D
    D --> E[挂起点]
    D --> F[状态机]
    D --> G[调度器]
    
    style B fill:#cfe2ff
    style C1 fill:#fff3cd
    style C2 fill:#d4edda
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 0.5 为什么这个问题值得讲透

我想抛三个几乎所有协程使用者都答不全的问题:

  1. async 函数返回的不是结果,而是"对结果的承诺"——为什么编译器要这么做? —— 因为函数的"中间状态"必须能被存下来,承诺对象就是这个状态机的"句柄"。
  2. 协程切换的成本是 ~50ns,线程切换是 ~5μs,差距 100 倍——这 100 倍从哪来? —— 因为协程切换全在用户态完成,不进内核、不刷 TLB、不涉及虚拟内存切换。
  3. 为什么 Go 的协程 ~2KB 栈而 Java Loom 的虚拟线程也号称"轻量"——它们的"轻"凭什么不一样? —— Go 用栈复制,Loom 用栈挂起到堆,机制完全不同。

读完本章你会懂:协程不是"轻量级线程"——是函数能力的根本扩展。它把"控制流"从硬件中抽出来,让程序员第一次能精确地设计"暂停"和"恢复"。


# 01.为什么需要协程:线程与回调的困境

# 1.1 线程模型的三大天花板

§0.1 那个事故就是这个问题的真实写照。线程有三个绕不开的硬伤:

瓶颈 数字 后果
内存开销 默认栈 1-8 MB(Linux 8MB) 1 万线程 = 80 GB 栈 → 物理内存爆
切换成本 内核线程切换 ~3-10 μs C10K 时 CPU 全花在切换上
阻塞浪费 一次 IO 阻塞 = 一个线程睡觉 1 万连接要 1 万线程常驻
flowchart LR
    A[1 万并发连接] --> B[1 万 OS 线程]
    B --> C[80 GB 栈内存]
    B --> D[频繁上下文切换]
    B --> E[大量线程阻塞 IO]
    C --> F[OOM]
    D --> G[CPU 全在切换]
    E --> H[吞吐上不去]
    
    style F fill:#f8d7da
    style G fill:#f8d7da
    style H fill:#f8d7da
1
2
3
4
5
6
7
8
9
10
11
12

这就是著名的 C10K 问题——线程模型在这个规模上被物理限制卡死。

根本症结:线程是 OS 资源——OS 不知道我们想干什么,只能假设"线程很贵",做最保守的资源分配。一旦应用确实只是想"等待 IO",那 8MB 栈和昂贵的内核切换都是浪费。

# 1.2 异步回调的"反人类"本质

为了解决线程瓶颈,工程界发明了事件驱动 + 回调:

// 早期 Node.js:回调地狱
fs.readFile('a.txt', (err, dataA) => {
    if (err) return handleError(err);
    fs.readFile('b.txt', (err, dataB) => {
        if (err) return handleError(err);
        process(dataA, dataB, (err, result) => {
            if (err) return handleError(err);
            db.save(result, (err) => {
                if (err) return handleError(err);
                console.log("done");
            });
        });
    });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14

痛点:

1. 横向缩进——每嵌套一层就右移一级
2. 错误处理散落——每层 if (err) return
3. 控制流碎裂——本来是顺序的逻辑被切成 N 个回调
4. 调试困难——栈追踪丢失(因为已经跨函数了)
5. 共享变量——闭包捕获带来生命周期管理噩梦
1
2
3
4
5

Promise 缓解了横向缩进,但链式调用本质还是回调:

fs.readFile('a.txt')
    .then(dataA => fs.readFile('b.txt')
        .then(dataB => process(dataA, dataB)))
    .then(result => db.save(result))
    .catch(handleError);
1
2
3
4
5

仍然是"控制流碎裂"——本来一行的逻辑变成了 then 链。

# 1.3 协程要解决的根本矛盾

flowchart LR
    A[线程模型] -->|致命伤| A1[资源昂贵<br/>万级即崩]
    B[回调模型] -->|致命伤| B1[控制流碎裂<br/>难写难读]
    
    A1 --> C{能不能两全?}
    B1 --> C
    
    C --> D[协程<br/>看似同步,实为异步]
    
    D --> D1[百万并发<br/>用户态切换]
    D --> D2[同步代码<br/>编译器变换]
    
    style C fill:#fff3cd
    style D fill:#d4edda
1
2
3
4
5
6
7
8
9
10
11
12
13
14

协程的核心承诺:

承诺 实现机制
代码看起来同步 在挂起点用 await/suspend 标记,避开回调
支持百万并发 不占 OS 线程,用户态调度
自动 IO 复用 挂起时让出线程,IO 就绪后再恢复
可结构化错误处理 try/catch 跨 await 自然工作

这是一个看起来"既要又要还要"的需求——但协程做到了。

# 1.4 协程史前史:60年的等待

§0.5 第二题。协程的概念其实非常古老:

1958 - Melvin Conway 发明 coroutine 概念,用于编译器构造
1963 - Simula 67 第一次实现协程
1975 - Donald Knuth 在《编程艺术》第 1 卷讨论协程
1980 - C 中模拟协程的 setjmp/longjmp 技术
1990 - Lua 1.0 提供协程
2007 - Python 用 yield 提供有限协程能力
2009 - Go goroutine 发布 ★
2012 - C# 5.0 引入 async/await ★
2015 - JavaScript ES7 提议 async/await
2017 - Kotlin 1.1 协程 GA ★
2018 - Python 3.7 asyncio 稳定
2019 - Rust 1.39 async/await 稳定
2023 - Java 21 虚拟线程 GA ★
1
2
3
4
5
6
7
8
9
10
11
12
13

为什么 1958-2010 这 50 年没爆发?

1. 单核时代:线程开销可接受(万级并发场景少)
2. 没有标准化语法:每种语言的协程 API 都不同
3. IDE/调试器支持差:协程的栈追踪难以实现
4. 生态不齐:缺少协程友好的库(数据库、HTTP)

2009 年后改变:
1. 多核+云时代:百万并发成为常态
2. async/await 语法被 C# 标准化、被各大语言抄走
3. 编译器/调试器工具链成熟
4. Reactor / RxJava 之类的"前协程"框架积累生态
1
2
3
4
5
6
7
8
9
10

协程是"概念早熟、生态晚熟"的典型——和 Actor 模型一样。


# 02.协程的本质:可挂起的函数

# 2.1 从"子程序"到"协程"的飞跃

经典函数(子程序)的执行模型:

调用方 → 进入函数 → 执行完所有代码 → 返回结果 → 调用方继续
1

它的特征:

1. 单一入口:从函数开头进入
2. 单一出口:return 后函数完全结束
3. 完整执行:除非异常,必须从头跑到尾
4. 完全清栈:返回时栈帧被销毁,所有局部变量没了
1
2
3
4

协程则打破了这个模型:

调用方 → 进入协程 → 执行到 yield/await → 暂停 → 把状态存起来 → 让出 CPU
   ↓
其他事情发生
   ↓
调用方/调度器 → 调用 resume → 协程从挂起点继续 → 直到下一个 yield 或完成
1
2
3
4
5

两个核心新增能力:

  1. 多入口:可以从挂起点继续执行,不只能从函数开头进入
  2. 不完整执行:可以暂停在中间,把执行状态保存下来
flowchart LR
    subgraph SUB["子程序(Subroutine)"]
        S1[入口] --> S2[执行] --> S3[return]
    end
    
    subgraph CORO["协程(Coroutine)"]
        C1[首次进入] --> C2[执行到 yield]
        C2 --> C3[暂停]
        C3 -.resume.-> C4[继续]
        C4 --> C5[再次 yield 或 return]
    end
    
    style C3 fill:#fff3cd
    style C4 fill:#d4edda
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 2.2 挂起与恢复的机器视角

核心问题:函数的"执行状态"包括什么?怎么把它"存起来"?

一个执行中的函数,它的"状态"由 4 部分组成:

1. PC(程序计数器):当前执行到哪一行
2. 局部变量:函数内部所有 var 的值
3. 调用栈:上层函数的调用关系
4. 寄存器:CPU 正在用的临时值
1
2
3
4

普通线程切换:CPU 把 1-4 全部保存到内核栈,加载新线程的状态。这是内核态操作,开销 ~5μs。

协程切换:把 1-4 保存到用户态的内存对象里,跳转到另一个协程。全程用户态,无系统调用,开销 ~50ns——比线程快 100 倍。

§0.5 第二题的答案:协程切换的"100 倍速度优势"来自三处:

1. 不进内核:避免用户态↔内核态切换的特权级转换(~1μs)
2. 不刷 TLB:协程间地址空间一样,TLB 不用清空(~1μs)
3. 不调度公平性:协程切换不需要更新 OS 调度器统计(~2μs)

→ 总差距 ~5μs,正是线程切换的全部成本
1
2
3
4
5

# 2.3 与线程、回调的关键区别

flowchart TB
    subgraph THREAD["线程模型"]
        T1[阻塞 read]
        T2[OS 内核切换<br/>5μs]
        T3[另一个线程跑]
    end
    
    subgraph CALLBACK["回调模型"]
        CB1[发起 read]
        CB2[注册回调]
        CB3[handler<br/>函数A 已结束]
    end
    
    subgraph CORO["协程模型"]
        CR1[await read]
        CR2[挂起<br/>用户态保存状态<br/>50ns]
        CR3[同一函数<br/>恢复执行]
    end
    
    style T2 fill:#f8d7da
    style CB3 fill:#fff3cd
    style CR2 fill:#d4edda
    style CR3 fill:#d4edda
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
维度 线程 回调 协程
代码形态 同步顺序 嵌套回调 同步顺序
切换开销 ~5μs(内核) ~0(栈帧已销毁) ~50ns(用户态)
栈占用 1-8 MB 0 2KB-几十KB
错误处理 try/catch 每层 if(err) try/catch
调试 完整栈 栈丢失 完整栈
百万并发 ❌ ✅ ✅

协程拿到了线程的可读性 + 回调的可扩展性——这就是它的革命性。

# 2.4 协程是语法糖还是机器特性

很多人把协程当作"async/await 语法糖"——这是误解。协程本质上要求语言/编译器/运行时三方协作:

语法层:    async/await 标记挂起点
编译器层:  把函数变换成"状态机"
运行时层:  调度器决定何时恢复
1
2
3

没有这三层协作,协程根本不可能存在——这就是为什么 C 没有协程(C 标准不支持),Java 必须等到 21 才有(JVM 改造了 5 年)。


# 03.两大流派:有栈 vs 无栈

§0.5 第三题。这是协程世界最重要的分野。

# 3.1 有栈协程Stackful:Goroutine设计

特征:每个协程拥有完整的、独立的栈,和线程的栈一模一样——只不过这个栈是用户态分配的,不是 OS 分配。

goroutine 1 的栈(2KB 起,按需扩展):
  func main → func handle → func read → func parse
  
goroutine 2 的栈(独立):
  func main → func compute → func encrypt
  
切换时:保存 SP/PC/寄存器 到 goroutine 结构体,加载另一个的
1
2
3
4
5
6
7

优点:

1. 任何函数都能挂起——不需要标记,不需要语法
2. 调用 C 函数也能挂起(Go 通过 cgo 桥接)
3. 学习曲线低:和写线程代码一模一样
1
2
3

缺点:

1. 每个协程要预留栈空间(Go 默认 2KB,按需增长)
2. 栈大小难以静态确定
3. 调度器和运行时复杂
1
2
3

Go 的天才设计——连续栈复制(Continuous Stack Copy):

初始栈:2 KB
栈不够时:
  1. 分配新的 4KB 栈
  2. 把旧栈所有内容 memcpy 到新栈
  3. 修正所有指向栈的指针(这是难点!)
  4. 释放旧栈
→ 始终保持单段连续栈
1
2
3
4
5
6
7

为什么 Go 能做到,C 做不到?(详见 2.4 函数调用栈与栈帧设计)

栈复制需要修正指针:
  局部变量 x 在旧栈地址 0x1000
  栈复制后 x 在新栈地址 0x5000
  所有指向 x 的指针(&x)都要改

Go:编译器知道每个栈帧的指针布局(GC 元信息)→ 可以精确修正
C:编译器不跟踪指针布局 → 无法做到
1
2
3
4
5
6
7

这是 Go 能轻松开 100 万 goroutine 的关键:

2 KB × 100 万 = 2 GB(可接受)
Java 线程:1 MB × 100 万 = 1 TB(不可能)
1
2

# 3.2 无栈协程Stackless:async/await

特征:协程没有独立的栈——它的"状态"被编译器变换成一个堆上对象(state machine)。

suspend fun fetchUser(id: Int): User {
    val rawData = api.get(id)        // 挂起点 1
    val parsed = parse(rawData)
    val enriched = api.enrich(parsed) // 挂起点 2
    return enriched
}
1
2
3
4
5
6

编译器把这个函数变换成一个状态机类:

class FetchUserCont(
    val cont: Continuation<User>,
    val id: Int
) : Continuation<Any?> {
    var state = 0
    var rawData: String? = null
    var parsed: User? = null
    
    override fun resumeWith(result: Result<Any?>) {
        when (state) {
            0 -> {
                state = 1
                api.getAsync(id, this)   // 异步调用,注册自己为回调
                return                   // ★ 函数返回,但状态保留在 this
            }
            1 -> {
                rawData = result as String
                parsed = parse(rawData!!)
                state = 2
                api.enrichAsync(parsed!!, this)
                return
            }
            2 -> {
                cont.resumeWith(Result.success(result as User))
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

协程的"挂起点"被翻译成"return + 状态保存"——下次 resume 时重新进入函数,根据 state 跳到正确位置继续。

这就是 async/await 的物理本质——编译器把"看起来同步"的代码自动变换成"等价的回调链"。

优点:

1. 无栈占用:状态机对象通常只有几十字节
2. 单机能开千万级协程
3. 编译期可见——挂起点在源码里就是 await
1
2
3

缺点:

1. "颜色"问题:suspend/async 函数会"传染"
   只有 suspend 函数能调用 suspend 函数
2. 没有挂起点的代码不能被中断
3. 不能从普通同步函数挂起
1
2
3
4

# 3.3 两派对比

维度 有栈协程(Go) 无栈协程(Kotlin/Rust/JS)
栈 独立栈(2KB+) 没有,状态在堆上对象
挂起标记 隐式(任何位置) 显式(await/suspend)
传染性 无 有(颜色问题)
单机规模 100 万级 1000 万级
调度器复杂度 高(要管理栈) 低
学习曲线 极低 中等
C 互操作 困难(栈不兼容) 容易(无栈,普通函数)
代表语言 Go, Lua, Erlang Kotlin, Rust, JS, C#, Python

# 3.4 为什么现代语言都转向无栈

§0.5 第三题。2010 年后新设计的语言协程几乎全是无栈:

有栈派:Go (2009), Lua, Erlang, Java Virtual Thread (2023)
无栈派:C# (2012), Kotlin (2017), Rust (2019), JavaScript, Python, Swift
1
2

为什么无栈成主流?

理由 1:内存占用

Go goroutine 平均 4-8 KB
Kotlin coroutine 平均 100-500 字节
→ Kotlin 单机能开千万级,Go 百万级
1
2
3

理由 2:编译器优化

无栈协程是普通对象 + 状态机
JIT/AOT 能完整内联、逃逸分析、特化
有栈协程的栈是独立内存 → 编译器分析受限
1
2
3

理由 3:跨语言互操作

JavaScript 协程 = Promise(普通对象)→ 完美兼容已有 JS 代码
Rust 协程 = Future(trait)→ 完美适配 Rust 类型系统

Go 的有栈 goroutine 调用 C 必须切换栈 → cgo 调用慢 100×
1
2
3
4

理由 4:精确控制

async/await 让程序员明确看到"哪里会挂起"
goroutine 任何函数都可能挂起 → 不可见
1
2

Java 21 的虚拟线程是个例外——它选择有栈,但用了一个聪明的折中:

虚拟线程的栈在挂起时被"复制到堆",恢复时再"复制回栈"
→ 平时不占栈空间(只在运行的虚拟线程占用 carrier 线程的栈)
→ 兼容老 Java 代码(不需要 async 关键字)
→ 代价:挂起/恢复比 Kotlin 协程慢一些(栈拷贝开销)
1
2
3
4

这是 Java 为了兼容性做出的工程妥协——不引入新语法,让百万旧 Java 代码自动获得协程能力。


# 04.协程实现三件套:挂起点/状态机/调度器

# 4.1 挂起点(Suspend Point)

挂起点是协程的"暂停按钮"——在这个位置,协程把自己的状态存起来,让出 CPU。

显式挂起点(Kotlin/Rust/JS):

suspend fun fetch() {
    val a = api.get1()    // ★ 挂起点
    val b = api.get2()    // ★ 挂起点
    return a + b
}
1
2
3
4
5

隐式挂起点(Go):任何 channel 操作、网络 IO、time.Sleep 都可能挂起。

挂起点的设计哲学差异:

显式(async/await):
  优势:程序员明确知道"哪里会挂起"
  劣势:必须写 await,颜色传染

隐式(goroutine):
  优势:代码看起来纯同步
  劣势:不知道哪里会切换上下文,调试更难
1
2
3
4
5
6
7

# 4.2 状态机:CPS 变换

CPS = Continuation Passing Style(续延传递风格)——把"剩下的代码"当作参数传给异步操作。

// 源代码
suspend fun process() {
    val a = await readA()
    val b = await readB()
    println(a + b)
}

// 编译器变换后(伪代码)
fun process(cont: Continuation) {
    var state = 0
    var a: Int = 0
    
    fun resume(result: Any?) {
        when (state) {
            0 -> {
                state = 1
                readA(::resume)   // ← 把"剩下的代码"作为回调
                return
            }
            1 -> {
                a = result as Int
                state = 2
                readB(::resume)
                return
            }
            2 -> {
                val b = result as Int
                println(a + b)
                cont.resume(Unit)
            }
        }
    }
    
    resume(null)   // 启动状态机
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

关键洞察:编译器把"看起来线性的代码"变换成"状态机 + 多次进入"。

状态机的存储:

Kotlin: 状态机对象(继承 Continuation)—— 几十字节
Rust:   Future trait 的实现 —— 编译期已知大小
JS:     Promise 对象 —— V8 内部优化
1
2
3

# 4.3 调度器:谁决定何时恢复

挂起后,协程靠调度器决定何时恢复。

Go 的 GMP 调度器:

G (Goroutine):协程
M (Machine):OS 线程
P (Processor):逻辑处理器(持有可运行 G 队列)

调度策略:
  M 始终绑定一个 P,从 P 的队列取 G 执行
  G 调用阻塞操作 → 把 P 转给另一个 M(避免线程被锁住)
  P 队列空 → 从其他 P 偷一半(work-stealing)
  G 数量多于 M:多对一调度
1
2
3
4
5
6
7
8
9

Kotlin 协程的 Dispatcher:

launch(Dispatchers.Default) { ... }    // CPU 密集,N 核 N 线程
launch(Dispatchers.IO) { ... }         // IO 密集,弹性扩到 64 线程
launch(Dispatchers.Main) { ... }       // UI 主线程
1
2
3

Rust tokio 的 multi-threaded runtime:

#[tokio::main]
async fn main() {
    // 默认创建 N 个 worker 线程,每个跑事件循环
    // 协程在 worker 之间 work-stealing
}
1
2
3
4
5

调度器的核心职责:

1. 多协程到少量线程的多对一映射(M:N)
2. 阻塞协程时让出 CPU 给其他协程
3. IO 完成时唤醒等待的协程
4. work-stealing 避免线程负载不均
1
2
3
4

# 05.跨语言对比与设计哲学

# 5.1 Kotlin协程:状态机+Continuation

suspend fun fetchUser(id: Int): User = withContext(Dispatchers.IO) {
    val raw = api.get(id)
    parseUser(raw)
}
1
2
3
4

实现要点:

suspend 关键字 → 编译器为这个函数生成 Continuation 参数
每个挂起点 → state 变量 + 状态机分支
withContext → 切换 Dispatcher
1
2
3

Kotlin 的优势:和 Java 完全互操作、Android 生态原生支持、Flow 提供完整响应式流。

# 5.2 Go goroutine:有栈协程+GMP

func fetchUser(id int) User {
    raw := api.Get(id)         // 看似同步,实际可能挂起
    return parseUser(raw)
}

go fetchUser(123)              // 启动协程,立即返回
1
2
3
4
5
6

Go 的优势:

1. 没有 async 标记 → 没有颜色问题
2. 任何函数都能阻塞,调度器自动处理
3. select + channel 是 CSP 模型的完美实现
1
2
3

Go 的劣势:

1. cgo 调用慢(栈不兼容)
2. 单协程内存比无栈多 10×
3. 抢占式调度直到 Go 1.14 才支持
1
2
3

# 5.3 Rust async/await:零成本抽象

async fn fetch_user(id: u32) -> User {
    let raw = api.get(id).await;
    parse_user(raw)
}
1
2
3
4

Rust 的特殊:没有内置的协程运行时——async fn 只是返回一个 Future,需要外部 runtime(tokio / async-std)来调度。

Future trait:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
1
2
3
4

poll 是核心——runtime 反复调用 poll:返回 Pending 表示还在等,返回 Ready(value) 表示完成。

Rust 的优势:

1. 零运行时开销(编译期状态机生成)
2. 无 GC,无垃圾回收暂停
3. 类型系统保证内存安全(借用检查器跨 await)
1
2
3

Rust 的痛点:Pin 概念极其复杂,初学者望而生畏。

# 5.4 JavaScript:Generator到async/await

JavaScript 协程的进化路径很有教育意义:

// 第一代:回调
fs.readFile('a.txt', (err, data) => { ... });

// 第二代:Promise(链式)
fs.readFile('a.txt').then(data => process(data));

// 第三代:Generator + co
function* fetch() {
    const data = yield fs.readFile('a.txt');
    return process(data);
}
co(fetch());   // 第三方库 co 把 generator 变成自动恢复的协程

// 第四代:async/await(标准化)
async function fetch() {
    const data = await fs.readFile('a.txt');
    return process(data);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

JavaScript 的 async 函数本质上就是 generator + automatic runner——编译器自动加 co 的功能。

# 5.5 Python asyncio

import asyncio

async def fetch_user(id):
    raw = await api.get(id)
    return parse_user(raw)

async def main():
    user = await fetch_user(1)
    print(user)

asyncio.run(main())
1
2
3
4
5
6
7
8
9
10
11

Python 的特点:

1. 全局解释器锁(GIL)→ 单线程,协程间真正并发只在 IO 等待时
2. asyncio 是核心库,但 Twisted/Tornado 是历史替代
3. async 函数必须在 event loop 内运行(asyncio.run)
1
2
3

# 5.6 Java虚拟线程Project Loom

§0.2 那个例子。Java 21 的虚拟线程故意伪装成线程:

Thread.startVirtualThread(() -> {
    // 在这里写阻塞代码——但不会阻塞 OS 线程!
    String result = httpClient.send(request, BodyHandlers.ofString());
    db.insert(result);
});
1
2
3
4
5

实现机制:

虚拟线程 = 协程
但 API 完全和 java.lang.Thread 一样
→ 老代码不需要改一行就能受益
→ 整个 Java 生态自动获得协程能力
1
2
3
4

这是 Java 的工程美学——通过运行时改造,让 100 万行旧代码"免费升级"。

# 5.7 跨语言横评

语言 类型 关键字 颜色问题 单机规模 内置 runtime
Go 有栈 go 无 100万 内置
Kotlin 无栈 suspend 有 1000万 kotlinx.coroutines
Rust 无栈 async/await 有 1000万 外部(tokio)
JS 无栈 async/await 有 100万 内置(V8)
Python 无栈 async/await 有 10万(GIL) 内置(asyncio)
C# 无栈 async/await 有 100万 内置(TPL)
Swift 无栈 async/await 有 100万 内置
Java 21 有栈(特殊) 无(透明) 无 100万 内置

# 06.协程的经典陷阱

# 6.1 陷阱一:async函数写阻塞调用

// ❌ 致命错误
suspend fun fetchData(): Data {
    val conn = jdbcConnection()
    val result = conn.execute("SELECT ...")    // ★ JDBC 是阻塞 IO!
    return parse(result)
}
1
2
3
4
5
6

后果:协程在挂起前先把整个 dispatcher 线程阻塞了——一个协程拖死整个调度器。

修复:

suspend fun fetchData(): Data = withContext(Dispatchers.IO) {
    val conn = jdbcConnection()
    val result = conn.execute("SELECT ...")
    parse(result)
}
1
2
3
4
5

Dispatchers.IO 是为阻塞调用设计的——它有更多线程,且阻塞它不会影响 CPU 密集型协程。

# 6.2 陷阱二:忘await让协程消失

async function badStart() {
    fetchData();              // ❌ 没 await,promise 被丢弃
    console.log("done");
}
1
2
3
4

后果:fetchData 启动了,但调用方根本不等它,可能错过它的错误,可能引发资源泄漏。

修复:明确决定"等"还是"不等":

async function good() {
    await fetchData();        // 等结果
    // 或
    void fetchData();         // 明确表示"启动后不等"
    // 或
    fetchData().catch(handleError);   // 不等但处理错误
}
1
2
3
4
5
6
7

# 6.3 陷阱三:协程泄漏

// ❌ 全局协程,没有取消机制
GlobalScope.launch {
    while (true) {
        try {
            doWork()
            delay(1000)
        } catch (e: Exception) { /* ignore */ }
    }
}
1
2
3
4
5
6
7
8
9

后果:协程永远跑下去,内存中累积越来越多协程对象。

修复:使用结构化并发(详见 3.18 结构化并发):

class MyService(scope: CoroutineScope) {
    init {
        scope.launch {        // 绑定到外部 scope,scope 取消时自动结束
            // ...
        }
    }
}
1
2
3
4
5
6
7

# 6.4 陷阱四:上下文丢失ThreadLocal失效

// ❌ 在虚拟线程中
ThreadLocal<User> currentUser = new ThreadLocal<>();
currentUser.set(getUser());

executor.submit(() -> {
    User u = currentUser.get();   // ❓ 是否能拿到?
});
1
2
3
4
5
6
7

问题:协程切换 carrier 线程时,ThreadLocal 不会跟着走(默认行为)。

修复:

// Java 21+ 用 ScopedValue 替代 ThreadLocal
ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, getUser()).run(() -> {
    executor.submit(() -> {
        User u = CURRENT_USER.get();   // ✅ 自动跟随协程
    });
});
1
2
3
4
5
6
7

# 6.5 陷阱五:细粒度切换反而慢

suspend fun computeSum(list: List<Int>): Int {
    var sum = 0
    for (x in list) {
        sum += withContext(Dispatchers.Default) { x * 2 }   // ❌ 每个元素都切换
    }
    return sum
}
1
2
3
4
5
6
7

问题:withContext 的切换开销 ~50ns——但 x * 2 只要 1ns。切换比计算还贵。

修复:批量处理:

suspend fun computeSum(list: List<Int>): Int = withContext(Dispatchers.Default) {
    list.sumOf { it * 2 }   // 一次切换,批量计算
}
1
2
3

铁律:协程切换适合"中粒度任务"(毫秒级),不适合纳秒级操作。

# 6.6 陷阱六:异常吞没

// ❌ launch 启动的协程,异常默认吞掉
val job = GlobalScope.launch {
    throw RuntimeException("oops")     // ★ 静默死亡
}
job.join()   // 主流程完全感知不到
1
2
3
4
5

修复:用 async(异常包装在 Deferred 里)或注册 CoroutineExceptionHandler:

val handler = CoroutineExceptionHandler { _, e -> log.error("协程出错", e) }
GlobalScope.launch(handler) { ... }
1
2

# 6.7 陷阱七:CPU密集任务错用Dispatcher

// ❌ CPU 密集任务用 IO Dispatcher
withContext(Dispatchers.IO) {
    encryptHugeFile()    // 占用一个 IO 线程几分钟
}
1
2
3
4

问题:Dispatchers.IO 是给"短时间阻塞"用的——长时间占用一个线程,IO 线程池就被打满。

修复:自己开个 dispatcher:

val cryptoDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
withContext(cryptoDispatcher) { encryptHugeFile() }
1
2

# 07.共性抽象与未来演进

# 7.1 去掉语法糖后的共同骨架

不管是 Go goroutine、Kotlin suspend、还是 JS async,协程的骨架只有 5 件事:

flowchart LR
    A[1. 启动<br/>spawn / launch] --> B[2. 执行<br/>到挂起点]
    B --> C[3. 挂起<br/>保存状态]
    C --> D[4. 让出 CPU<br/>调度器接管]
    D --> E[5. 恢复<br/>从挂起点继续]
    E --> B
1
2
3
4
5
6

任何语言的协程都是这 5 件事的不同包装——具体语法、调度策略、状态机生成方式有差异,但骨架不变。

# 7.2 协程的面向对象时刻结构化并发

协程让"启动并发任务"变得极其简单——但简单到一个程度,就成了灾难:

// 谁负责取消这些协程?谁负责处理它们的异常?
fun bad() {
    GlobalScope.launch { task1() }
    GlobalScope.launch { task2() }
    GlobalScope.launch { task3() }
}
1
2
3
4
5
6

这就像 1960 年代的 GOTO 语句——能跳到任何地方,但难以推理。

结构化并发(Structured Concurrency) 是协程的"面向对象时刻"——让协程的生命周期跟随作用域,详见 3.18 结构化并发设计思想。

suspend fun good() = coroutineScope {
    launch { task1() }
    launch { task2() }
    launch { task3() }
    // 离开 scope 时,所有协程要么已完成,要么被取消
}
1
2
3
4
5
6

# 7.3 历史的回望:50 年的循环

flowchart LR
    A[1958 Conway<br/>提出 coroutine] --> B[1980s 线程兴起<br/>协程被遗忘]
    B --> C[2000s 回调地狱]
    C --> D[2009 Go 重生协程]
    D --> E[2012 C# async/await]
    E --> F[2017+ Kotlin/Rust/JS<br/>全面拥抱]
    F --> G[2023 Java 虚拟线程<br/>透明化]
    
    style D fill:#fff3cd
    style F fill:#d4edda
    style G fill:#cfe2ff
1
2
3
4
5
6
7
8
9
10
11

协程花了 60 年从概念到工业主流——这是计算机科学的"螺旋式上升"。

# 7.4 未来:协程会怎么演进

趋势 1:透明化

Java Loom 的方向——"让协程看起来像线程"
未来:业务代码完全不需要懂 async,运行时自动决定如何调度
1
2

趋势 2:和类型系统深度结合

Rust 的 async/await + 借用检查器
未来:编译期检查"在 await 之间不持有锁"
1
2

趋势 3:跨语言互操作

WebAssembly 的 Component Model
未来:JS 协程能 await Rust 协程,统一通过 ABI 交互
1
2

# 08.一句话总结

# 8.1 三层认知阶梯

第一层(知其然):会写 async/await 或 go 关键字
  ↓
第二层(知其所以然):理解状态机变换、有栈/无栈差异、调度器机制
  ↓
第三层(知其将所以然):能根据场景选择协程策略,能避开 6 大陷阱,能设计结构化并发
1
2
3
4
5

读完本章后,你应该能回答开头§0.3 提出的三个问题:

  1. 协程的物理本质? → 一个能在中间暂停、保存状态、之后恢复的函数。通过编译器变换(无栈派)或独立栈(有栈派)实现"非完整执行"。
  2. 为什么 60 年才爆发? → 1958-2010 单核时代线程够用、async/await 语法没标准化、生态不齐;2010 后多核 + 云原生让百万并发成常态,协程的所有优势才落地。
  3. 有栈 vs 无栈怎么选? → 有栈(Go/Loom):兼容旧代码、无颜色问题;无栈(Kotlin/Rust/JS):内存占用极小、单机千万并发、编译器优化更激进。新设计的语言基本都选无栈。

# 8.2 协程选型决策树

flowchart TD
    A[需要协程?] --> B{是否要兼容<br/>大量阻塞代码?}
    B -->|是| B1[选有栈<br/>Go / Java Loom]
    B -->|否| C{追求<br/>极致性能?}
    
    C -->|是| C1[Rust async]
    C -->|否| D{语言生态?}
    
    D -->|Android/JVM| D1[Kotlin]
    D -->|Web/Node| D2[JS async/await]
    D -->|Python/科学计算| D3[asyncio]
    D -->|.NET| D4[Task/async]
    
    style B1 fill:#fff3cd
    style C1 fill:#d4edda
    style D1 fill:#cfe2ff
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 8.3 七字真言

  1. 协程是函数能力的扩展——不是"轻量线程"。
  2. 挂起点必须显式或隐式标记——编译器靠它生成状态机。
  3. 不要在协程里阻塞——切到专用 IO Dispatcher。
  4. 每个协程必须有归宿——绑定到 scope 避免泄漏。
  5. 细粒度切换是反模式——切换开销 > 计算开销时浪费。
  6. 异常处理要显式——launch 默认吞异常。
  7. ThreadLocal 用 ScopedValue 替代——协程不绑定线程。

# 8.4 与下篇的承接

本篇我们看到了协程如何让"百万并发 + 同步代码"成为可能。但还有一个问题——协程之间怎么通信?

最朴素的方法是共享变量 + 锁,但这又回到了线程时代的问题。有没有更优雅的方法?

下一篇 3.14 Actor 与 CSP 并发模型 我们会看到——让协程通过消息传递协作,这是 Erlang 和 Go 共同的答卷。


# 🔗 延伸阅读

  • 同卷上篇:3.11 异步和同步的设计 | 3.12 单线程模型的思想
  • 同卷下篇:3.14 Actor 与 CSP 并发模型 | 3.18 结构化并发设计思想
  • 第 2 卷视角:2.4 函数调用栈与栈帧设计(理解协程栈的物理基础)
  • 经典文献:
    • Continuations and Coroutines(Christopher Strachey, 1974)
    • The Art of Computer Programming Vol 1(Knuth, 1968)—— 协程章节经典论述
    • Goroutines vs Coroutines(Russ Cox 博客)
    • Coroutines in Kotlin(Roman Elizarov, KotlinConf 演讲)
    • Java Loom: Virtual Threads(Brian Goetz, JEP 444)
    • Async/Await Under the Hood in Rust(withoutboats 博客)
上次更新: 2026/06/07, 10:26:12
12.单线程模型的思想
14.Actor与CSP并发模型

← 12.单线程模型的思想 14.Actor与CSP并发模型→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式