後線程時代 的 應用程序 架構

「後線程時代」, 這跟 好幾個 名詞 有關係,  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    。

相關文章
相關標籤/搜索