我们可以看到,Node.js 的结构大致分为三个层次:
Node.js 标准库,这部分是由 Javascript 编写的,即我们使用过程中直接能调用的 API。在源码中的lib目录下可以看到。
Node bindings,这一层是 Javascript 与底层 C/C++ 能够沟通的关键,前者通过 bindings 调用后者,相互交换数据。实现在node.cc
这一层是支撑 Node.js 运行的关键,由 C/C++ 实现。
V8:Google 推出的 Javascript VM,也是 Node.js 为什么使用的是 Javascript 的关键,它为 Javascript 提供了在非浏览器端运行的环境,它的高效是 Node.js 之所以高效的原因之一。
Libuv:它为 Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力,是 Node.js 如此强大的关键。
C-ares:提供了异步处理 DNS 相关的能力。
http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其他的能力。
Libuv
Libuv 是 Node.js 关键的一个组成部分,它为上层的 Node.js 提供了统一的 API 调用,使其不用考虑平台差距,隐藏了底层实现。
可以看出,它是一个对开发者友好的工具集,包含定时器,非阻塞的网络 I/O,异步文件系统访问,子进程等功能。它封装了 Libev、Libeio 以及 IOCP,保证了跨平台的通用性。
我们只要先知道它本身是异步和事件驱动的,记住这点,下面的问题就有了答案,我们一一来看。
与操作系统交互
举个简单的例子,我们想要打开一个文件,并进行一些操作,可以写下面这样一段代码:
varfs =require('fs');
fs.open('./test.txt',"w",function(err, fd){
//..do something
});
这段代码的调用过程大致可描述为:lib/fs.js→src/node_file.cc→uv_fs
具体来说,当我们调用fs.open时,Node.js 通过process.binding调用 C/C++ 层面的Open函数,然后通过它调用 Libuv 中的具体方法uv_fs_open,最后执行的结果通过回调的方式传回,完成流程。在图中,可以看到平台判断的流程,需要说明的是,这一步是在编译的时候已经决定好的,并不是在运行时中。
总体来说,我们在 Javascript 中调用的方法,最终都会通过process.binding传递到 C/C++ 层面,最终由他们来执行真正的操作。Node.js 即这样与操作系统进行互动。
通过这个过程,我们可以发现,实际上,Node.js 虽然说是用的 Javascript,但只是在开发时使用 Javascript 的语法来编写程序。真正的执行过程还是由 V8 将 Javascript 解释,然后由 C/C++ 来执行真正的系统调用,所以并不需要过分担心 Javascript 执行效率的问题。可以看出,Node.js 并不是一门语言,而是一个平台,这点一定要分清楚。
异步、非阻塞I/O
通过上文,我们了解到,真正执行系统调用的其实是 Libuv。之前我们提到,Libuv 本身就是异步和事件驱动的,所以,当我们将 I/O操作的请求传达给 Libuv 之后,Libuv 开启线程来执行这次 I/O 调用,并在执行完成后,传回给 Javascript 进行后续处理。
这里面的 I/O 包括文件 I/O 和 网络 I/O。两者的底层执行略有不同。从上面的 Libuv 官网的图中,我们可以看到,文件I/O,DNS 等操作,都是依托线程池(Thread Pool)来实现的。而网络 I/O 这一大类,包括:TCP、UDP、TTY 等,是由epoll、IOCP、kqueue 来具体实现的。
总结来说,一个异步 I/O 的大致流程如下:
发起 I/O 调用
1.用户通过 Javascript 代码调用 Node 核心模块,将参数和回调函数传入到核心模块;
2.Node 核心模块会将传入的参数和回调函数封装成一个请求对象;
3.将这个请求对象推入到 I/O 线程池等待执行;
4.Javascript 发起的异步调用结束,Javascript 线程继续执行后续操作。
执行回调
1.I/O 操作完成后,会将结果储存到请求对象的 result 属性上,并发出操作完成的通知;
2.每次事件循环时会检查是否有完成的 I/O 操作,如果有就将请求对象加入到 I/O 观察者队列中,之后当做事件处理;
3.处理 I/O 观察者事件时,会取出之前封装在请求对象中的回调函数,执行这个回调函数,并将 result 当参数,以完成 Javascript 回调的目的。
这里面涉及到了 Libuv 本身的一个设计理念,事件循环(Event Loop),它是一个类似于while true的无限循环,其核心函数是uv_run,下文会用到。
从这里,我们可以看到,我们其实对 Node.js 的单线程一直有个误会。事实上,它的单线程指的是自身 Javascript运行环境的单线程,Node.js 并没有给 Javascript 执行时创建新线程的能力,最终的实际操作,还是通过 Libuv以及它的事件循环来执行的。这也就是为什么 Javascript 一个单线程的语言,能在 Node.js 里面实现异步操作的原因,两者并不冲突。
事件驱动
说到,事件驱动,对于前端来说,并不陌生。事件,是一个在 GUI 开发时很常用的一个概念,常见的有鼠标事件,键盘事件等等。在异步的多种实现中,事件是一种比较容易理解和实现的方式。
说到事件,一定会想到回调,当我们写了一大堆事件处理函数后,Libuv 如何来执行这些回调呢?这就提到了我们之前说到的uv_run,先看一张它的执行流程图:
在uv_run函数中,会维护一系列的监视器:
typedefstructuv_loop_suv_loop_t;
typedefstructuv_err_suv_err_t;typedefstructuv_handle_suv_handle_t;
typedefstructuv_stream_suv_stream_t;typedefstructuv_tcp_suv_tcp_t;
typedefstructuv_udp_suv_udp_t;
typedefstructuv_pipe_suv_pipe_t;
typedefstructuv_tty_suv_tty_t;
typedefstructuv_poll_suv_poll_t;
typedefstructuv_timer_suv_timer_t;
typedefstructuv_prepare_suv_prepare_t;
typedefstructuv_check_suv_check_t;
typedefstructuv_idle_suv_idle_t;
typedefstructuv_async_suv_async_t;
typedefstructuv_process_suv_process_t;
typedefstructuv_fs_event_suv_fs_event_t;
typedefstructuv_fs_poll_suv_fs_poll_t;
typedefstructuv_signal_suv_signal_t;
这些监视器都有对应着一种异步操作,它们通过uv_TYPE_start,来注册事件监听以及相应的回调。
在uv_run执行过程中,它会不断的检查这些队列中是或有pending状态的事件,有则触发,而且它在这里只会执行一个回调,避免在多个回调调用时发生竞争关系,因为 Javascript 是单线程的,无法处理这种情况。
上面的图中,对 I/O 操作的事件驱动,表达的比较清楚。除了我们常提到的 I/O 操作,图中还表述了一种情况,timer(定时器)。它与其他两者不同之处在于,它没有单独开立新的线程,而是在事件循环中直接完成的。
事件循环除了维护那些观察者队列,还维护了一个time字段,在初始化时会被赋值为0,每次循环都会更新这个值。所有与时间相关的操作,都会和这个值进行比较,来决定是否执行。
在图中,与 timer 相关的过程如下:
更新当前循环的 time 字段,即当前循环下的“现在”;
1.检查循环中是否还有需要处理的任务(handlers/requests),如果没有就不必循环了,即是否 alive。
2.检查注册过的 timer,如果某一个 timer 中指定的时间落后于当前时间了,说明该 timer 已到期,于是执行其对应的回调函数;
3.执行一次 I/O polling(即阻塞住线程,等待 I/O 事件发生),如果在下一个timer 到期时还没有任何 I/O 完成,则停止等待,执行下一个 timer 的回调。如果发生了 I/O事件,则执行对应的回调;由于执行回调的时间里可能又有 timer 到期了,这里要再次检查 timer 并执行回调。
Node.js 会一直调用uv_run直到到循环不在 alive。
同步方法
虽然 Node.js 是以异步为主要模式的,但我们在实际开发中,难免会有一些情况是有时序性的,如果由异步来写,就会写出很丑的 Callback Hell,如下:
db.query('selectnicknamefromuserswhereid="12"', function() {
db.query('select*fromxxxwhereid="12"', function() {
db.query('select*fromxxxwhereid="12"', function() {
db.query('select*fromxxxwhereid="12"', function() {
//...
});
});
});
});
这个时候如果有同步方法,就会方便很多。这一点,Node.js 的开发者也想到了,目前大部分的异步操作函数,都存在其对应的同步版本,只需要在其名称后面加上Sync即可,不用传入回调。
varfile= fs.readFileSync('/test.txt', {"encoding": "utf-8});
这写方法还是比较好用的,执行 shell 命令,读取文件等都比较方便。不过,体验不太好的一点就是这种调用的错误收集,它不会像回调函数那样,在第一参数中传入错误信息,它会将错误直接抛出,你需要使用try...catch来获取,如下:
var data;
try{
data = fs.readFileSync('/test.txt');
} catch ( e ) {
if(e.code =='ENOENT') {
//...
}
//...
}
网友评论