理解闭包
从形式来看,闭包就是在函数里面定义一个函数,从特点来说,子函数能够读写父函数的局部变量。
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线程中。