有 觀點 認為, 從 內存 到 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” ? 和 “雲原生” 有 什么關系 呢 ?
“異步回調流” 是 “異步回調流派” 的 簡稱 。