1. 背景介紹

由於直接介紹可能理解不了相關概念,建議先大概瞭解Vert.X的一些對應知識以及其相對位置,參考另一篇文章:Modern Web Programming 學習總結與思考,簡化成一張時間線的圖就是:

2. Modern Web 發展歷史及其演變

2.1 並髮帶來的問題

Servlet3.0標準出來之前,由於阻塞模型導致大部分web應用的並發量無法上來,3.0標準以及後續的3.1標準出來以後,web應用的並發量得以迅猛提升,但是並發量的提高同時顯現了另一個問題:根據木桶原理,系統的瓶頸取決於性能最低的那個模塊,這個模塊逐漸變成了---- IO.

對於計算型web應用來說還好,系統的CPU可以得到充分的利用,而對於大多數互聯網web應用來說,大量的資料庫操作,NoSQL操作,跨系統調用等,都涉及到IO操作,而這些操作短至幾ms,長至十幾秒,涉及到對應線程的阻塞,時間片的競爭等等。而另外一點是:相比協程的概念來說(如goroutine等),Java的Thread開銷是很大的,不僅一個Thread通常需要幾MB的開銷(為棧分配的內存),其也涉及到和內核線程(Kernel Thread)的交互。從而一定程度上浪費了CPU資源。

總的來說,對於Java的IO瓶頸型應用,其痛點主要為2點,第一線程開銷太大,第二因為阻塞切換的CPU等操作開銷太大。目前業內主流通過兩種方式來解決:第一,輕量級線程,協程;第二通過async/await模型,既純非同步操作來解決阻塞+線程切換的問題。

2.2 輕量級線程解決方案 -- 如何在Java中實現類似Go中的goroutine

2.2.1 Thread與協程的區別與思考

Go在語言層面實現了輕量級的協程,其主要概念就是 (continuation + scheduler)(原諒我此處無法翻譯成中文,因為目前還沒有標準統一的中文翻譯,大概的意思就是程序剩餘要執行的部分及調度命令)。

而相對於Java的Thread來說,區別主要是:Java的Thread是對系統線程(Kernel Thread)的一種抽象,就算可以通過-Xss來設置線程大小,但是Java線程本質上是對操作系統的抽象,其start方法是調用系統線程來操作的:

而通常來說,其理論可生成最大線程數是由底層系統可以生成的最大線程數量決定的:在Linux中:/proc/sys/kernel/threads-max,可其默認值是32080。所以我們在JVM中,就算有充足的內存,運行起來其理論值也只是接近這個數值而已。其次,如果修改Linux的默認值,由於Thread本身的限制,創建單個Thread最小內存消耗也比較大,實際運行中幾個G的內存通常也無法撐起海量線程(調小線程棧內存也容易Stack Over Flow)。

而Go等語言是在語言層面實現協程的,所以可以達到輕量級實現,在我們深入理解不用級別線程之前,關於用戶線程(User Thread)和系統線程(Kernel Thread), 可以參考文章:用戶線程 VS 系統線程

另一個額外可能要提出的點就是:通常我們知道線程是操作系統可以操作的最小單元,但是更近一步說,還有另外兩點需要進一步理解:Java中的Thread是對系統/內核線程(Kernel Thread)的一種abstraction,而系統線程則是按順序執行的計算機指令序列(A thread is a sequence of computer instructions executed sequentially.)-->這能幫我們進一步理解其系統瓶頸在哪兒:當我們在操作系統中完成一件事,不僅僅是計算(calculation),還涉及到IO網卡數據交互(IO wait),時間暫停(time wait),從內核匯流排同步數據(synchreonize/volatile)等等操作。

2.2.2 天然語法層面的實現 -- Kotlin

2018.10月,Kotlin 1.3 版本已經從語法層面正式支持Kotlin協程conroutine,其可以和Java相互轉換的特性可以讓我們在stable版本嘗試到協程帶來的好處,和Java的切換也很無縫,IntelliJ已經天然支持自動轉換。不過介於目前大部分Java開發對於kotlin語言還是不夠熟悉,可能從Java切換到Kotlin難度較大。

2.2.3 Java -- Project Quasar

Quasar 主要實現了協程級別的用戶線程(User Thread),通常被稱為纖程,其數據抽象模型和Thread基本一樣。可以【通過javaagent在運行時修改位元組碼】 或者 通【過 AOT instrumentation在編譯期實現代碼的攔截】。其纖程單元fiber單個實例需要400BYTE-2KB左右的級別開銷,其操作方式和線程的方式類似,如下創建100萬個用戶線程,最終消耗內存1G左右,單個Fiber消耗1KB左右,只要內存無限制,可以無限量起纖程:

我們可以通過類似Thread的方式在我們的項目中啟動大量的纖程,從而增大並發,提高系統性能。但是Fiber模式也有2個主要的缺陷:1.對代碼有一定的侵入性。2.本質上是多個fiber共享一個kernel thread,如果kernel thread被阻塞,那麼所有在這個kernel thread上分配的fiber將被阻塞。這就意味著,任意一組fibers,不能有任何一個在運行中調用阻塞的API,否則會阻塞 kernel thread,導致這一組fibers都被阻塞。關於Fiber更詳細的介紹,可以參考以下幾篇:

Quasar: Efficient and Elegant Fibers, Channels and Actors

Java中的纖程庫 - Quasar

繼續瞭解Java的纖程庫 - Quasar

2.2.4 Project Loom

Project Loom 是openJDK Lead的項目,目前正在開發中,有可能會被納入openJDK11中,其主旨和Quasar基本一致,基本可以認為是Quasar的加強版本,並且借鑒了Quasar的設計思路。其中關鍵的是目前Quasar由於不是官方項目,其對代碼的侵入性或者說AOT方式不是JCP提倡的「不在編譯器做任何修改」原則,後續此特性加入JDK以後,Quasar上述提到的缺點1便不存在了。關於Loom項目,值得一提的是其官方介紹,一個長篇英文立意論述,很精彩,從很底層很深入的描述了現代web應用中面臨的並發問題以及在操作系統層面的思考,討論,以及解決之道,並且詳細給出了實現代碼。和Quasar一致,未來Loom會提供Fiber類型,和Thread同樣繼承與共同的父類Strand,文章需要半天以上時間的閱讀+理解,鏈接如下:

Project Loom: Fibers and Continuations for the Java Virtual Machine

除此之外,一些有益於理解的鏈接如下:

Project Loom openJDK官方說明介紹

項目Lead -- Project Loom with Ron Pressler and Alan Bateman

Project Loom: Fibers and Continuations for the Java Virtual Machine with Ron Pressler

2.3 Async/await模型解決方案

2.3.1 async/await定義

首先可以規整下對應的定義,編程中主流的兩種編程風格,以及它們對應的風格,不同的實踐方式,對應的模式簡稱,分別是:

OOP --> 面向對象編程 --> Thread/Green Thread --> proactor pattern

FP --> 函數編程 --> Event Driven --> reactor pattern

在我的另一篇文章(Reactive Stack系列(一):響應式編程從入門到放棄)中提到了關於響應式宣言的一些介紹,其中在目前的Java為主的OOP編程中,FP概念的趨勢有增強的趨勢,如RxJava,Netty,Lambda,JDK9 Flow等較新的特性和框架都是FP,reactor pattern的。而reactor pattern 中最為熟知的一個特性應該算是:callback(回調)。

async/await模式常見於大前端概念中(網頁,IOS/Andriod客戶端等),因為常見的點擊等響應式事件使得這個響應式(reactive)風格易於理解,而後端通常的命令式/邏輯式編程比較不適應這種編程風格(其實是思考方式)。async/await模式要求後端把所有的IO相關的操作都用callback方式來橋接起來,而reactor pattern天然適合寫成非同步編程模式。

2.3.2 Vert.X背景介紹

Netty是reactor pattern的一個典型實現,其是網路層的抽象框架(不是應用框架重複三遍)。 所以其天然基於事件驅動,彈性擴展等特性。

Vert.X最初起源於Node JS,所以不難理解為什麼它是reactor pattern的實現。Vert.X是應用級別的框架,基本等同於SpringBoot系列+Spring Cloud系列全家桶而又不僅僅限於此。所有的模塊都是非同步實現的,凡是涉及到IO通訊的非同步實現基本都是基於Netty之上的。(再次引用之前自己畫的圖)。

在Web框架中,SpringBoot2.0中引入的WebFlux模塊和Vert.X中的Web模塊基本原型概念到實現沒有太大的差別,但是由於Vert.X天生沒有servlet stack的包袱,基於Netty構建,所以web構建相對於WebFlux會簡潔很多。

另外Spring中目前核心的Spring Core模塊仍然沒有對應的reactor pattern配套模塊,只能靠JDK8中的Stream和Lambda來實現。而Vert.X由於天生是純reactor pattern,所以提供了一套完成的Vert.X Reactive模塊。

2.3.3 Vert.X 的問題

說是Vert.X的問題,其實也不是Vert.X的問題,而是Java程序員的問題:就是長期OOP寫服務端,很難去很熟練 的轉到FP模式寫代碼,也不僅僅如此,其實FP寫代碼的一個共識問題就是:Callback Hell,非同步模式雖然不阻塞,但是由於我們在業務代碼中通常邏輯會深入很深,如果對應的阻塞操作都改成非同步操作,那麼回調地獄問題會特別嚴重。隨便舉個例子如下,做一個資料庫操作,失敗了需要回滾事務:

這僅僅是一個資料庫操作,如果再加上Redis,跨系統IO調用等,括弧會飛上天。

2.3.4 利用Fiber來解決Vert.X中的Callback Hell問題

對於上述提到的Callback Hell問題,由於我們知道Fiber模型相對Thread來說開銷很小,所以這裡我們可以用Fiber來改寫成熟悉的proactor pattern。據官方數據,用Fiber改寫性能損失大約在3%-5%(為了換取可讀性,應該可以接受?)

以下給出一段實例代碼,為拼團業務中用戶開團下訂單的過程。:

這段不算長的代碼中,涉及到比較複雜的下單邏輯,流程依次涉及到:

--->啟動伺服器

------>接受客戶端請求

--------->Redis搶鎖防重

------------>Redis校驗庫存並扣除

--------------->去下游收單系統下單

------------------>資料庫訂單表插入數據

--------------------->資料庫團信息表插入數據

------------------------>返回客戶端請求

我們可以看到,基本邏輯很清爽,沒有任何的callback hell現象,核心在於awaitResult方法,其用lambda方式,在其中調用中產生了Fiber對象,通過run()方法來獲取結果,這樣就很優雅的解決了callback hell對我們造成的殺傷力。

另外在這段代碼中,我們也可以把所有通過Netty接受的請求從Thread線程切換到纖程模式,可以以更加輕量的方式來承接更多的請求:

3 總結

上面的所有討論,總的來說在圍繞著木桶原理不斷地對瓶頸部分做底層優化:對CPU做文章,減少CPU的空閑時間,等待時間與切換開銷;用async/await,本質對IO操作分組,分線程組等;用Fiber來解決線程過重等問題。實際項目中我們可能需要更加靈活的根據實際情況來選擇合適的框架,比如計算密集型任務reactor pattern解決不了問題,更應該選擇簡單合適易上手的框架。


推薦閱讀:
相關文章