关于 Node.js 的一些事情(一)
Node.js 应该是如今最火的技术了。在之前的工作中,我也一直在使用 Node.js 进行开发,多多少少对 Node.js 有一些了解,今天就来和大家分享一下我对 Node.js 的认识。
1 什么是 Node.js
Node.js 并不是一个 Web Framework
,它是一个搭建在 Chrome V8
引擎上的平台。在 Node.js 中,JS 可以随心所欲地访问本地文件,可以用来搭建 WebSocket 服务器,可以连接数据库,同时它不在与 CSS
、DOM
打交道。因此简单的说,Node.js 就是一个 JS 在 Server 端的运行环境。
2. Node.js 的起源
Ryan Dahl 是 Node.js 的创始人,在创造出 Node.js 之前,他是一名资深的 C/C++ 工程师,主要工作都是围绕高性能 Web 服务器进行的。在经过自己的不断摸索之后,他提出了高性能服务器的几个要点:事件驱动
,非阻塞 I/O
。
备注: Ryan Dahl 现在已经不参与 Node.js 的开发维护了,他在把 Node.js 的整体架构基本构建完成之后就消失了。。。他本人的演讲也很有意思,非常值得一看。
2.1 为什么选择了 JavaScript
在写 Node.js 的时候,Ryan Dahl 曾经评估过 C、Lua、Haskell、Ruby 等语言作为备选实现。最后都弃用了,包括如下原因:
- C 的开发门槛高
- Lua 自身已经有很多阻塞 I/O 库,历史包袱比较重
- Haskell 落选是因为 Ryan Dahl 觉得自己还玩不转它。。。
- Ruby 的虚拟机性能不给力,所以也落选了
而 JavaScript 则符合了上述所有的要求
- 虽然坑多,但是初学起来还是比较容易
- 之前在服务端毫无表现,所以没有历史包袱
- JS 之前在前端开发中,基于事件驱动有广泛的应用
$('button').on('click', function () {
// ...
});
- Chrome 浏览器的 JS 引擎
V8
过于炫酷,JS 在速度方面已经远远甩开其他的解释型语言了
2.2 Node.js 的特点
得益于 Chrome V8
引擎的优势,V8 让 Node.js 在性能上得到了巨大的提示,因为它去掉了中间环节,执行的不是字节码,用的也不是解释器,而是直接编译成了本地机器码。之前看到一个简单的测试,使用不同语言进行斐波那契数列计算,以 n=40 计算。性能至尊 C 占用的用户时间为 0m0.202s
,使用 C++ 模块拓展的 Node.js 占用的用户时间为 0m1.001s
甚至比 Java(0m1.305s
)、Go(0m1.667s
) 这些静态语言还要快,即使未使用 C++ 模块拓展的 Node.js 也仅仅占用了 0m2.872s
,相较于其他的解释型语言(Ruby 2.0.0 0m27.78s
, Python pypy 0m30.01s
)也是快了很多个量级。虽然这种简单的测试并不能完全反应出语言的性能,但是还是可以反应出 Node.js 在性能上不俗的表现。
Node.js 的主旨是使 I/O 操作与 CPU 操作分离(在 Web 开发中,通常是 I/O 占据了大量的时间),不同于一般的每线程/每请求
的方式,Node.js 通过事件驱动的方式处理请求,无须为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低,这使得即使在大量连接的情况下,也不受线程上下文切换开销的影响,这是 Node.js 高性能的一个原因。Node.js 保留了前端浏览器 JS 的那些接口,没有改变语言本身的任何特性,依旧基于作用域和原型链。区别在于将前端中广泛应用的思想迁移到了服务器后端。
2.2.1 异步 I/O
// fs 是 Node.js 中的文件管理系统
var fs = require('fs');
fs.readFile('path/to/file', function (err, file) {
console.log('读取完成');
});
console.log('发起读取');
在终端中运行这段代码,我们会发现发起读取
是在读取完成
之前输出的。因为 fs.readFile()
发起了一个异步调用,当所有的数据都读取完成后,会调用后面的回调函数。这个过程是在后台完成的,这样在该过程中,我们可以继续处理其他任何操作,直到数据准备好。
Node 中异步 I/O 的实现
通过让部分线程进行阻塞 I/O 或者非阻塞 I/O 加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将 I/O 得到的数据进行传递,这就轻松实现了异步 I/O(尽管它是模拟的)。
2.2.2 单线程
Node.js 保持了 JavaScript 在浏览器中单线程的特点,而且在 Node 中,JavaScript 与其余线程是无法共享任何状态的。这带来的好处是:
- 无需像多线程编程那样处处在意状态同步的问题
- 没有死锁的存在
- 没有线程上下文切换带来的性能上的开销
同时也带来了潜在的问题:
- 无法利用多核 CPU
- 错误会引起整个应用退出,健壮性值得考验
- 大量占用 CPU 计算会导致后续的异步 I/O 发不出调用,已完成的异步 I/O 的回调函数也不会得到及时的执行
对于上面的问题,目前比较好的解决方案是通过类似 NGINX 中 Master-Worker
的管理方式。通过将大量计算分解掉,分发到子进程,再通过进程之间的事件消息来传递结果。
2.2.3 事件循环
在 Node 进程启动时,会创建一个类似于 while(true) 的循环,每执行一次循环体的过程我们称为 Tick
。每个 Tick
的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果有新的事件调用,则分发到 I/O 线程池
中进行处理。这里单线程与 I/O线程池之间看起来有些矛盾的样子。实际上,Node 中除了 JavaScript 是单线程外,Node 自身其实是多线程的
,只是 I/O 线程使用的 CPU 较少。另一方面,除了用户的代码无法并行执行外(因为 JavaScript 是单线程
)外,所有的 I/O (磁盘 I/O 和 网络 I/O) 则是可以并行起来的。
2.2.4 异步编程模型
由于 Node.js 中一切都是异步的,因此我们编程的方式也会不同于以往,以写一个遍历目录的函数为例
- 通常同步的方式
function travel(dir, callback) {
fs.readdirSync(dir).forEach(function (file) {
var pathname = path.join(dir, file);
if (fs.statSync(pathname).isDirectory()) {
travel(pathname, callback);
} else {
callback(pathname);
}
});
}
- 异步遍历目录的方式就完全不一样了
function travel(dir, callback, finish) {
fs.readdir(dir, function (err, files) {
(function next(i) {
if (i < files.length) {
var pathname = path.join(dir, files[i]);
fs.stat(pathname, function (err, stats) {
if (stats.isDirectory()) {
travel(pathname, callback, function () {
next(i + 1);
});
} else {
callback(pathname, function () {
next(i + 1);
})
}
});
} else {
finish && finish();
}
}(0));
});
}
可以看到,由于 readdir
,stat
这些函数都是异步执行的,返回的结果都需要我们在回调函数处理。开始时候写的时候会觉得逻辑有些反人类。。。但是一旦我们适应了,对这种异步代码的编写也就不那么困扰了。而且随着新的 JS 标准的推广,deferred/promise
的使用会越来越普遍。编写异步代码的痛苦会小很多,起码不需要这么多 callback 去嵌套导致写出很晦涩的代码了(Google 里面搜索 JavaScript callback 关键字,提示排第一的就是 JavaScript callback hell。。。)。
这次先大概说这些,虽然我自己是一个 Ruby 开发者,但是 Node.js 在构建一些高实时性的 Web 应用方面确实有着得天独厚的优势,同时它的编程模型也与 Ruby 截然不同。因此在空闲的时候,去学习一下这些新东西,也算是对我们自己编程思维的一种训练和拓展。