從 內存 到 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

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM