从 内存 到 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   看起来  也是 把  源代码 切割成了  一个个 任务,    也算是  任务机,    但 实际 的 性能 如何  ?

 

 

 

而   《云原生时代,Java还能走多远?》   https://mp.weixin.qq.com/s?__biz=MzIzNjUxMzk2NQ==&mid=2247503699&idx=1&sn=3280cd6dbcb8b098b237387b236a16d4&chksm=e8d43091dfa3b987e82e21bda120e0b836199a54e8977bd3fd041e85e745d2a3c6f72fe484e4&mpshare=1&scene=23&srcid=12178I7ZbPMDZPC800erHFzw&sharer_sharetime=1608212243039&sharer_shareid=3ccc4c584e52d03ca8b47b71b3001007#rd

 

这篇 文章 里 讲到 :

 

一次内存访问(将主内存数据调入处理器 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”  ?      和  “云原生”  有 什么关系 呢 ?

 

“异步回调流”  是   “异步回调流派”   的 简称  。

 

 

 

还可以 看看 这篇文章    《现代存储性能“过剩”,API成最大瓶颈》         https://mp.weixin.qq.com/s?__biz=MzIzNjUxMzk2NQ==&mid=2247503386&idx=2&sn=f8b78a53f1a44c2640037eb9bd5aa0d6&chksm=e8d431d8dfa3b8ce646c80aa0e0aefb9a1f346cd21891ded96053f969ebbf5476b2b239776f1&mpshare=1&scene=23&srcid=12175huoFGNEG27KJvOhXpmy&sharer_sharetime=1608212465010&sharer_shareid=3ccc4c584e52d03ca8b47b71b3001007#rd

 

 

posted on 2020-12-17 23:50  凯特琳  阅读(859)  评论(0编辑  收藏  举报

导航