理解Node.js的事件循环

本文译至Mixu’s tech blog的《Understanding the node.js event loop》, 翻译为意译, 如有不准确之处请指**出.**

关于Node.js的第一个基本论点是——I/O是昂贵的:

I/O开销

一级缓存 3 周期
二级缓存 14 周期
内存 250 周期
磁盘 41 000 000 周期
网络 240 000 000 周期

现在的编程技术中最大的浪费在于等待I/O完成.

有几种方式能解决I/O造成的性能影响:

  • 同步: 每次只处理一个请求,轮流处理所有的请求.缺陷:每一个请求都会阻塞其他的请求
  • fork进程: 开启新的进程来处理每一个请求. 缺陷: 伸缩性太差, 数以百计的连接意味着你需要创建数以百计的进程. fork()是Unix程序员的锤子, 它很好用, 所以每个问题看起来都是钉子, 但滥用fork()可能招致更大的麻烦.
  • 线程: 开启新的线程处理每一个请求, 它很简单, 并且比调用内核的fork()来得亲切, 线程的开销也比较少. 缺陷: 你的机器可能不支持多线程, 而且多线程编程后的代码很快就会变得晦涩难懂, 你还得担心对共享资源的访问控制.

关于Node.js的第二个基本论点是——每个线程连接对于内存来说都是昂贵的:

Apache是多线程的, 它为每个请求都生成线程(或者进程, 这取决于配置), 你可以看看它在并行连接数量增长时和需要同时服务于多个客户端时吃内存的开销.

Nginx和Node.js并不是多线程的, 因为使用线程和进程都要负担着沉重的内存开销. 它们是单线程的, 基于事件, 这使得成本下降, 因为成千上万的连接都只由一个单独的线程处理.

你的Node.js代码始终在一个线程里

Node.js是真的在单线程里运行: 你不能并行任何代码, 以一个sleep做例子, 它会阻塞整个服务器一秒钟:

while(new Date().getTime() < now + 1000) {
   // do nothing
}

所以当代码运行时, Node.js不能响应任何客户端请求, 因为它只在一个线程里运行你的代码. 如果你有一些CPU密集型的代码, 比如调整图片大小, 它也会阻塞其他的所有请求.

…然而, 除了你的代码一切都是并行的

在一个请求里没有办法并行代码. 然而, 全部的I/O操作都是事件和异步的, 所以以下的代码不会阻塞服务器:

c.query(
   'SELECT SLEEP(20);',
   function (err, results, fields) {
     if (err) {
       throw err;
     }
     res.writeHead(200, {'Content-Type': 'text/html'});
     res.end('<html><head><title>Hello</title></head><body><h1>Return from async DB query</h1></body></html>');
     c.end();
    }
);

如果你在一个请求中这样做, 当数据库执行sleep时其他的请求也可以被处理.

为什么这样做更好? 我们该在什么时候从同步转向并行和异步?

同步执行是好的方案, 因为它简化了编写代码(相比之下, 线程实现的并发性问题会让你想骂人).

在Node.js里, 你不用担心并发时后端会发生什么问题: 当你处理I/O时, 只会调用回调函数, 你只需要保证你的代码不会被中断执行, I/O操作不会阻止其他请求, 而且这不会产生类似于Apache那样的创建线程和进程的内存开销.

异步I/O操作是好的方案, 因为I/O比起大多数代码来说要显得昂贵, 我们应该在等待I/O完成的时候尽可能多做一些事情.

一个事件循环的实体和过程处理的外部事件都会被转换为回调函数的调用. 所以I/O调用无论在哪一个节点, Node.js都可以转换一个请求到另一个. 在一个I/O调用时, 代码将会保存回调并且返回控制节点. Node.js运行时环境, 将调用的内容回调后, 数据是确实可用的.

当然, 在后端有线程和进程的数据库访问和其他进程都能执行. 然而, 这些都不会暴露在你的代码中, 所以不用担心不了解I/O与数据库的交互, 或者其他进程的交互. 从每个请求这些线程返回的结果通过事件循环到你的代码, 相比Apache的有很多线程和进程的模型要省下不少的开销, 因为每个连接不需要一个线程, 当你有必须并行运行的任务的时候, 都是由Node.js来管理的.

Node.js期待所有的请求都能快速返回, 所以当存在超过I/O调用浪费的时间的任务时, 像这种CPU密集型的工作应该分离到另一个能使用事件互相影响的进程或者使用一个类似WebWorkers的抽象机制来完成. 这意味着你不能在没有另一个线程可以在后台使用事件进行交互时的情况下并行你的代码. 基本上, 在Node.js里所有对象所发出的事件(例如EventEmitter实例)都支持异步交互, 都能以这种方式去阻塞的使用文件, Socket或者子进程, 都是因为有EventEmitters. 多核处理器就可以使用这种途径, 参见node-http-proxy.

底层实现

在底层, Node.js依赖libev提供的事件循环, 以libeio辅助的线程池来提供异步I/O. 要学习更多, 请看libev的相关文档.

那么我们怎么才能在Node.js里异步?

Tim Caswell在他优秀的示例里描述了这一模式:

  • 一类函数: 例如我们传递的作为参数的函数, 当需要时才会执行它们.
  • 组合函数: 在I/O事件发生后的匿名函数和闭包执行.
  • 回调计数器: 对于多个事件回调, 你不能保证I/O事件以特定的顺序执行, 所以如果你的操作需要多个查询来完成, 通常只需要记录并行I/O回调完成的数量, 以检查所有必要的操作都完成了.
  • 事件循环: 和之前说的一样, 你可以包装一个抽象事件阻塞的对象, 就像运行一个返回数据处理的子进程那样.

Node.js, 它真的很简单!