高併發也算是這幾年的熱門詞彙了,尤爲在互聯網圈,開口不聊個高併發問題,都很差意思出門。高併發有那麼邪乎嗎?動不動就千萬併發、億級流量,聽上去的確挺嚇人。但仔細想一想,這麼大的併發與流量不都是經過路由器來的嗎react
0x00 一切源自網卡nginx
高併發的流量經過低調的路由器進入咱們系統,第一道關卡就是網卡,網卡怎麼抗住高併發?這個問題壓根就不存在,千萬併發在網卡看來,同樣同樣的,都是電信號,網卡眼裏根本區分不出來你是千萬併發仍是一股洪流,因此衡量網卡牛不牛都說帶寬,歷來沒有併發量的說法。redis
網卡位於物理層和鏈路層,最終把數據傳遞給網絡層(IP層),在網絡層有了IP地址,已經能夠識別出你是千萬併發了,因此搞網絡層的能夠自豪的說,我解決了高併發問題,能夠出來吹吹牛了。誰沒事搞網絡層呢?主角就是路由器,這玩意主要就是玩兒網絡層算法
0x01 一頭霧水數據庫
非專業的咱們,通常都把網絡層(IP層)和傳輸層(TCP層)放到一塊兒,操做系統提供,對咱們是透明的,很低調、很靠譜,以致於咱們都把他忽略了。編程
吹過的牛是從應用層開始的,應用層一切都源於Socket,那些千萬併發最終會通過傳輸層變成千萬個Socket,那些吹過的牛,不過就是如何快速處理這些Socket。處理IP層數據和處理Socket究竟有啥不一樣呢?設計模式
0x02 沒有鏈接,就沒用等待緩存
最重要的一個不一樣就是IP層不是面向鏈接的,而Socket是面向鏈接的,IP層沒有鏈接的概念,在IP層,來一個數據包就處理一個,不用瞻前也不用顧後;而處理Socket,必須瞻前顧後,Socket是面向鏈接的,有上下文的,讀到一句我愛你,激動半天,你不前先後後地看看,就是瞎激動了。安全
你想前先後後地看明白,就要佔用更多的內存去記憶,就要佔用更長的時間去等待;不一樣鏈接要搞好隔離,就要分配不一樣的線程(或者協程)。全部這些都解決好,貌似仍是有點難度的。網絡
0x03 感謝操做系統
操做系統是個好東西,在Linux系統上,全部的IO都被抽象成了文件,網絡IO也不例外,被抽象成Socket,可是Socket還不只是一個IO的抽象,它同時還抽象瞭如何處理Socket,最著名的就是select和epoll了,知名的nginx、netty、redis都是基於epoll搞的,這仨傢伙基本上是在千萬併發領域必備神技。
可是多年前,Linux只提供了select的,這種模式能處理的併發量很是小,而epoll是專爲高併發而生的,感謝操做系統。不過操做系統沒有解決高併發的全部問題,只是讓數據快速地從網卡流入咱們的應用程序,如何處理纔是老大難。
操做系統的使命之一就是最大限度的發揮硬件的能力,解決高併發問題,這也是最直接、最有效的方案,其次纔是分佈式計算。前面咱們提到的nginx、netty、redis都是最大限度發揮硬件能力的典範。如何才能最大限度的發揮硬件能力呢?
0x04 核心矛盾
要最大限度的發揮硬件能力,首先要找到核心矛盾所在。我認爲,這個核心矛盾從計算機誕生之初直到如今,幾乎沒有發生變化,就是CPU和IO之間的矛盾。
CPU以摩爾定律的速度野蠻發展,而IO設備(磁盤,網卡)卻乏善可陳。龜速的IO設備成爲性能瓶頸,必然致使CPU的利用率很低,因此提高CPU利用率幾乎成了發揮硬件能力的代名詞。
0x05 中斷與緩存
CPU與IO設備的協做基本都是以中斷的方式進行的,例如讀磁盤的操做,CPU僅僅是發一條讀磁盤到內存的指令給磁盤驅動,以後就當即返回了,此時CPU能夠接着幹其餘事情,讀磁盤到內存自己是個很耗時的工做,等磁盤驅動執行完指令,會發箇中斷請求給CPU,告訴CPU任務已經完成,CPU處理中斷請求,此時CPU能夠直接操做讀到內存的數據。
中斷機制讓CPU以最小的代價處理IO問題,那如何提升設備的利用率呢?答案就是緩存。
操做系統內部維護了IO設備數據的緩存,包括讀緩存和寫緩存,讀緩存很容易理解,咱們常常在應用層使用緩存,目的就是儘可能避免產生讀IO。
寫緩存應用層使用的很少,操做系統的寫緩存,徹底是爲了提升IO寫的效率。操做系統在寫IO的時候會對緩存進行合併和調度,例如寫磁盤會用到電梯調度算法。
0x06 高效利用網卡
高併發問題首先要解決的是如何高效利用網卡。網卡和磁盤同樣,內部也是有緩存的,網卡接收網絡數據,先存放到網卡緩存,而後寫入操做系統的內核空間(內存),咱們的應用程序則讀取內存中的數據,而後處理。
除了網卡有緩存外,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。
0x07 阻塞式IO
咱們以讀操做爲例,當咱們調用read方法讀取Socket上的數據時,若是此時Socket讀緩存是空的(沒有數據從Socket的另外一端發過來),操做系統會把調用read方法的線程掛起,直到Socket讀緩存裏有數據時,操做系統再把該線程喚醒。
固然,在喚醒的同時,read方法也返回了數據。我理解所謂的阻塞,就是操做系統是否會掛起線程。
0x08 非阻塞式IO
而對於非阻塞式IO,若是Socket的讀緩存是空的,操做系統並不會把調用read方法的線程掛起,而是當即返回一個EAGAIN的錯誤碼,在這種情景下,能夠輪詢read方法,直到Socket的讀緩存有數據則能夠讀到數據,這種方式的缺點很是明顯,就是消耗大量的CPU。
0x09 多路複用IO
對於阻塞式IO,因爲操做系統會掛起調用線程,因此若是想同時處理多個Socket,就必須相應地建立多個線程,線程會消耗內存,增長操做系統進行線程切換的負載,因此這種模式不適合高併發場景。有沒有辦法較少線程數呢?
非阻塞IO貌似能夠解決,在一個線程裏輪詢多個Socket,看上去能夠解決線程數的問題,但實際上這個方案是無效的,緣由是調用read方法是一個系統調用,系統調用是經過軟中斷實現的,會致使進行用戶態和內核態的切換,因此很慢。
可是這個思路是對的,有沒有辦法避免系統調用呢?有,就是多路複用IO。
在Linux系統上select/epoll這倆系統API支持多路複用IO,經過這兩個API,一個系統調用能夠監控多個Socket,只要有一個Socket的讀緩存有數據了,方法就當即返回,而後你就能夠去讀這個可讀的Socket了,若是全部的Socket讀緩存都是空的,則會阻塞,也就是將調用select/epoll的線程掛起。
因此select/epoll本質上也是阻塞式IO,只不過他們能夠同時監控多個Socket。
0x0A select和epoll的區別
爲何多路複用IO模型有兩個系統API?我分析緣由是,select是POSIX標準中定義的,可是性能不夠好,因此各個操做系統都推出了性能更好的API,如Linux上的epoll、Windows上的IOCP。
至於select爲何會慢,你們比較承認的緣由有兩點,一點是select方法返回後,須要遍歷全部監控的Socket,而不是發生變化的Ssocket,還有一點是每次調用select方法,都須要在用戶態和內核態拷貝文件描述符的位圖(經過調用三次copy_from_user方法拷貝讀、寫、異常三個位圖)。epoll能夠避免上面提到的這兩點。
0x0B 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原理圖
0x0C Nginx多進程模型
Nginx默認採用的是多進程模型,Nginx分爲Master進程和Worker進程,真正負責監聽網絡請求並處理請求的只有Worker進程,全部的Worker進程都監聽默認的80端口,可是每一個請求只會被一個Worker進程處理。
這裏面的玄機是:每一個進程在accept請求前必須爭搶一把鎖,獲得鎖的進程纔有權處理當前的網絡請求。每一個Worker進程只有一個主線程,單線程的好處是無鎖處理,無鎖處理併發請求,這基本上是高併發場景裏面的最高境界了。(參考http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf)
數據通過網卡、操做系統、網絡協議中間件(Tomcat、Netty等)重重關卡,終於到了咱們應用開發人員手裏,咱們如何處理這些高併發的請求呢?咱們仍是先從提高單機處理能力的角度來思考這個問題。
0x0D 突破木桶理論
據通過網卡、操做系統、中間件(Tomcat、Netty等)重重關卡,終於到了咱們應用開發人員手裏,咱們如何處理這些高併發的請求呢?
咱們仍是先從提高單機處理能力的角度來思考這個問題,在實際應用的場景中,問題的焦點是如何提升CPU的利用率(誰叫它發展的最快呢),木桶理論講最短的那根板決定水位,那爲啥不是提升短板IO的利用率,而是去提升CPU的利用率呢?
這個問題的答案是在實際應用中,提升了CPU的利用率每每會同時提升IO的利用率。固然在IO利用率已經接近極限的條件下,再提升CPU利用率是沒有意義的。咱們先來看看如何提升CPU的利用率,後面再看如何提升IO的利用率。
0x0E 並行與併發
提高CPU利用率目前主要的方法是利用CPU的多核進行並行計算,並行和併發是有區別的,在單核CPU上,咱們能夠一邊聽MP3,一邊Coding,這個是併發,但不是並行,由於在單核CPU的視野,聽MP3和Coding是不可能同時進行的。
只有在多核時代,纔會有並行計算。並行計算這東西過高級,工業化應用的模型主要有兩種,一種是共享內存模型,另一種是消息傳遞模型。
0x0F 多線程設計模式
對於共享內存模型,其原理基本都來自大師Dijkstra在半個世紀前(1965)的一篇論文《Cooperating sequential processes》,這篇論文提出了大名鼎鼎的概念信號量,Java裏面用於線程同步的wait/notify也是信號量的一種實現。
大師的東西看不懂,學不會也不用以爲丟人,畢竟大師的嫡傳子弟也沒幾個。東洋有個叫結城浩的總結了一下多線程編程的經驗,寫了本書叫《JAVA多線程設計模式》,這個仍是挺接地氣(能看懂)的。下面簡單介紹一下。
1. Single Threaded Execution
這個模式是把多線程變成單線程,多線程在同時訪問一個變量時,會發生各類莫名其妙的問題,這個設計模式直接把多線程搞成了單線程,因而安全了,固然性能也就下來了。最簡單的實現就是利用synchronized將存在安全隱患的代碼塊(方法)保護起來。在併發領域有個臨界區(criticalsections)的概念,我感受和這個模式是一回事。
2. Immutable Pattern
若是共享變量永遠不變,那就多個線程訪問就沒有任何問題,永遠安全。這個模式雖然簡單,可是用的好,能解決不少問題。
3. Guarded Suspension Patten
這個模式其實就是等待-通知模型,當線程執行條件不知足時,掛起當前線程(等待),當條件知足時,喚醒全部等待的線程(通知),在Java語言裏利用synchronized,wait/notifyAll能夠很快實現一個等待通知模型。結城浩將這個模式總結爲多線程版的If,我以爲很是貼切。
4. Balking
這個模式和上個模式相似,不一樣點是當線程執行條件不知足時直接退出,而不是像上個模式那樣掛起。這個用法最大的應用場景是多線程版的單例模式,當對象已經建立了(不知足建立對象的條件)就不用再建立對象(退出)。
5. Producer-Consumer
生產者-消費者模式,全世界人都知道。我接觸的最多的是一個線程處理IO(如查詢數據庫),一個(或者多個)線程處理IO數據,這樣IO和CPU就都能成分利用起來。若是生產者和消費者都是CPU密集型,再搞生產者-消費者就是本身給本身找麻煩了。
6. Read-Write Lock
讀寫鎖解決的讀多寫少場景下的性能問題,支持並行讀,可是寫操做只容許一個線程作。若是寫操做很是很是少,而讀的併發量很是很是大,這個時候能夠考慮使用寫時複製(copy on write)技術,我我的以爲應該單獨把寫時複製單獨做爲一個模式。
7. Thread-Per-Message
就是咱們常常提到的一請求一線程。
8. Worker Thread
一請求一線程的升級版,利用線程池解決線程的頻繁建立、銷燬致使的性能問題。BIO年代Tomcat就是用的這種模式。
9. Future
當你調用某個耗時的同步方法很心煩,想同時乾點別的事情,能夠考慮用這個模式,這個模式的本質是個同步變異步的轉換器。同步之因此能變異步,本質上是啓動了另一個線程,因此這個模式和一請求一線程仍是多少有點關係的。
10. Two-Phase Termination
這個模式能解決優雅地終止線程的需求。
11. Thread-Specific Storage
線程本地存儲,避免加鎖、解鎖開銷的利器,C#裏面有個支持併發的容器ConcurrentBag就是採用了這個模式,這個星球上最快的數據庫鏈接池HikariCP借鑑了ConcurrentBag的實現,搞了個Java版的,有興趣的同窗能夠參考。
12. Active Object(這個不講也罷)
這個模式至關於降龍十八掌的最後一掌,綜合了前面的設計模式,有點複雜,我的以爲借鑑的意義大於參考實現。
最近國人也出過幾本相關的書,但整體仍是結城浩這本更能經得住推敲。基於共享內存模型解決併發問題,主要問題就是用好鎖,可是用好鎖,仍是有難度的,因此後來又有人搞了消息傳遞模型,這個後面再聊。
基於共享內存模型解決併發問題,主要問題就是用好鎖,可是用好鎖,仍是有難度的,因此後來又有人搞了消息傳遞模型。
0x10 消息傳遞模型
共享內存模型難度仍是挺大的,並且你沒有辦法從理論上證實寫的程序是正確的,咱們總一不當心就會寫出來個死鎖的程序來,每當有了問題,總會有大師出來,因而消息傳遞(Message-Passing)模型橫空出世(發生在上個世紀70年代),消息傳遞模型有兩個重要的分支,一個是Actor模型,一個是CSP模型
0x11 Actor模型
Actor模型由於Erlang聲名鵲起,後來又出現了Akka。在Actor模型裏面,沒有操做系統裏所謂進程、線程的概念,一切都是Actor,咱們能夠把Actor想象成一個更全能、更好用的線程。
在Actor內部是線性處理(單線程)的,Actor之間以消息方式交互,也就是不容許Actor之間共享數據,沒有共享,就無需用鎖,這就避免了鎖帶來的各類反作用。
Actor的建立和new一個對象沒有啥區別,很快、很小,不像線程的建立又慢又耗資源;Actor的調度也不像線程會致使操做系統上下文切換(主要是各類寄存器的保存、恢復),因此調度的消耗也很小。
Actor還有一個有點爭議的優勢,Actor模型更接近現實世界,現實世界也是分佈式的、異步的、基於消息的、尤爲Actor對於異常(失敗)的處理、自愈、監控等都更符合現實世界的邏輯。
可是這個優勢改變了編程的思惟習慣,咱們目前大部分編程思惟習慣實際上是和現實世界有不少差別的(這個回頭再細說),通常來說,改變咱們思惟習慣的事情,阻力老是超乎咱們的想象。
0x12 CSP模型
Golang在語言層面支持CSP模型,CSP模型和Actor模型的一個感官上的區別是在CSP模型裏面,生產者(消息發送方)和消費者(消息接收方)是徹底鬆耦合的,生產者徹底不知道消費者的存在,可是在Actor模型裏面,生產者必須知道消費者,不然沒辦法發送消息。
CSP模型相似於咱們在多線程裏面提到的生產者-消費者模型,核心的區別我以爲在於CSP模型裏面有相似綠色線程(green thread)的東西,綠色線程在Golang裏面叫作協程,協程一樣是個很是輕量級的調度單元,能夠快速建立並且資源佔用很低。
Actor在某種程度上須要改變咱們的思惟方式,而CSP模型貌似沒有那麼大動靜,更容易被如今的開發人員接受,都說Golang是工程化的語言,在Actor和CSP的選擇上,也能夠看到這種體現。
0x13 多樣世界
除了消息傳遞模型,還有事件驅動模型、函數式模型。事件驅動模型相似於觀察者模式,在Actor模型裏面,消息的生產者必須知道消費者才能發送消息,而在事件驅動模型裏面,事件的消費者必須知道消息的生產者才能註冊事件處理邏輯。
Akka裏消費者能夠跨網絡,事件驅動模型的具體實現如Vertx裏,消費者也能夠訂閱跨網絡的事件,從這個角度看,你們都在取長補短。