編者按:本文轉(zhuǎn)載自知乎專欄《前端隨想錄》,快來一起學(xué)習(xí)吧! V8 和 Node.js 的關(guān)系,是許多前端同學(xué)們所津津樂道的——瀏覽器里的語言,又兼容了瀏覽器外的環(huán)境,兩份快樂重疊在一起。而這兩份快樂,又帶來了更多的快樂……但你有沒有想過,這兩份快樂到底是如何重疊在一起的呢?下面我們將以嵌入式 JS 引擎 QuickJS 為例,介紹一個(gè) JS 引擎是如何被逐步定制為一個(gè)新的 JS 運(yùn)行時(shí)的。 本文將分上下兩篇,逐一覆蓋(或者說,用盡可能簡(jiǎn)單的代碼實(shí)現(xiàn))這些內(nèi)容:
上篇主要涉及前三節(jié),主要介紹 QuickJS 這一嵌入式 JS 引擎自身的基本使用,并移植其自帶的 Event Loop 示例。而下篇所對(duì)應(yīng)的后兩節(jié)中,我們將引入 libuv,講解如何基于 libuv 實(shí)現(xiàn)擴(kuò)展性更好的 Event Loop,并支持宏任務(wù)與微任務(wù)。 閑話少說,進(jìn)入白學(xué)現(xiàn)場(chǎng)吧 :) 集成嵌入式 JS 引擎在我的理解里,JS 引擎的「嵌入式」可以從兩種層面來理解,一種意味著它面向低端的嵌入式設(shè)備,另一種則說明它很易于嵌入到原生項(xiàng)目中。而 JS 運(yùn)行時(shí) (Runtime) 其實(shí)也是一種原生項(xiàng)目,它將 JS 引擎作為專用的解釋器,為其提供操作系統(tǒng)的網(wǎng)絡(luò)、進(jìn)程、文件系統(tǒng)等平臺(tái)能力。因此,要想自己實(shí)現(xiàn)一個(gè) JS 運(yùn)行時(shí),首先應(yīng)該考慮的自然是「如何將 JS 引擎嵌入到原生項(xiàng)目中」了。
怎樣才算將 JS 引擎嵌入了呢?我們知道,最簡(jiǎn)單的 C 程序就是個(gè) main 函數(shù)。如果我們能在 main 函數(shù)里調(diào)用引擎執(zhí)行一段 JS 代碼,那不就成功「嵌入」了嗎——就好像只要在地球兩頭各放一片面包,就能把地球做成三明治一樣。 所以,又該怎樣在自己寫的 C 代碼中調(diào)用引擎呢?從 C 開發(fā)者的視角看,JS 引擎也可以被當(dāng)作一個(gè)第三方庫(kù)來使用,它的集成方式和普通的第三方庫(kù)并沒有什么不同,簡(jiǎn)單說包括這幾步:
對(duì) QuickJS 來說,只要一行 make && sudo make install 就能完成編譯和安裝(再啰嗦一句,原生軟件包的所謂安裝,其實(shí)就是把頭文件與編譯出來的庫(kù)文件、可執(zhí)行文件,分別復(fù)制到符合 Unix 標(biāo)準(zhǔn)的目錄下而已),然后就可以在我們的 C 源碼里使用它了。 完成 QuickJS 的編譯安裝后,我們甚至不用親自動(dòng)手寫 C,可以偷懶讓 QuickJS 幫你生成,因?yàn)樗С职?JS 編譯到 C 噢。像這樣的一行 JS:
就可以用 qjsc -e 命令編譯成這樣的 C 源碼:
這不就是我們要的 main 函數(shù)示例嗎?這個(gè) Hello World 已經(jīng)變成了數(shù)組里的字節(jié)碼,嵌入到最簡(jiǎn)單的 C 項(xiàng)目中了。
當(dāng)然,這份 C 源碼還要再用 C 編譯器編譯一次才行。就像使用 Babel 和 Webpack 時(shí)的配置那樣,原生工程也需要構(gòu)建配置。對(duì)于構(gòu)建工具,這里選擇了現(xiàn)代工程中幾乎標(biāo)配的 CMake。和這份 C 源碼相配套的 CMakeLists.txt 構(gòu)建配置,則是這樣的:
CMake 的使用很簡(jiǎn)單,在此不再贅述??傊?,上面的配置能編譯出 runtime 二進(jìn)制文件,直接運(yùn)行它能輸出 Hello World,知道這些就夠啦。 為 JS 引擎擴(kuò)展原生能力上一步走通后,我們其實(shí)已經(jīng)將 JS 引擎套在了一個(gè) C 程序的殼里了。然而,這只是個(gè)「純凈版」的引擎,也就意味著它并不支持語言標(biāo)準(zhǔn)之外,任何由平臺(tái)提供的能力。像瀏覽器里的 document.getElementById 和 Node.js 里的 fs.readFile,就都屬于這樣的能力。因此,在實(shí)現(xiàn)更復(fù)雜的 Event Loop 之前,我們至少應(yīng)該能在 JS 引擎里調(diào)用到自己寫的 C 原生函數(shù),就像瀏覽器控制臺(tái)里司空見慣的這樣:
所以,該怎樣將 C 代碼封裝為這樣的函數(shù)呢?和其它 JS 引擎一樣地,QuickJS 提供了標(biāo)準(zhǔn)化的 API,方便你用 C 來實(shí)現(xiàn) JS 中的函數(shù)和類。下面我們以計(jì)算斐波那契數(shù)的遞歸 fib 函數(shù)為例,演示如何將 JS 的計(jì)算密集型函數(shù)改由 C 實(shí)現(xiàn),從而大幅提升性能。 JS 版的原始 fib 函數(shù)是這樣的:
而 C 版本的 fib 函數(shù)則是這樣的,怎么看起來這么像呢?
要想在 QuickJS 引擎中使用上面這個(gè) C 函數(shù),大致要做這么幾件事:
這一共只要約 30 行膠水代碼就夠了,相應(yīng)的 fib.c 源碼如下所示:
上面這個(gè) fib.c 文件只要加入 CMakeLists.txt 中的 add_executable 項(xiàng)中,就可以被編譯進(jìn)來使用了。這樣在原本的 main.c 入口里,只要在 eval JS 代碼前多加兩行初始化代碼,就能準(zhǔn)備好帶有原生模塊的 JS 引擎環(huán)境了:
這樣,我們就能用這種方式在 JS 中使用 C 模塊了:
作為嵌入式 JS 引擎,QuickJS 的默認(rèn)性能自然比不過帶 JIT 的 V8。實(shí)測(cè) QuickJS 里 fib(42) 需要約 30 秒,而 V8 只要約 3.5 秒。但一旦引入 C 原生模塊,QuickJS 就能一舉超越 V8,在不到 2 秒內(nèi)完成計(jì)算,輕松提速 15 倍!
移植默認(rèn) Event Loop到此為止,我們應(yīng)該已經(jīng)明白該如何嵌入 JS 引擎,并為其擴(kuò)展 C 模塊了。但是,上面的 fib 函數(shù)只是個(gè)同步函數(shù),并不是異步的。各類支持回調(diào)的異步能力,是如何被運(yùn)行時(shí)支持的呢?這就需要傳說中的 Event Loop 了。 目前,前端社區(qū)中已有太多關(guān)于 Event Loop 的概念性介紹,可惜仍然鮮有人真正用簡(jiǎn)潔的代碼給出可用的實(shí)現(xiàn)。好在 QuickJS 隨引擎附帶了個(gè)很好的例子,告訴大家如何化繁為簡(jiǎn)地從頭實(shí)現(xiàn)自己的 Event Loop,這也就是本節(jié)所希望覆蓋的內(nèi)容了。 Event Loop 最簡(jiǎn)單的應(yīng)用,可能就是 setTimeout 了。和語言規(guī)范一致地,QuickJS 默認(rèn)并沒有提供 setTimeout 這樣需要運(yùn)行時(shí)能力的異步 API 支持。但是,引擎編譯時(shí)默認(rèn)會(huì)內(nèi)置 std 和 os 兩個(gè)原生模塊,可以這樣使用 setTimeout 來支持異步:
稍微檢查下源碼就能發(fā)現(xiàn),這個(gè) os 模塊并不在 quickjs.c 引擎本體里,而是和前面的 fib.c 如出一轍地,通過標(biāo)準(zhǔn)化的 QuickJS API 掛載上去的原生模塊。這個(gè)原生的 setTimeout 函數(shù)是怎么實(shí)現(xiàn)的呢?它的源碼其實(shí)很少,像這樣:
可以看出,這個(gè) setTimeout 的實(shí)現(xiàn)中,并沒有任何多線程或 poll 的操作,只是把一個(gè)存儲(chǔ) timer 信息的結(jié)構(gòu)體通過 JS_SetOpaque 的方式,掛到了最后返回的 JS 對(duì)象上而已,是個(gè)非常簡(jiǎn)單的同步操作。因此,就和調(diào)用原生 fib 函數(shù)一樣地,在 eval 執(zhí)行 JS 代碼時(shí),遇到 setTimeout 后也是同步地執(zhí)行一點(diǎn) C 代碼后就立刻返回,沒有什么特別之處。 但為什么 setTimeout 能實(shí)現(xiàn)異步呢?關(guān)鍵在于 eval 之后,我們就要啟動(dòng) Event Loop 了。而這里的奧妙其實(shí)也在 QuickJS 編譯器生成的代碼里明確地寫出來了,沒想到吧:
因此,eval 后的這個(gè) js_std_loop 就是真正的 Event Loop,而它的源碼則更是簡(jiǎn)單得像是偽代碼一樣:
這不就是在雙重的死循環(huán)里先執(zhí)行掉所有的 Job,然后調(diào) os_poll_func 嗎?可是,for 循環(huán)不會(huì)吃滿 CPU 嗎?這是個(gè)前端同學(xué)們?nèi)菀渍`解的地方:在原生開發(fā)中,進(jìn)程里即便寫著個(gè)死循環(huán),也未必始終在前臺(tái)運(yùn)行,可以通過系統(tǒng)調(diào)用將自己掛起。 例如,一個(gè)在死循環(huán)里通過 sleep 系統(tǒng)調(diào)用不停休眠一秒的進(jìn)程,就只會(huì)每秒被系統(tǒng)執(zhí)行一個(gè) tick,其它時(shí)間里都不占資源。而這里的 os_poll_func 封裝的,就是原理類似的 poll 系統(tǒng)調(diào)用(準(zhǔn)確地說,用的其實(shí)是 select),從而可以借助操作系統(tǒng)的能力,使得只在【定時(shí)器觸發(fā)、文件描述符讀寫】等事件發(fā)生時(shí),讓進(jìn)程回到前臺(tái)執(zhí)行一個(gè) tick,把此時(shí)應(yīng)該運(yùn)行的 JS 回調(diào)跑一遍,而其余時(shí)間都在后臺(tái)掛起。在這條路上繼續(xù)走下去,就能以經(jīng)典的異步非阻塞方式來實(shí)現(xiàn)整個(gè)運(yùn)行時(shí)啦。
鑒于 os_poll_func 的代碼較長(zhǎng),這里只概括下它與 timer 相關(guān)的工作:
這樣,setTimeout 的流程就說得通了:先在 eval 階段簡(jiǎn)單設(shè)置一個(gè) timer 結(jié)構(gòu),然后在 Event Loop 里用這個(gè) timer 的參數(shù)去調(diào)用操作系統(tǒng)的 poll,從而在被喚醒的下一個(gè) tick 里把到期 timer 對(duì)應(yīng)的 JS 回調(diào)執(zhí)行掉就行。 所以,看明白這個(gè) Event Loop 的機(jī)制后,就不難發(fā)現(xiàn)如果只關(guān)心 setTimeout 這個(gè)運(yùn)行時(shí) API,那么照抄,啊不移植的方法其實(shí)并不復(fù)雜:
這其實(shí)就是件按部就班就能完成的事,實(shí)際代碼示例會(huì)和下篇一起給出。 到現(xiàn)在為止這些對(duì) QuickJS 的分析,是否能讓大家發(fā)現(xiàn),許多經(jīng)常聽到的高大上概念,實(shí)現(xiàn)起來其實(shí)也沒有那么復(fù)雜呢?別忘了,QuickJS 出自傳奇程序員 Fabrice Bellard。讀他代碼的感受,就像讀高中習(xí)題的參考答案一樣,既不漏過每個(gè)關(guān)鍵的知識(shí)點(diǎn)又毫不拖泥帶水,非常有啟發(fā)性。他本人也像金庸小說里創(chuàng)造「天下武學(xué)正宗」的中神通王重陽(yáng)那樣,十分令人嘆服。帶著問題閱讀更高段位的代碼,也幾乎總能帶來豐富的收獲。 |
|