从 内存 到 CPU Cache 之间 的 数据读写 的 时间消耗 是 线程切换 性能消耗 的 主要原因 之一 是 不正确 的
有 观点 认为, 从 内存 到 CPU Cache 之间 的 数据读写 的 时间消耗 是 线程切换 性能消耗 的 主要原因 之一 。 这是 不正确 的 。
这是 一个 误区 。
换句话说, 从 内存 到 CPU Cache 之间 的 数据读写 的 时间消耗 不是 线程切换 性能消耗 的 主要原因 。
若 要 “从 内存 到 CPU Cache 之间 的 数据读写 的 时间消耗 是 线程切换 性能消耗 的 主要原因 之一” 这一 观点 成立, 需要 满足 以下 2 点 :
1 线程切换 时 将 线程 的 整个 栈 载入 CPU Cache
2 线程 执行 的 代码 用到 的 数据 全部 都 在 栈 里
要 弄清楚 这个 问题, 需要 考虑 一点, CPU 对于 Cache 的 管理, 是不是 和 操作系统 虚拟内存 一样 的 “页式管理” ?
函数 的 调用层级 越 多, 栈 里 存 的 上下文 数据 就 越多, 上下文 数据 是 函数 每次调用 的 参数 和 局部变量 。
栈 的 数据 多, 是不是 也会 增加 CPU Cache 和 内存 之间 载入载出 数据 的 次数 ?
假设 一个 任务 进行了 1000 层 函数调用, 可以考虑 分解 为 10 个 任务, 平均 每个 任务 进行 100 层 函数调用, 这样 栈 数据 也会 减少 到 只有 1/10 。
因为 在 1000 层 调用 中, 实际上 大部分 局部变量 和 参数 并不是 从头到尾 都用到, 也不需要 因为 参数 传递 等 原因 在 栈 里 重复 保存 。
分解 为 10 个 任务 后, 每个 任务 返回 下一个 任务 需要用到 的 数据, 这 只是 少数 的 几个 值 。
这样 就 减少了 栈 数据, 也就是 减少 了 栈 对 内存空间 的 使用 。 这样, 是不是 就 可以 减少 CPU Cache 和 内存 之间 载入载出 数据 的 次数 ?
这个 问题, 已经 不是 线程切换 的 问题, 即使 只有 一个 线程 或 少数 几个 线程, 这个 问题 一样 的 存在 。
将 多层 函数调用 分解 为 函数调用 层级 较少 的 多个 任务, 这种 模式 或 架构 称为 “任务机” 。
异步回调框架 只是 刚好 自然 的 在 一定程度 上 将 程序 架构 变成了 任务机 。 异步回调框架 比如 libuv 、netty , 异步回调 思想 和 框架 在 java 社区 和 Linux 服务器端 很流行 。
node.js 也是 异步回调框架 的 代表, node.js 也使用 libuv 。
以 高并发 著名 的 Erlang 似乎 就是 任务机 。
Erlang , 可以 说是 一个 操作系统, 也可以说 是 一个 平台, 也可以说是 一个 框架 。
由此, 大家 可以 看看, C# 的 async await 解决 的 是 语法糖 问题, 还是 性能 问题, 还是 什么 问题 ?
这些 问题 分析 清楚 了, 可以 在 程序 的 层面 由 程序员 解决, 不用 搞 “抽象层” 、 语法糖 、 “黑魔法” 。 “黑魔法” 出自 “编译器黑魔法” 。
假设 CPU Cache, 比如 三级 Cache , 和 内存 之间 的 数据 映射 和 载入载出 是 “页式管理”,
假设 现在 有 一个 线程, 运行完 后 销毁, 然后 再 创建 一个 新的 线程, 同样 也是 运行完 后 销毁, 再 创建 一个 新的 线程, 重复 这个 过程 。
假设 这个 过程 中 创建 和 销毁 了 1000 个 线程, 但 考虑 到 栈 空间 可能 会 重复利用, 也就是说, 操作系统 分配给 新线程 的 栈空间 是 刚销毁 的 线程 的 栈空间,
这样 的 话, 这 1000 个 线程 使用 的 是 同一段 栈空间, 则 在 创建 、运行 、销毁 这 1000 个 线程 的 过程 中, 这段 栈空间 可以 常驻 CPU Cache, 不用 重复 的 和 内存 映射地址 和 载入载出 数据 。
也就是说, 只 需要 在 创建 第一个 线程 时 将 栈空间 从 内存 映射到 CPU Cache(比如 三级 Cache), 和 从 内存 载入数据 。
之后, 栈空间 就 常驻 CPU Cache, 在 创建 、运行 、销毁 这 1000 个 线程 的 过程 中, CPU 直接 读写 Cache, 而 Cache 不需要 向 内存 载入载出 数据 。
当然, 线程 启动 时 栈 数据 通常 并不多, 就是 入口函数 的 几个 参数 , 但是, CPU (存储管理部件) 并不知道 栈空间 里 哪些 数据 有用, 哪些 没用, 会 把 整个 页 的 数据 从 内存 加载 到 Cache 。
这里 说 整个 页, 而不是 整个 栈, 因为, 如果 栈 的 空间 比较 大, 由 多个 页 组成, 那么, 不一定 一次 就将 栈 的 全部 页 从 内存 载入 Cache, 这 和 操作系统 虚拟内存 的 管理方法 可能 是 类似 的 。
当 Cache 空间 不够 时, 栈 的 不常用 的 一些 页 可能 会 被 载出, 将 空间 腾出来 给 其它 的 数据 用 。
同理, 假设 有 100 个 线程, 每个 线程 运行 完成 后, 就 销毁, 并 创建 新 的 线程, 运行, 完成后 销毁, 再创建 新 的 线程, 重复 这个 过程 。
这样, 线程 的 数量 保持 在 100 个, 假设 创建 和 销毁 了 1 万 个 线程, 这个 过程 中, 线程 数量 保持 在 100 个, 考虑 到 操作系统 会 重复 利用 栈空间, 就是 会 把 销毁 的 线程 的 栈空间 分配 给 新 的 线程 用, 这样, 假设 这 100 个 线程 的 栈 一开始 就在 Cache 里, 比如 三级 Cache, 那么, 在 创建 、运行 、 销毁 了 1 万 个 线程 的 过程 中 , 这 1 万 个 线程 的 栈空间 始终 都 在 Cache 里, 不会 和 内存 载入载出 数据 。
当 Cache 不够 时, 会 将 一些 不常用 的 页 载出 到 内存, 将 空间 腾出来 给 其它 的 数据 用 。 此时, 一些 比较长 时间 未运行 的 线程 的 栈 的 页 可能会被 载出, 最近 运行 的 一些 线程 的 栈 中 比较长 时间 未用到 的 数据 的 页 也 可能 被 载出 。
Cache 除了 存 栈 数据, 还 会 存 堆 数据 和 操作系统 数据, 等等 。
但 事实上, 栈空间 可能 不是 操作系统 来 分配, 而是 应用程序 自己 分配, 如果 是 在 运行时 创建 线程, 可能 是 从 堆 里 分配, 这样, 新 创建 的 线程 的 栈空间 是否 使用 刚 销毁 的 线程 的 栈空间, 这 取决于 应用程序 对 堆 的 使用情况 和 管理方式 。 也许, 新 创建 的 线程 的 栈空间 使用 刚 销毁 的 线程 的 栈空间 是 一个 理想状况 。
比较 理想 的 状况 是, 只有 少数 几个 线程, 这几个 线程 的 栈 都 在 Cache 里, 这几个 线程 执行 的 都是 小任务 。 小任务 指 函数调用 层级 较少 的 任务 。
小任务 之间 通常 通过 堆 共享(传递) 数据, 从 这个 角度 来看, 堆 的 申请分配 算法 可能 在 最近用到 的 空间 附近 分配 比较 好, 这样 可以 比较 大概率 避免 在 Cache 在 内存 间 载入载出 数据 。
比如 一个 小任务 返回了一个 DataTable, 放在 堆 里, 下一个 小任务 要 用到 这个 DataTable, 同时 也要 申请 一些 堆 空间, 如果 在 这个 DataTable 的 邻近 位置 申请 空间, 则 新 申请 的 空间 和 DataTable 的 空间 是 邻近 的, 可能 在 一个 页 里, 而 这个 页 在 存 DataTable 时 就 应该在 Cache 里, 这样 下一个 小任务 申请空间 就 可以 直接 使用 Cache 里 的 这个 页, 不用 映射 一块 新的 内存空间(页), 也不用 从 内存 载入 数据 到 Cache 。
即使 上一个 小任务 的 数据 大于 一个 页, 或者 下一个 小任务 的 数据 大于 一个 页, 或者 上一个 小任务 和 下一个 小任务 的 数据 加起来 大于 一个 页, 但, 只要 在 最近用到 的 空间 附近 分配 新 申请 的 内存块, 应该 能 营造 出 常用 的 页 比较 大概率 总是 在 Cache 的 效果 。 这样 可以 避免 在 Cache 和 内存 间 频繁 载入载出 数据 。
但 问题 是, 怎样 是 “最近用到 的 空间”, 我 觉得 简单 的 办法 就是 刚刚 分配 或者 回收 的 空间 附近 。
但 应该 指出, 以上 只是 从 一个 角度 来 考虑 堆 分配 的 策略, 不是 全面 的 考虑 。
由上, 可以看到, 协程 也 存在 同样 的 问题, 协程 并不能 减小 任务 的 栈 数据, 协程 的 作用 应该 主要 是 避免了 线程切换 和 调度 时 切换 到 操作系统 进程 的 开销 。
协程 切换, 只是 在 线程 里 简单 的 执行 几句 代码, 和 执行 几句 普通 代码 一样 。
线程 切换, 需要 中断 发起, 调用 操作系统原语, 切换 到 操作系统 进程, 操作系统 还要 做 一些 调度逻辑, 总之 看起来 是 比较 繁琐 “重型” 的 一个 过程 。
“重型” , 是 “轻量” 的 反义词 。
和 线程切换 相比, 协程切换 就 很 轻量 。
如果 协程 很多, 这些 协程 的 栈空间 加起来 远远 大于 CPU Cache, 比如 三级 Cache, 那么, 当 协程 切换 时 , 大概率的, 切换到 的 协程 的 栈空间 不在 Cache 里, 要 从 内存 映射 到 Cache, 并 载入 数据 。
所以, 协程 也 不能 搞 太多 。
我 以前 写过 一篇 文章 《再见 异步回调, 再见 Async Await, 10 万 个 协程 的 时代 来 了》 https://www.cnblogs.com/KSongKing/p/10802278.html ,
但 现在 看来, 协程 也不能 玩 10 万 个 。
“线程切换 的 性能消耗” 的 问题 的 本质 是 CPU Cache 和 内存 间 的 时间延迟 和 保存了 很多个 执行单位 的 上下文 数据 之间 的 矛盾制约 。
广义的 , CPU Cache 和 内存 间 的 时间延迟 是 分级存储 的 时间延迟, 也可以说是 分级存储 的 瓶颈 ,
所以, 也可以说, “线程切换 的 性能消耗” 的 问题 的 本质 是 分级存储 的 时间延迟 和 保存了 很多个 执行单位 的 上下文 数据 之间 的 矛盾制约 。
或者, “线程切换 的 性能消耗” 的 问题 的 本质 是 分级存储 的 瓶颈 和 保存了 很多个 执行单位 的 上下文 数据 之间 的 矛盾制约 。
在 计算机系统结构 中, 分级存储 普遍 存在, 比如 硬盘 和 内存 组成 的 虚拟内存, 内存 和 CPU 三级 Cache, CPU 一级 Cache 二级 Cache 三级 Cache 。
对于 分级存储 和 多线程 高并发 的 瓶颈制约, 其实, 线程池 + IO 异步 是 简单直接 的 解决方法 。
C# async await 看起来 也是 把 源代码 切割成了 一个个 任务, 也算是 任务机, 但 实际 的 性能 如何 ?
这篇 文章 里 讲到 :
“
一次内存访问(将主内存数据调入处理器 Cache)大约需要耗费数百个时钟周期,而大部分简单指令的执行只需要一个时钟周期而已。因此,在程序执行性能这个问题上,如果编译器能减少一次内存访问,可能比优化掉几十、几百条其他指令都来得更有效果。
……
通过分析,得知一个对象不会传递到方法之外,那就不需要真实地在对中创建完整的对象布局,完全可以绕过对象标识符,将它拆散为基本的原生数据类型来创建,甚至是直接在栈内存中分配空间(HotSpot 并没有这样做),方法执行完毕后随着栈帧一起销毁掉。
”
这个 优化 也是 因为 冯诺依曼瓶颈, 也就是 内存 到 CPU 之间 的 时间延迟, 也就是 CPU 和 内存 之间 的 速度差, 也就是 从 内存 到 CPU Cache 之间 的 数据读写 的 时间消耗 。
但是, 这个 优化 也是 没有 意义 的 , 道理 同上 。
编译器 没有必要 去 干 这些 无聊 的 事 。 无聊 的 事 指 各种各样 奇形怪状 的 优化 。
现代编译器 的 优化 技术 深奥 复杂, 俨然 各家各派 的 秘技 , 哈哈哈哈 。
一个 架构, 一个 设计, 简单明了, 效率 自然 就 高, 且 安全 健壮 。
优化, 通常 针对 一些 特定 的 情况, 越 特殊 的 情况, 优化 步骤 大概 越 繁琐复杂 。
优化, 会不会 篡改 和 擅自揣测 源代码 的 意图, 增加 系统 的 不透明性, 对 安全 和 健壮性 造成 隐患 ?
优化 会 产生 一些 代码副本, 导致 代码膨胀 。 对 每一种 特定情况 的 优化 会 产生 一段 特定 的 代码, 对应 一个 特定 的 代码副本, 也就是说, 一份 源代码, 经过 优化, 得到 若干份 目标代码 副本, 这就是 代码膨胀 。
当然, 这里 的 副本 , 并不一定 对应 全部 源代码, 而是 对应 被 优化 的 那一段 代码, 被 优化 的 一段 代码 会 产生 若干 副本, 用在 适合 的 场合 。
比如, 这个 场合 用 这个 副本 更 高效, 就 使用 这个 副本, 另一个 场合 使用 另一个 副本 更 高效, 就 使用 另一个 副本 。
副本 导致 代码膨胀, 也就是 目标代码 的 代码量 增加, 这意味 着 代码 占用 的 存储空间 增加, 这 是不是 也会 增加 CPU Cache 和 内存 之间 载入载出 数据 的 次数 ?
代码膨胀, 和 泛型 相似, 和 泛型 类比 一下 就 很清楚 。 泛型 为 每一种 具体类型 生成 一份 代码, 造成了 代码膨胀, 泛型 是 代码膨胀 的 经典 代表 。
什么 “尾递归优化”, 如果 觉得 栈 的 大小 不够, 怕 堆栈溢出, 可以 在 堆 (Heap) 里 创建 一个 栈 (new Stack()), 把 递归 的 参数 存在 这个 Stack 对象 里, 自己 递归 。
如果 希望 把 递归 写成 循环, 且 能 写成 循环, 自己 写 不是 更香 吗 ?
说起 优化, 会想起 简单类型 和 结构体 的 赋值 和 参数 传递, 这又 想起 内存 的 数据复制, CPU 的 一级 Cache 二级 Cache 三级 Cache 之间, 三级 Cache 和 内存 之间, 内存 和 内存 之间, 存不存在 “批量复制” 数据 ?
批量复制, 如果 存在, 应 存在于 汇编 和 硬件 层面 。
我记得 在 什么地方 看到过, C 语言 里 有一个 宏 还是 关键字 是 内存 的 批量复制 。 这个 宏 或 关键字 好像 还是 Windows 操作系统 特有 的 。
按理, 批量复制 应该存在, 内存 和 外设 之间, 是 有 批量复制 的, 可以 连续 传输 一个 数据块, 完成后, 再 通知 CPU 。 这是 内存 和 外设 的 控制电路 实现 的 功能 。
所以, 按理, CPU 的 一级 Cache 二级 Cache 三级 Cache 之间, 三级 Cache 和 内存 之间, 内存 和 内存 之间, 存在 “批量复制” 数据 。
事实上, 上面 提到 CPU Cache 和 内存 之间 的 数据 载入载出 是否是 “页式管理”, 这样的话, CPU Cache 和 内存 之间 的 数据 载入载出, 包括 批量复制, 这部分 是 CPU 硬件设计 比较 复杂 和 重要 的 一块 。
《云原生时代,Java还能走多远?》 这篇文章 还 提到 :
“
Java 语言抽象出来隐藏了各种操作系统线程差异性的统一线程接口,这曾经是它区别于其他编程语言(C/C++ 表示有被冒犯到)的一大优势,不过,统一的线程模型不见得永远都是正确的。
Java 目前主流的线程模型是直接映射到操作系统内核上的 1:1 模型,这对于计算密集型任务这很合适,既不用自己去做调度,也利于一条线程跑满整个处理器核心。但对于 I/O 密集型任务,譬如访问磁盘、访问数据库占主要时间的任务,这种模型就显得成本高昂,主要在于内存消耗和上下文切换上:64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB,线程的内核元数据(Kernel Metadata)还要额外消耗 2-16KB 内存,所以单个虚拟机的最大线程数量一般只会设置到 200 至 400 条,当程序员把数以百万计的请求往线程池里面灌时,系统即便能处理得过来,其中的切换损耗也相当可观。
”
这个 线程 昂贵 的 问题, 不是 由 “异步回调流” 解决了吗 ? 怎么 还会 影响到 “云原生时代 的 java” ? 和 “云原生” 有 什么关系 呢 ?
“异步回调流” 是 “异步回调流派” 的 简称 。