來自:京東技術(微訊號:jingdongjishu)
高併發也算是這幾年的熱門詞彙了,尤其在網際網路圈,開口不聊個高併發問題,都不好意思出門。高併發有那麼邪乎嗎?動不動就千萬併發、億級流量,聽上去的確挺嚇人。但仔細想想,這麼大的併發與流量不都是透過路由器來的嗎?
0x00 一切源自網絡卡
高併發的流量透過低調的路由器進入我們系統,第一道關卡就是網絡卡,網絡卡怎麼抗住高併發?這個問題壓根就不存在,千萬併發在網絡卡看來,一樣一樣的,都是電訊號,網絡卡眼裡根本區分不出來你是千萬併發還是一股洪流,所以衡量網絡卡牛不牛都說頻寬,從來沒有併發量的說法。
網絡卡位於物理層和鏈路層,最終把資料傳遞給網路層(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都是最大限度發揮硬體能力的典範。如何才能最大限度的發揮硬體能力呢?
要最大限度的發揮硬體能力,首先要找到核心矛盾所在。我認為,這個核心矛盾從計算機誕生之初直到現在,幾乎沒有發生變化,就是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等)重重關卡,終於到了我們應用開發人員手裡,我們如何處理這些高併發的請求呢?我們還是先從提升單機處理能力的角度來思考這個問題。
據經過網絡卡、作業系統、中介軟體(Tomcat、Netty等)重重關卡,終於到了我們應用開發人員手裡,我們如何處理這些高併發的請求呢?
我們還是先從提升單機處理能力的角度來思考這個問題,在實際應用的場景中,問題的焦點是如何提高CPU的利用率(誰叫它發展的最快呢),木桶理論講最短的那根板決定水位,那為啥不是提高短板IO的利用率,而是去提高CPU的利用率呢?
這個問題的答案是在實際應用中,提高了CPU的利用率往往會同時提高IO的利用率。當然在IO利用率已經接近極限的條件下,再提高CPU利用率是沒有意義的。我們先來看看如何提高CPU的利用率,後面再看如何提高IO的利用率。
提升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裡,消費者也可以訂閱跨網路的事件,從這個角度看,大家都在取長補短。
●編號734,輸入編號直達本文
●輸入m獲取文章目錄
演演算法與資料結構
更多推薦《18個技術類公眾微信》
涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。