前言
日常工作中,对 Node.js 的使用都比较粗浅,趁未羊之际,来学点稍微高级的,那就先从 cluster 开始吧。
鲁迅说过,“带着问题去学习是一个比较好的方法”,所以我们也来试一试。
当初使用 cluster 时,一直好奇它是怎么做到多个子进程监听同一个端口而不冲突的,比如下面这段代码:
1 | const cluster = require('cluster') |
该段代码通过父进程 fork 出了多个子进程,且这些子进程都监听了 9999 这个端口并能正常提供服务,这是如何做到的呢?我们来研究一下。
准备调试环境
学习 Node.js 官方提供库最好的方式当然是调试一下,所以,我们先来准备一下环境。注:本文的操作系统为 macOS Big Sur 11.6.6,其他系统请自行准备相应环境。
编译 Node.js
- 下载 Node.js 源码
1 | git clone https://github.com/nodejs/node.git |
然后在下面这两个地方加入断点,方便后面调试用:
1 | // lib/internal/cluster/primary.js |
1 | // lib/internal/cluster/child.js |
- 进入目录,执行
1 | ./configure --debug |
之后会生成 out/Debug/node
准备 IDE 环境
使用 vscode 调试,配置好 launch.json 就可以了(其他 IDE 类似,请自行解决):
1 | { |
其中第一个是用于调式 C++ 代码(需要安装 C/C++ 插件),第二个用于调式 JS 代码。接下来就可以开始调试了,我们暂时用调式 JS 代码的那个配置就好了。
Cluster 源码调试
准备好调试代码(为了调试而已,这里启动一个子进程就够了):
1 | debugger |
很明显,我们的程序可以分父进程和子进程这两部分来进行分析。
首先进入的是父进程:
执行 require('cluster') 时,会进入 lib/cluster.js 这个文件:
1 | const childOrPrimary = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'primary' |
会根据当前 process.env 上是否有 NODE_UNIQUE_ID 来引入不同的模块,此时是没有的,所以会引入 internal/cluster/primary.js 这个模块:
1 | ... |
该模块主要是在 cluster 对象上挂载了一些属性和方法,并导出,这些后面回过头再看,我们继续往下调试。往下调试会进入 if (cluster.isPrimary) 分支,代码很简单,仅仅是 fork 出了一个新的子进程而已:
1 | // lib/internal/cluster/primary.js |
cluster.setupPrimary():比较简单,初始化一些参数啥的。
createWorkerProcess(id, env):
1 | // lib/internal/cluster/primary.js |
可以看到,该方法主要是通过 fork 启动了一个子进程来执行我们的 index.js,且启动子进程的时候设置了环境变量 NODE_UNIQUE_ID,这样 index.js 中 require('cluster') 的时候,引入的就是 internal/cluster/child.js 模块了。
worker.process.on('internalMessage', internal(worker, onmessage)):监听子进程传递过来的消息并处理。
接下来就进入了子进程的逻辑:
前面说了,此时引入的是 internal/cluster/child.js 模块,我们先跳过,继续往下,执行 server.listen(9999) 时实际上是调用了 Server 上的方法:
1 | // lib/net.js |
可以看到,最终是调用了 listenInCluster:
1 | // lib/net.js |
由于是在子进程中执行,所以最后会调用 cluster._getServer(server, serverQuery, listenOnPrimaryHandle):
1 | // lib/internal/cluster/child.js |
该函数最终会向父进程发送 queryServer 的消息,父进程处理完后会调用回调函数,回调函数中会调用 cb 即 listenOnPrimaryHandle。看来,listen 的逻辑是在父进程中进行的了。
接下来进入父进程:
父进程收到 queryServer 的消息后,最终会调用 queryServer 这个方法:
1 | // lib/internal/cluster/primary.js |
可以看到,这里主要是对 handle 的处理,这里的 handle 指的是调度策略,分为 SharedHandle 和 RoundRobinHandle,分别对应抢占式和轮询两种策略(文章最后补充部分有关于两者对比的例子)。
Node.js 中默认是 RoundRobinHandle 策略,可通过环境变量 NODE_CLUSTER_SCHED_POLICY 来修改,取值可以为 none(SharedHandle) 或 rr(RoundRobinHandle)。
SharedHandle
首先,我们来看一下 SharedHandle,由于我们这里是 TCP 协议,所以最后会通过 net._createServerHandle 创建一个 TCP 对象挂载在 handle 属性上(注意这里又有一个 handle,别搞混了):
1 | // lib/internal/cluster/shared_handle.js |
在 createServerHandle 中除了创建 TCP 对象外,还绑定了端口和地址:
1 | // lib/net.js |
然后,queryServer 中继续执行,会调用 add 方法,最终会将 handle 也就是 TCP 对象传递给子进程:
1 | // lib/internal/cluster/primary.js |
之后进入子进程:
子进程收到父进程对于 queryServer 的回复后,会调用 shared:
1 | // lib/internal/cluster/child.js |
shared 中最后会调用 cb 也就是 listenOnPrimaryHandle:
1 | // lib/net.js |
这里会把 handle 赋值给 server._handle,这里的 server 是 index.js 中 net.createServer 返回的那个对象,并调用 server._listen2,也就是 setupListenHandle:
1 | // lib/net.js |
首先会执行 this._handle.onconnection = onconnection,由于客户端请求过来时会调用 this._handle(也就是 TCP 对象)上的 onconnection 方法,也就是会执行lib/net.js 中的 onconnection 方法建立连接,之后就可以通信了。为了控制篇幅,该方法就不继续往下了。
然后调用 listen 监听,注意这里参数 backlog 跟之前不同,不是表示端口,而是表示在拒绝连接之前,操作系统可以挂起的最大连接数量,也就是连接请求的排队数量。我们平时遇到的 listen EADDRINUSE: address already in use 错误就是因为这行代码返回了非 0 的错误。
如果还有其他子进程,也会同样走一遍上述的步骤,不同之处是在主进程中 queryServer 时,由于已经有 handle 了,不需要再重新创建了:
1 |
|
以上内容整理成流程图如下:

所谓的 SharedHandle,其实是在多个子进程中共享 handle 的 fd,当客户端请求过来时,多个进程会去竞争该请求的处理权,会导致任务分配不均的问题,这也是为什么需要 RoundRobinHandle 的原因。接下来继续看看这种调度方式。
RoundRobinHandle
1 | // lib/internal/cluster/round_robin_handle.js |
如上所示,RoundRobinHandle 会调用 net.createServer() 创建一个 server,然后调用 listen 方法,最终会来到 setupListenHandle:
1 | // lib/net.js |
且由于此时 this._handle 为空,会调用 createServerHandle() 生成一个 TCP 对象作为 _handle。之后就跟 SharedHandle 一样了,最后也会回到子进程:
1 | // lib/internal/cluster/child.js |
不过由于 RoundRobinHandle 不会传递 handle 给子进程,所以此时会执行 rr:
1 | function rr(message, {indexesKey, index}, cb) { |
可以看到,这里构造了一个假的 handle,然后执行 cb 也就是 listenOnPrimaryHandle。最终跟 SharedHandle 一样会调用 setupListenHandle 执行 this._handle.onconnection = onconnection。
RoundRobinHandle 逻辑到此就结束了,好像缺了点什么的样子。回顾下,我们给每个子进程中的 server 上都挂载了一个假的 handle,但它跟绑定了端口的 TCP 对象没有任何关系,如果客户端请求过来了,是不会执行它上面的 onconnection 方法的。之所以要这样写,估计是为了保持跟之前 SharedHandle 代码逻辑的统一。
此时,我们需要回到 RoundRobinHandle,有这样一段代码:
1 | // lib/internal/cluster/round_robin_handle.js |
在 listen 执行完后,会触发 listening 事件的回调,这里重写了 handle 上面的 onconnection。
所以,当客户端请求过来时,会调用 distribute 在多个子进程中轮询分发,这里又有一个 handle,这里的 handle 姑且理解为 clientHandle,即客户端连接的 handle,别搞混了。总之,最后会将这个 clientHandle 发送给子进程:
1 | // lib/internal/cluster/round_robin_handle.js |
而子进程在 require('cluster') 时,已经监听了该事件:
1 | // lib/internal/cluster/child.js |
最终也同样会走到 net.js 中的 function onconnection(err, clientHandle) 方法。这个方法第二个参数名就叫 clientHandle,这也是为什么前面的 handle 我想叫这个名字的原因。
还是用图来总结下:

跟 SharedHandle 不同的是,该调度策略中 onconnection 最开始是在主进程中触发的,然后通过轮询算法挑选一个子进程,将 clientHandle 传递给它。
为什么端口不冲突
cluster 模块的调试就到此告一段落了,接下来我们来回答一下一开始的问题,为什么多个进程监听同一个端口没有报错?
网上有些文章说是因为设置了 SO_REUSEADDR,但其实跟这个没关系。通过上面的分析知道,不管什么调度策略,最终都只会在主进程中对 TCP 对象 bind 一次。
我们可以修改一下源代码来测试一下:
1 | // deps/uv/src/unix/tcp.c 下面的 SO_REUSEADDR 改成 SO_DEBUG |
编译后执行发现,我们仍然可以正常使用 cluster 模块。
那这个 SO_REUSEADDR 到底影响的是啥呢?我们继续来研究一下。
SO_REUSEADDR
首先,我们我们知道,下面的代码是会报错的:
1 | const net = require('net') |
但是,如果我稍微修改一下,就不会报错了:
1 | const net = require('net') |
原因在于 listen 时,如果不指定 address,则相当于绑定了所有地址,当两个 server 都这样做时,请求到来就不知道要给谁处理了。
我们可以类比成找对象,port 是对外貌的要求,address 是对城市的要求。现在甲乙都想要一个 port 是 1米7以上 不限城市的对象,那如果有一个 1米7以上 来自 深圳 的对象,就不知道介绍给谁了。而如果两者都指定了城市就好办多了。
那如果一个指定了 address,一个没有呢?就像下面这样:
1 | const net = require('net') |
结果是:设置了 SO_REUSEADDR 可以正常运行,而修改成 SO_DEBUG 的会报错。
还是上面的例子,甲对城市没有限制,乙需要是来自 深圳 的,那当一个对象来自 深圳,我们可以选择优先介绍给乙,非 深圳 的就选择介绍给甲,这个就是 SO_REUSEADDR 的作用。
补充
SharedHandle 和 RoundRobinHandle 两种模式的对比
先准备下测试代码:
1 | // cluster.js |
1 | // client.js |
RoundRobin
先执行 node cluster.js,然后执行 node client.js,会看到如下输出,可以看到没有任何一个进程的 PID 是紧挨着的。至于为什么没有一直按照一样的顺序,后面再研究一下。
1 | PID: 42904! |
Shared
先执行 NODE_CLUSTER_SCHED_POLICY=none node cluster.js,则 Node.js 会使用 SharedHandle,然后执行 node client.js,会看到如下输出,可以看到同一个 PID 连续输出了多次,所以这种策略会导致进程任务分配不均的现象。就像公司里有些人忙到 996,有些人天天摸鱼,这显然不是老板愿意看到的现象,所以不推荐使用。
1 | PID: 42561! |
参考
https://cloud.tencent.com/developer/article/1600191
https://www.jianshu.com/p/141aa1c41f15
https://theanarkh.github.io/understand-nodejs/chapter15-Cluster/
https://www.tripfe.cn/node-js-four-postures-of-source-code-debugging/