既然我們已經解決了過萬並發連接(C10K concurrent connection problem)的問題,現在如何升級到支持千萬級的並發連接?你會說:“不可能”。不,現在,一些系統通過使用一些不廣為人知的先進技術,已經能夠提供千萬級的並發連接。
為了明白這是如何實現,我們找到了Errata Security的CEO—— Robert Graham和他在Shmoocon 2013上精彩絕倫的演講—— C10M Defending The Internet At Scale(譯者注:翻牆的同學可以去看看)。
Robert解決這個問題的方法如此技藝高超,此前我從未聽說過。他首先講了一段Unix的歷史,他提到Unix系統最開始並不是設計成通用的服務器操作系統,而是設計成電話網絡中的控制系統。電話網絡中,控制面與數據面有明顯的區分,數據傳輸是在電話網絡中進行的。問題就在於我們現在把Unix服務器當成是用戶面的部分來使用,這是不應該的。如果我們設計內核時是為了讓每台服務器運行一個應用程序,那會與現在的多用戶內核有巨大得差別。
所以,他說關鍵是在於明白:
·內核並不是解決方法,內核是問題所在。
意思是:
·不要讓內核做所有繁重的工作。將數據包處理,內存管理和線程調度等從內核中移出來,放到應用程序里,使其處理得更加高效。讓Linux內核處理控制面,應用程序處理數據面。
這樣,系統在處理上千萬的並發連接時,200個時鍾周期用於數據包處理,1400個時鍾周期用於程序邏輯。由於內存訪問要使用300個時鍾周期,使用減少代碼和減少cache丟失的方法進行設計也是關鍵所在。
一個專門的數據面處理系統,可以處理每秒1千萬的數據包。而一個控制面的處理系統,每秒只能處理1百萬的數據包。
如果這看起來很極端,那么記住一句老話:可擴展性是一門技術活。為了做出成功的系統,千萬別把性能“外包”給操作系統。你必須親自去完成。
現在,然我們看一下Robert是如何構建一個具備支持千萬級別並發連接能力的系統……
C10K 問題 – 上一個十年
十年前,工程師們解決了C10K(concurrent 10,000)可擴展性問題。他們通過為內核打補丁、從多線程服務器(如Apache)遷移到事件驅動服務器(如Nginx和Node)。人們從Apache到可擴展服務器遷移了10年。在最近幾年,我們看到了人們更快的采用可擴展服務器。
Apache的問題
·Apache的問題在於,隨着連接的增多,性能愈發下降。
·關鍵點:性能與可擴展性是正交的。這兩個是不同的概念。當人們討論擴展時他們常常會說到性能,但是這兩者間有着明顯的區別。
·對於只持續幾秒的短連接,我們稱之為快事務(quick transaction),如果你能夠處理1000TPS(Transaction Per Second),那么你的服務器只能支持1000的並發連接。
·如果事務的時長改為10秒,現在在1000TPS的情況下你能夠支持10,000的連接。Apache的性能會急劇下降,即使這可能會觸發了DoS攻擊的檢測。通過大量的下載就能使Apache宕機。
·如果你能夠每秒處理5000連接,而你要怎么做才能夠每秒處理10,000連接呢?假設你升級了硬件而且使處理器快了兩倍。會發生什么?你能夠獲得兩倍的性能,但是你不能獲得兩倍的擴展。也許你只能夠處理每秒6000的連接。如果持續升級硬件,你會得到同樣的結果。性能與可擴展性是不一樣的。
·問題在於Apache會創建一個CGI進程然后殺掉他。這導致了其不可擴展。
·為什么?服務器不能處理10,000個並發連接是由於內核使用O(n)算法。
·內核中兩個基本問題:
1) 連接數=線程數/進程數。一個數據包進來,內核要遍歷所有10,000個進程找到處理這個數據包的進程。
2) 連接數=select數/poll數。同樣的可擴展問題,每一個數據包都要遍歷sockets列表。
·解決方法:為內核打上補丁,使其查找時間為常數。
1) 現在無論線程數量多少,線程的切換時間是常數。
2) 使用epoll()/IOCompeltionPort 可擴展的系統調用能夠在常數時間查找socket。
·線程調度仍不能夠擴展,所以服務器使用epoll的異步編程模型,在Node和Nginx中都體現了。即使一台較慢的服務器,增加連接數時性能不會急劇下降。10,000的連接,筆記本都能比16核的服務器快。
C10M問題 -- 下一個十年
在不遠的未來,服務器將需要處理百萬級別的並發連接。隨着IPv6的普及,我們要開始下一個階段的擴展,使得服務器支持的連接數達到百萬。
·需要這樣的可擴展性應用包括:IDS/IPS,因為他們連接到服務器的骨干。其他應用如DNS根服務器,TOR節點,因特網的Nmap,視頻流,金融,NAT,Voip交換機,負載均衡服務器,web緩存,防火牆,郵件接收服務,垃圾郵件過濾等。
·其他遇到擴展問題的人包括設備供應商,因為他們銷售軟硬一體的設備。你購買這些設備直接放置到數據中心里使用。這些設備可能會有一塊專門用於加密,數據包解析等的Intel主板或者網絡處理器。
·在新蛋網上一台40gbps,32核,256G內存的X86服務器的價格只要5000美金。這樣的服務器能夠處理超過10,000的連接。如果不行的話,是因為你軟件設計不好,並不是硬件的問題。這樣的硬件可以很輕易擴展到千萬的並發連接。
千萬並發連接的挑戰意味着什么:
1. 一千萬並發連接數
2. 每秒一百萬連接數 —— 每個連接持續時間大概是10秒
3. 每秒100億比特 —— 因特網的快速鏈接
4. 每秒1千萬個數據包 —— 預計,當前服務器每秒處理50,000個數據包,這將要提高一個層次。每個數據包會觸發一次中斷,而之前服務器每秒能處理100,000個中斷。
5. 10毫秒的時延 —— 可擴展的服務器或許能夠解決得了擴展問題,但是時延並不行。
6. 10毫秒的抖動 —— 限制最大時延
7. 10個CPU —— 軟件將要擴展到多核的服務器。大多數軟件只可以輕易擴展到4個核,由於服務器要擴展到更多核的服務器,所以軟件也要重寫做相應的支持。
我們了解到Unix並不是用於網絡編程
·將近一代的程序員都學習過Richard Stevens編寫的<<Unix Network Programming>>。問題在於這本書是關於Unix,而不僅僅是網絡編程。他告訴你如何讓Unix完成繁重的工作,而你也只在Unix上編寫一個小小的服務器。但是內核並不是可擴展的。解決方法在於將繁重的工作從內核剝離,由自己完成。
·比如,考慮一下Apache每個連接一個線程的模型帶來的影響。這意味着線程調度器根據收到的數據包來決定調用哪一個線程的read()。這等於是將線程調度器當做數據包調度器來使用。(我真的很喜歡這個模型,以前從來沒想過這個方法。譯者注:這是反話吧)
·而Nginx並不把線程調度器當做數據包調度器來使用。而是自己完成數據包調度。使用select來查找socket,一旦發現數據馬上讀取,這樣就不會產生阻塞。
·讓Unix處理網絡協議棧,而你處理所有其他的事情。
如何編寫可擴展的軟件
如何修改軟件使其可擴展呢?許多關於硬件處理能力的經驗估計都是錯誤的。我們要知道實際的性能能力是什么。
為了更進一步,我們要解決一下幾個問題:
1. 數據包可擴展性
2. 多核可擴展性
3. 內存可擴展性
數據包擴展 —— 編寫自定義的驅動旁路內核協議棧
·數據包的問題在於他們需要穿過Unix內核。內核協議棧既復雜又慢。數據包到達你的程序的路徑應該更加直接。不要讓操作系統來處理數據包。
·編寫你自己的驅動方法是,驅動只需要將數據包發送給你的應用程序,而不要經過內核的協議棧。你可以找到的驅動:PF_RING, Netmap, Intel DPDK (數據包開發套件)。Intel是不開源的,但是其提供許多的支持。
·多快?Intel有一個基准,在一台輕量級服務器上每秒能處理8千萬個數據包。這是在用戶態實現的。數據包到達用戶態,然后再發送出去。而使用Linux處理UDP的情況下,每秒只能達到1百萬個數據包。自定義的驅動是Linux的80倍。
·為了實現每秒處理1千萬個數據包,200個時鍾周期用於獲取數據包,剩余1400個時鍾周期用於實現應用程序功能。
·通過PF_RING收到的原生數據包,你必須自己實現TCP協議棧。很多人已經實現了用戶態的協議棧。比如Intel就提供了一個高性能可擴展的TCP協議棧。
多核的可擴展性
多核的擴展和多線程的擴展並不完全相同。我們知道,相比更快的處理器,獲取更多的處理器要來得容易。
大多數的代碼不能擴展超過4個核。當我們添加更多的核時,性能並沒有不斷提升。這是因為軟件的問題。我們要使軟件能夠隨着核數線性的擴展,要使軟件通過增多核數來提高性能。
多線程編程並不是多核編程
·多線程:
·每個核超過一個線程
·線程通過鎖來同步
·每個線程一個任務
·多核:
·每個核一個線程
·當兩個線程訪問同一個數據的時候,他們不能停下來等待對方
·所有的線程做同一個任務
·我們的問題是如何擴展應用程序到多個核
·在Unix中鎖是在內核中實現。當核在等待線程釋放鎖時,內核開始占用我們的CPU資源。 我們需要的架構是像高速公路而不是有交通燈控制的十字路口。我們要使每個線程都按照自己的步伐在運行,而不是等待。
·解決方法:
·每個核一個數據結構。
·原子性。使用C語言中CPU支持的原子指令。保證原子性,而絕不要發生沖突。但隨之而來的是昂貴的代價,所以不要到處使用。
·免鎖的數據結構。線程間訪問這些數據是不需要停止和等待。千萬不要自己去實現,因為這樣的數據結構跨平台的實現會非常復雜。
·線程模型。流水線和工作者線程模型。問題不僅僅在於同步,而且在於線程的設計。
·處理核附着。讓操作系統使用前兩個核。設置你的線程到其他處理核上。同樣的方法可以用到中斷上。這樣你就可以獨占這些CPU,而不受Linux內核影響。
內存擴展
·問題在於,如果你有20G的內存,假設每個連接使用2K,在你只有20M的L3緩存的情況下,沒有任何連接數據是在緩存里的。這會消耗300個時鍾周期用來到內存中取數據。
·仔細考慮我們每個數據包1400個時鍾周期的處理預算。記住那200個時鍾周期/每數據包的額外開銷。我們只可以允許4次cache丟失,這是個問題。
·使數據放置到一起
·不要通過指針胡亂的引用內存里的數據。每一次你引用指針將會導致一次cache丟失:[hash pointer] -> [Task Control Block] -> [Socket] -> [App]。這就4次cache丟失。
·將數據放到同一個內存塊里:[TCB|Socket|App]。通過預分配所有的內存塊來保護內存。這會將cache丟失次數從4減到1。
·分頁
·32G內存就需要64M的分頁表,cache裝不下。所以你會有兩次cache丟失,一次是分頁表,而另一次是頁表所指的內存。這是我們考慮可擴展軟件時不能忽略的細節。
·解決方法:壓縮數據;使用高效的緩存數據結構替代有大量內存訪問的二叉搜索樹。
·NUMA架構使得內存訪問時間加倍。
·內存池
·啟動時一次預分配所有的內存。
·基於每一個對象,每一個線程,每一個套接字進行分配。
·超線程
·網絡處理器上每個處理器可以跑4個線程,而Intel只可以跑2個。
·這可以掩蓋時延,比如內存訪問,因為當一個線程在等待時,另一個可全速執行。
·超大頁
·減少分頁表的大小。從一開始就保留內存,然后由應用程序來管理。
總結
·NIC
·問題:數據包通過內核協議棧,效率不高
·解決方法:編寫自己的驅動來管理協議棧
·CPU
·問題:如果你使用傳統的方法在內核上構建應用程序,這並不高效
·解決方法:賦予Linux前兩個CPU,剩下的CPU由你的應用程序管理。這些CPU上不允許網卡中斷。
·內存
·問題:需要仔細考慮如何使其更加高效
·解決方法:在系統啟動時預分配大部分內存,由自己來管理。
控制面留給Linux,數據面跑在應用程序的代碼里。他從不與內核交互,沒有線程調度,沒有系統調用,沒有中斷,什么都沒有。
還有,你手上的是可以正常調試運行在Linux上的代碼,而不是由特定工程師構造的一些奇怪的硬件系統。你可以通過熟悉的編程語言和開發環境,達到你期望的特定硬件處理數據的性能。
[英文原文: The Secret To 10 Million Concurrent Connections -The Kernel Is The Problem, Not The Solution]