千萬級併發實現的祕密:內核不是解決方案,而是問題所在!
既然我們已經解決了 C10K併發連接問題,應該如何提高水平支持千萬級併發連接?你可能會說不可能。不,現在系統已經在用你可能不熟悉甚至激進的方式支持千萬級別的併發連接。
要知道它是如何做到的,我們首先要了解Errata Security的CEO Robert Graham,以及他在Shmoocon 2013大會上的“無稽之談”—— C10M Defending The Internet At Scale。
Robert用一種我以前從未聽說的方式來很巧妙地解釋了這個問題。他首先介紹了一點有關Unix的歷史,Unix的設計初衷並不是一般的服務器操作系統,而是電話網絡的控制系統。由於是實際傳送數據的電話網絡,所以在控制層和數據層之間有明確的界限。問題是我們現在根本不應該使用Unix服務器作爲數據層的一部分。正如設計只運行一個應用程序的服務器內核,肯定和設計多用戶的服務器內核是不同的。
這意味着:
- 不要讓內核執行所有繁重的任務。將數據包處理,內存管理,處理器調度等任務從內核轉移到應用程序高效地完成。讓Linux只處理控制層,數據層完全交給應用程序來處理。
最終就是要設計這樣一個系統,該系統可以處理千萬級別的併發連接,它在200個時鐘週期內處理數據包,在14萬個時鐘週期內處理應用程序邏輯。由於一次主存儲器訪問就要花費300個時鐘週期,所以這是最大限度的減少代碼和緩存丟失的關鍵。
面向數據層的系統可以每秒處理1千萬個數據包,面向控制層的系統,每秒只能處理1百萬個數據包。
這似乎很極端,請記住一句老話:可擴展性是專業化的。爲了做好一些事情,你不能把性能問題外包給操作系統來解決,你必須自己做。
現在,讓我們學習Robert如何創建一個能夠處理千萬級別併發連接的系統。
十年前,工程師處理C10K可擴展性問題時,儘量避免服務器處理超過1萬個的併發連接。通過改進操作系統內核以及用事件驅動服務器(如Nginx和Node)代替線程服務器(Apache),這個問題已經被解決。人們用十年的時間從Apache轉移到可擴展服務器,在近幾年,可擴展服務器的採用率增長得更快了。
Apache的問題
- Apache的問題在於服務器的性能會隨着連接數的增多而變差
- 關鍵點:性能和可擴展性並不是一回事。當人們談論規模時,他們往往是在談論性能,但是規模和性能是不同的,比如Apache。
- 持續幾秒的短期連接,比如快速事務,如果每秒處理1000個事務,只有約1000個併發連接到服務器。
- 事務延長到10秒,要維持每秒1000個事務,必須打開1萬個併發連接。這種情況下:儘管你不顧DoS攻擊,Apache也會性能陡降;同時大量的下載操作也會使Apache崩潰。
- 如果每秒處理的連接從5千增加到1萬,你會怎麼做?比方說,你升級硬件並且提高處理器速度到原來的2倍。發生了什麼?你得到兩倍的性能,但你沒有得到兩倍的處理規模。每秒處理的連接可能只達到了6000。你繼續提高速度,情況也沒有改善。甚至16倍的性能時,仍然不能處理1萬個併發連接。所以說性能和可擴展性是不一樣的。
- 問題在於Apache會創建一個CGI進程,然後關閉,這個步驟並沒有擴展。
- 爲什麼呢?內核使用的O(N^2)算法使服務器無法處理1萬個併發連接。
- 內核中的兩個基本問題:
- 連接數=線程數/進程數。當一個數據包進來,內核會遍歷其所有進程以決定由哪個進程來處理這個數據包。
- 連接數=選擇數/輪詢次數(單線程)。同樣的可擴展性問題,每個包都要走一遭列表上所有的socket。
- 解決方法:改進內核使其在常數時間內查找。
- 使線程切換時間與線程數量無關。
- 使用一個新的可擴展epoll()/IOCompletionPort常數時間去做socket查詢。
- 因爲線程調度並沒有得到擴展,所以服務器大規模對socket使用epoll方法,這樣就導致需要使用異步編程模式,而這些編程模式正是Nginx和Node類型服務器具有的;所以當從Apache遷移到Nginx和Node類型服務器時,即使在一個配置較低的服務器上增加連接數,性能也不會突降;所以在10K連接時,一臺筆記本電腦的速度甚至超過了16核的服務器。
C10M問題——未來十年
不遠的將來,服務器將要處理數百萬的併發連接。IPv6協議下,每個服務器的潛在連接數都是數以百萬級的,所以處理規模需要升級。
- 如IDS / IPS這類應用程序需要支持這種規模,因爲它們連接到一個服務器骨幹網。其他例子:DNS根服務器,TOR節點,互聯網Nmap,視頻流,銀行,Carrier NAT,VoIP PBX,負載均衡器,網頁緩存,防火牆,電子郵件接收,垃圾郵件過濾。
- 通常人們將互聯網規模問題歸根於應用程序而不是服務器,因爲他們賣的是硬件+軟件。你買設備,並將其應用到你的數據中心。這些設備可能包含一塊Intel主板或網絡處理器以及用來加密和檢測數據包的專用芯片等。
- 截至2013年2月,40gpbs, 32-cores, 256gigs RAM的X86服務器在Newegg網站上的報價是5000美元。該服務器可以處理1萬個以上的併發連接,如果它們不能,那是因爲你選擇了錯誤的軟件,而不是底層硬件的問題。這個硬件可以很容易地擴展到1千萬個併發連接。
10M的併發連接挑戰意味着什麼:
- 1千萬的併發連接數
- 100萬個連接/秒——每個連接以這個速率持續約10秒
- 10GB/秒的連接——快速連接到互聯網。
- 1千萬個數據包/秒——據估計目前的服務器每秒處理50K的數據包,以後會更多。過去服務器每秒可以處理100K的中斷,並且每一個數據包都產生中斷。
- 10微秒的延遲——可擴展服務器也許可以處理這個規模,但延遲可能會飆升。
- 10微秒的抖動——限制最大延遲
- 併發10核技術——軟件應支持更多核的服務器。通常情況下,軟件能輕鬆擴展到四核。服務器可以擴展到更多核,因此需要重寫軟件,以支持更多核的服務器。
我們所學的是Unix而不是網絡編程
- 很多程序員通過W. Richard Stevens所著的《Unix網絡編程》學習網絡編程技術。問題是,這本書是關於Unix的,而不只是網絡編程。它告訴你,讓Unix做所有繁重的工作,你只需要在Unix的上層寫一個小服務器。但內核規模不夠,解決的辦法是儘可能將業務移動到內核之外,並且自己處理所有繁重的業務。
- 這方面有影響的一個例子是Apache每個連接線程的模型。這意味着線程調度程序根據將要到來的數據確定接下來調用哪一個read()函數,也就是把線程調度系統當作數據包調度系統來用。(我真的很喜歡這一點,從來沒有想過這樣的說法)。
- Nginx宣稱,它不把線程調度當作數據包調度程序,而是自己進行數據包調度。使用select找到socket,我們知道數據來了,就可以立即讀取並處理數據,數據也不會堵塞。
- 經驗:讓Unix處理網絡堆棧,但之後的業務由你來處理。
怎樣編寫規模較大的軟件?
如何改變你的軟件,使其規模化?許多隻提升硬件性能去支撐項目擴展的經驗都是錯誤的,我們需要知道性能的實際情況。
要達到到更高的水平,需要解決的問題如下:
- 數據包的可擴展性
- 多核的可擴展性
- 內存的可擴展性
實現數據包可擴展——編寫自己的個性化驅動來繞過堆棧
- 數據包的問題是它們需經Unix內核的處理。網絡堆棧複雜緩慢,數據包最好直接到達應用程序,而非經過操作系統處理之後。
- 做到這一點的方法是編寫自己的驅動程序。所有驅動程序將數據包直接發送到應用程序,而不是通過堆棧。你可以找到這種驅動程序:PF_RING,NETMAP,Intel DPDK(數據層開發套件)。Intel不是開源的,但有很多相關的技術支持。
- 速度有多快?Intel的基準是在一個相當輕量級的服務器上,每秒處理8000萬個數據包(每個數據包200個時鐘週期)。這也是通過用戶模式。將數據包向上傳遞,使用用戶模式,處理完畢後再返回。Linux每秒處理的數據包個數不超過百萬個,將UDP數據包提高到用戶模式,再次出去。客戶驅動程序和Linux的性能比是80:1。
- 對於每秒1000萬個數據包的目標,如果200個時鐘週期被用來獲取數據包,將留下1400個時鐘週期實現類似DNS / IDS的功能。
- 通過PF_RING得到的是原始數據包,所以你必須做你的TCP堆棧。人們所做的是用戶模式棧。Intel有現成的可擴展TCP堆棧
多核的可擴展性
多核可擴展性不同於多線程可擴展性。我們都熟知這個理念:處理器的速度並沒有變快,我們只是靠增加數量來達到目的。
大多數的代碼都未實現4核以上的並行。當我們添加更多內核時,下降的不僅僅是性能等級,處理速度可能也會變得越來越慢,這是軟件的問題。我們希望軟件的提高速度同內核的增加接近線性正相關。
多線程編程不同於多核編程
- 多線程
- 每個CPU內核中不止一個線程
- 用鎖來協調線程(通過系統調用)
- 每個線程有不同的任務
- 多核
- 每個CPU內核中只有一個線程
- 當兩個線程/內核訪問同一個數據時,不能停下來互相等待
- 同一個任務的不同線程
- 要解決的問題是怎樣將一個應用程序分佈到多個內核中去
- Unix中的鎖在內核實現。4內核使用鎖的情況是大多數軟件開始等待其他線程解鎖。因此,增加內核所獲得的收益遠遠低於等待中的性能損耗。
- 我們需要這樣一個架構,它更像高速公路而不是紅綠燈控制的十字路口,無需等待,每個人都以自己的節奏行進,儘可能節省開銷。
- 解決方案:
- 在每個核心中保存數據結構,然後聚合的對數據進行讀取。
- 原子性。CPU支持可以通過C語言調用的指令,保證原子性,避免衝突發生。開銷很大,所以不要處處使用。
- 無鎖的數據結構。線程無需等待即可訪問,在不同的架構下都是複雜的工作,請不要自己做。
- 線程模型,即流水線與工作線程模型。這不只是同步的問題,而是你的線程如何架構。
- 處理器關聯。告訴操作系統優先使用前兩個內核,然後設置線程運行在哪一個內核上,你也可以通過中斷到達這個目的。所以,CPU由你來控制而不是Linux。
內存的可擴展性
- 如果你有20G的RAM,假設每次連接佔用2K的內存,如果你還有20M的三級緩存,緩存中會沒有數據。數據轉移到主存中處理花費300個時鐘週期,此時CPU沒有做任何事情。
- 每個數據包要有1400個時鐘週期(DNS / IDS的功能)和200個時鐘週期(獲取數據包)的開銷,每個數據包我們只有4個高速緩存缺失,這是一個問題。
- 聯合定位數據
- 不要通過指針在滿內存亂放數據。每次你跟蹤一個指針,都會是一個高速緩存缺失:[hash pointer] -> [Task Control Block] -> [Socket] -> [App],這是四個高速緩存缺失。
- 保持所有的數據在一個內存塊:[TCB |socket| APP]。給所有塊預分配內存,將高速緩存缺失從4減少到1。
- 分頁
- 32GB的數據需佔用64MB的分頁表,不適合都存儲在高速緩存。所以存在兩個高速緩存缺失——分頁表和它所指向的數據。這是開發可擴展的軟件不能忽略的細節。
- 解決方案:壓縮數據,使用有很多內存訪問的高速緩存架構,而不是二叉搜索樹
- NUMA架構加倍了主存訪問時間。內存可能不在本地socket,而是另一個socket上。
- 內存池
- 啓動時立即預先分配所有的內存
- 在對象,線程和socket的基礎上進行分配。
- 超線程
- 每個網絡處理器最多可以運行4個線程,英特爾只能運行2個。
- 在適當的情況下,我們還需要掩蓋延時,比如內存訪問中一個線程在等待另一個全速的線程。
- 大內存頁
- 減小頁表規模。從一開始就預留內存,讓你的應用程序管理內存。
總結
- 網卡
- 問題:通過內核工作效率不高
- 解決方案:使用自己的驅動程序並管理它們,使適配器遠離操作系統。
- CPU
- 問題:使用傳統的內核方法來協調你的應用程序是行不通的。
- 解決方案:Linux管理前兩個CPU,你的應用程序管理其餘的CPU。中斷只發生在你允許的CPU上。
- 內存
- 問題:內存需要特別關注,以求高效。
- 解決方案:在系統啓動時就分配大部分內存給你管理的大內存頁
控制層交給Linux,應用程序管理數據。應用程序與內核之間沒有交互,沒有線程調度,沒有系統調用,沒有中斷,什麼都沒有。
然而,你有的是在Linux上運行的代碼,你可以正常調試,這不是某種怪異的硬件系統,需要特定的工程師。你需要定製的硬件在數據層提升性能,但是必須是在你熟悉的編程和開發環境上進行。
原文連接:The Secret To 10 Million Concurrent Connections -The Kernel Is The Problem, Not The Solution (文/周小璐,審校/仲浩)