高並發也算是這幾年的熱門詞彙了,尤其在互連網圈,開口不聊個高並發問題,都不好意思出門。
高並發有那麼邪乎嗎?動不動就千萬並發、億級流量,聽上去的確挺嚇人。但仔細想想,這麼大的並發與流量不都是通過路由器來的嗎?
一切源自網卡
高並發的流量通過低調的路由器進入我們系統,第一道關卡就是網卡,網卡怎麼抗住高並發?
這個問題壓根就不存在,千萬並發在網卡看來,一樣一樣的,都是電訊號,網卡眼雷根本區分不出來你是千萬並發還是一股洪流,所以衡量網卡牛不牛都說頻寬,從來沒有並發量的說法。
網卡位於物理層和鏈路層,最終把資料傳遞給網路層(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 裡,消費者也可以訂閱跨網路的事件,從這個角度看,大家都在取長補短。