从Node.js进入人们的视野时,我们所知道的它就由这些关键字组成 事件驱动、非阻塞I/O、高效、轻量,它在官网中也是这么描述自己的。
Node.js® is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient.
于是,会有下面的场景出现:
当我们刚开始接触它时,可能会好奇:
当我们在用它进行文件 I/O 和网络 I/O 的时候,发现方法都需要传入回调,是异步的:
当我们习惯了用回调来处理 I/O,发现当需要顺序处理时,Callback Hell 出现了,于是有想到了同步的方法:
身为一个前端,你在使用时,发现它的异步处理是基于事件的,跟前端很相似:
当我们慢慢写的多了,处理了大量 I/O 请求的时候,你会想:
之后你还会想:
等等。。。
看到这些问题,是否有点头大,别急,带着这些问题我们来慢慢看这篇文章。
上面的问题,都挺底层的,所以我们从 Node.js 本身入手,先来看看 Node.js 的结构。
我们可以看到,Node.js 的结构大致分为三个层次:
Libuv 是 Node.js 关键的一个组成部分,它为上层的 Node.js 提供了统一的 API 调用,使其不用考虑平台差距,隐藏了底层实现。
具体它能做什么,官网的这张图体现的很好:
可以看出,它是一个对开发者友好的工具集,包含定时器,非阻塞的网络 I/O,异步文件系统访问,子进程等功能。它封装了 Libev、Libeio 以及 IOCP,保证了跨平台的通用性。
我们只要先知道它本身是异步和事件驱动的,记住这点,下面的问题就有了答案,我们一一来看。
举个简单的例子,我们想要打开一个文件,并进行一些操作,可以写下面这样一段代码:
var fs = require('fs');fs.open('./test.txt', "w", function(err, fd) { //..do something});
这段代码的调用过程大致可描述为:lib/fs.js → src/node_file.cc → uv_fs
Node.js 深入浅出上的一幅图:
具体来说,当我们调用 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 并不是一门语言,而是一个 平台,这点一定要分清楚。
通过上文,我们了解到,真正执行系统调用的其实是 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 的大致流程如下:
这里面涉及到了 Libuv 本身的一个设计理念,事件循环(Event Loop),它是一个类似于 while true 的无限循环,其核心函数是 uv_run,下文会用到。
从这里,我们可以看到,我们其实对 Node.js 的单线程一直有个误会。事实上,它的单线程指的是自身 Javascript 运行环境的单线程,Node.js 并没有给 Javascript 执行时创建新线程的能力,最终的实际操作,还是通过 Libuv 以及它的事件循环来执行的。这也就是为什么 Javascript 一个单线程的语言,能在 Node.js 里面实现异步操作的原因,两者并不冲突。
说到,事件驱动,对于前端来说,并不陌生。事件,是一个在 GUI 开发时很常用的一个概念,常见的有鼠标事件,键盘事件等等。在异步的多种实现中,事件是一种比较容易理解和实现的方式。
说到事件,一定会想到回调,当我们写了一大堆事件处理函数后,Libuv 如何来执行这些回调呢?这就提到了我们之前说到的 uv_run,先看一张它的执行流程图:
在 uv_run 函数中,会维护一系列的监视器:
typedef struct uv_loop_s uv_loop_t;typedef struct uv_err_s uv_err_t;typedef struct uv_handle_s uv_handle_t;typedef struct uv_stream_s uv_stream_t;typedef struct uv_tcp_s uv_tcp_t;typedef struct uv_udp_s uv_udp_t;typedef struct uv_pipe_s uv_pipe_t;typedef struct uv_tty_s uv_tty_t;typedef struct uv_poll_s uv_poll_t;typedef struct uv_timer_s uv_timer_t;typedef struct uv_prepare_s uv_prepare_t;typedef struct uv_check_s uv_check_t;typedef struct uv_idle_s uv_idle_t;typedef struct uv_async_s uv_async_t;typedef struct uv_process_s uv_process_t;typedef struct uv_fs_event_s uv_fs_event_t;typedef struct uv_fs_poll_s uv_fs_poll_t;typedef struct uv_signal_s uv_signal_t;
这些监视器都有对应着一种异步操作,它们通过 uv_TYPE_start,来注册事件监听以及相应的回调。
在 uv_run 执行过程中,它会不断的检查这些队列中是或有 pending 状态的事件,有则触发,而且它在这里只会执行一个回调,避免在多个回调调用时发生竞争关系,因为 Javascript 是单线程的,无法处理这种情况。
上面的图中,对 I/O 操作的事件驱动,表达的比较清楚。除了我们常提到的 I/O 操作,图中还表述了一种情况,timer(定时器)。它与其他两者不同之处在于,它没有单独开立新的线程,而是在事件循环中直接完成的。
事件循环除了维护那些观察者队列,还维护了一个 time 字段,在初始化时会被赋值为0,每次循环都会更新这个值。所有与时间相关的操作,都会和这个值进行比较,来决定是否执行。
在图中,与 timer 相关的过程如下:
Node.js 会一直调用 uv_run 直到到循环不在 alive。
虽然 Node.js 是以异步为主要模式的,但我们在实际开发中,难免会有一些情况是有时序性的,如果由异步来写,就会写出很丑的 Callback Hell,如下:
db.query('select nickname from users where id="12"', function() { db.query('select * from xxx where id="12"', function() { db.query('select * from xxx where id="12"', function() { db.query('select * from xxx where id="12"', function() { //... }); }); });});
这个时候如果有同步方法,就会方便很多。这一点,Node.js 的开发者也想到了,目前大部分的异步操作函数,都存在其对应的同步版本,只需要在其名称后面加上 Sync 即可,不用传入回调。
var file = fs.readFileSync('/test.txt', {"encoding": "utf-8});
这写方法还是比较好用的,执行 shell 命令,读取文件等都比较方便。不过,体验不太好的一点就是这种调用的错误收集,它不会像回调函数那样,在第一参数中传入错误信息,它会将错误直接抛出,你需要使用 try...catch 来获取,如下:
var data;try { data = fs.readFileSync('/test.txt');} catch (e) { if (e.code == 'ENOENT') { //... } //...}
至于这些方法如何实现的,我们下回再论。
这里只见到讨论下自己的理解,欢迎指正。
首先,文件的 I/O 方面,用户代码的运行,事件循环的通知等,是通过 Libuv 维护的线程池来进行操作的,它会运行全部的文件系统操作。既然这样,我们抛开硬盘的影响,对于严谨的 C/C++ 来说,这个线程池一定是有大小限制的。官方默认给出的大小是 4。当然是可以改变的。在启动时,通过设置 UV_THREADPOOL_SIZE 来改变这个值即可。不过,最大也只能是 128,因为这个是涉及到内存占用的。
这个线程池对于所有的事件循环是共享的。当一个函数要使用线程池的时候(比如调用 uv_queue_work),Libuv 会预先分配并初始化 UV_THREADPOOL_SIZE 所允许的线程出来。而128 占用的内存大约是 1MB,如果设置的太高,当使用线程池频繁时,会因为内存占用过多而降低线程的性能。具体说明;
对于网络 I/O 方面,以 Linux 系统下来说,网络 I/O 采用的是 epoll 这个异步模型。它的优点是采用了事件回调的方式,大大降低了文件描述符的创建(Linux下什么都是文件)。
在每次调用 epoll_wait 时,实际返回的是就绪描述符的数量,根据这个值,去 epoll 指定的数组里面取对应数量的描述符,是一种 内存映射 的方式,减少了文件描述符的复制开销。
上面提到的 epoll 指定的数组,它的大小即可监听的数量大小,它在不同的系统下,有不同的默认值,可见这里 epoll create。
有了大小的限制,还远不够,为了保证运行的稳定,防止你在调用 epoll 函数时,指针越界,导致内存泄漏。还会用到另外一个值 maxevents,它是 epoll_wait 所能处理的最大数量,在调用 epoll_wait 时可以指定。一般情况下小于创建时(epoll_create)的数组大小,当然,也可以设置的比 size 大,不过应该没什么用。可以想到如果就绪的事件很多,超过了 maxevents,那么超出的事件就要等待前面的事件处理完成,才可以继续,可能会导致效率的下降。
在这种情况下,你可能会担心事件会丢失。其实,是不会丢失的,它会通过 ep_collect_ready_items 将这些事件保存在一个队列中,在下一个 epoll_wait 再进行通知。
虽然看起来,Node.js 可以做很多事情,并且拥有很高的性能。比如做聊天室,搭建 Blog 等等,这些 I/O 密集型的应用,是比较适合 Node.js 的。
但是,有一种类型的应用,可能 Node.js 处理起来会比较吃力,那就是 CPU 密集型的应用。前文提到,Libuv 通过事件循环来处理异步的事件,这是存在于 Node.js 主线程的机制。通过这个机制,所有的 I/O 操作,底层API的调用都变成了异步的。但用户的 Javascript 代码是运行在主线程中的,如果这部分代码运行耗时很长,就会导致事件循环被阻塞。因为,它对于事件的处理,都是按照队列顺序的,所以如果其中的任何一个事务/事件本身没有完成,那么其他的回调、监听器、超时、nextTick() 都得不到运行的机会,被阻塞的事件循环没有机会去处理它们。这样下去,轻则效率降低,重则运行停滞。
比如我们常见的模板渲染,压缩,解压缩,加/解密等操作,都是 Node.js 的软肋,所以使用的时候要考虑到这方面。
点赞+转发,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓-_-)
关注 {我},享受文章首发体验!
每周重点攻克一个前端技术难点。更多精彩前端内容私信 我 回复“教程”!
原文链接:
https://fed.taobao.org/blog/2015/10/30/deep-into-node-1/
作者:凌桓