閱讀957 返回首頁    go 阿裏雲 go 技術社區[雲棲]


[Java]如何提高Web服務端並發效率的異步編程技術?

【編者按】在Java裏開發多線程最強有力的實踐就是做服務端的並發處理,本文作者闡述了實施多線程的具體實踐方法,要真的掌握某種技術你就必須要知其所以然。筆者轉發至此,希望對Web開發者有所幫助。


全文如下:

作為一名Web工程師都希望自己做的Web應用能被越來越多的人使用,如果我們所做的Web應用隨著用戶的增多而宕機了,那麼越來越多的人就會變得越來越少了,為了讓我們的Web應用能有更多人使用,我們就得提升Web應用服務端的並發能力。那麼我們如何做到這點了,根據現有的並發技術我們會有如下選擇:

給服務端請求開啟線程

第一個做法:為了每個客戶端發送給服務端的請求都開啟一個線程,等請求處理完畢後該線程就被銷毀掉,這種做法很直觀,但是在現代的Web服務器裏這種做法已經很少使用了,原因是新建一個線程,銷毀一個線程的開銷(開銷是指占用計算機係統資源例如:CPU、內存等)是很大的,它時常會大於實際處理請求本身的開銷,因此這種方式不能充分利用計算機資源,提升並發的效率是有效的,要是還碰到線程安全的問題,使用到線程的鎖機製,數據同步技術,並發提升就會受到更大的限製;除此之外,來一個請求就開啟一個線程,對線程數量沒有任何控製,這就會很容易導致計算機資源被用盡,對於Web服務端的穩定性產生很大的威脅。

提高服務端並發量

第二個做法:鑒於上麵的問題,我們就產生了第二種提高服務端並發量的方法,首先我們不再是一個客戶端請求過來就開啟一個新線程,請求處理完畢就銷毀線程,而是使用一種池技術即線程池技術,線程池技術就是事先創建一批線程,這批線程被放入到一個池子裏,在沒有請求到達服務端時候,這些線程都是處於待命狀態,當請求到達時候,程序會從線程池裏取出一個線程,這個線程處理到達的請求,請求處理完畢,該線程不會被銷毀,而是被線程池回收,這種方式使用線程我們降低了隨意創建線程和銷毀線程所導致係統開銷,同時也控製了服務端線程的數量,一般一個線程對應一個請求,也就控製了並發請求的個數,該方案比第一種方案提升了係統的穩定性(控製並發數量,防止並發過多導致服務程序宕機)同時也提升了並發的數量(原因是減少了創建線程和銷毀線程的開銷,更充分的利用了計算機的係統資源)。

但是做法二也是有很大的問題的,具體如下:做法二和做法一相比,做法二要好多了,但是這隻是和做法一比,如果按照我們設計的目標,做法二並非完美,原因如下:首先做法二會讓很多技術不紮實人認為線程池開啟多少線程就決定了係統並發的數量,因此出於讓係統能處理更多請求以及充分利用計算機資源的考慮,有些人會一開始就把線程池裏新建線程的個數設置為最大,一個Web應用的並發量在一定時間裏都是一個曲線形式,峰值在一定時間範圍內都是少數情況,因此一開始就開啟最大線程數,自然在大多數時間內都是在浪費係統資源,如果這些被浪費被閑置的計算資源能用來處理請求,或許這些請求處理的效率會更高。

此外,一個服務器到底預先開啟多少個線程,這個標準很難把控,還有就是不管你用線程池技術還是新建線程的方式,處理請求的數量和線程數量數量是一一對應的關係,如果有一個時間點過來的請求數量正好超出了線程池裏線程數量,例如就多了一個,那麼這個請求因為找不到對應線程很有可能會被程序所遺棄掉,其實這多的一個請求並沒有超出計算機所能承受的負載,而是因為我們程序設計不合理才被遺棄的,這肯定是開發人員所不願意發生的事情。

JDK裏的線程池對線程池大小的設定很關鍵

針對這些問題在Java的JDK裏提供的線程池做了很好的解決(線程池技術是博大精深的,如果我們沒有研究透池技術,還是不要自己去寫個而是用現成的),JDK裏的線程池對線程池大小的設定使用兩個參數,一個是核心線程個數,一個是最大線程個數,核心線程在係統啟動時候就會被創建,如果用戶請求沒有超過核心線程處理能力,那麼線程池不會再創建新線程,如果核心線程個數已經處理不過來了,線程池就會開啟新線程,新線程第一次創建後,使用完畢後也不是立即對其銷毀,也是被會收到線程池裏,當線程池裏的線程總數超過了最大線程個數,線程池將不會再創建新線程,這種做法讓線程數量根據實際請求的情況進行調整,這樣既達到了充分利用計算機資源的目的,同時也避免了係統資源的浪費。

JDK的線程池還有個超時時間,當超出核心線程的線程在一定時間內一直未被使用,那麼這些線程將會被銷毀,資源就會被釋放,這樣就讓線程池的線程的數量總是處在一個合理的範圍裏;如果請求實在太多了,線程池裏的線程暫時處理不過來了,JDK的線程池還提供一個隊列機製,讓這些請求排隊等待,當某個線程處理完畢,該線程又會從這個隊列裏取出一個請求進行處理,這樣就避免請求的丟失,JDK的線程池對隊列的管理有很多策略,有興趣的童鞋可以問問度娘,這裏我還要說的是JDK線程池的安全策略做的很好,如果隊列的容量超出了計算機的處理能力,隊列會拋棄無法處理的請求,這個也叫做線程池的拒絕策略。

看我這麼詳細的描述做法二,是不是做法二就是一個完美的方案了?答案當然是否定了,做法二並非最高效的方案,做法二也沒有充分利用好計算機的係統資源,我這裏還有做法三了,其具體做法如下:

首先我要提出一個問題,並發處理一個任務和單線程的處理同樣一個任務,那種方式的效率更高?也許有很多人會認為當然是並發處理任務效率更高了,兩個人做一件事情總比一個人要厲害吧,這個問題的答案是要看場景的,在單核時代,單線程處理一個任務的效率往往會比並發方式效率更高,為什麼呢?因為多線程在單核即單個CPU上運算,CPU並不是也可以並發處理的,CPU每次都隻能處理一個計算任務,因此並發任務對於CPU而言就有線程的上下文切換操作,而這種線程上下文的開銷是比較大的,因此單核上處理並發請求不一定會比單線程更有效率,但是如果到了多核的計算機,並發任務平均分配給每一個CPU,那麼並發處理的效率就會比單線程處理要高很多,因為此時可以避免線程上下文的切換。

對於一個網絡請求的處理,是由兩個不同類型的操作共同完成,這兩個操作是CPU的計算操作和IO操作,如果我們以處理效率角度來評判這兩個操作,CPU操作效率是光速的,而IO操作就不盡然了,計算機裏的IO操作就是對存儲數據介質的操作,計算機裏有如下幾個介質可以存儲數據,它們分別是:CPU的一級緩存、二級緩存、內存、硬盤和網絡,一級緩存存儲和讀取數據的能力接近光速,它比二級緩存快個5倍到6倍,但是不管是一級緩存還是二級緩存,它們存儲數據量太少了,做不了什麼大事情,下麵就是內存了,以一級緩存的效率做參照,一級緩存比內存速度快100多倍,到了硬盤存儲和讀取數據效率就更慢了,一級緩存比硬盤要快1000多萬倍,到了網絡就慢的更不像話了,一級緩存比網絡要快一億多倍,可見一個請求處理的效率瓶頸都是由IO引起的,而CPU雖然處理很快但是CPU對任務的計算都是一個接著一個處理,假如一個請求首先要等待網絡數據的處理在進行CPU運算,那麼必然就拖慢了CPU的處理的整體效率,這一慢就是上億倍了,但是現實中一個網絡請求處理就是由這兩個操作組合而成的。

對於IO操作在Java裏有兩種方式,一種方式叫做阻塞的IO,一種方式叫做非阻塞的IO,阻塞的IO就是在做IO操作時候,CPU要等待IO操作,這就造成了CPU計算資源的浪費,浪費的程度上文裏已經寫到了,是很可怕的,因此我們就想當一個請求一個線程做IO操作時候,CPU不用等待它而是接著處理其他的線程和請求,這種做法效率必然很高,這時候非阻塞IO就登場了,非阻塞IO可以在線程進行IO操作時候讓CPU去處理別的線程,那麼非阻塞IO怎麼做到這一點的呢?非阻塞IO操作在請求和CPU計算之間添加了一個中間層,請求先發到這個中間層,中間層獲取了請求後就直接通知請求發送者,請求接收到了,注意這個時候中間層啥都沒幹,隻是接收了請求,真正的計算任務還沒開始哦,這個時候中間層如果要CPU處理那麼就讓CPU處理,如果計算過程到了要進行IO操作,中間層就告訴CPU不用等我了,中間層就讓請求做IO操作,CPU這時候可以處理別的請求,等IO操作做完了,中間層再把任務交給CPU去處理,處理完成後,中間層將處理結果再發送給客戶端,這種方式就可以充分利用CPU的計算機資源,有了非阻塞IO其實使用單線程也可以開發多線程任務,甚至這個單線程的處理效率可能比多線程更高,因為它沒有線程創建銷毀的開銷,也沒有線程上下文切換的開銷。

Node.js利用非阻塞的技術編寫更高效的Web服務器

其實實現一個非阻塞的請求是個大課題,裏麵使用到了很多先進和複雜的技術例如:回調函數和輪詢等,對於非阻塞的開發我目前掌握的還不夠好,等我有天完全掌握了它我一定會再寫一篇文章,不過這裏要提到的是像Java裏netty技術,Nginx,PHP的並發處理都用到這種機製的原理,特別是現在很火的Node.js它產生的原因就是依靠這種非阻塞的技術來編寫更高效的Web服務器,可以說Node.js把這種技術用到了極致,不過這裏要糾正下,非阻塞是針對IO操作的技術,對於Node.js,netty的實現機製有更好的術語描述就是事件驅動(其實就是使用回調函數,觀察者模式實現的)以及異步的IO技術(就是非阻塞的IO技術)。

現在我們回到做法三的描述,做法三的核心思想就是讓每個線程資源利用率更加有效,做法三是建立在做法二的基礎上,使用事件驅動的開發思想,采用非阻塞的IO編程模式,當客戶端多個請求發到服務端,服務端可以隻用一個線程對這些請求進行處理,利用IO操作的性能瓶頸,充分利用CPU的計算能力,這樣就達到一個線程處理多個請求的效率並不比多線程差,甚至還高,同時單線程處理能力的增強也會導致整個Web服務並發性能的提升。大家可以想想,按這種方式在一個多核服務器下,假如這個服務器有8個內核,每個內核開啟一個線程,這8個線程也許就能承載數千並發量,同時也充分利用每個CPU計算能力,如果我們開啟線程越多(當然新增的線程數最好是8的倍數,這樣對多核利用率更好)那麼並發的效率也就更高,提升是按幾何倍數進行的,大家想想Nginx,它就采用此模式,所以它剛推出來的時候其並發處理能力是Apache服務器的數倍,現在Nginx已經和Apache一樣普及了,事件驅動的異步機製功不可沒。

好了,文章寫畢,今天寫這篇文章算是對我最近研究多線程的一點總結,也是我最近轉向研究Node.js的開始,Node.js有完美的異步編程模型,但是最近我確一直懷疑它的並發能力,因為我一直沒找到Node.js裏像Java裏那麼複雜的異步編程技術,現在我發現,Node.js用了一種更加巧妙的方式解決異步開發的問題,而且這種方式是高效,就這一點Node.js太有魅力了,所以很值得研究和學習。

最後更新:2017-04-03 05:40:17

  上一篇:go Swift字典類
  下一篇:go [麵試題]const與指針