协程 的 主要作用 是 让 单核 GC 变成 单线程 GC, 这样, 在 单核 范围 内, GC 要 工作 时, 不需要 挂起 线程, 只需要 挂起 协程,
因为 协程 的 代码 实际上 是 一个 线程 里 执行的代码, GC 也是一个 协程, 也 运行在 这个 线程 里, 因此 GC 要 工作 时,挂起协程, 不用 把 寄存器 里 的 对象引用 写回 内存(这个 写回 内存 和 保存 线程 上下文 是 两回事, 线程 切换 时 要 把 寄存器 里 的 数据 保存到内存,这是 保存 上下文, 而 这里 是 把 寄存器 里 的 对象引用 写回 内存 里 对应 的 变量,相当于 一个 内存屏障, 但 理论上 只 写回 引用数据 就行,其它 数据 不关心, 因为 在 线程 架构下, GC 要 扫描 引用, 若 引用 在 寄存器 里 被改写 又 没有 写回 内存, 则 GC 扫描 内存 里 的 引用 就 不对 了), 这样 GC 的 架构 就简单 。 但 协程 架构下的 GC 仍然要 扫描 栈 里 的 局部变量 持有 的 对象引用 。 而 状态机 更进一步, 状态机 可以 在 Step 函数 执行完成 后 执行 GC, 这样 GC 不用 扫描 栈 里 的 局部变量 (持有 的 对象引用) 。 状态机 见 下文 。
等等, 纠正一下, (GC 工作前), 协程 也要 把 寄存器 里的 引用数据 写回 内存, 除非 编译器 预知 切换后 下一个 要 执行 的 协程 是 哪一个, 并 将 下一个 协程 的 代码 内联进来 做 寄存器 优化 (寄存器 布局)。
在 一些 场合, 编译器 可以知道 接下来 要 执行 的 协程 , 比如 一些 异步操作, 故 可以 把 接下来的 协程 (异步操作) 的 代码 内联 进来, 统一 做 寄存器 优化 (寄存器 布局)。
这样的话, 这些 已经 不是 协程 架构了, 这些 是 状态机 的 内容, 也可以说, 这是 协程 向 状态机 进化 的 苗头 。
但 其实 意义不大, 比如 GC 的 代码 不太可能 和 用户代码 内联 在一起, 通常 用户代码 和 GC 是 两个 独立 的 Step 函数, 既然 是 两个 独立 的 函数, 每个 函数 就是 一个 自然 的 内存屏障 。 既然 是 内存屏障, 当然 函数结束 时 要 把 寄存器 里 的 数据 都 写回 内存 。
这样搞 很晕, 很乱 的 。
协程 还是 和 线程 类似 的 “上下文 + 切换” 架构, 当 挂起 协程 时, 要 保存上下文,如果 是 GC 即将工作 要求 挂起的, 还要 把 寄存器 里 的 引用数据 写回 内存, 这和 保存上下文 一定程度 上 是 重复工作 。
状态机 是 彻底 的 函数化, 以 函数 为 执行单位, 不存在 协程 那样 的 “上下文”, 只要 考虑 内存屏障 即可, 同时 编译器 可以 对 函数 统一 做 尽可能深化 的 内联 和 寄存器优化 (寄存器布局) 。
说到 这里 想起一个 问题, .Net / C# async 方法 里 似乎 不能用 lock, 只能用 AsyncLock, 而 Task 里 似乎 是 能 用 lock 的, Task 里 用 lock 会 怎么样 ? lock 时 要 挂起 Task 等待 的 话, 是否 采用 协程技术 ? 还是 状态机 ? 而 如果 Task 里 使用 AsyncLock 又会怎么样 ? 感觉 很多 重复技术 嵌套 在 一起, 很乱的, 很费力 。
在 完全 的 Task 化 的 情况下, (Task 化 也 包括 状态机 和 Step 函数) , 单核 的 GC 架构 可以 变成 最简单 的 单线程 GC, 即 Task 工作结束 后 GC 才工作, GC 工作时 没有 Task 在 工作, 这样 不用 挂起线程, 也不用 挂起协程, 也不用 扫描 栈 里的 局部变量 (持有 的 对象引用) , 这样 的 GC 是 最简单 的 形态, 1000 行 代码可以写好, 且 架构 简单清晰, 因为 架构简单清晰, 就 容易做到 安全 稳定 。
所以, 小朋友们, 要 让 通用 处理器 和 操作系统 再次 回到 状态机 吗 ?
也许 这个时候, 编译器 就 跳出来 说话了, 这些 不用 改变 通用 处理器 和 操作系统, 所谓 的 “状态机” 可以由 编译器 在 代码 层面 “模拟” 出来 。
哈哈, 慢慢 玩 吧 。
挂起 n 个 协程 比 挂起 n 个 线程 的 开销 低 很多, 最起码, 挂起 线程 需要跟 操作系统 通信, 而 挂起 协程 纯粹 是 一个 线程 内 的 一些 代码 执行 。
另外, 因为 n 个 协程 都 只是 一个 线程 内 的 代码 执行, 因此, 可以 对 这些 代码 进行 各种 优化, 就像 优化 普通 的 代码, 一些 函数调用, 包括 内联 和 寄存器优化, 且 可以 减少内存屏障 的 使用次数 。
但 想了一下, 协程 不能 减少 内存屏障 的 使用次数,
要 深化 内联 和 寄存器优化 的 程度, 减少 内存屏障 使用次数, 应该是 由 一个 状态机 来执行 n 个 任务(协程), 这样 可以 进一步 把 任务(协程) 的 代码 内联 在 状态机 里, 进行 寄存器优化, 比如 .Net / C# async await, 但 其实 这个技术 很勉强 。
我在 《从 内存 到 CPU Cache 之间 的 数据读写 的 时间消耗 是 线程切换 性能消耗 的 主要原因 之一 是 不正确 的》 https://www.cnblogs.com/KSongKing/p/14152765.html 里 说
“所以, 协程 也 不能 搞 太多 。”
“但 现在 看来, 协程 也不能 玩 10 万 个 。”
是说, 协程 不能 解决 线程切换 导致 线程 的 栈 载入载出 Cache 的 性能消耗 的 问题, 协程 的 栈 也有这个问题 。
由此 看来, 编译 为 一个 状态机, 代替 n 个 任务(协程), 可以 彻底 取消 n 个 栈, 只要 状态机 一个 栈, 并且 控制 每一个 Step 函数 的 函数调用层级 不要太多, 这样 可以 使 栈 总是 保持 较小 。 当然, 单个 函数 也 不宜 过长, 单个 函数代码 过长,变量过多, 当然 也会 导致 需要 的 栈空间 较大 。
所以, 编译器 要 做一个 权衡, 使得, Step 函数 不要太大, 也不要 太小, 太大 会 占用 较大 的 栈空间, 太小 又 可能 函数调用层级 比较多, 也会 占用 较大 栈空间 。
前几天 和 之前 讨论 D++ 时候 就 冒出了 本文 的 部分想法, 现在 随想记录一下 。
以上 内容 写于 2022-04-23 、2022-04-24 。