深入剖析Nodejs的异步IO

前言:Nodejs最赖以自豪的优势莫过于“单线程实现异步IO”了,也许你仍然丈二和尚摸不着头脑,Nodejs自我标榜是单线程,还能实现异步IO操作,这两者难道不是相互矛盾的么?葫芦里到底藏着什么药? 且听我娓娓道来……


一、首先,看看Nodejs的架构

这里写图片描述

http://nodejs.cn/download/ 你可以到Nodejs中文网下载Node源码。

这里写图片描述

Nodejs结构大体分为三个部分:

1)Node.js标准库:这部分由JavaScript编写。也就是平时我们经常require的各个模块,如:http,fs、express,request…… 这部分在源码的lib目录下可以看到;

2)Node bingdings: nodejs程序的main函数入口,还有提供给lib模块的C++类接口,这一层是javascript与底层C/C++沟通的桥梁,由C++编写,这部分在源码的src目录下可以看到;

3)最底层,支持Nodejs运行的关键: V8 引擎:用来解析、执行javascript代码的运行环境。 libuv: 提供最底层的IO操作接口,包括文件异步IO的线程池管理和网络的IO操作,是整个异步IO实现的核心! 这部分由C/C++编写,在源码的deps目录下可以看到。

小结:我们其实对 Node.js的单线程一直有个很深的误会。事实上,这里的“单线程”指的是我们(开发者)编写的代码只能运行在一个线程当中(习惯称之为主线程),Node.js并没有给 Javascript 执行时创建新线程的能力,所以称为单线程,也就是所谓的主线程。 其实,Nodejs中许多异步方法在具体的实现时(NodeJs底层封装了Libuv,它提供了线程池、事件池、异步I/O等模块功能,其完成了异步方法的具体实现),内部均采用了多线程机制。

二、异步IO操作调用流程

这里写图片描述

这里,主线程就是nodejs所谓的单线程,也就是用户javascript代码运行的线程

IO线程是由Libuv(Linux下由libeio具体实现;window下则由IOCP具体实现)管理的线程池控制的,本质上是多线程。即采用了线程池与阻塞IO模拟了异步IO。

以文件操作为例子,回调函数是何时被加载执行的呢?也就是异步IO操作内部是如何实现的?

新建一个文件yzx_file.js ,内容如下:

var fs = require('fs');
var path = require('path');

fs.readFile(__dirname + '/test01.txt', {flag: 'r+', encoding: 'utf8'}, function (err, data) {

    console.log(data); //打印test01.txt文本内容
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

这里写图片描述

整个文件操作的调用过程如下:

1)首先,用户写的javascript调用Node的核心模块fs.js ;

2)接下来,Node的核心模块调用C++内建模块node_file.cc ;

3)最后,根据不同平台(Linux或者window),内建模块通过libuv进行系统调用

然后,接下来你可能会产生疑问:那回调函数何时被执行呢?

三、Nodejs运行流程

当你运行上面的例子,如 node yzx_file.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。

(文件描述符的理解: 对于每个程序系统都有一张单独的表。精确地讲,系统为每个运行的进程维护一张单独的文件描述符表。当进程打开一个文件时,系统把一个指向此文件内部数据结构的指针写入文件描述符表,并把该表的索引值返回给调用者 。应用程序只需记住这个描述符,并在以后操作该文件时使用它。操作系统把该描述符作为索引访问进程描述符表,通过指针找到保存该文件所有的信息的数据结构。)

(观察者的理解:在每个Tick(在程序启动时,Node便会创建一个类似于while(true)的循环,没执行一次循环体的过程我们称为Tick)的过程中,为了判断是否有事件需要处理,所以引入了观察者的概念,每个事件循环中有一个或多个观察者,判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。在node中,事件主要来源于网络请求,文件IO等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。事件轮询是一个典型的生产者、消费者模型,异步I/O、网络请求等则是事件的生产者,源源不断为node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。)

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对象推入线程池中等待执行,该方法的代码如下所示QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT);QueueUserWorkItem()方法接收3个参数:第一个参数是将要执行的方法的引用,这里引用的是uv_fs_thread_proc,这个参数是uv_fs_thread_proc运行时所需要的参数;第三个参数是执行的标志。当线程池中有可用线程时,我们会调用uv_fs_thread_proc()方法。uv_fs_thread_proc()方法会根据传入参数的类型调用相应的底层函数。以uv_fs_open()为例,实际上调用的是fs__open()方法。

至此,JavaScript调用立即返回,由JavaScript层面发起的异步调用的第一阶段就此结束。JavaScript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不管它是否会阻塞I/O,都不会影响到JavaScript线程的后续执行,如此就达到到了异步的目的。

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

这里写图片描述

这里写图片描述

5)这里要强调的是:只有用户的js代码全部执行完后,nodejs才调用libuv的事件循环入口函数uv_run(),即回调函数才有可能被执行。所以,如果主线程的js代码调用了阻塞方法,那么整个事件轮询就会被阻塞,事件队列中的事件便得不到及时处理。 为了验证这个事实:我做了一个实验如下:

新建 index.js文件,内容如下:(同时在根目录下新建一个test01.tet文件,内容为“我是test01!”)

var fs = require('fs');
var path = require('path');

fs.readFile(__dirname + '/test01.txt', {flag: 'r+', encoding: 'utf8'}, function (err, data) {

    console.log(data); //打印test01.txt文本内  
});

//自己写的一个延迟函数
function sleep(milliSeconds){
    var StartTime =new Date().getTime();
    while (new Date().getTime() <StartTime+milliSeconds);
}

sleep(5000);  //延迟5s

程序很简单,即在主线程中,调用了一个阻塞函数,延时5s;运行程序,你会发现, 
5s以后,异步文件操作的回调函数才会被触发执行。这也说明了,如果真正想做到异步IO操作,主线程应该尽量避免大量的耗时计算或调用阻塞函数

总结:事件循环、观察者、请求对象、IO线程池这四者共同构成了Node异步IO操作的基本要素。

posted on 2017-04-13 15:04  沐雨橙风丶  阅读(5555)  评论(0编辑  收藏  举报