多處理網絡服務器模型指南
已發表: 2022-03-11作為一個多年來一直在編寫高性能網絡代碼的人(我的博士論文是關於適用於多核系統的分佈式應用程序的緩存服務器的主題),我看到很多關於這個主題的教程完全錯過或忽略了任何討論網絡服務器模型的基礎知識。 因此,本文旨在作為網絡服務器模型的有用概述和比較,目的是解開編寫高性能網絡代碼的一些奧秘。
本文面向“系統程序員”,即後端開發人員,他們將處理其應用程序的低級細節,實現網絡服務器代碼。 這通常在 C++ 或 C 中完成,儘管現在大多數現代語言和框架都提供了不錯的低級功能,並具有不同級別的效率。
我將把常識當作常識,因為通過添加內核來擴展 CPU 更容易,因此調整軟件以盡可能地使用這些內核是很自然的。 因此,問題變成瞭如何在可以在多個 CPU 上並行執行的線程(或進程)之間劃分軟件。
我還理所當然地認為讀者知道“並發”基本上意味著“多任務處理”,即同時處於活動狀態的多個代碼實例(無論是相同的代碼還是不同的代碼,都沒有關係)。 並發可以在單個 CPU 上實現,在現代之前,通常是這樣。 具體而言,可以通過在單個CPU上的多個進程或線程之間快速切換來實現並發。 這就是舊的單 CPU 系統如何設法同時運行許多應用程序,以一種用戶會認為應用程序同時執行的方式,儘管實際上並非如此。 另一方面,並行性具體意味著代碼正在同時執行,從字面上看,是由多個 CPU 或 CPU 內核執行的。
將應用程序分區(分成多個進程或線程)
出於討論的目的,如果我們談論線程或完整進程,這在很大程度上是不相關的。 現代操作系統(Windows 除外)幾乎將進程視為輕量級線程(或者在某些情況下,反之亦然,線程獲得了使其與進程一樣重的特性)。 如今,進程和線程之間的主要區別在於跨進程或跨線程通信和數據共享的能力。 在進程和線程之間的區別很重要的地方,我會做一個適當的說明,否則,可以安全地認為本節中的“線程”和“進程”這兩個詞是可以互換的。
常見的網絡應用程序任務和網絡服務器模型
本文專門處理網絡服務器代碼,它必然實現以下三個任務:
- 任務 #1:建立(和拆除)網絡連接
- 任務 #2:網絡通信 (IO)
- 任務#3:有用的工作; 即有效載荷或應用程序存在的原因
有幾種通用的網絡服務器模型可以跨進程劃分這些任務; 即:
- MP:多進程
- SPED:單進程,事件驅動
- SEDA:分階段的事件驅動架構
- AMPED:非對稱多進程事件驅動
- SYMPED:對稱多進程事件驅動
這些是學術界使用的網絡服務器模型名稱,我記得至少找到了其中一些的“in the wild”同義詞。 (當然,名稱本身並不重要——真正的價值在於如何推斷代碼中發生的事情。)
這些網絡服務器模型中的每一個都將在下面的部分中進一步描述。
多進程 (MP) 模型
MP網絡服務器模型是大家習慣首先學習的模型,尤其是在學習多線程的時候。 在 MP 模型中,有一個接受連接的“主”進程(任務 #1)。 一旦建立連接,主進程就會創建一個新進程並將連接套接字傳遞給它,因此每個連接都有一個進程。 然後,這個新進程通常以一種簡單的、順序的、鎖步的方式與連接一起工作:它從中讀取一些東西(任務#2),然後進行一些計算(任務#3),然後向它寫入一些東西(任務#2)再次)。
MP 模型實現起來非常簡單,只要進程總數保持在相當低的水平,它實際上工作得非常好。 有多低? 答案實際上取決於任務 #2 和 #3 的內容。 根據經驗,假設進程或線程的數量不應超過 CPU 內核數量的兩倍左右。 一旦有太多進程同時處於活動狀態,操作系統往往會花費太多時間來顛簸(即,在可用的 CPU 內核上處理進程或線程),這樣的應用程序通常最終會花費幾乎所有的 CPU時間在“sys”(或內核)代碼中,幾乎沒有做真正有用的工作。
優點:實現起來非常簡單,只要連接數量很少,就可以很好地工作。
缺點:如果進程數量增長過大,往往會使操作系統負擔過重,並且在網絡 IO 等待負載(計算)階段結束時可能會出現延遲抖動。
單進程事件驅動 (SPED) 模型
SPED 網絡服務器模型因一些相對較新的備受矚目的網絡服務器應用程序而聞名,例如 Nginx。 基本上,它在同一個進程中完成所有三個任務,在它們之間進行多路復用。 為了提高效率,它需要一些相當高級的內核功能,例如 epoll 和 kqueue。 在這個模型中,代碼由傳入的連接和數據“事件”驅動,並實現了一個“事件循環”,如下所示:
- 詢問操作系統是否有任何新的網絡“事件”(例如新連接或傳入數據)
- 如果有新的連接可用,建立它們(任務 #1)
- 如果有可用數據,請閱讀它(任務 #2)並對其採取行動(任務 #3)
- 重複直到服務器退出
所有這些都在單個進程中完成,並且可以非常高效地完成,因為它完全避免了進程之間的上下文切換,這通常會扼殺 MP 模型中的性能。 這裡唯一的上下文切換來自系統調用,並且通過僅對附加了一些事件的特定連接進行操作來最小化這些切換。 只要有效負載工作(任務#3)不是過於復雜或資源密集,這個模型就可以同時處理數万個連接。
但是,這種方法有兩個主要缺點:
- 由於所有三個任務都在一個循環迭代中按順序完成,因此有效負載工作(任務#3)與其他所有任務同步完成,這意味著如果計算對客戶端接收到的數據的響應需要很長時間,其他所有任務在此過程中停止,從而引入潛在的巨大延遲波動。
- 僅使用單個 CPU 內核。 再次,這具有絕對限制操作系統所需的上下文切換數量的好處,這提高了整體性能,但具有任何其他可用 CPU 內核根本不做任何事情的顯著缺點。
正是由於這些原因,需要更高級的模型。

優點:可以在操作系統上實現高性能和簡單(即,需要最少的操作系統干預)。 只需要一個 CPU 內核。
缺點:僅使用單個 CPU(無論可用的數量如何)。 如果有效載荷工作不統一,則會導致響應延遲不統一。
分階段事件驅動架構 (SEDA) 模型
SEDA 網絡服務器模型有點複雜。 它將復雜的、事件驅動的應用程序分解為一組由隊列連接的階段。 但是,如果不仔細實施,它的性能可能會遇到與 MP 案例相同的問題。 它是這樣工作的:
- 有效負載工作(任務#3)被劃分為盡可能多的階段或模塊。 每個模塊都實現了一個特定的功能(想想“微服務”或“微內核”),它駐留在自己的獨立進程中,這些模塊通過消息隊列相互通信。 這種架構可以表示為節點圖,其中每個節點都是一個進程,邊是消息隊列。
- 單個進程執行任務 #1(通常遵循 SPED 模型),它將新連接卸載到特定入口點節點。 這些節點可以是純網絡節點(任務#2),將數據傳遞給其他節點進行計算,也可以實現有效負載處理(任務#3)。 通常沒有“主”進程(例如,收集和聚合響應並通過連接將它們發送回的進程),因為每個節點都可以自己響應。
理論上,該模型可以任意複雜,節點圖可能具有循環、與其他類似應用程序的連接,或者節點實際在遠程系統上執行的位置。 然而,在實踐中,即使有明確定義的消息和高效的隊列,思考和推理整個系統的行為也會變得笨拙。 與 SPED 模型相比,如果在每個節點上完成的工作很短,則消息傳遞開銷會破壞該模型的性能。 該模型的效率明顯低於SPED模型,因此通常用於有效載荷工作複雜且耗時的情況。
優點:終極軟件架構師的夢想:一切都被隔離成整潔的獨立模塊。
缺點:複雜性會因模塊的數量而爆炸式增長,而且消息隊列仍然比直接內存共享慢得多。
非對稱多進程事件驅動 (AMPED) 模型
AMPED 網絡服務器是一種更溫和、更易於建模的 SEDA 版本。 沒有那麼多不同的模塊和流程,也沒有那麼多的消息隊列。 以下是它的工作原理:
- 以 SPED 樣式在單個“主”流程中實施任務 #1 和 #2。 這是唯一進行網絡 IO 的進程。
- 在單獨的“工作”進程(可能在多個實例中啟動)中實施任務 #3,通過隊列(每個進程一個隊列)連接到主進程。
- 當在“主”進程中接收到數據時,找到一個未充分利用(或空閒)的工作進程並將數據傳遞到其消息隊列。 當響應準備好時,進程會向主進程發送消息,此時它將響應傳遞給連接。
這裡重要的是,有效負載工作是在固定(通常可配置)數量的進程中執行的,這與連接數無關。 這裡的好處是有效載荷可以任意複雜,並且不會影響網絡 IO(這對延遲有好處)。 也有可能提高安全性,因為只有一個進程在做網絡 IO。
優點:網絡 IO 和有效負載工作的非常清晰的分離。
缺點:利用消息隊列在進程之間來回傳遞數據,這取決於協議的性質,可能會成為瓶頸。
對稱多進程事件驅動 (SYMPED) 模型
SYMPED 網絡服務器模型在很多方面都是網絡服務器模型的“聖杯”,因為它就像擁有多個獨立的 SPED“工作”進程實例。 它是通過在一個循環中讓一個進程接受連接,然後將它們傳遞給工作進程來實現的,每個工作進程都有一個類似於 SPED 的事件循環。 這有一些非常有利的後果:
- CPU 會根據產生的進程數進行加載,這些進程在每個時間點都在進行網絡 IO 或負載處理。 沒有辦法進一步提高 CPU 利用率。
- 如果連接是獨立的(例如使用 HTTP),則工作進程之間沒有進程間通信。
事實上,這是新版本的 Nginx 所做的; 它們產生少量的工作進程,每個工作進程都運行一個事件循環。 為了讓事情變得更好,大多數操作系統都提供了一個功能,通過該功能,多個進程可以獨立地偵聽 TCP 端口上的傳入連接,從而無需專門處理網絡連接的特定進程。 如果您正在處理的應用程序可以通過這種方式實現,我建議您這樣做。
優點:嚴格的 CPU 使用上限,具有可控數量的類似 SPED 的循環。
缺點:由於每個進程都有一個類似 SPED 的循環,如果負載工作不均勻,延遲可能會再次發生變化,就像正常的 SPED 模型一樣。
一些低級技巧
除了為您的應用程序選擇最佳架構模型之外,還有一些可用於進一步提高網絡代碼性能的低級技巧。 以下是一些更有效的簡短列表:
- 避免動態內存分配。 作為解釋,只需查看流行的內存分配器的代碼——它們使用複雜的數據結構、互斥體,而且其中的代碼非常多(例如,jemalloc 大約有 450 KiB 的 C 代碼!)。 上面的大多數模型都可以使用完全靜態(或預分配)的網絡和/或緩衝區來實現,它們只在需要時更改線程之間的所有權。
- 使用操作系統可以提供的最大值。 大多數操作系統允許多個進程在單個套接字上偵聽,並實現在套接字上接收到第一個字節(甚至是第一個完整請求!)之前不會接受連接的功能。 如果可以,請使用 sendfile()。
- 了解您正在使用的網絡協議! 例如,禁用 Nagle 算法通常是有意義的,如果(重新)連接率很高,則禁用延遲也是有意義的。 了解 TCP 擁塞控制算法,看看嘗試一種較新的算法是否有意義。
在以後的博文中,我可能會更多地討論這些,以及使用的其他技術和技巧。 但就目前而言,這有望為編寫高性能網絡代碼的架構選擇及其相對優缺點提供有用且信息豐富的基礎。
