「後線程時代」, 這跟 好幾個 名詞 有關係, C# async await 關鍵字, Socket Async, ThreadPool, 單體(Monosome), 「異步回調流」 。html
「異步回調流」 是 「異步回調流派」 的 意思, node.js, libuv, Java Netty , 這些 是 典型的 異步回調流 。node
async await 是 單體(Monosome),git
我在以前的 文章 《我 反對 使用 async await》 http://www.javashuo.com/article/p-kfsmekfd-cv.html 中 提到, 「async await 正帶領 C# 向 Javascript 進化」 。github
至於 Socket Async , 和 async await 有關係, 也跟 異步回調流 有關係 。編程
咱們來看看 一位網友 從 一篇文章 上 節取 下來的 2 段文字 :api
因此, 從 理論 上看, 過多的 線程切換 對 性能 的 消耗 是 挺大的, 若是能 省去 這部分 開銷, 「節省」 下來的 性能 是 可觀 的, 也許能讓 服務器 的 吞吐量(併發量) 提升 1 個 數量級 。緩存
因此, Visual Studio 本身也在使用 async await, 從 Visual Studio 有時候 報錯 的 錯誤信息 來看, 錯誤信息 中含有 「MoveNext_xx ……」 這樣的文字, 這就是 async await 。服務器
線程池(ThreadPool) 自己 就能 將 線程數量 控制在一個 有限 的 範圍內 ,閉包
而 將 線程數量 控制在一個 有限 的 範圍內 是 減小 線程切換 的 基礎 。架構
我 猜想 async await 的 底層 是 基於 ThreadPool 的, 是以 ThreadPool 做基礎的 。
若是是這樣, 那麼 async await 和 異步回調流 是 等價 的 。
什麼是 異步回調流 ?
咱們能夠把 程序 分爲 3 個部分 :
1 順序執行
2 等待 IO
3 定時輪詢
1 把 順序執行 的 多任務 放到 ThreadPool 的 工做隊列 裏 排隊, 讓 ThreadPool 調度執行,
2 對於 IO 調用, 採用 異步調用 的 方式, 傳入 回調委託, 當 IO 完成時, 當 IO 完成時, 回調委託,
3 對於 定時輪詢, 採用 ThreadPool 提供的方式, 如 Timer,
這樣, 作到以上 3 點, 就是 純粹 的 異步回調流 。
理論上, 異步回調 流 能夠將 線程數量 控制在 有限 的 範圍內, 或者, 只須要 使用 很小數量 的 線程 。
這樣, 就像上面說的, 能夠節省「可觀」的 性能, 可能能讓 服務器 的 吞吐量 提升 1 個 數量級 。
我寫了一個 對 Socket 使用 各類 線程模型 的 測試項目 : https://github.com/kelin-xycs/SocketThreadTest
從 實驗 中, 咱們看到, 在 併發量 大 時, 好比 800 個 Socket 鏈接 以上時, ThreadPool 的 性能 優於 NewThread 的方式, NewThread 是指 爲 每一個鏈接 建立一個 線程 。
可是, Async 和 Begin 的 方式 效率 低於 同步方法(Socket.Receive(), Socket.Send()) 的 方式 。
甚至, Begin 方式 中 把 BeginSend() 改爲了 Send() 後, 效率還提升了一些 。 固然 Receive 仍然是使用 BeginReceive() 。
Async 方式 中 Accept, Receive, Send 所有使用 Async 方法, 即 AcceptAsync(), ReceiveAsync(), SendAsync() 方法 。
因此, 若是 Server 端 Socket 的 操做 所有使用 異步 的 方式, 是否 會比 同步的 Receive() Send() 方式 的 性能 更高, 這個 沒有 看到 有說服力 的 實驗 。
So ……
So …… ?
So ?
我寫了一個 對 async await 性能測試 的 項目: https://github.com/kelin-xycs/AsyncAwaitTest
解決方案 裏 包括 4 個 項目, 這 4 個 項目 都是 經過 ThreadPool 來 運行 讀取文件 的 任務 :
1 ThreadPoolRead, 使用 File.Read() 方法
2 ThreadPoolReadAsync, 使用 await File.ReadAsync()
3 ThreadPoolReadWait, 使用 Task t = File.ReadAsync(); t.Wait();
4 ThreadPoolBeginRead, 使用 File.BeginRead() 方法
5 ThreadPoolContinueWith, 使用 Task t = File.ReadAsync(); t.ContinueWith();
6 ThreadPoolGetAwaiter, 使用 Task t = File.ReadAsync(); t.GetAwaiter().OnCompleted();
任務 是 從 文件 中 讀取 2 KB 的數據, 默認開啓 10 萬 個 任務, 能夠本身修改 任務數量 。
測試結果是 :
10 萬 個 任務, 完成用時 ,
Read() : 0.43 秒, 屢次測試 表現 穩定, 基本上 穩定在 0.43 秒左右 。 CPU 佔用率 高峯期 15% 左右, 可能略小 。
ReadAsync() : 最快 0.6 秒, 屢次測試 的 表現 差距很大, 受電腦上 其它進程 的影響很大, 在 幾秒 到 20 幾秒 之間不等 。 CPU 佔用率 高峯期 15% 左右 。
ReadWait : 定在那裏, 沒有結果, 可能 ThreadPool 裏不能 t.Wait() 。 定着時候 CPU 佔用率 0% 。
BeginRead : 最快 1.1 秒, 屢次測試 的 表現 差距很大, 受電腦上 其它進程 的影響很大, 在 幾秒 到 20 幾秒 之間不等 。 CPU 佔用率 高峯期 15% 左右 。
ContinueWith : 最快 0.83 秒, 屢次測試 的 表現 差距很大, 受電腦上 其它進程 的影響很大, 在 幾秒 到 20 幾秒 之間不等 。 CPU 佔用率 高峯期 15% 左右 。
GetAwaiter : 最快 0.7 秒, 屢次測試 的 表現 差距很大, 受電腦上 其它進程 的影響很大, 在 幾秒 到 20 幾秒 之間不等 。 CPU 佔用率 高峯期 15% 左右 。
總的來講, Read 的 方式 效率 最高, 且 是 穩定運行的, 其它 的 方式 效率 略低, 且不穩定 。
從我這幾回的測試, 包括 Socket 和 File, 異步 問題不少, 效率 低於 Socket.Receive(), Socket.Send(), File.Read() 方法, 且不穩定 。
目前看起來 ThreadPool + 同步方法調用 是 最優的 方案, 高效穩定 。 能夠這麼說, 能夠用這個架構 來 在 .Net 上 構建 服務器端 應用 。
( 注: 括號裏的這段註解內容是我後來補充的, 後來經過對 「無阻塞」 編程 的 研究, 發現 異步方法 的 意義 在於 無阻塞, 因此 對於 大併發應用 來說, ThreadPool + 異步方法 無阻塞 的 方式 會 更適合, 參考 《無阻塞 編程模型》 http://www.javashuo.com/article/p-qxtbtyjk-ck.html
有 網友 說, 在 測試中, 同時發起多個 讀取文件操做, 沒有 指定 FileStream.Position, 因此 每一個任務 讀取的內容 是 不肯定的 。 確實, 存在這樣的問題, 但個人這個測試主要是爲了觀察 各類線程模型 在 大併發 包含 IO 操做 下的 表現, 因此 Position 的 問題 不影響 觀察 實驗結果 。 對於能夠 併發讀取 的 IO 操做 好比 Socket, 這個實驗 是有 類比參考意義 的 。 又假設 文件操做 也是能夠 併發 的, 那麼 在 讀取文件 的 方法(好比 Read(), BeginRead(), ReadAsync() ) 裏能夠傳入 position 參數, 這樣就能夠 併發讀取 。 )
而這些 測試 也代表了, async await 的 表現 並非想象中那樣理想 。 相對於 同步方法 不只效率沒有更高, 還更低 。
也就是說, 咱們從 理論上 看到的 線程切換 帶來的 性能損耗 及其 推論 的 相關理論, 和 實際 不徹底 相符,
這暗示着, 計算機 可能 在 按 另外的 規律 在 運行 。
技術上, 本身能夠實現 狀態機 和 Promise 之類的, 用相似 Task.Factory.FromAsync( BeginXXX …… ) 這樣的方式, 經過咱們本身寫一個 相似 FromAsync() 這樣的方法, 能夠 截獲 BeginXXX 方法 返回的 IAsyncResult 對象, 咱們 能夠把 IAsyncResult 放入 狀態機 的 隊列 裏, 而後, 狀態機 經過 ThreadPool 的 Timer 來 定時 (好比 10 毫秒) 來 遍歷 檢查 這些 IAsyncResult 的 狀態 看 異步調用 是否結束, 若 結束 則 調用 回調, 或者 按照 Promise .When() 的邏輯 等待 幾個 任務 的 IAsyncResult 的 狀態 都是 完成時, 再 調用 Then 委託 。
這樣能夠實現 async await 的 狀態機, 也能夠實現 Promise 。
但問題是 定時 和 遍歷, 尤爲是 遍歷, 效率 不見得 高 。
另外, 將 代碼 切割 成 多塊, 頻繁 的 把 小塊任務 放到 ThreadPool 的 隊列 裏 排隊, 也會 下降效率, 由於 操做隊列 須要 Lock(同步 互斥), 頻繁 的 把 小塊任務 放入 隊列 和 取出 執行 會 發生 更多的 Lock 。
同時, 將 代碼 切割 成 多塊, 變爲 回調 的 方式, 也會增長一些工做量, 好比 閉包 封送參數, 或是 State 對象 傳遞參數, 以及 異步回調 相關的代碼 。
因此, 從 這裏 也說明了, 我所作 的 多次 實驗, 從 Socket 到 File, Begin Async 等 異步方法 效率 老是 低於 同步的 Socket.Receive(), Socket.Send(), File.Read() 方法 的 緣故 。
async await 多是 微軟 的 一支戰略 吧, 不過 看起來 微軟 到如今 對 async await 都 語焉不詳 。
不過 async await 大概是 微軟 要 實踐 「單體」 這個 理論, 因此, 說它 帶領 C# 向 Javascript 進化 一點 不爲過 。
但 實踐代表, 這個 「單體」 的 性能 不見得 是 最優 , 減小 線程 切換 和 完全的 單線程(單體) 之間 有一個 最大公約數 。
從 通訊 上, IO 完成時 , 發信號 通知 線程, 進入就緒隊列, 這個 是 最優 的, 但問題是 帶來了 切換上下文 問題 。
但 若是 不想 切換 上下文,就要 線程 「本身」 去 看 IO 完成沒 , 就變成 輪詢 。 So ……
減小 線程 切換 和 完全的 單線程(單體) 之間 有一個 折中 點,不是 徹底 偏向 哪邊 就是 最好的 。
單體, 就是 一個 線程 負責 全部的 任務調度 。
從 這幾天 的 實踐 能夠 大概 看到, 省掉了 切換上下文, 可是 頻繁 的 把 任務 放到 ThreadPool 的 工做隊列 裏 排隊, 實際上 又 增長了 性能消耗, 實時響應性 反而很差 。
其實 從 個人 ThreadPoolRead 這個 項目, 就是 用 Read 方法 的 這個項目, 10 萬 次 讀取文件 0.43 秒 完成的 這個 ,
能夠 推算 出 一次 線程切換 是 多少時間 。
或者說, 1 秒鐘 能夠切換 多少次 線程 。
由於 數據量 小, 且是 重複讀取, 因此, 第一次 以後, 都是 從 緩衝區 讀取, 是 內存 -> 內存 的 拷貝, 很快 。
這樣, 業務操做 越簡單, 越能 反映 出 線程切換 的 時間, 或者說, 1 秒 能 切換 多少次 線程 。 如今看到的 數量 是 很可觀 的 。
有 網友 提到 性能測試 要在 「密集計算」 下 測, 所謂 密集計算, 我想 就是指 包含 大量業務邏輯 的 計算 。 在 業務邏輯 複雜 的 狀況下, 線程切換 時 CPU Cache 被刷新 的 效應 可能會 更顯著 。
不過 具體 對 性能 的 影響 如何, 仍是要 經過 實驗 來 看 實際 的 效果 。
咱們來看看 docs.microsoft 對 Thread 的 說明 : https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.thread.-ctor?view=netframework-4.7.2#System_Threading_Thread__ctor_System_Threading_ThreadStart_System_Int32_
默認 最大的 棧 大小 是 1 MB, 最小的 棧 大小 大概是 256 KB, 大概是這麼一個 體量 。
從 某個 角度 來看, 線程 使用中的 堆棧 空間 越小, 切換線程 的 時間 就 越快 。
理想的情況, 線程 的 堆棧數據 能夠長期 存放在 CPU 3 級 Cache, 這樣 能夠 快速 的 切換線程 。
咱們來看看 內存 的 讀寫速度 : https://zhidao.baidu.com/question/1797460631148535467.html
DDR 3 的 讀寫速度 是 12.8 GB/S, 能夠認爲 是 1 納秒 能夠讀取 10 B, 1 微秒 能夠讀取 10 KB 。
1 微秒 10 KB, 100 微秒 1 MB, 因此, 徹底 刷新 一個 線程 1MB 的 棧, 須要 100 微秒, 即 0.1 毫秒 。
所謂 「刷新」, 是指 將 數據 從 內存 複製到 CPU 3 級緩存 。
這樣的話, 若是 一個線程 的 棧 是 1 MB, 固然 這 算是大的了, 切換到 這個線程 的 時間 須要 0.1 毫秒 以上(由於還有其它操做),
這 有點 太 「重型」 了 。
實際的 狀況 不徹底 是這樣, 咱們看看上面 docs.microsoft 對 Thread 的 說明 :
能夠看到, 有一個 「頁大小 64KB」, 從這裏咱們能夠想到, 操做系統 從 內存 複製 數據 到 3 級緩存 時, 不見得會把 整個 棧 的 數據 複製過來, 而 應該是 把 當前 可能用到的 那一段 數據 複製過來 。 而 複製數據 的 單位 就是 虛擬內存頁, 一個 虛擬內存頁 是 64 KB 。
根據上面推算的 1 微秒 10 KB, 從 內存 複製 64 KB 數據 到 3 級 Cache 要 6.4 微秒 。
但, 若是 堆棧 的 數據 可以 長期 存放在 3 級 Cache, 那 這個 6.4 微秒 的 時間 也不須要了 。
因此, 我提出一個定理 :
若是 n 個線程 使用的 堆棧空間 大小總和 是 CPU 3 級 Cache 的 1/3, 則 這 n 個線程 的 線程切換 是 健康的, 常規的 。
好比, 有 100 個 線程, 每一個 線程 最大堆棧 空間 是 64 KB, 那麼, 10 個 線程 的 堆棧空間 總和 是 64 KB * 100 約等於 6.4 MB,
則若 CPU 的 3 級緩存 大小 是 6.4 MB * 3 = 19.2 MB 以上的話, 這 100 個線程 的 線程切換 就是 健康的, 常規的 。
從這個角度來說, 若是 硬件技術 在 CPU Cache 上可以有效進步的話, 將來若干年內, 摩爾定律 將會 繼續有效 。
減少 線程上下文,減小 線程切換的工做量,線程切換 輕量化,線程 輕量化, 是 操做系統 輕量化 的 一個 方向 。
這一點 我也 加到了 《將來須要的是 輕量操做系統 而不是 容器》 http://www.javashuo.com/article/p-oeegsskj-gp.html 一文裏 。
最後, 本文結論 是 :
1 用 ThreadPool 合理利用 線程資源 就能夠了, 沒必要 過分使用 異步回調 來 達到 節省性能 的 目的 。
2 能夠 有針對性 的 改善 硬件資源 來 減少 線程切換 的 性能損耗 。 好比 CPU Cache, 尤爲是 3 級 Cache 。
3 仍是 那幾句 老話 「硬件是最廉價的」, 「代碼是寫給人看的」, 「維護軟件的成本比購買硬件的成本高」, 「人是最昂貴的」 。
再 加上 一條, 通過這幾天的研究, 發現 無阻塞 是 有利的, 能夠參考 《無阻塞 編程模型》 http://www.javashuo.com/article/p-qxtbtyjk-ck.html 。