探讨 Node.js 的设计

published

设计并不是艺术,艺术可以漫无边际,而设计必须有其应用场景。

应用场景

Node.js 的应用场景 —— I/O 密集型应用。

为什么是事件驱动模型?

I/O 密集型应用的性能瓶颈

由于硬件及网络的限制,I/O 的速度往往是固定的。下表对比了在不同介质上进行 I/O 操作所花费的 CPU 时间:

I/O CPU Cycle
L1-cache 3
L2-cache 14
RAM 250
Disk 41000000
Network 240000000

可以看出,访问磁盘数据及网络数据所花费的 CPU 时间是访问内存时的数十万倍,而 I/O 密集型应用,却需要大量的访问磁盘和网络,如:

  • 读取图片
  • 数据库查询
  • 访问公共 API
  • ……

由于这种速度差异,CPU 会产生大量空闲,从而造成其使用效率不高。CPU 使用效率不高,便是 I/O 密集型应用的性能瓶颈。

解决性能瓶颈

针对 I/O 密集型应用,提高 CPU 使用效率,便是解决性能瓶颈的方法。可能的方法有两种:

  1. 提高磁盘和网络的 I/O 速度,以减少 CPU 的空闲时间。
  2. 不对磁盘和网络 I/O 进行阻塞式的等待,以减少 CPU 的空闲时间。

由于当今的科技水平,磁盘和网络的 I/O 速度难有提高,所以方法 1 就别想了。于是,重担落在了方法 2 身上。对磁盘和网络 I/O 进行阻塞式的等待是由 I/O 模型决定的,所以,I/O 模型高效与否便是提高性能的关键。

常见的 I/O 模型

单线程阻塞式 I/O 模型

服务器仅有一个线程,在处理某一个请求时,其他请求需要等待,直到服务器的线程空闲后,才能处理其他请求。显然,这种 I/O 模型效率很低,而且在大量请求面前,可用性很低。所以,这种模型已经很少被使用。

多线程阻塞式 I/O 模型

服务器为每个请求分配一个线程。I/O 效率虽然比之前的方式高了不少,但也还是有不少问题:

  • 服务器每创建一个线程,会使用大约 2M 的内存。
  • 线程之间的切换会占用 CPU 时间,降低了处理效率。
  • 多线程程序很难调试和测试,开发者需要考虑死锁,数据不一致等一系列问题。

虽然这种模型有缺点,但确实也还算是一个高效的处理方式,Apache 就采用了这种模型。

事件驱动模型

Node.js 使用单一的事件轮循线程处理请求,将 I/O 操作分派至各个异步处理模块以防止事件轮循线程被阻塞。这种模型有两个优点:

  • 解决了单线程模式下 I/O 阻塞的问题。
  • 避免了多线程模式下资源分配及抢占的问题。

这种模型具备了前两种模型的优点,同时又规避了它们的缺点。

最终的选择

基于对以上三种 I/O 模型的分析,Node.js 自然而然地选择了事件驱动模型。

值得一提的是,Node.js 并非此种模型的首个使用者。C 的 Libevent,Python 的 Twisted,Perl 的 AnyEvent,Ruby 的 EventMachine 也都使用此种模型。

为什么是 JavaScript?

其实,在 Node.js 实现之初,Ryan Dahl 并没有选择 JavaScript。 Ryan Dahl 回答过这个问题

开始我没有用 JavaScript,我用 C、Lua 和 Haskell 做了几个失败的小项目。Haskell 很不错,但是不还没有足够聪明可以去玩通 GHC —— Haskell 的编译器。Lua 是一种不太理想,但是很可爱的语言,我并不喜欢他,因为他已经有了大量的包含阻塞代码的库了。无论我做了什么,有些人总是愿意去读取有阻塞的 Lua 库。C 语言有一些和 Lua 相似的问题,而且它的开发门槛有些高。我开始的确想写一种像 Node.js 的 libc,我也的确做了一段时间。这个时候 V8 也出来了,我也做了一些研究,我突然意识到,Javascript 的确是一种完美的语言,他有我想要的一切:单线程,没有服务端的 I/O 处理,没有各种同步 API 的历史包袱。

总结起来就是,JavaScript:

  • 天生就是使用事件驱动模型
  • 支持函数式编程范型
  • 开发门槛适中
  • V8 速度快
  • 没有同步 API 的历史包袱