高併發也算是這幾年的熱門詞彙了,尤爲在互聯網圈,開口不聊個高併發問題,都很差意思出門。react
高併發有那麼邪乎嗎?動不動就千萬併發、億級流量,聽上去的確挺嚇人。但仔細想一想,這麼大的併發與流量不都是經過路由器來的嗎?算法
一切源自網卡數據庫
高併發的流量經過低調的路由器進入咱們系統,第一道關卡就是網卡,網卡怎麼抗住高併發?編程
這個問題壓根就不存在,千萬併發在網卡看來,同樣同樣的,都是電信號,網卡眼裏根本區分不出來你是千萬併發仍是一股洪流,因此衡量網卡牛不牛都說帶寬,歷來沒有併發量的說法。設計模式
網卡位於物理層和鏈路層,最終把數據傳遞給網絡層(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
咱們以讀操做爲例,當咱們調用 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 爲何會慢,你們比較承認的緣由有兩點:
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 裏,消費者也能夠訂閱跨網絡的事件,從這個角度看,你們都在取長補短。