理解閉包
從形式來看,閉包就是在函數里面定義一個函數,從特點來說,子函數能夠讀寫父函數的局部變量。
function parent() {
var count = 0;
return function children(){
count++;
console.log(count);
}
}
var children = parent();
children(); // 1
children(); // 2
閉包能夠訪問外部函數的變量,在外部函數執行完畢后,外部函數中的變量內存依然存在并未釋放,它的生命周期會保存到children變量內存被回收為止。要避免內存泄漏,就要考慮何時注銷閉包函數的引用,理解它的生命周期,才能盡量避免可能產生的內存泄漏。
所以要關注包含大對象的閉包函數對象,是否被引用到了root對象上,是否被注冊到事件循環中,是否對應執行了反注冊方法,是否置空,具體內存接下來再花一篇來重點分析一下。
理解異步
我們在接觸學習node時總會聽到node的單線程模型,其實這里會導致對 Node.js的單線程會有個很深的誤會。事實上,這里的單線程指的是我們(開發者)編寫的代碼只能運行在一個線程當中(習慣稱之為主線程),Node.js并沒有給 Javascript 執行時創建新線程的能力,所以稱為單線程,也就是所謂的主線程。 其實,Nodejs中許多異步方法在具體的實現時(NodeJs底層封裝了Libuv,它提供了線程池、事件池、異步I/O等模塊功能,其完成了異步方法的具體實現),內部均采用了多線程機制。
這里,主線程就是nodejs所謂的單線程,也就是用戶javascript代碼運行的線程,I/O線程即執行異步操作的線程。
執行node app.js的流程如上圖所示:
1)node啟動,進入main函數;
2)初始化核心數據結構 default_loop_struct;這個數據結構是事件循環的核心,當node執行到“加載js文件”時,如果用戶的javascript代碼中具有異步IO操作時,如讀寫文件。這時候,javascript代碼調用–>lib模塊–>C++模塊–>libuv接口–>最終系統底層的API,系統返回一個文件描述符fd 和javascript代碼傳進來的回調函數callback,然后封裝成一個IO觀察者(一個uv__io_s類型的對象),保存到default_loop_struct。
3)加載用戶javascript文件,調用V8引擎接口,解析并執行javascript代碼。如果有異步IO,則通過一系列調用系統底層API。
若是網絡IO,如http.get() 或者 app.listen() ,則把系統調用后返回的結果(文件描述符fd)和事件綁定的回調函數callback,一起封裝成一個IO觀察者,保存到default_loop_struct。
如果是文件IO,例如在uv_fs_open()的調用過程中,我們創建了一個FSReqWrap請求對象。從JavaScript層傳入的參數和當前方法都被封裝在這個請求對象中,其中我們最為關心的回調函數則被設置在這個對象的oncomplete_sym屬性上:req_wrap->object_->Set(oncomplete_sym, callback)。對象包裝完畢后,在Windows下,則調用QueueUserWorkItem()方法將這個FSReqWrap對象推入線程池中等待執行。。
至此,JavaScript調用立即返回,由JavaScript層面發起的異步調用的第一階段就此結束。JavaScript線程可以繼續執行當前任務的后續操作。當前的I/O操作在線程池中等待執行,不管它是否會阻塞I/O,都不會影響到JavaScript線程的后續執行,如此就達到到了異步的目的。
等異步線程操作完畢,通知事件循環有異步io結束,需要調用回調函數。
4)進入事件循環,即調用libuv的事件循環入口函數uv_run();當處理完 js代碼,如果有io操作,那么這時default_loop_struct是保存著對應的io觀察者的。處理完js代碼,main函數繼續往下調用libuv的事件循環入口uv_run(),node進程進入事件循環:
uv_run()的while循環做的就是一件事,判斷default_loop_struct是否有存活的io觀察者。 a. 如果沒有io觀察者,那么uv_run()退出,node進程退出。 b. 而如果有io觀察者,那么uv_run()進入epoll_wait(),線程掛起等待,監聽對應的io觀察者是否有數據到來。有數據到來調用io觀察者里保存著的callback(js代碼),沒有數據到來時一直在epoll_wait()進行等待。
異步調用各線程流程圖及關系如下:
理解事件循環
事件循環的職責,就是不斷得等待事件的發生,然后將這個事件的所有處理器,以它們訂閱這個事件的時間順序,依次執行。當這個事件的所有處理器都被執行完畢之后,事件循環就會開始繼續等待下一個事件的觸發,不斷往復。
Node.js采用V8作為js的解析引擎,而I/O處理方面使用了自己設計的libuv,libuv是一個基于事件驅動的跨平臺抽象層,封裝了不同操作系統一些底層特性,對外提供統一的API,上文提到的事件循環機制是它里面的實現,代碼如下:
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
// timers階段
uv__run_timers(loop);
// I/O callbacks階段
ran_pending = uv__run_pending(loop);
// idle階段
uv__run_idle(loop);
// prepare階段
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// poll階段
uv__io_poll(loop, timeout);
// check階段
uv__run_check(loop);
// close callbacks階段
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
每次事件循環都包含了6個階段,對應上段代碼 libuv 源碼中的實現。
-
timers 階段:
timers 是事件循環的第一個階段,Node 會去檢查有無已過期的timer,如果有則把它的回調壓入timer的任務隊列中等待執行,事實上,Node 并不能保證timer在預設時間到了就會立即執行,因為Node對timer的過期檢查不一定靠譜,它會受機器上其它運行程序影響,或者那個時間點主線程不空閑。
I/O callbacks 階段:執行一些系統調用錯誤,比如網絡通信的錯誤回調。
idle, prepare 階段:僅node內部使用。
-
poll 階段:獲取新的I/O事件, 適當的條件下node將阻塞在這里。
主要有2個功能:
- 處理 poll 隊列的事件
- 當有已超時的 timer,執行它的回調函數
在timers階段產生的超時回調,在這個階段會執行,直到超時timers隊列為空或執行的回調達到系統上限(上限具體多少未詳)。接下來even loop會去檢查有無預設的
setImmediate()
,分兩種情況:若有預設的setImmediate()
, event loop將結束poll階段進入check階段,并執行check階段的任務隊列。若沒有預設的setImmediate()
,event loop將阻塞在該階段等待。這種阻塞狀態會被兩種情況打破,一個是timeout達到,一個是setImmediate方法執行,這時候會進入下一次loop循環,重新檢查是否有超時的timers需要處理,進入下一個消息循環。
check 階段:執行
setImmediate()
的回調。close callbacks 階段:執行
socket
的close
事件回調
所以為什么
const fs = require('fs')
fs.readFile('test.txt', () => {
console.log('readFile')
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
的執行結果是
readFile
immediate
timeout
因為setImmediate方法打破阻塞狀態優先執行check方法,而后才從超時隊列中取出超時timer回調執行,再次進入阻塞狀態。
注意上文中提到setTimeout并不是嚴格按照時間節點來,如果在回調中執行耗時的操作,導致下次消息循環觸發時間會整體延后,比如
var sleep = require('sleep');
setTimeout(() => {
console.log('timeout')
}, 100);
setImmediate(() => {
console.log('immediate')
sleep.sleep(2);
})
則timeout的打印時間為2100秒以后,所以盡量不要在主線程中執行耗時操作,耗時操作盡量都放在Worker線程中。