前言
從Node.js進(jìn)入人們的視野時(shí)蹄溉,我們所知道的它就由這些關(guān)鍵字組成 事件驅(qū)動(dòng)屎暇、非阻塞I/O、高效况脆、輕量饭宾,它在官網(wǎng)中也是這么描述自己的。
Node.js? is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient.
于是格了,會(huì)有下面的場(chǎng)景出現(xiàn):
當(dāng)我們剛開始接觸它時(shí)看铆,可能會(huì)好奇:
為什么在瀏覽器中運(yùn)行的 Javascript 能與操作系統(tǒng)進(jìn)行如此底層的交互?
當(dāng)我們?cè)谟盟M(jìn)行文件 I/O 和網(wǎng)絡(luò) I/O 的時(shí)候盛末,發(fā)現(xiàn)方法都需要傳入回調(diào)弹惦,是異步的:
那么這種異步否淤,非阻塞的 I/O 是如何實(shí)現(xiàn)的?
當(dāng)我們習(xí)慣了用回調(diào)來(lái)處理 I/O棠隐,發(fā)現(xiàn)當(dāng)需要順序處理時(shí)石抡,Callback Hell 出現(xiàn)了,于是有想到了同步的方法:
那么在異步為主的 Node.js助泽,有同步的方法嘛啰扛?
身為一個(gè)前端,你在使用時(shí)嗡贺,發(fā)現(xiàn)它的異步處理是基于事件的侠讯,跟前端很相似:
那么它如何實(shí)現(xiàn)的這種事件驅(qū)動(dòng)的處理方式呢?
當(dāng)我們慢慢寫的多了暑刃,處理了大量 I/O 請(qǐng)求的時(shí)候厢漩,你會(huì)想:
Node.js 異步非阻塞的 I/O 就不會(huì)有瓶頸出現(xiàn)嗎?
之后你還會(huì)想:
Node.js 這么厲害岩臣,難道沒(méi)有它不適合的事情嗎溜嗜?
等等。架谎。炸宵。
看到這些問(wèn)題,是否有點(diǎn)頭大谷扣,別急土全,帶著這些問(wèn)題我們來(lái)慢慢看這篇文章。
Node.js 結(jié)構(gòu)
上面的問(wèn)題会涎,都挺底層的裹匙,所以我們從 Node.js 本身入手,先來(lái)看看 Node.js 的結(jié)構(gòu)末秃。
[圖片上傳中概页。。练慕。(1)]
我們可以看到惰匙,Node.js 的結(jié)構(gòu)大致分為三個(gè)層次:
Node.js 標(biāo)準(zhǔn)庫(kù),這部分是由 Javascript 編寫的铃将,即我們使用過(guò)程中直接能調(diào)用的 API项鬼。在源碼中的 lib 目錄下可以看到。
Node bindings劲阎,這一層是 Javascript 與底層 C/C++ 能夠溝通的關(guān)鍵绘盟,前者通過(guò) bindings 調(diào)用后者,相互交換數(shù)據(jù)。實(shí)現(xiàn)在 node.cc
這一層是支撐 Node.js 運(yùn)行的關(guān)鍵奥此,由 C/C++ 實(shí)現(xiàn)。V8:Google 推出的 Javascript VM雁比,也是 Node.js 為什么使用的是 Javascript 的關(guān)鍵稚虎,它為 Javascript 提供了在非瀏覽器端運(yùn)行的環(huán)境,它的高效是 Node.js 之所以高效的原因之一偎捎。
Libuv:它為 Node.js 提供了跨平臺(tái)蠢终,線程池,事件池茴她,異步 I/O 等能力寻拂,是 Node.js 如此強(qiáng)大的關(guān)鍵。
C-ares:提供了異步處理 DNS 相關(guān)的能力丈牢。
http_parser祭钉、OpenSSL、zlib 等:提供包括 http 解析己沛、SSL慌核、數(shù)據(jù)壓縮等其他的能力。
Libuv
Libuv 是 Node.js 關(guān)鍵的一個(gè)組成部分申尼,它為上層的 Node.js 提供了統(tǒng)一的 API 調(diào)用垮卓,使其不用考慮平臺(tái)差距,隱藏了底層實(shí)現(xiàn)师幕。
具體它能做什么粟按,官網(wǎng)的這張圖體現(xiàn)的很好:
可以看出,它是一個(gè)對(duì)開發(fā)者友好的工具集霹粥,包含定時(shí)器灭将,非阻塞的網(wǎng)絡(luò) I/O,異步文件系統(tǒng)訪問(wèn)后控,子進(jìn)程等功能宗侦。它封裝了 Libev、Libeio 以及 IOCP忆蚀,保證了跨平臺(tái)的通用性矾利。
我們只要先知道它本身是異步和事件驅(qū)動(dòng)的,記住這點(diǎn)馋袜,下面的問(wèn)題就有了答案男旗,我們一一來(lái)看。
與操作系統(tǒng)交互
舉個(gè)簡(jiǎn)單的例子欣鳖,我們想要打開一個(gè)文件察皇,并進(jìn)行一些操作,可以寫下面這樣一段代碼:
var fs = require('fs');fs.open('./test.txt', "w", function(err, fd) { //..do something});
這段代碼的調(diào)用過(guò)程大致可描述為:lib/fs.js → src/node_file.cc → uv_fs
Node.js 深入淺出上的一幅圖:
具體來(lái)說(shuō),當(dāng)我們調(diào)用 fs.open
時(shí)什荣,Node.js 通過(guò) process.binding
調(diào)用 C/C++ 層面的 Open
函數(shù)矾缓,然后通過(guò)它調(diào)用 Libuv 中的具體方法 uv_fs_open
,最后執(zhí)行的結(jié)果通過(guò)回調(diào)的方式傳回稻爬,完成流程嗜闻。在圖中,可以看到平臺(tái)判斷的流程桅锄,需要說(shuō)明的是琉雳,這一步是在編譯的時(shí)候已經(jīng)決定好的,并不是在運(yùn)行時(shí)中友瘤。
總體來(lái)說(shuō)翠肘,我們?cè)?Javascript 中調(diào)用的方法,最終都會(huì)通過(guò) process.binding
傳遞到 C/C++ 層面辫秧,最終由他們來(lái)執(zhí)行真正的操作束倍。Node.js 即這樣與操作系統(tǒng)進(jìn)行互動(dòng)。
通過(guò)這個(gè)過(guò)程盟戏,我們可以發(fā)現(xiàn)肌幽,實(shí)際上,Node.js 雖然說(shuō)是用的 Javascript抓半,但只是在開發(fā)時(shí)使用 Javascript 的語(yǔ)法來(lái)編寫程序喂急。真正的執(zhí)行過(guò)程還是由 V8 將 Javascript 解釋,然后由 C/C++ 來(lái)執(zhí)行真正的系統(tǒng)調(diào)用笛求,所以并不需要過(guò)分擔(dān)心 Javascript 執(zhí)行效率的問(wèn)題廊移。可以看出探入,Node.js 并不是一門語(yǔ)言狡孔,而是一個(gè) 平臺(tái),這點(diǎn)一定要分清楚蜂嗽。
異步苗膝、非阻塞 I/O
通過(guò)上文,我們了解到植旧,真正執(zhí)行系統(tǒng)調(diào)用的其實(shí)是 Libuv辱揭。之前我們提到,Libuv 本身就是異步和事件驅(qū)動(dòng)的病附,所以问窃,當(dāng)我們將 I/O 操作的請(qǐng)求傳達(dá)給 Libuv 之后,Libuv 開啟線程來(lái)執(zhí)行這次 I/O 調(diào)用完沪,并在執(zhí)行完成后域庇,傳回給 Javascript 進(jìn)行后續(xù)處理嵌戈。
這里面的 I/O 包括文件 I/O 和 網(wǎng)絡(luò) I/O。兩者的底層執(zhí)行略有不同听皿。從上面的 Libuv 官網(wǎng)的圖中熟呛,我們可以看到,文件 I/O尉姨,DNS 等操作庵朝,都是依托線程池(Thread Pool)來(lái)實(shí)現(xiàn)的。而網(wǎng)絡(luò) I/O 這一大類啊送,包括:TCP、UDP欣孤、TTY 等馋没,是由 epoll、IOCP降传、kqueue 來(lái)具體實(shí)現(xiàn)的篷朵。
總結(jié)來(lái)說(shuō),一個(gè)異步 I/O 的大致流程如下:
發(fā)起 I/O 調(diào)用
用戶通過(guò) Javascript 代碼調(diào)用 Node 核心模塊婆排,將參數(shù)和回調(diào)函數(shù)傳入到核心模塊声旺;
Node 核心模塊會(huì)將傳入的參數(shù)和回調(diào)函數(shù)封裝成一個(gè)請(qǐng)求對(duì)象;
將這個(gè)請(qǐng)求對(duì)象推入到 I/O 線程池等待執(zhí)行段只;
Javascript 發(fā)起的異步調(diào)用結(jié)束腮猖,Javascript 線程繼續(xù)執(zhí)行后續(xù)操作。
執(zhí)行回調(diào)
I/O 操作完成后赞枕,會(huì)將結(jié)果儲(chǔ)存到請(qǐng)求對(duì)象的 result 屬性上澈缺,并發(fā)出操作完成的通知;
每次事件循環(huán)時(shí)會(huì)檢查是否有完成的 I/O 操作炕婶,如果有就將請(qǐng)求對(duì)象加入到 I/O 觀察者隊(duì)列中姐赡,之后當(dāng)做事件處理;
處理 I/O 觀察者事件時(shí)柠掂,會(huì)取出之前封裝在請(qǐng)求對(duì)象中的回調(diào)函數(shù)项滑,執(zhí)行這個(gè)回調(diào)函數(shù),并將 result 當(dāng)參數(shù)涯贞,以完成 Javascript 回調(diào)的目的枪狂。
這里面涉及到了 Libuv 本身的一個(gè)設(shè)計(jì)理念,事件循環(huán)(Event Loop)宋渔,它是一個(gè)類似于 while true
的無(wú)限循環(huán)摘完,其核心函數(shù)是 uv_run
,下文會(huì)用到傻谁。
從這里孝治,我們可以看到,我們其實(shí)對(duì) Node.js 的單線程一直有個(gè)誤會(huì)。事實(shí)上谈飒,它的單線程指的是自身 Javascript 運(yùn)行環(huán)境的單線程岂座,Node.js 并沒(méi)有給 Javascript 執(zhí)行時(shí)創(chuàng)建新線程的能力,最終的實(shí)際操作杭措,還是通過(guò) Libuv 以及它的事件循環(huán)來(lái)執(zhí)行的费什。這也就是為什么 Javascript 一個(gè)單線程的語(yǔ)言,能在 Node.js 里面實(shí)現(xiàn)異步操作的原因手素,兩者并不沖突鸳址。
事件驅(qū)動(dòng)
說(shuō)到,事件驅(qū)動(dòng)泉懦,對(duì)于前端來(lái)說(shuō)稿黍,并不陌生。事件崩哩,是一個(gè)在 GUI 開發(fā)時(shí)很常用的一個(gè)概念巡球,常見的有鼠標(biāo)事件赐写,鍵盤事件等等厅篓。在異步的多種實(shí)現(xiàn)中,事件是一種比較容易理解和實(shí)現(xiàn)的方式志衣。
說(shuō)到事件汹押,一定會(huì)想到回調(diào)矿筝,當(dāng)我們寫了一大堆事件處理函數(shù)后,Libuv 如何來(lái)執(zhí)行這些回調(diào)呢棚贾?這就提到了我們之前說(shuō)到的 uv_run
跋涣,先看一張它的執(zhí)行流程圖:
在 uv_run
函數(shù)中,會(huì)維護(hù)一系列的監(jiān)視器:
typedef struct uv_loop_s uv_loop_t;typedef struct uv_err_s uv_err_t;typedef struct uv_handle_s uv_handle_t;typedef struct uv_stream_s uv_stream_t;typedef struct uv_tcp_s uv_tcp_t;typedef struct uv_udp_s uv_udp_t;typedef struct uv_pipe_s uv_pipe_t;typedef struct uv_tty_s uv_tty_t;typedef struct uv_poll_s uv_poll_t;typedef struct uv_timer_s uv_timer_t;typedef struct uv_prepare_s uv_prepare_t;typedef struct uv_check_s uv_check_t;typedef struct uv_idle_s uv_idle_t;typedef struct uv_async_s uv_async_t;typedef struct uv_process_s uv_process_t;typedef struct uv_fs_event_s uv_fs_event_t;typedef struct uv_fs_poll_s uv_fs_poll_t;typedef struct uv_signal_s uv_signal_t;
這些監(jiān)視器都有對(duì)應(yīng)著一種異步操作鸟悴,它們通過(guò) uv_TYPE_start
陈辱,來(lái)注冊(cè)事件監(jiān)聽以及相應(yīng)的回調(diào)。
在 uv_run
執(zhí)行過(guò)程中细诸,它會(huì)不斷的檢查這些隊(duì)列中是或有 pending
狀態(tài)的事件沛贪,有則觸發(fā),而且它在這里只會(huì)執(zhí)行一個(gè)回調(diào)震贵,避免在多個(gè)回調(diào)調(diào)用時(shí)發(fā)生競(jìng)爭(zhēng)關(guān)系利赋,因?yàn)?Javascript 是單線程的,無(wú)法處理這種情況猩系。
上面的圖中媚送,對(duì) I/O 操作的事件驅(qū)動(dòng),表達(dá)的比較清楚寇甸。除了我們常提到的 I/O 操作塘偎,圖中還表述了一種情況疗涉,timer(定時(shí)器)。它與其他兩者不同之處在于吟秩,它沒(méi)有單獨(dú)開立新的線程咱扣,而是在事件循環(huán)中直接完成的。
事件循環(huán)除了維護(hù)那些觀察者隊(duì)列涵防,還維護(hù)了一個(gè) time
字段闹伪,在初始化時(shí)會(huì)被賦值為0,每次循環(huán)都會(huì)更新這個(gè)值壮池。所有與時(shí)間相關(guān)的操作偏瓤,都會(huì)和這個(gè)值進(jìn)行比較,來(lái)決定是否執(zhí)行椰憋。
在圖中厅克,與 timer 相關(guān)的過(guò)程如下:
更新當(dāng)前循環(huán)的 time 字段,即當(dāng)前循環(huán)下的“現(xiàn)在”熏矿;
檢查循環(huán)中是否還有需要處理的任務(wù)(handlers/requests)已骇,如果沒(méi)有就不必循環(huán)了离钝,即是否 alive票编。
檢查注冊(cè)過(guò)的 timer,如果某一個(gè) timer 中指定的時(shí)間落后于當(dāng)前時(shí)間了卵渴,說(shuō)明該 timer 已到期慧域,于是執(zhí)行其對(duì)應(yīng)的回調(diào)函數(shù);
執(zhí)行一次 I/O polling(即阻塞住線程浪读,等待 I/O 事件發(fā)生)昔榴,如果在下一個(gè) timer 到期時(shí)還沒(méi)有任何 I/O 完成,則停止等待碘橘,執(zhí)行下一個(gè) timer 的回調(diào)互订。如果發(fā)生了 I/O 事件,則執(zhí)行對(duì)應(yīng)的回調(diào)痘拆;由于執(zhí)行回調(diào)的時(shí)間里可能又有 timer 到期了仰禽,這里要再次檢查 timer 并執(zhí)行回調(diào)。
Node.js 會(huì)一直調(diào)用 uv_run
直到到循環(huán)不在 alive纺蛆。
同步方法
雖然 Node.js 是以異步為主要模式的吐葵,但我們?cè)趯?shí)際開發(fā)中,難免會(huì)有一些情況是有時(shí)序性的桥氏,如果由異步來(lái)寫温峭,就會(huì)寫出很丑的 Callback Hell,如下:
db.query('select nickname from users where id="12"', function() { db.query('select * from xxx where id="12"', function() { db.query('select * from xxx where id="12"', function() { db.query('select * from xxx where id="12"', function() { //... }); }); });});
這個(gè)時(shí)候如果有同步方法字支,就會(huì)方便很多凤藏。這一點(diǎn)奸忽,Node.js 的開發(fā)者也想到了,目前大部分的異步操作函數(shù)清笨,都存在其對(duì)應(yīng)的同步版本月杉,只需要在其名稱后面加上 Sync
即可,不用傳入回調(diào)抠艾。
var file = fs.readFileSync('/test.txt', {"encoding": "utf-8});
這寫方法還是比較好用的苛萎,執(zhí)行 shell 命令,讀取文件等都比較方便检号。不過(guò)腌歉,體驗(yàn)不太好的一點(diǎn)就是這種調(diào)用的錯(cuò)誤收集,它不會(huì)像回調(diào)函數(shù)那樣齐苛,在第一參數(shù)中傳入錯(cuò)誤信息翘盖,它會(huì)將錯(cuò)誤直接拋出,你需要使用 try...catch
來(lái)獲取凹蜂,如下:
var data;try { data = fs.readFileSync('/test.txt');} catch (e) { if (e.code == 'ENOENT') { //... } //...}
至于這些方法如何實(shí)現(xiàn)的馍驯,我們下回再論。
一些可能的瓶頸
這里只見到討論下自己的理解玛痊,歡迎指正汰瘫。
首先,文件的 I/O 方面擂煞,用戶代碼的運(yùn)行混弥,事件循環(huán)的通知等,是通過(guò) Libuv 維護(hù)的線程池來(lái)進(jìn)行操作的对省,它會(huì)運(yùn)行全部的文件系統(tǒng)操作蝗拿。既然這樣,我們拋開硬盤的影響蒿涎,對(duì)于嚴(yán)謹(jǐn)?shù)?C/C++ 來(lái)說(shuō)哀托,這個(gè)線程池一定是有大小限制的。官方默認(rèn)給出的大小是 4劳秋。當(dāng)然是可以改變的仓手。在啟動(dòng)時(shí),通過(guò)設(shè)置 UV_THREADPOOL_SIZE
來(lái)改變這個(gè)值即可俗批。不過(guò)俗或,最大也只能是 128,因?yàn)檫@個(gè)是涉及到內(nèi)存占用的岁忘。
這個(gè)線程池對(duì)于所有的事件循環(huán)是共享的辛慰。當(dāng)一個(gè)函數(shù)要使用線程池的時(shí)候(比如調(diào)用 uv_queue_work
),Libuv 會(huì)預(yù)先分配并初始化 UV_THREADPOOL_SIZE
所允許的線程出來(lái)干像。而128 占用的內(nèi)存大約是 1MB帅腌,如果設(shè)置的太高驰弄,當(dāng)使用線程池頻繁時(shí),會(huì)因?yàn)閮?nèi)存占用過(guò)多而降低線程的性能速客。具體說(shuō)明;
對(duì)于網(wǎng)絡(luò) I/O 方面戚篙,以 Linux 系統(tǒng)下來(lái)說(shuō),網(wǎng)絡(luò) I/O 采用的是 epoll 這個(gè)異步模型溺职。它的優(yōu)點(diǎn)是采用了事件回調(diào)的方式岔擂,大大降低了文件描述符的創(chuàng)建(Linux下什么都是文件)。
在每次調(diào)用 epoll_wait
時(shí)浪耘,實(shí)際返回的是就緒描述符的數(shù)量乱灵,根據(jù)這個(gè)值,去 epoll 指定的數(shù)組里面取對(duì)應(yīng)數(shù)量的描述符七冲,是一種 內(nèi)存映射 的方式痛倚,減少了文件描述符的復(fù)制開銷。
上面提到的 epoll 指定的數(shù)組澜躺,它的大小即可監(jiān)聽的數(shù)量大小蝉稳,它在不同的系統(tǒng)下,有不同的默認(rèn)值掘鄙,可見這里 epoll create耘戚。
有了大小的限制,還遠(yuǎn)不夠通铲,為了保證運(yùn)行的穩(wěn)定毕莱,防止你在調(diào)用 epoll 函數(shù)時(shí)器贩,指針越界颅夺,導(dǎo)致內(nèi)存泄漏。還會(huì)用到另外一個(gè)值 maxevents
蛹稍,它是 epoll_wait
所能處理的最大數(shù)量吧黄,在調(diào)用 epoll_wait
時(shí)可以指定。一般情況下小于創(chuàng)建時(shí)(epoll_create)的數(shù)組大小唆姐,當(dāng)然拗慨,也可以設(shè)置的比 size 大,不過(guò)應(yīng)該沒(méi)什么用奉芦≌郧溃可以想到如果就緒的事件很多,超過(guò)了 maxevents
声功,那么超出的事件就要等待前面的事件處理完成烦却,才可以繼續(xù),可能會(huì)導(dǎo)致效率的下降先巴。
在這種情況下其爵,你可能會(huì)擔(dān)心事件會(huì)丟失冒冬。其實(shí),是不會(huì)丟失的摩渺,它會(huì)通過(guò) ep_collect_ready_items
將這些事件保存在一個(gè)隊(duì)列中简烤,在下一個(gè) epoll_wait
再進(jìn)行通知。
Node.js 不適合做什么
雖然看起來(lái)摇幻,Node.js 可以做很多事情横侦,并且擁有很高的性能。比如做聊天室绰姻,搭建 Blog 等等丈咐,這些 I/O 密集型的應(yīng)用,是比較適合 Node.js 的龙宏。
但是棵逊,有一種類型的應(yīng)用,可能 Node.js 處理起來(lái)會(huì)比較吃力银酗,那就是 CPU 密集型的應(yīng)用辆影。前文提到,Libuv 通過(guò)事件循環(huán)來(lái)處理異步的事件黍特,這是存在于 Node.js 主線程的機(jī)制蛙讥。通過(guò)這個(gè)機(jī)制,所有的 I/O 操作灭衷,底層API的調(diào)用都變成了異步的次慢。但用戶的 Javascript 代碼是運(yùn)行在主線程中的,如果這部分代碼運(yùn)行耗時(shí)很長(zhǎng)翔曲,就會(huì)導(dǎo)致事件循環(huán)被阻塞迫像。因?yàn)椋鼘?duì)于事件的處理瞳遍,都是按照隊(duì)列順序的闻妓,所以如果其中的任何一個(gè)事務(wù)/事件本身沒(méi)有完成,那么其他的回調(diào)掠械、監(jiān)聽器由缆、超時(shí)、nextTick() 都得不到運(yùn)行的機(jī)會(huì)猾蒂,被阻塞的事件循環(huán)沒(méi)有機(jī)會(huì)去處理它們均唉。這樣下去,輕則效率降低肚菠,重則運(yùn)行停滯舔箭。
比如我們常見的模板渲染,壓縮案糙,解壓縮限嫌,加/解密等操作靴庆,都是 Node.js 的軟肋,所以使用的時(shí)候要考慮到這方面怒医。
總結(jié)
Node.js 通過(guò) libuv
來(lái)處理與操作系統(tǒng)的交互炉抒,并且因此具備了異步、非阻塞稚叹、事件驅(qū)動(dòng)的能力焰薄。
Node.js 實(shí)際上是 Javascript 執(zhí)行線程的單線程,真正的的 I/O 操作扒袖,底層 API 調(diào)用都是通過(guò)多線程執(zhí)行的塞茅。
CPU 密集型的任務(wù)是 Node.js 的軟肋。