1. 程式人生 > >如何將高並發拉下神壇!

如何將高並發拉下神壇!

mut 操作系統 最大 調度算法 設計模式 場景 事件分發 消息接收 nio

高並發也算是這幾年的熱門詞匯了,尤其在互聯網圈,開口不聊個高並發問題,都不好意思出門。

高並發有那麽邪乎嗎?動不動就千萬並發、億級流量,聽上去的確挺嚇人。但仔細想想,這麽大的並發與流量不都是通過路由器來的嗎?

一切源自網卡

高並發的流量通過低調的路由器進入我們系統,第一道關卡就是網卡,網卡怎麽抗住高並發?

這個問題壓根就不存在,千萬並發在網卡看來,一樣一樣的,都是電信號,網卡眼裏根本區分不出來你是千萬並發還是一股洪流,所以衡量網卡牛不牛都說帶寬,從來沒有並發量的說法。

網卡位於物理層和鏈路層,最終把數據傳遞給網絡層(IP 層),在網絡層有了 IP 地址,已經可以識別出你是千萬並發了。

所以搞網絡層的可以自豪的說,我解決了高並發問題,可以出來吹吹牛了。誰沒事搞網絡層呢?主角就是路由器,這玩意主要就是玩兒網絡層。

一頭霧水

非專業的我們,一般都把網絡層(IP 層)和傳輸層(TCP 層)放到一起,操作系統提供,對我們是透明的,很低調、很靠譜,以至於我們都把它忽略了。

吹過的牛是從應用層開始的,應用層一切都源於 Socket,那些千萬並發最終會經過傳輸層變成千萬個 Socket,那些吹過的牛,不過就是如何快速處理這些 Socket。處理 IP 層數據和處理 Socket 究竟有啥不同呢?

沒有連接,就沒有等待

最重要的一個不同就是 IP 層不是面向連接的,而 Socket 是面向連接的。IP 層沒有連接的概念,在 IP 層,來一個數據包就處理一個,不用瞻前也不用顧後。

而處理 Socket,必須瞻前顧後,Socket 是面向連接的,有上下文的,讀到一句我愛你,激動半天,你不前前後後地看看,就是瞎激動了。

你想前前後後地看明白,就要占用更多的內存去記憶,就要占用更長的時間去等待;不同連接要搞好隔離,就要分配不同的線程(或者協程)。所有這些都解決好,貌似還是有點難度的。

感謝操作系統

操作系統是個好東西,在 Linux 系統上,所有的 IO 都被抽象成了文件,網絡 IO 也不例外,被抽象成 Socket。

但是 Socket 還不僅是一個 IO 的抽象,它同時還抽象了如何處理 Socket,最著名的就是 select 和 epoll 了。

知名的 Nginx、Netty、Redis 都是基於 epoll 做的,這仨家夥基本上是在千萬並發領域的必備神技。

但是多年前,Linux 只提供了 select,這種模式能處理的並發量非常小,而 epoll 是專為高並發而生的,感謝操作系統。

不過操作系統沒有解決高並發的所有問題,只是讓數據快速地從網卡流入我們的應用程序,如何處理才是老大難。

操作系統的使命之一就是最大限度的發揮硬件的能力,解決高並發問題,這也是最直接、最有效的方案,其次才是分布式計算。

前面我們提到的 Nginx、Netty、Redis 都是最大限度發揮硬件能力的典範。如何才能最大限度的發揮硬件能力呢?

核心矛盾

要最大限度的發揮硬件能力,首先要找到核心矛盾所在。我認為,這個核心矛盾從計算機誕生之初直到現在,幾乎沒有發生變化,就是 CPU 和 IO 之間的矛盾。

CPU 以摩爾定律的速度野蠻發展,而 IO 設備(磁盤,網卡)卻乏善可陳。龜速的 IO 設備成為性能瓶頸,必然導致 CPU 的利用率很低,所以提升 CPU 利用率幾乎成了發揮硬件能力的代名詞。

中斷與緩存

CPU 與 IO 設備的協作基本都是以中斷的方式進行的,例如讀磁盤的操作,CPU 僅僅是發一條讀磁盤到內存的指令給磁盤驅動,之後就立即返回了。

此時 CPU 可以接著幹其他事情,讀磁盤到內存本身是個很耗時的工作,等磁盤驅動執行完指令,會發個中斷請求給 CPU,告訴 CPU 任務已經完成,CPU 處理中斷請求,此時 CPU 可以直接操作讀到內存的數據。

中斷機制讓 CPU 以最小的代價處理 IO 問題,那如何提高設備的利用率呢?答案就是緩存。

操作系統內部維護了 IO 設備數據的緩存,包括讀緩存和寫緩存。讀緩存很容易理解,我們經常在應用層使用緩存,目的就是盡量避免產生讀 IO。

寫緩存應用層使用的不多,操作系統的寫緩存,完全是為了提高 IO 寫的效率。

操作系統在寫 IO 的時候會對緩存進行合並和調度,例如寫磁盤會用到電梯調度算法。

高效利用網卡

高並發問題首先要解決的是如何高效利用網卡。網卡和磁盤一樣,內部也是有緩存的,網卡接收網絡數據,先存放到網卡緩存,然後寫入操作系統的內核空間(內存),我們的應用程序則讀取內存中的數據,然後處理。

除了網卡有緩存外,TCP/IP 協議內部還有發送緩沖區和接收緩沖區以及 SYN 積壓隊列、accept 積壓隊列。

這些緩存,如果配置不合適,則會出現各種問題。例如在 TCP 建立連接階段,如果並發量過大,而 Nginx 裏面 Socket 的 backlog 設置的值太小,就會導致大量連接請求失敗。

如果網卡的緩存太小,當緩存滿了後,網卡會直接把新接收的數據丟掉,造成丟包。

當然如果我們的應用讀取網絡 IO 數據的效率不高,會加速網卡緩存數據的堆積。如何高效讀取網絡數據呢?目前在 Linux 上廣泛應用的就是 epoll 了。

操作系統把 IO 設備抽象為文件,網絡被抽象成了 Socket,Socket 本身也是一個文件,所以可以用 read/write 方法來讀取和發送網絡數據。在高並發場景下,如何高效利用 Socket 快速讀取和發送網絡數據呢?

要想高效利用 IO,就必須在操作系統層面了解 IO 模型,在《UNIX網絡編程》這本經典著作裏總結了五種 IO 模型,分別是:

阻塞式 IO

非阻塞式 IO

多路復用 IO

信號驅動 IO

異步 IO

阻塞式 IO

我們以讀操作為例,當我們調用 read 方法讀取 Socket 上的數據時,如果此時 Socket 讀緩存是空的(沒有數據從 Socket 的另一端發過來),操作系統會把調用 read 方法的線程掛起,直到 Socket 讀緩存裏有數據時,操作系統再把該線程喚醒。

當然,在喚醒的同時,read 方法也返回了數據。我理解所謂的阻塞,就是操作系統是否會掛起線程。

非阻塞式 IO

而對於非阻塞式 IO,如果 Socket 的讀緩存是空的,操作系統並不會把調用 read 方法的線程掛起,而是立即返回一個 EAGAIN 的錯誤碼。

在這種情景下,可以輪詢 read 方法,直到 Socket 的讀緩存有數據則可以讀到數據,這種方式的缺點非常明顯,就是消耗大量的 CPU。

多路復用 IO

對於阻塞式 IO,由於操作系統會掛起調用線程,所以如果想同時處理多個 Socket,就必須相應地創建多個線程。

線程會消耗內存,增加操作系統進行線程切換的負載,所以這種模式不適合高並發場景。有沒有辦法較少線程數呢?

非阻塞 IO 貌似可以解決,在一個線程裏輪詢多個 Socket,看上去可以解決線程數的問題,但實際上這個方案是無效的。

原因是調用 read 方法是一個系統調用,系統調用是通過軟中斷實現的,會導致進行用戶態和內核態的切換,所以很慢。

但是這個思路是對的,有沒有辦法避免系統調用呢?有,就是多路復用 IO。

在 Linux 系統上 select/epoll 這倆系統 API 支持多路復用 IO,通過這兩個 API,一個系統調用可以監控多個 Socket,只要有一個 Socket 的讀緩存有數據了,方法就立即返回。

然後你就可以去讀這個可讀的 Socket 了,如果所有的 Socket 讀緩存都是空的,則會阻塞,也就是將調用 select/epoll 的線程掛起。

所以 select/epoll 本質上也是阻塞式 IO,只不過它們可以同時監控多個 Socket。

select 和 epoll 的區別

為什麽多路復用 IO 模型有兩個系統 API?我分析原因是,select 是 POSIX 標準中定義的,但是性能不夠好,所以各個操作系統都推出了性能更好的 API,如 Linux 上的 epoll、Windows 上的 IOCP。

至於 select 為什麽會慢,大家比較認可的原因有兩點:

一點是 select 方法返回後,需要遍歷所有監控的 Socket,而不是發生變化的 Socket。

還有一點是每次調用 select 方法,都需要在用戶態和內核態拷貝文件描述符的位圖(通過調用三次 copy_from_user 方法拷貝讀、寫、異常三個位圖)。

epoll 可以避免上面提到的這兩點。

Reactor 多線程模型

在 Linux 操作系統上,性能最為可靠、穩定的 IO 模式就是多路復用,我們的應用如何能夠利用好多路復用 IO 呢?

經過前人多年實踐總結,搞了一個 Reactor 模式,目前應用非常廣泛,著名的 Netty、Tomcat NIO 就是基於這個模式。

Reactor 的核心是事件分發器和事件處理器,事件分發器是連接多路復用 IO 和網絡數據處理的中樞,監聽 Socket 事件(select/epoll_wait)。

然後將事件分發給事件處理器,事件分發器和事件處理器都可以基於線程池來做。

需要重點提一下的是,在 Socket 事件中主要有兩大類事件,一個是連接請求,另一個是讀寫請求,連接請求成功處理之後會創建新的 Socket,讀寫請求都是基於這個新創建的 Socket。

所以在網絡處理場景中,實現 Reactor 模式會稍微有點繞,但是原理沒有變化。

具體實現可以參考 Doug Lea 的《Scalable IO in Java》(http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf)。

Reactor 原理圖

Nginx 多進程模型

Nginx 默認采用的是多進程模型,Nginx 分為 Master 進程和 Worker 進程。

真正負責監聽網絡請求並處理請求的只有 Worker 進程,所有的 Worker 進程都監聽默認的 80 端口,但是每個請求只會被一個 Worker 進程處理。

這裏面的玄機是:每個進程在 accept 請求前必須爭搶一把鎖,得到鎖的進程才有權處理當前的網絡請求。

每個 Worker 進程只有一個主線程,單線程的好處是無鎖處理,無鎖處理並發請求,這基本上是高並發場景裏面的最高境界了。(參考http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf)

數據經過網卡、操作系統、網絡協議中間件(Tomcat、Netty 等)重重關卡,終於到了我們應用開發人員手裏,我們如何處理這些高並發的請求呢?我們還是先從提升單機處理能力的角度來思考這個問題。

突破木桶理論

我們還是先從提升單機處理能力的角度來思考這個問題,在實際應用的場景中,問題的焦點是如何提高 CPU 的利用率(誰叫它發展的最快呢)。

木桶理論講最短的那根板決定水位,那為啥不是提高短板 IO 的利用率,而是去提高 CPU 的利用率呢?

這個問題的答案是在實際應用中,提高了 CPU 的利用率往往會同時提高 IO 的利用率。

當然在 IO 利用率已經接近極限的條件下,再提高 CPU 利用率是沒有意義的。我們先來看看如何提高 CPU 的利用率,後面再看如何提高 IO 的利用率。

並行與並發

提升 CPU 利用率目前主要的方法是利用 CPU 的多核進行並行計算,並行和並發是有區別的。

在單核 CPU 上,我們可以一邊聽 MP3,一邊 Coding,這個是並發,但不是並行,因為在單核 CPU 的視野,聽 MP3 和 Coding 是不可能同時進行的。

只有在多核時代,才會有並行計算。並行計算這東西太高級,工業化應用的模型主要有兩種,一種是共享內存模型,另外一種是消息傳遞模型。

多線程設計模式

對於共享內存模型,其原理基本都來自大師 Dijkstra 在半個世紀前(1965)的一篇論文《Cooperating sequential processes》。

這篇論文提出了大名鼎鼎的概念信號量,Java 裏面用於線程同步的 wait/notify 也是信號量的一種實現。

大師的東西看不懂,學不會也不用覺得丟人,畢竟大師的嫡傳子弟也沒幾個。

東洋有個叫結城浩的總結了一下多線程編程的經驗,寫了本書叫《JAVA多線程設計模式》,這個還是挺接地氣(能看懂)的,下面簡單介紹一下。

Single Threaded Execution

這個模式是把多線程變成單線程,多線程在同時訪問一個變量時,會發生各種莫名其妙的問題,這個設計模式直接把多線程搞成了單線程,於是安全了,當然性能也就下來了。

最簡單的實現就是利用 synchronized 將存在安全隱患的代碼塊(方法)保護起來。

在並發領域有個臨界區(criticalsections)的概念,我感覺和這個模式是一回事。

Immutable Pattern

如果共享變量永遠不變,那多個線程訪問就沒有任何問題,永遠安全。這個模式雖然簡單,但是用的好,能解決很多問題。

Guarded Suspension Patten

這個模式其實就是等待-通知模型,當線程執行條件不滿足時,掛起當前線程(等待);當條件滿足時,喚醒所有等待的線程(通知),在 Java 語言裏利用 synchronized,wait/notifyAll 可以很快實現一個等待通知模型。

結城浩將這個模式總結為多線程版的 If,我覺得非常貼切。

Balking

這個模式和上個模式類似,不同點是當線程執行條件不滿足時直接退出,而不是像上個模式那樣掛起。

這個用法最大的應用場景是多線程版的單例模式,當對象已經創建了(不滿足創建對象的條件)就不用再創建對象(退出)。

Producer-Consumer

生產者-消費者模式,全世界人都知道。我接觸的最多的是一個線程處理 IO(如查詢數據庫),一個(或者多個)線程處理 IO 數據,這樣 IO 和 CPU 就都能充分利用起來。

如果生產者和消費者都是 CPU 密集型,再搞生產者-消費者就是自己給自己找麻煩了。

Read-Write Lock

讀寫鎖解決的是讀多寫少場景下的性能問題,支持並行讀,但是寫操作只允許一個線程做。

如果寫操作非常非常少,而讀的並發量非常非常大,這個時候可以考慮使用寫時復制(copy on write)技術,我個人覺得應該單獨把寫時復制作為一個模式。

Thread-Per-Message

就是我們經常提到的一請求一線程。

Worker Thread

一請求一線程的升級版,利用線程池解決線程的頻繁創建、銷毀導致的性能問題。BIO 年代 Tomcat 就是用的這種模式。

Future

當你調用某個耗時的同步方法很心煩,想同時幹點別的事情,可以考慮用這個模式,這個模式的本質是個同步變異步的轉換器。

同步之所以能變異步,本質上是啟動了另外一個線程,所以這個模式和一請求一線程還是多少有點關系的。

Two-Phase Termination

這個模式能解決優雅地終止線程的需求。

Thread-Specific Storage

線程本地存儲,避免加鎖、解鎖開銷的利器,C# 裏面有個支持並發的容器 ConcurrentBag 就是采用了這個模式。

這個星球上最快的數據庫連接池 HikariCP 借鑒了 ConcurrentBag 的實現,搞了個 Java 版的,有興趣的同學可以參考。

Active Object(這個不講也罷)

這個模式相當於降龍十八掌的最後一掌,綜合了前面的設計模式,有點復雜,個人覺得借鑒的意義大於參考實現。

最近國人也出過幾本相關的書,但總體還是結城浩這本更能經得住推敲。基於共享內存模型解決並發問題,主要問題就是用好鎖。

但是用好鎖,還是有難度的,所以後來又有人搞了消息傳遞模型。

消息傳遞模型

共享內存模型難度還是挺大的,而且你沒有辦法從理論上證明寫的程序是正確的,我們總一不小心就會寫出來個死鎖的程序來,每當有了問題,總會有大師出來。

於是消息傳遞(Message-Passing)模型橫空出世(發生在上個世紀 70 年代),消息傳遞模型有兩個重要的分支,一個是 Actor 模型,一個是 CSP 模型。

Actor 模型

Actor 模型因為 Erlang 聲名鵲起,後來又出現了 Akka。在 Actor 模型裏面,沒有操作系統裏所謂進程、線程的概念,一切都是 Actor,我們可以把 Actor 想象成一個更全能、更好用的線程。

在 Actor 內部是線性處理(單線程)的,Actor 之間以消息方式交互,也就是不允許 Actor 之間共享數據。沒有共享,就無需用鎖,這就避免了鎖帶來的各種副作用。

Actor 的創建和 new 一個對象沒有啥區別,很快、很小,不像線程的創建又慢又耗資源。

Actor 的調度也不像線程會導致操作系統上下文切換(主要是各種寄存器的保存、恢復),所以調度的消耗也很小。

Actor 還有一個有點爭議的優點,Actor 模型更接近現實世界,現實世界也是分布式的、異步的、基於消息的、尤其 Actor 對於異常(失敗)的處理、自愈、監控等都更符合現實世界的邏輯。

但是這個優點改變了編程的思維習慣,我們目前大部分編程思維習慣其實是和現實世界有很多差異的。一般來講,改變我們思維習慣的事情,阻力總是超乎我們的想象。

CSP 模型

Golang 在語言層面支持 CSP 模型,CSP 模型和 Actor 模型的一個感官上的區別是在 CSP 模型裏面,生產者(消息發送方)和消費者(消息接收方)是完全松耦合的,生產者完全不知道消費者的存在。

但是在 Actor 模型裏面,生產者必須知道消費者,否則沒辦法發送消息。

CSP 模型類似於我們在多線程裏面提到的生產者-消費者模型,核心的區別我覺得在於 CSP 模型裏面有類似綠色線程(green thread)的東西。

綠色線程在 Golang 裏面叫做協程,協程同樣是個非常輕量級的調度單元,可以快速創建而且資源占用很低。

Actor 在某種程度上需要改變我們的思維方式,而 CSP 模型貌似沒有那麽大動靜,更容易被現在的開發人員接受,都說 Golang 是工程化的語言,在 Actor 和 CSP 的選擇上,也可以看到這種體現。

多樣世界

除了消息傳遞模型,還有事件驅動模型、函數式模型。事件驅動模型類似於觀察者模式,在 Actor 模型裏面,消息的生產者必須知道消費者才能發送消息、

而在事件驅動模型裏面,事件的消費者必須知道消息的生產者才能註冊事件處理邏輯。

Akka 裏消費者可以跨網絡,事件驅動模型的具體實現如 Vertx 裏,消費者也可以訂閱跨網絡的事件,從這個角度看,大家都在取長補短。

如何將高並發拉下神壇!