異步編程一:異步編程的魅力

一個(gè)故事

先來講一個(gè)故事,在很久很久以前,當(dāng)我還是一個(gè)懵懂的程序員,負(fù)責(zé)優(yōu)化過公司里的鑒權(quán)網(wǎng)關(guān),當(dāng)時(shí)我們的架構(gòu)大概是這個(gè)樣子的:


image.png

當(dāng)時(shí)利用tengine的鑒權(quán)模塊,每次請(qǐng)求進(jìn)來時(shí),tengine會(huì)攜帶請(qǐng)求參數(shù)先問一下auth模塊,當(dāng)auth模塊返回200狀態(tài)才會(huì)把請(qǐng)求透傳到業(yè)務(wù)API上面去。當(dāng)時(shí)我們自定義開發(fā)的鑒權(quán)模塊是圖中的auth模塊,基于tomcat開發(fā)。
當(dāng)時(shí)的優(yōu)化思路:

  • 保證auth里的邏輯盡量簡單,多用緩存,少用數(shù)據(jù)庫
  • 確認(rèn)jvm gc沒有問題
  • 調(diào)整tomcat默認(rèn)線程池大小,降低上下文切換

事實(shí)證明,真正起到?jīng)Q定性效果的是第一點(diǎn),然后是第三點(diǎn),優(yōu)化到最后,基本就是在不讓停的實(shí)驗(yàn),線程池在多大的情況下響應(yīng)時(shí)間最優(yōu)。
具體的數(shù)值,早已經(jīng)忘記,總之最后也確定了一套配置,即某種最精簡的代碼邏輯下,在一個(gè)經(jīng)過實(shí)驗(yàn)得出的最優(yōu)的線程池大小情況下,在某一個(gè)95響應(yīng)時(shí)間的前提下,達(dá)到了一個(gè)實(shí)例可制成的最大的一個(gè)并發(fā),大概200~300吧,記不清楚了,再高,就需要擴(kuò)節(jié)點(diǎn),否則響應(yīng)時(shí)間就無法保證。
后來公司里的大神號(hào)稱用golang寫了一版,同樣的壓力下,響應(yīng)時(shí)間可以控制在30ms之內(nèi),并聲稱壓測是系統(tǒng)上下文切換比java版本的明顯降低。
上面是一個(gè)真實(shí)的經(jīng)歷,里面的數(shù)值已經(jīng)很模糊了。
但重點(diǎn)不在這里,而是基于傳統(tǒng)的tomcat來開發(fā)的web項(xiàng)目,在并發(fā)達(dá)到一定程度的時(shí)候,性能會(huì)急劇下降,解決這種問題只有兩個(gè)思路:

  • 擴(kuò)機(jī)器
  • 代碼架構(gòu)優(yōu)化

幾年前,我的團(tuán)隊(duì)選擇了前者
又過了幾年之后,我認(rèn)為后者是更好的方案
現(xiàn)在,我覺得前者后者,只是一個(gè)權(quán)衡,回想起來當(dāng)年的大神估計(jì)是經(jīng)過了一番權(quán)衡,并沒有那份golang的代碼交給我

擴(kuò)機(jī)器方案,看似low,但是對(duì)公司來講,僅僅是多花了一些機(jī)器錢而已,代碼維護(hù)成本低,初中級(jí)程序員即可玩得轉(zhuǎn);
代碼架構(gòu)優(yōu)化這個(gè)方案,看似省了一些機(jī)器的錢,但需要招更高級(jí)的程序員進(jìn)行開發(fā)和維護(hù),這兩條路適用于不同的階段,采用哪種方案,不能只站在技術(shù)角度進(jìn)行考慮。

本文主要講異步編程,著重講一講代碼架構(gòu)優(yōu)化
其實(shí)這個(gè)一個(gè)已知問題,業(yè)界早有成熟的方案,C10K問題
而解決這些問題的所有方案的目標(biāo)都是在有限的物理資源情況下,支撐更多的并發(fā),換句話講,系統(tǒng)是可伸縮的,在請(qǐng)求并發(fā)加大的時(shí)候系統(tǒng)吞吐量會(huì)隨之線性增長,實(shí)現(xiàn)高吞吐量低延遲。
這里引用一句vertx首頁的描述

Eclipse Vert.x is event driven and non blocking. This means your app can handle a lot of concurrency using a small number of kernel threads. Vert.x lets your app scale with minimal hardware.

C10K

上文有提到C10K問題,權(quán)威解釋建議看一看,這里我做一下"贅述"


image.png

這張圖是在下原創(chuàng)手繪的,看圖中一條一條的線,像不像一種“試紙”
圖中每一條,表示一個(gè)請(qǐng)求要做的事情,條的長度代表請(qǐng)求的處理時(shí)間
紅色的是代表io操作,綠色的標(biāo)代表非io操作
現(xiàn)在的計(jì)算機(jī),cpu內(nèi)存的速度和磁盤、網(wǎng)絡(luò)的速度不在一個(gè)數(shù)量級(jí)上,所以現(xiàn)實(shí)中綠色和紅色的長短比例比圖中畫的要懸殊的多

單個(gè)接口處理登錄
依次需要處理一系列的io操作和非io操作,io操作的處理時(shí)間要遠(yuǎn)遠(yuǎn)高于非io操作的處理時(shí)間,但總體請(qǐng)求在60ms左右可以返回

系統(tǒng)同時(shí)處理8個(gè)請(qǐng)求
這時(shí),系統(tǒng)假如同時(shí)受到了8個(gè),這時(shí)候和處理一個(gè)請(qǐng)求也差不多,處理時(shí)間也可以控制在60ms

系統(tǒng)同時(shí)處理8w個(gè)請(qǐng)求
圖中的8w個(gè)請(qǐng)求的處理圖,響應(yīng)時(shí)間依然是60ms,這時(shí)一種很理想的狀態(tài),也是我們的優(yōu)化目標(biāo)
即同時(shí)處理一個(gè)請(qǐng)求和同時(shí)處理上萬的請(qǐng)求,系統(tǒng)延遲沒有降低,這便是可伸縮的系統(tǒng)。
如果圖中的系統(tǒng),后臺(tái)是一個(gè)tomcat,那么別說是8w個(gè)并發(fā),即使是2k并發(fā),響應(yīng)時(shí)間就可以用慘不忍睹來形容了

問題的根源
一個(gè)線程處理一個(gè)請(qǐng)求,即 per connect per thread。

tomcat的線程模型,或者說servlet的線程模型就是這種;上面的每一根“條條”代表一次請(qǐng)求,或者更具體講代表一次請(qǐng)求背后服務(wù)器索要執(zhí)行的動(dòng)作;在這種模式下,服務(wù)器處理每一個(gè)請(qǐng)求索要做的動(dòng)作 是與操作系統(tǒng)里的線程強(qiáng)綁定的,即用同一根線程從始至終的把一件任務(wù)做完,即使一件任務(wù)背后有時(shí)候是空閑的,在等待io的;如果線程是寶貴的資源,那么這一定是一種浪費(fèi)。

事實(shí)上線程數(shù)真不能太多,一方面,每一個(gè)線程都需要消耗一定的內(nèi)存,內(nèi)存即一種瓶頸;另一方面,在海量線程狀態(tài)下,CPU會(huì)存在大量的無謂的線程上下文切換,占用大量的cpu時(shí)鐘,cpu看上去很忙,但都是“瞎忙”。

有人可能說了,8w個(gè)請(qǐng)求,加幾臺(tái)機(jī)器一起撐唄?
這當(dāng)然也是一種解題思路,但做技術(shù)還是要有點(diǎn)追求的,不能永遠(yuǎn)靠擴(kuò)機(jī)器解決問題,在某些階段,某種場景,擴(kuò)機(jī)器并不是最佳的解決方案。

再說一種更過分的場景,長連接的服務(wù),比如websocket,一個(gè)服務(wù)對(duì)外提供websocket接口,同時(shí)在線8w個(gè)客戶端,但是真正同時(shí)發(fā)過來的報(bào)文并不多,這種情況下,擴(kuò)一堆機(jī)器,只是為了保持更多的連接,但其實(shí)機(jī)器都沒有在處理真正的業(yè)務(wù),都在做上下文切換,這未免也太說不過去了。

非阻塞IO

上面故事和問題分析,我們不停的在提到一個(gè)詞 IO
那么解決這個(gè)問題的思路,也是要從IO入手
如果有了解過這方面的知識(shí)讀者,會(huì)聽說過如下幾種IO模型:

  • 阻塞 I/O
  • 非阻塞 I/O
  • I/O 的多路復(fù)用(select 和 poll)
  • 信號(hào)驅(qū)動(dòng)的 I/O(SIGIO)
  • 異步 I/O(POSIX 的 aio_functions)

但問題不必上來就講的這么復(fù)雜,此處我按照自己的理解做一點(diǎn)贅述:

  • 我們的程序與io交互,無論是磁盤還是網(wǎng)絡(luò),都是要經(jīng)過操作系統(tǒng)的

  • 最關(guān)鍵的第一步,程序與操作系統(tǒng)提供的io接口交互時(shí),這個(gè)接口不能阻塞,否則程序的當(dāng)前線程就死在這了,動(dòng)也動(dòng)不了了

  • 操作系統(tǒng)既然提供了非阻塞的io接口,那么接下來的問題就是,我們的程序如何感知到操作系統(tǒng)處理完了

  • 這當(dāng)然都要依賴操作系統(tǒng)的實(shí)現(xiàn),最好的解決方案自然是操作系統(tǒng)處理好了,回調(diào)一下我們的某一個(gè)接口,通知我們處理好了,程序在做下一步處理,這便是epoll了,linux體系的最佳方案

  • 其實(shí)還有更好的方案,操作系統(tǒng)處理好了之后,把io處理的結(jié)果,放到我們程序需要的指定的內(nèi)存地址,對(duì)我們程序來講就是操作系統(tǒng)把準(zhǔn)備好的數(shù)據(jù),賦值給我們指定的變量,程序拿來即用, 真正的異步IO, 也就是AIO,目前l(fā)inux是沒有實(shí)現(xiàn),windows有,server一般都跑在linux

  • 還有一種不那么好的方案,程序定時(shí)輪詢變量,發(fā)現(xiàn)哪個(gè)好了,就做接下來的處理,這便是 select 和 poll

  • 總結(jié)起來一句話,linux上,用epoll搞,就對(duì)(性價(jià)比角度)了

  • 網(wǎng)上有很多文章在解釋什么是異步,什么是非阻塞,一般還要配合著拿餐館舉例子,在下覺得全扯淡,復(fù)雜了,容易把人繞暈了
    這里在引用耗子叔在《左耳聽風(fēng)》78講里的觀點(diǎn)

基本上來說,異步 I/O 模型的發(fā)展技術(shù)是: select -> poll -> epoll -> aio -> libevent -> libuv

耗子叔的專欄,真是經(jīng)典中的經(jīng)典,建議訂閱

異步編程風(fēng)格

什么是異步編程,舉個(gè)例子:
假設(shè)我們要實(shí)現(xiàn)一個(gè)系統(tǒng)登錄的邏輯,需要在網(wǎng)關(guān)層獲取用戶名密碼,然后請(qǐng)求account服務(wù)校驗(yàn)用戶名密碼的正確性,如果正確,根據(jù)返回的用戶id請(qǐng)求權(quán)限服務(wù),獲取用戶對(duì)應(yīng)的權(quán)限,并放置到用戶的上下文中

同步方式的代碼大概是這樣寫(kotlin 偽代碼)

var loginResult = accountRpc.login(userName, password)
if(!loginResult.success){
  throw LoginException("用戶名或密碼錯(cuò)誤")
}
var permission = rbacRpc.permission(loginResult.userId)
UserContext.setContext(permission)

異步方式寫代碼(偽代碼):

//入?yún)?loginPromise
accountRpc.login(userName, password) {
  if (!it.success){
    loginPromise.failed(it.cause)
  }
  rbac.permission(it.userId){ permission ->
     UserContext.setContext(permission)
    loginPromise.success()
  }
}

基于非阻塞IO進(jìn)行編程,編程語言分為了兩種解題思路,一種基于eventloop加回調(diào)(什么promise、reactive,說到底就是基于回調(diào)封裝的一些模式),一種基于協(xié)程

eventloop + 回調(diào)
java就是典型代表, 代碼邏輯都是運(yùn)行在線程上的,java基于nio可以實(shí)現(xiàn)非阻塞IO, 基于nio需要開發(fā)者注冊(cè)一堆的handler,就是回調(diào)。
nio太難用, 就有大神寫了netty,netty對(duì)nio、epoll甚至bio都做了統(tǒng)一化的api封裝,簡化了java網(wǎng)絡(luò)編程。
后來業(yè)界又推出了reactive編程范式(此處不展開,感興趣可以看下:Reactor2-93.pdf

以上可能是基于線程來解決異步編程的已知的比較不錯(cuò)的方案了,還是無法避免編程上的復(fù)雜度。

協(xié)程
協(xié)程,一種更輕量級(jí)的線程,是語言層面對(duì)執(zhí)行動(dòng)作線程的一次解耦,此處說的是語言層面,即這個(gè)抽象是做在編程語言層面,底層基于操作系統(tǒng)的線程,上層在進(jìn)程內(nèi)基于編程語言開發(fā)了自己的一套調(diào)度策略,封裝出了一個(gè)叫做協(xié)程的概念,這樣做的好處是,開發(fā)者可以以同步的方式寫異步的代碼,典型的代表: golang、kotlin;
golang是天生支持,支持的效果會(huì)讓開發(fā)者感覺不到線程的存在,反過來想,也會(huì)讓開發(fā)者越來越白癡(這又是一個(gè)權(quán)衡,語言太好了,開發(fā)者就...)
jvm生態(tài)里,kotlin也號(hào)稱支持協(xié)程,不過由于歷史包袱的原因,開發(fā)者還是會(huì)感覺到線程和協(xié)程,比如runBlocking函數(shù)就是用來銜接線程和協(xié)程執(zhí)行的, 此處貼一下函數(shù)注釋:

Runs new coroutine and blocks current thread interruptibly until its completion

最近在下也一直在研究kotlin,我對(duì)這門語言還是持擁抱態(tài)度的,不只是協(xié)程,還有各種語法糖(相對(duì)于java來講),可以減少一點(diǎn)開發(fā)工作量。

回到剛剛的話題往下聊,jvm體系里,能和golang相抗衡的協(xié)程方案,必須是要坐在jvm級(jí)別的,目前已知的是阿里的wisp
和openjdk的loom
這兩種方案,都還沒有深入了解過,不敢妄言。

異步編程的魅力

從本文最開始的一個(gè)故事開始,依次引出了c10k問題,然后又贅述了很多的解決之道,最后引出異步編程。按照這個(gè)套路來闡述,是因?yàn)楸救松穹锤屑夹g(shù)文檔不講解決什么問題,上來就拋出一大堆技術(shù)概念,在下喜歡從頭講故事。
現(xiàn)在可以聊一聊異步編程的魅力了,異步編程會(huì)比同步編程更復(fù)雜一些
基于此演進(jìn)出了很多花里胡哨的技術(shù)名詞,對(duì)于一個(gè)有追求的開發(fā)人員,異步編程是必須要了解和運(yùn)用的一種技術(shù);
異步編程的魅力在哪,我個(gè)人是因?yàn)閷?duì)這個(gè)陌生領(lǐng)域一知半解的時(shí)間太久了,就是要搞定這件事情。

c10k的問題,不僅僅是異步編程就可以解決的,“不談存儲(chǔ)層設(shè)計(jì)的高并發(fā),都是耍流氓”,高并發(fā)是對(duì)整個(gè)分布式系統(tǒng)里全鏈路的挑戰(zhàn),除非整個(gè)系統(tǒng)簡單,無狀態(tài)。

后續(xù)

后續(xù)會(huì)寫一系列的文章,包括異步編程里的 promise模式、reactor模式、協(xié)程等。
這不是一篇解決具體問題的文章,是一系列需要靜下心來慢慢讀的文章,是關(guān)于異步編程的一些思考和總結(jié)。

系列文章快速導(dǎo)航:
異步編程一:異步編程的魅力
異步編程二:promise模式
異步編程三:reactor模式
異步編程四:協(xié)程

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容