Node异步

理解闭包

从形式来看,闭包就是在函数里面定义一个函数,从特点来说,子函数能够读写父函数的局部变量

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等模块功能,其完成了异步方法的具体实现),内部均采用了多线程机制。

image.png

这里,主线程就是nodejs所谓的单线程,也就是用户javascript代码运行的线程,I/O线程即执行异步操作的线程。

image.png

执行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()进行等待。

异步调用各线程流程图及关系如下:

image.png

理解事件循环

事件循环的职责,就是不断得等待事件的发生,然后将这个事件的所有处理器,以它们订阅这个事件的时间顺序,依次执行。当这个事件的所有处理器都被执行完毕之后,事件循环就会开始继续等待下一个事件的触发,不断往复。

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 源码中的实现。

image.png
  • 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 阶段:执行 socketclose 事件回调

所以为什么

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线程中。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。