- 文档
- 教程
- 后端
【免费下载链接】node-interview
How to pass the Node.js interview of ElemeFE.
本篇是饿了么大前端(ElemeFE)Node.js 面试指南中「进程」篇的完整解读(原文档见 process.md)。进程是服务端编程绕不开的地基:从操作系统层面的进程模型,到 Node.js 暴露的process对象,再到child_process子进程、cluster多核集群、IPC 进程间通信与守护进程,本指南会以面试常见问题为线索逐一展开。读完本文,你将掌握ps -ef各字段的含义、process.nextTick与事件循环的关系、六种子进程创建方式的取舍、cluster 的两种连接分发机制、NODE_CHANNEL_FD的 IPC 建立原理,以及守护进程的完整 C 语言实现与 Node.js 等价写法,能够直接应对服务端职位面试中与进程相关的追问。
进程:操作系统与 Node.js 的双重视角
要讨论 Process,必须先分清两个概念:①操作系统(OS)层面的进程;②Node.js 中的process对象。对服务端开发者而言,操作进程就像前端掌握 HTML 一样基础,想做好服务端编程,就无法绕开 Unix/Linux。
在 Linux/Unix/Mac 系统中运行ps -ef命令,即可看到当前系统中运行的进程列表,各字段含义如下:
| 列名称 | 意义 |
|---|---|
| UID | 执行该进程的用户 ID |
| PID | 进程编号 |
| PPID | 该进程的父进程编号 |
| C | 该进程所在的 CPU 利用率 |
| STIME | 进程执行时间 |
| TTY | 进程相关的终端类型 |
| TIME | 进程所占用的 CPU 时间 |
| CMD | 创建该进程的指令 |
关于进程与操作系统更深入的细节(进程控制、fork/exec、信号、会话与进程组、文件描述符等),推荐阅读 APUE(《UNIX 环境高级编程》)等经典书籍。
维护视角的进程命令:除了ps,还应熟悉top(实时监控 CPU/内存/负载)、pstree(以树状展示进程之间的父子关系)等基础命令。结合本仓库 OS 篇 的内容,ps -x输出中 TTY 列为?的进程即不依赖终端的进程,也就是后面要讲的守护进程;通过w命令可以看到每个登录窗口对应一个新的 tty,进程与终端(tty)的绑定关系由此建立。
Node.js 的 process 对象
在代码中直接执行console.log(process)即可打印出完整的process对象,它暴露了大量有用的属性与方法,官方文档已有详尽说明,主要包括但不限于:
- 进程基础信息(
process.pid、process.ppid、process.platform、process.arch、process.argv、process.execPath等) - 进程 Usage(
process.cpuUsage()、process.memoryUsage()等) - 进程级事件(
exit、uncaughtException、beforeExit、SIGINT等信号事件) - 依赖模块/版本信息(
process.versions、process.config) - OS 基础信息(与
os模块互补,如process.platform()与 os.platform() 等价) - 账户信息(
process.getuid()、process.getgid()、process.getgroups()等) - 信号收发(
process.kill(pid, signal)等) - 三个标准流(
process.stdin、process.stdout、process.stderr)
process.nextTick:游离于事件循环阶段之外的队列
process.nextTick是一个重要且基础的方法,它并不属于事件循环(Event Loop)中的某一个阶段,而是在事件循环的每一个阶段结束后,直接执行nextTickQueue中插入的 "Tick",并且会一直执行到整个队列处理完毕。事件循环的阶段结构如下(与 event-async.md 中的图示一致):
┌───────────────────────┐ ┌─>│ timers │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ I/O callbacks │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ idle, prepare │ │ └──────────┬────────────┘ ┌───────────────┐ │ ┌──────────┴────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────┬────────────┘ │ data, etc. │ │ ┌──────────┴────────────┐ └───────────────┘ │ │ check │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ └──┤ close callbacks │ └───────────────────────┘因此面试中常出现一个经典追问:递归调用process.nextTick会怎样?
function test() { process.nextTick(() => test()); }它与下面的写法有什么区别?为什么?
function test() { setTimeout(() => test(), 0); }关键在于两者被调度的位置不同:process.nextTick的回调在当前阶段结束后、进入下一阶段之前就整体处理完nextTickQueue,递归调用nextTick会让回调永远插在事件循环阶段切换之前,导致 poll 等阶段迟迟得不到执行,宏观表现就是"饿死"事件循环;而setTimeout(fn, 0)的回调被投递到 timers 阶段,递归setTimeout只是让回调在每个循环周期中排入 timers 队列,事件循环仍能正常在各阶段间推进。这正是区分"硬异步(IO 走 libuv)"与"软异步(setTimeout 类)"的典型素材,详见 event-async.md。
配置:环境变量与配置文件,以及当前工作目录
配置是开发部署中很常见的问题,通常有两种方式:
- 环境变量:通过设置环境变量来指定配置,程序内用
process.env读取配置项。 - 配置文件:通过读取定义好的配置文件获取,这方面的库有
dotenv、node-config等。
在使用这些库加载配置文件时,通常会碰到一个**当前工作目录(cwd)**的问题:
进程的当前工作目录是什么?有什么作用?
当前进程启动的目录即当前工作目录,通过process.cwd()获取,通常是命令行启动时所在的目录(也可以在启动时通过cd指定)。文件操作等使用相对路径时会相对当前工作目录来定位文件。很多第三方配置模块正是通过你的当前目录来寻找配置文件的,所以如果你在错误的目录启动脚本,可能得不到正确的结果。在程序中可以通过process.chdir()来改变当前工作目录。
注意:本仓库原文档此处引用的是外部图片(RisingStack 的 2016 Node.js 环境变量配置调查图),其托管于外部站点而非仓库 assets 目录,故本文不重复引用外部图片,配置原理以上述文字为准。
标准流:stdin / stdout / stderr
process对象上暴露了process.stderr、process.stdout以及process.stdin三个标准流:
| 流 | 类型 | fd |
|---|---|---|
process.stdin | Readable | 0 |
process.stdout | Writable | 1 |
process.stderr | Writable | 2 |
熟悉 C/C++/Java 的同学对此并不陌生。与这三个流相关的常见面试问题是:console.log是同步还是异步?如何实现一个console.log?
从 IO 篇 的源码解读可以知道,console.log的实现本质上是向this._stdout(默认为process.stdout)写入格式化后的字符串加换行:
Console.prototype.log = function(...args) { this._stdout.write(`${util.format.apply(null, args)}\n`); };因此自己实现一个极简版console.log只需要一行:
let print = (str) => process.stdout.write(str + '\n'); print('hello world');注意该实现并没有处理多参数和占位符(即util.format的功能)。console.log是同步还是异步取决于与谁相连以及目标os:例如在终端(TTY)上process.stdout通常是同步写,而管道到文件时则可能异步缓冲——官方文档中对此有专门说明(A note on process I/O)。这类细节与 OS 篇 中用Boolean(process.stdout.isTTY)判断终端环境的技巧互为印证。
如果简历中出现 C/C++ 关键字,面试官一般还会追问如何实现一个同步的输入(类似 C 语言的scanf、C++ 的cin、Python 的raw_input)。思路是:获取用户输入的本质是读取进程的输入流process.stdin的数据;要同步读取,就用同步的readSync接口去读 stdin,例如用fs.readSync(fd, buf, 0, BUFSIZE)配合/dev/stdin或process.stdin.fd实现逐块读取(完整实现见 io.md)。
Child Process:子进程模块
子进程(Child Process)是进程概念中非常重要的一环。通过child_process模块,Node.js 可以执行可执行文件、调用命令行命令(包括其他语言的程序),也可以把.js代码以子进程的方式启动。比较有名的网易分布式架构 [pomelo] 正是基于该模块(而非cluster)实现多进程分布式架构的。
六种方法总览
| 方法 | 说明 | 同步/异步 | 要点 |
|---|---|---|---|
spawn() | 启动一个子进程来执行命令 | 异步 | options.detached决定父进程死后子进程是否存活;options.stdio指定子进程的三个标准流 |
spawnSync() | 同步版的spawn | 同步 | 可指定超时,返回的对象可获取子进程情况 |
exec() | 启动子进程执行命令,带回调参数获知子进程情况 | 异步 | 可指定进程运行的超时时间 |
execSync() | 同步版的exec() | 同步 | 可指定超时,返回子进程的输出(stdout) |
execFile() | 启动子进程执行一个可执行文件 | 异步 | 可指定进程运行的超时时间 |
execFileSync() | 同步版的execFile() | 同步 | 返回子进程输出;如果超时或 exit code 不为 0,会直接 throw Error |
fork() | 加强版的spawn() | 异步 | 返回值是ChildProcess对象,可以与子进程交互(IPC) |
安全提醒:exec/execSync方法会直接调用 bash 来解释命令,所以如果命令带有外部参数,需要警惕命令注入的情况。
child_process.fork 与 POSIX fork 的区别
child_process.fork 与 POSIX 的 fork 有什么区别?
Node.js 的child_process.fork()在 Unix 上的实现最终调用了 POSIX 的fork(2),但两者使用体验有显著差异:
- POSIX fork 需要手动管理子进程的资源释放(
waitpid),而child_process.fork()不需要关心这个问题,Node.js 会自动释放资源; fork()的 option 中可以指定detached,选择父进程死后是否允许子进程存活。
child.kill 与 child.send 的区别
另一个常见面试题是child.kill与child.send的区别:
child.kill基于信号系统(signal),向子进程发送信号(默认SIGTERM);child.send基于IPC,在父子进程之间传递消息。
孤儿进程与僵尸进程
父进程或子进程的死亡是否会影响对方?什么是孤儿进程?
- 子进程死亡不会影响父进程。不过子进程死亡时(线程组的最后一个线程,通常是"领头"线程死亡时),会向它的父进程发送死亡信号。
- 父进程死亡,一般情况下子进程也会随之死亡;但如果此时子进程处于可运行态、僵死状态等,子进程将被
进程1(init 进程)收养,从而成为孤儿进程(orphan process)。 - 当子进程死亡(处于"终止状态")时,如果父进程没有及时调用
wait()或waitpid()来回收死亡进程的相关信息,子进程的 PCB 会残留在进程表中,此时它被称为僵尸进程(zombie process)。
Cluster:多核集群
Cluster 是 Node.js 利用多核 CPU 的常见方案。它基于child_process.fork()实现,因此 cluster 产生的进程之间通过 IPC 通信;与 POSIX fork 不同,cluster 并没有拷贝父进程的空间,而是通过cluster.isMaster这个标识来区分父进程与子进程,达到类似 POSIX fork 的效果。
经典示例代码(来自原文档 process.md):
const cluster = require('cluster'); // | | const http = require('http'); // | | const numCPUs = require('os').cpus().length; // | | 都执行了 // | | if (cluster.isMaster) { // |-|----------------- // Fork workers. // | for (var i = 0; i < numCPUs; i++) { // | cluster.fork(); // | } // | 仅父进程执行 (a.js) cluster.on('exit', (worker) => { // | console.log(`${worker.process.pid} died`); // | }); // | } else { // |------------------- // Workers can share any TCP connection // | // In this case it is an HTTP server // | http.createServer((req, res) => { // | res.writeHead(200); // | 仅子进程执行 (b.js) res.end('hello world\n'); // | }).listen(8000); // | } // |------------------- // | | console.log('hello'); // | | 都执行了注意:上述代码中numCPUs虽然是全局变量,但在父进程中修改它,子进程中的值并不会改变——因为父进程与子进程是完全独立的两个内存空间。所谓的"共有"仅仅指"两段代码都被执行了",并不是同一份数据。可以把父进程执行的部分想象成a.js,子进程执行的部分想象成b.js:先执行node a.js,然后cluster.fork几次就相当于执行了几次node b.js;cluster 模块则是二者之间的桥梁,通过 cluster 提供的方法可以让两者进行沟通交流。
How It Works:两种连接分发方式
worker 进程由child_process.fork()方法创建,所以可以通过 IPC 在主进程和子进程之间相互传递服务器句柄(server handle)。cluster 模块提供两种分发连接的方式:
- 时间片轮转法(round-robin,默认方式,不适用于 Windows):主进程监听端口,接收到新连接之后,通过时间片轮转法决定将客户端的 socket 句柄传递给哪个 worker 处理。至于每个连接由哪个 worker 处理,完全由内置的循环算法决定。
- 句柄分发方式:主进程创建 socket 监听端口后,把 socket 句柄直接分发给相应的 worker;当连接进来时,由相应的 worker 直接接收并处理连接。
第二种方式在理论上性能更高,但实际存在负载不均衡的问题:由于操作系统调度器的随意性,观测到 70% 左右的连接仅被 8 个 worker 中的 2 个处理,而其他 worker 比较清闲。这正是面试中"cluster 如何保证负载均衡"(见 README.md 常见问题列表)的答案要点。
进程间通信(IPC)
IPC(Inter-process communication)即进程间通信技术。常见的进程间通信方式及特性对比表如下:
| 类型 | 无连接 | 可靠 | 流控制 | 优先级 |
|---|---|---|---|---|
| 普通 PIPE | N | Y | Y | N |
| 命名 PIPE | N | Y | Y | N |
| 消息队列 | N | Y | Y | N |
| 信号量 | N | Y | Y | Y |
| 共享存储 | N | Y | Y | Y |
| UNIX 流 SOCKET | N | Y | Y | N |
| UNIX 数据包 SOCKET | Y | Y | N | N |
Node.js 中的 IPC 通信由libuv 通过管道技术实现:在 Windows 下由命名管道(named pipe)实现(对应上表倒数第二项),在*nix系统下则采用 **UDS(Unix Domain Socket)**实现。
普通的 socket 是为网络通讯设计的,而网络本身是不可靠的;为 IPC 设计的 socket 则不然——因为默认本地网络环境是可靠的,可以简化大量不必要的 encode/decode 与校验计算,得到效率更高的 UDS 通信。
IPC 通道是如何建立的:NODE_CHANNEL_FD
理解 Node.js 的 IPC 后,可以问一个有意思的问题:
在 IPC 通道建立之前,父进程与子进程是怎么通信的?如果没有通信,那 IPC 是怎么建立的?
思路其实很简单:通过child_process建立子进程时,是可以指定子进程的env(环境变量)的。所以 Node.js 在启动子进程的时候,主进程先建立 IPC 频道,然后将 IPC 频道的 fd(文件描述符)通过环境变量NODE_CHANNEL_FD传递给子进程,子进程再通过这个 fd 连上 IPC 与父进程建立连接。
这与 IO 篇 中对 fd 的介绍是呼应的:Linux/Unix 的 fd 被设计为整型数字,从 0 开始,标准流分别占 0、1、2,传递 fd 本质上就是传递一个整型数字。所以"在 IPC 建立之前无法通信"的谜题,答案是"通过环境变量传递 fd 完成首次握手"。
业务层面,面试一般不会直接问 IPC 的实现,而是问什么情况下需要 IPC,以及用 IPC 处理过什么业务场景——例如多进程间分发任务、共享状态、cluster 中主进程向 worker 分发连接句柄等。
守护进程(Daemon)
守护进程是服务端最基础的概念之一。很多人只知道用 pm2 之类的工具把进程以守护进程方式启动,却不了解什么是守护进程、为什么要用守护进程。
- 普通的进程,在用户退出终端之后会直接关闭;
- 通过
&启动到后台的进程,之后会由于会话(session 组)被回收而终止; - 守护进程是不依赖终端(tty)的进程,不会因为用户退出终端而停止运行。
在 OS 篇 中可以看到,ps -x输出里 TTY 列为?的就是没有依赖 TTY 的进程,即守护进程;Node.js 中也可以通过process.stdout.isTTY判断当前进程是否处于终端环境(管道重定向下为false)。
守护进程的经典实现(C 语言版)
以下为原文档给出的完整 C 语言守护进程实现,逐行注释:
// 守护进程实现 (C语言版本) void init_daemon() { pid_t pid; int i = 0; if ((pid = fork()) == -1) { printf("Fork error !\n"); exit(1); } if (pid != 0) { exit(0); // 父进程退出 } setsid(); // 子进程开启新会话, 并成为会话首进程和组长进程 if ((pid = fork()) == -1) { printf("Fork error !\n"); exit(-1); } if (pid != 0) { exit(0); // 结束第一子进程, 第二子进程不再是会话首进程 // 避免当前会话组重新与tty连接 } chdir("/tmp"); // 改变工作目录 umask(0); // 重设文件掩码 for (; i < getdtablesize(); ++i) { close(i); // 关闭打开的文件描述符 } return; }各步骤的目的:
- 第一次 fork 后父进程退出:让子进程脱离原会话,成为孤儿;
setsid():让子进程开启新会话,成为会话首进程(session leader)和组长进程(process group leader);- 第二次 fork 并让第一子进程退出:确保第二子进程不再是会话首进程,避免当前会话组重新与 tty 连接(防止其重新获取控制终端);
chdir("/tmp"):改变工作目录,避免占用可卸载的文件系统(如挂载点);umask(0):重设文件掩码,保证后续创建文件的权限不受继承掩码影响;- 关闭所有文件描述符:释放从父进程继承下来的 stdin/stdout/stderr 等 fd。
最后一步与 IO 篇 的阐述互为印证:如果切到后台的守护进程没有关闭 stdio,你在 shell 操作过程中屏幕上会莫名其妙多出一些输出,因为该进程的 fd 仍指向终端的 socket;所以守护进程需要关闭继承下来的 stdio(对应for (; i < getdtablesize(); ++i) { close(i); }这段代码)。
Node.js 视角的守护进程
在 Node.js 中编写守护进程,核心思路与 C 语言一致:利用child_process.spawn()的options.detached: true让子进程脱离父进程的进程组与终端会话独立存活,随后对子进程调用child.unref(),使父进程不再维持对子进程的引用计数,父进程退出后子进程依旧运行。将标准流重定向(如stdio: 'ignore'或重定向到日志文件)即可等效完成 C 实现中"关闭 fd、脱离 tty"的目标。用 Node.js 编写守护进程的完整示例可参考 cnodejs 社区专题(原文档文末给出的链接,因外部链接规范此处不再展开);而 pm2 等进程管理工具正是在此基础上附加了日志采集、自动重启、负载均衡等能力。
本仓库以 docsify 构建(见 package.json 的serve脚本,运行npm run serve即可本地预览全部文档),「进程」篇是其中承上启下的章节:它上接事件/异步中的事件循环与 nextTick,下连 IO 篇 的 stdio/fd 机制,并与 OS 篇 的 TTY 判定、负载指标共同构成服务端基础的完整拼图。建议读者带着本文中的每个面试问题(cwd、fork 区别、孤儿/僵尸进程、cluster 负载均衡、守护进程实现)逐一在本地验证,再对照英文版 en-us/process.md 巩固术语表达。
- 文档
- 教程
- 后端
【免费下载链接】node-interview
How to pass the Node.js interview of ElemeFE.
相关推荐
agent-browser Daemon 基准测试:Node.js 守护进程与 Rust 原生守护进程的全面对比
agent browser Daemon 基准测试:Node.js 守护进程与 Rust 原生守护进程的全面对比 agent browser 是面向 AI Ag
浏览器控制CLIAI 应用GUI 自动化开发工具AI 技能MCP 服务PM2 进程管理器实战指南:守护进程、集群负载均衡、日志管理与开机自启
PM2 进程管理器实战指南:守护进程、集群负载均衡、日志管理与开机自启 PM2 是面向 Node.js / Bun 应用的生产级进程管理器,内置负载均衡器,本指
运维CLI可观测性PM2 生产级进程管理实战指南:守护、集群、日志与开机自启全解析
PM2 生产级进程管理实战指南:守护、集群、日志与开机自启全解析 PM2 是 Node.js / Bun 应用最常用的生产进程管理器,提供守护进程、崩溃自动重启
文档教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考