前面內容,咱們學習了 Linux 網絡的基礎原理以及性能觀測方法。簡單回顧一下,Linux網絡基於 TCP/IP 模型,構建了其網絡協議棧,把繁雜的網絡功能劃分爲應用層、傳輸層、網絡層、網絡接口層等四個不一樣的層次,既解決了網絡環境中設備異構的問題,也解耦了網絡協議的複雜性。編程
基於 TCP/IP 模型,咱們還梳理了 Linux 網絡收發流程和相應的性能指標。在應用程序經過套接字接口發送或者接收網絡包時,這些網絡包都要通過協議棧的逐層處理。咱們一般
用帶寬、吞吐、延遲、PPS 等來衡量網絡性能。數組
今天,咱們主要來回顧下經典的 C10K 和 C1000K 問題,以更好理解 Linux 網絡的工做原理,並進一步分析,如何作到單機支持 C10M緩存
注意,C10K 和 C1000K 的首字母 C 是 Client 的縮寫。C10K 就是單機同時處理 1 萬個請求(併發鏈接 1 萬)的問題,而 C1000K 也就是單機支持處理 100 萬個請求(併發鏈接100 萬)的問題。bash
C10K 問題最先由 Dan Kegel 在 1999 年提出。那時的服務器還只是 32 位系統,運行着Linux 2.2 版本(後來又升級到了 2.4 和 2.6,而 2.6 才支持 x86_64),只配置了不多的
內存(2GB)和千兆網卡。服務器
從資源上來講,對 2GB 內存和千兆網卡的服務器來講,同時處理 10000 個請求,只要每一個請求處理佔用不到 200KB(2GB/10000)的內存和 100Kbit (1000Mbit/10000)的網絡
網絡帶寬就能夠。因此,物理資源是足夠的,接下來天然是軟件的問題,特別是網絡的I/O 模型問題。架構
說到 I/O 的模型,我在文件系統的原理中,曾經介紹過文件 I/O,其實網絡 I/O 模型也相似。在 C10K 之前,Linux 中網絡處理都用同步阻塞的方式,也就是每一個請求都分配一個
進程或者線程。請求數只有 100 個時,這種方式天然沒問題,但增長到 10000 個請求時,10000 個進程或線程的調度、上下文切換乃至它們佔用的內存,都會成爲瓶頸。併發
第一,怎樣在一個線程內處理多個請求,也就是要在一個線程內響應多個網絡 I/O。之前的同步阻塞方式下,一個線程只能處理一個請求,到這裏再也不適用,是否是能夠用非阻塞
I/O 或者異步 I/O 來處理多個網絡請求呢?負載均衡
第二,怎麼更節省資源地處理客戶請求,也就是要用更少的線程來服務這些請求。是否是能夠繼續用原來的 100 個或者更少的線程,來服務如今的 10000 個請求呢?異步
固然,事實上,如今 C10K 的問題早就解決了,在繼續學習下面的內容前,你能夠先本身思考一下這兩個問題。結合前面學過的內容,你是否是已經有了解決思路呢?
異步、非阻塞 I/O 的解決思路,你應該據說過,其實就是咱們在網絡編程中常常用到的I/O 多路複用(I/O Multiplexing)。I/O 多路複用是什麼意思呢?
別急,詳細瞭解前,我先來說兩種 I/O 事件通知的方式:水平觸發和邊緣觸發,它們經常使用在套接字接口的文件描述符中。
一、水平觸發:只要文件描述符能夠非阻塞地執行 I/O ,就會觸發通知。也就是說,應用程序能夠隨時檢查文件描述符的狀態,而後再根據狀態,進行 I/O 操做。
二、邊緣觸發:只有在文件描述符的狀態發生改變(也就是 I/O 請求達到)時,才發送一次通知。這時候,應用程序須要儘量多地執行 I/O,直到沒法繼續讀寫,才能夠中止。
若是 I/O 沒執行完,或者由於某種緣由沒來得及處理,那麼此次通知也就丟失了
接下來,咱們再回過頭來看 I/O 多路複用的方法。這裏其實有不少實現方法,我帶你來逐個分析一下。
根據剛纔水平觸發的原理,select 和 poll 須要從文件描述符列表中,找出哪些能夠執行I/O ,而後進行真正的網絡 I/O 讀寫。因爲 I/O 是非阻塞的,一個線程中就能夠同時監控
一批套接字的文件描述符,這樣就達到了單線程處理多請求的目的。
因此,這種方式的最大優勢,是對應用程序比較友好,它的 API 很是簡單。
可是,應用軟件使用 select 和 poll 時,須要對這些文件描述符列表進行輪詢,這樣,請求數多的時候就會比較耗時。而且,select 和 poll 還有一些其餘的限制。
select 使用固定長度的位相量,表示文件描述符的集合,所以會有最大描述符數量的限制。好比,在 32 位系統中,默認限制是 1024。而且,在 select 內部,檢查套接字狀態
是用輪詢的方法,再加上應用軟件使用時的輪詢,就變成了一個 O(n^2) 的關係。
而 poll 改進了 select 的表示方法,換成了一個沒有固定長度的數組,這樣就沒有了最大描述符數量的限制(固然還會受到系統文件描述符限制)。但應用程序在使用 poll 時,同
樣須要對文件描述符列表進行輪詢,這樣,處理耗時跟描述符數量就是 O(N) 的關係。
除此以外,應用程序每次調用 select 和 poll 時,還須要把文件描述符的集合,從用戶空間傳入內核空間,由內核修改後,再傳出到用戶空間中。這一來一回的內核空間與用戶空
間切換,也增長了處理成本。
有沒有什麼更好的方式來處理呢?答案天然是確定的。
既然 select 和 poll 有那麼多的問題,就須要繼續對其進行優化,而 epoll 就很好地解決了這些問題。
epoll 使用紅黑樹,在內核中管理文件描述符的集合,這樣,就不須要應用程序在每次操做時都傳入、傳出這個集合。
epoll 使用事件驅動的機制,只關注有 I/O 事件發生的文件描述符,不須要輪詢掃描整個集合。
不過要注意,epoll 是在 Linux 2.6 中才新增的功能(2.4 雖然也有,但功能不完善)。因爲邊緣觸發只在文件描述符可讀或可寫事件發生時才通知,那麼應用程序就須要儘量多
地執行 I/O,並要處理更多的異常事件。
在前面文件系統原理的內容中,我曾介紹過異步 I/O 與同步 I/O 的區別。異步 I/O 容許應用程序同時發起不少 I/O
操做,而不用等待這些操做完成。而在 I/O 完成後,系統會用事件通知(好比信號或者回調函數)的方式,告訴應用程序。這時,應用程序纔會去查詢 I/O 操做的結果。
異步 I/O 也是到了 Linux 2.6 才支持的功能,而且在很長時間裏都處於不完善的狀態,好比 glibc 提供的異步 I/O 庫,就一直被社區詬病。同時,因爲異步 I/O 跟咱們的直觀邏輯
不太同樣,想要使用的話,必定要當心設計,其使用難度比較高。
瞭解了 I/O 模型後,請求處理的優化就比較直觀了。使用 I/O 多路複用後,就能夠在一個進程或線程中處理多個請求,其中,又有下面兩種不一樣的工做模型。
這種方法的一個通用工做模式就是:
主進程執行 bind() + listen() 後,建立多個子進程; 而後,在每一個子進程中,都經過 accept() 或 epoll_wait() ,來處理相同的套接字。
好比,最經常使用的反向代理服務器 Nginx 就是這麼工做的。它也是由主進程和多個 worker進程組成。主進程主要用來初始化套接字,並管理子進程的生命週期;而 worker 進程,
則負責實際的請求處理。我畫了一張圖來表示這個關係。
這裏要注意,accept() 和 epoll_wait() 調用,還存在一個驚羣的問題。換句話說,當網絡I/O 事件發生時,多個進程被同時喚醒,但實際上只有一個進程來響應這個事件,其餘被
喚醒的進程都會從新休眠。
爲了不驚羣問題, Nginx 在每一個 worker 進程中,都增長一個了全局鎖(accept_mutex)。這些 worker 進程須要首先競爭到鎖,只有競爭到鎖的進程,纔會加
入到 epoll 中,這樣就確保只有一個 worker 子進程被喚醒。
不過,根據前面 CPU 模塊的學習,你應該還記得,進程的管理、調度、上下文切換的成本很是高。那爲何使用多進程模式的 Nginx ,卻具備很是好的性能呢?
這裏最主要的一個緣由就是,這些 worker 進程,實際上並不須要常常建立和銷燬,而是在沒任務時休眠,有任務時喚醒。只有在 worker 因爲某些異常退出時,主進程才須要創
建新的進程來代替它。
固然,你也能夠用線程代替進程:主線程負責套接字初始化和子線程狀態的管理,而子線程則負責實際的請求處理。因爲線程的調度和切換成本比較低,實際上你能夠進一步把
epoll_wait() 都放到主線程中,保證每次事件都只喚醒主線程,而子線程只須要負責後續的請求處理。
在這種方式下,全部的進程都監聽相同的接口,而且開啓 SO_REUSEPORT 選項,由內核負責將請求負載均衡到這些監聽進程中去。這一過程以下圖所示。
因爲內核確保了只有一個進程被喚醒,就不會出現驚羣問題了。好比,Nginx 在 1.9.1 中就已經支持了這種模式。
不過要注意,想要使用 SO_REUSEPORT 選項,須要用 Linux 3.9 以上的版本才能夠。
基於 I/O 多路複用和請求處理的優化,C10K 問題很容易就能夠解決。不過,隨着摩爾定律帶來的服務器性能提高,以及互聯網的普及,你並不難想到,新興服務會對性能提出更高的要求。
很快,原來的 C10K 已經不能知足需求,因此又有了 C100K 和 C1000K,也就是併發從原來的 1 萬增長到 10 萬、乃至 100 萬。從 1 萬到 10 萬,其實仍是基於 C10K 的這些理
論,epoll 配合線程池,再加上 CPU、內存和網絡接口的性能和容量提高。大部分狀況下,C100K 很天然就能夠達到。
首先從物理資源使用上來講,100 萬個請求須要大量的系統資源。好比,
假設每一個請求須要 16KB 內存的話,那麼總共就須要大約 15 GB 內存。而從帶寬上來講,假設只有 20% 活躍鏈接,即便每一個鏈接只須要 1KB/s 的吞吐量,總
共也須要 1.6 Gb/s 的吞吐量。千兆網卡顯然知足不了這麼大的吞吐量,因此還須要配置萬兆網卡,或者基於多網卡 Bonding 承載更大的吞吐量。
其次,從軟件資源上來講,大量的鏈接也會佔用大量的軟件資源,好比文件描述符的數量、鏈接狀態的跟蹤(CONNTRACK)、網絡協議棧的緩存大小(好比套接字讀寫緩存、
TCP 讀寫緩存)等等。
最後,大量請求帶來的中斷處理,也會帶來很是高的處理成本。這樣,就須要多隊列網卡、中斷負載均衡、CPU 綁定、RPS/RFS(軟中斷負載均衡到多個 CPU 核上),以及將
網絡包的處理卸載(Offload)到網絡設備(如 TSO/GSO、LRO/GRO、VXLANOFFLOAD)等各類硬件和軟件的優化。
C1000K 的解決方法,本質上仍是構建在 epoll 的非阻塞 I/O 模型上。只不過,除了 I/O模型以外,還須要從應用程序到 Linux 內核、再到 CPU、內存和網絡等各個層次的深度優
化,特別是須要藉助硬件,來卸載那些原來經過軟件處理的大量功能。
顯然,人們對於性能的要求是無止境的。再進一步,有沒有可能在單機中,同時處理1000 萬的請求呢?這也就是 C10M 問題。
實際上,在 C1000K 問題中,各類軟件、硬件的優化極可能都已經作到頭了。特別是當升級完硬件(好比足夠多的內存、帶寬足夠大的網卡、更多的網絡功能卸載等)後,你可能
會發現,不管你怎麼優化應用程序和內核中的各類網絡參數,想實現 1000 萬請求的併發,都是極其困難的。
究其根本,仍是 Linux 內核協議棧作了太多太繁重的工做。從網卡中斷帶來的硬中斷處理程序開始,到軟中斷中的各層網絡協議處理,最後再到應用程序,這個路徑實在是太長
了,就會致使網絡包的處理優化,到了必定程度後,就沒法更進一步了。
要解決這個問題,最重要就是跳過內核協議棧的冗長路徑,把網絡包直接送到要處理的應用程序那裏去。這裏有兩種常見的機制,DPDK 和 XDP。
(圖片來自 https://blog.selectel.com/introduction-dpdk-architecture-principles/)
提及輪詢,你確定會下意識認爲它是低效的象徵,可是進一步反問下本身,它的低效主要體如今哪裏呢?是查詢時間明顯多於實際工做時間的狀況下吧!那麼,換個角度來想,如
果每時每刻都有新的網絡包須要處理,輪詢的優點就很明顯了。好比:
在 PPS 很是高的場景中,查詢時間比實際工做時間少了不少,絕大部分時間都在處理網絡包;
而跳過內核協議棧後,就省去了繁雜的硬中斷、軟中斷再到 Linux 網絡協議棧逐層處理的過程,應用程序能夠針對應用的實際場景,有針對性地優化網絡包的處理邏輯,而不
須要關注全部的細節。
此外,DPDK 還經過大頁、CPU 綁定、內存對齊、流水線併發等多種機制,優化網絡包的處理效率。
XDP 底層跟咱們以前用到的 bcc-tools 同樣,都是基於 Linux 內核的 eBPF 機制實現的。XDP 的原理以下圖所示:
(圖片來自 https://www.iovisor.org/technology/xdp)
你能夠看到,XDP 對內核的要求比較高,須要的是 Linux 4.8 以上版本,而且它也不提供緩存隊列。基於 XDP 的應用程序一般是專用的網絡應用,常見的有 IDS(入侵檢測系
統)、DDoS 防護、 cilium 容器網絡插件等。
今天我帶你回顧了經典的 C10K 問題,並進一步延伸到了 C1000K 和 C10M 問題。
C10K 問題的根源,一方面在於系統有限的資源;另外一方面,也是更重要的因素,是同步阻塞的 I/O 模型以及輪詢的套接字接口,限制了網絡事件的處理效率。Linux 2.6 中引入
的 epoll ,完美解決了 C10K 的問題,如今的高性能網絡方案都基於 epoll。
從 C10K 到 C100K ,可能只須要增長系統的物理資源就能夠知足;但從 C100K 到C1000K ,就不只僅是增長物理資源就能解決的問題了。這時,就須要多方面的優化工做
了,從硬件的中斷處理和網絡功能卸載、到網絡協議棧的文件描述符數量、鏈接狀態跟蹤、緩存隊列等內核的優化,再到應用程序的工做模型優化,都是考慮的重點。
再進一步,要實現 C10M ,就不僅是增長物理資源,或者優化內核和應用程序能夠解決的問題了。這時候,就須要用 XDP 的方式,在內核協議棧以前處理網絡包;或者用 DPDK
直接跳過網絡協議棧,在用戶空間經過輪詢的方式直接處理網絡包。
固然了,實際上,在大多數場景中,咱們並不須要單機併發 1000 萬的請求。經過調整系統架構,把這些請求分發到多臺服務器中來處理,一般是更簡單和更容易擴展的方案。