本文翻译自 MIXU's BLOG 的文章 《Understanding the node.js event loop


关于 Node.js 有一个基本的共识就是,I/O 的消耗是非常大的。

当前编程技术中,消耗最大的部分来自于等待 I/O 完成,以下有几种方法可以解决性能影响:

  • 同步:每次只处理一个请求,依次处理。优点:简单;缺点:任何一个请求都会阻塞其他所有请求。
  • 多进程:对于每一个请求,都开启一个新的进程来处理。优点:简单;缺点:不好拓展,一千个请求就要开启一千个进程。fork() 确实 unix 程序员的好工具,但是不能滥用。
  • 多线程:对于每一个请求,开启一个新的线程来处理。优点:简单,而且对内核的消耗远小于多进程。缺点:你的机器可能不支持多线程,而且多线程编程因为要控制对共享资源的访问,会变的非常复杂。

关于 Node.js 的第二个基本共识是,连接的每一条线程都是非常占用内存的。

  • Apache 是多线程的,它给每一条请求创建一个线程(或者进程,取决于具体配置)。随着并发连接数量的增加,需要更多的线程来服务连接的客户端,你可以看到这些线程的开销是如何吞噬掉内存的。
  • NginxNode.js 不是多线程的,因为多线程和多进程对内存的消耗非常大。它们是单线程的,但是是基于事件的。用单线程处理众多的连接,消除了大量的线程/进程对内存的消耗。

Node.js 给你的代码只保留了一个线程...

它确实是单个线程在运行:你不能执行任何的并行代码。例如:

function sleep(milliSeconds) {
    var startTime = new Date().getTime();
    while (new Date().getTime() < startTime + milliSeconds);
}

执行 sleep(1000) 时会阻塞代码 1 秒。

当代码执行的过程中,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();
    }
);

如果你在一个请求中执行此操作,在数据库处于休眠状态期间,其他请求也可以正常处理。

咦?我们什么时候从同步执行变成异步/并行的呢?

同步执行是非常好的,因为它简化了代码的编写(相比之下,多线程的并发性问题会导致很多 WTF 的结果)。

在 Node.js 中,你不需要担心后台发生了什么:只需要在做 I/O 时使用回调即可。这样可以保证你的代码不会被中断,I/O 的操作也不会阻塞其他的请求,也不需要负担每条请求的线程/进程开销(如 Apache 的内存消耗)。

异步 I/O 是非常棒的,因为 I/O 操作比绝大多数代码的开销要大。在 I/O 操作执行期间,我们应该做一些更有意义的事情,而不仅仅只是等待。

事件轮询是一个 处理外部事件,并将其转化成回调来调用 的实体,所以 I/O 调用是 Node.js 可以从一个请求切换到另一个的点。在一个 I/O 调用中,代码将回调保存好,并将控制权交给 Node.js 运行环境,当数据可用时,回调将会被调用。

当然,在后台还是存在 多线程和多进程来进行数据库访问及流程执行 的,只是这些并没有显式地暴露出来,所以你只需要知道 I/O 交互就可以了,其他的事情不需要担心。例如,从每一个请求的角度来看,数据库操作和其他操作都是异步的,因为这些线程处理的结果通过事件轮询返回给你的代码。

除了 I/O 调用,Node.js 希望所有的请求都能快速返回。例如,CPU 密集型的工作应该分割给其他进程,然后通过事件,或者一个抽象概念(如 WebWorkers)进行交互。这(显然)意味着,你无法并行执行你的代码,除非后台有别的线程通过事件与你交互。基本上,所有能发出事件的对象(如 EventEmitter 的实例)都支持异步事件交互,你可以通过这种方式跟阻塞代码进行交互,如使用文件,sockets 和子进程,这些都是 Node.js 中的 EventEmitter 对象。通过这种方法,我们甚至可以实现多核(参见:node-http-proxy)。

内部实现

在内部,node.js 依赖 libev(基于 libeio 实现)来实现事件轮询,而 libeio 通过线程池来提供异步 I/O 的,详见 libev 参考文档

所以我们如何在 Node.js 中实现异步呢?

Tim Caswell 在他出色的演讲中描述了这些模式:

  • 一类函数:将函数像数据一样传递,转换,并在适当的时候执行。
  • 复合函数:又称匿名函数或闭包,在 I/O 中某些事件触发之后执行。

(译者注:即第一种是传递定义好的函数,第二种是传递匿名函数)

最后修改:2021 年 11 月 25 日 10 : 02 AM
如果觉得我的文章对你有用,请随意赞赏