探讨 Node.js 的设计
设计并不是艺术,艺术可以漫无边际,而设计必须有其应用场景。
应用场景
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 使用效率,便是解决性能瓶颈的方法。可能的方法有两种:
- 提高磁盘和网络的 I/O 速度,以减少 CPU 的空闲时间。
- 不对磁盘和网络 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 的历史包袱