Node.js 异步编程与并发模型

深入 Node.js 异步编程:Event Loop 事件循环阶段详解、Promise/async-await、Worker Threads 多线程、Cluster 多进程与并发性能优化。

1. Event Loop 六阶段深度剖析

Node.js 的单线程非阻塞 I/O 模型建立在 libuv 实现的事件循环之上。与浏览器中简化的 Event Loop 不同,Node.js 的事件循环由六个明确的阶段构成,每个阶段维护独立的回调队列。理解这些阶段的执行顺序与边界条件,是排查定时器漂移、I/O 延迟和微任务异常的关键。

1.1 六个阶段详解

事件循环遵循严格的顺时针顺序推进:

timers 阶段:执行 setTimeout 和 setInterval 的回调。此阶段不会等待定时器精确到期,而是检查当前时间是否已过设定的阈值。如果系统中存在大量 pending 的 timer 回调, timers 阶段的单次执行可能被 libuv 的 uv__run_timers 限制处理数量,导致定时器漂移。

pending callbacks 阶段:处理系统操作中延迟的 I/O 回调。例如 TCP 中的 ECONNREFUSED 错误回调会被推迟到这个阶段,而非在 poll 阶段直接触发。libuv 内部通过 uv__io_poll 前的 pending queue 管理这些异常回调。

idle / prepare 阶段:仅供 libuv 内部使用,开发者的业务代码不会直接进入这里。prepare 句柄在每轮循环 poll 之前被触发,idle 句柄则在事件循环空闲时被调用,常用于实现事件循环健康度量工具。

poll 阶段:事件循环的核心阶段。它的职责是从内核取出新的 I/O 事件并执行对应的 I/O 回调。 poll 阶段的行为分为两种情况:当 I/O 队列不为空时,它会依次执行回调直到队列清空或达到上限;当队列为空时,它会检查是否有待执行的 timer:若有 timer 已到期,则直接跳到 timers 阶段;若没有 timer,则会在此处阻塞等待,直到有新的 I/O 事件到来。这种阻塞特性直接影响 setTimeout(0) 与 setImmediate 的相对执行顺序。

check 阶段:专为 setImmediate 设计。 poll 阶段结束后若没有 timer 到期,事件循环会先执行 check 阶段的回调,再进入下一轮 timers。因此在纯同步上下文中,setTimeout(0) 通常在 setImmediate 之前执行,因为 poll 阶段结束后 timer 已到期,循环直接跳回 timers;但在有 I/O 的上下文中,poll 阶段可能因 I/O 回调执行而无暇他顾,导致 setImmediate 先于 setTimeout(0) 执行。

close callbacks 阶段:执行 Socket.on('close') 等关闭事件的回调。例如 server.close() 后的 close 事件、文件描述符关闭时的句柄清理,都在这里统一处理。

1.2 nextTick 与 microtask 的插队机制

每个宏阶段结束后,Node.js 会先清空 process.nextTick 队列,再清空 Promise microtask 队列。nextTick 的优先级高于 Promise,且 nextTick 回调中如果再次调用 process.nextTick,新注册的回调会在当前阶段继续执行。这种递归特性可能导致事件循环被极端的 nextTick 递归完全阻塞。

console.log('1');

setTimeout(() => console.log('2'), 0);
setImmediate(() => console.log('3'));

Promise.resolve().then(() => console.log('4'));
process.nextTick(() => console.log('5'));

console.log('6');

// 输出: 1, 6, 5, 4, 2, 3
// 解释:
//   1. 同步代码: 1, 6
//   2. nextTick 队列: 5
//   3. Promise microtask 队列: 4
//   4. timers 阶段: 2 (setTimeout)
//   5. poll 阶段阻塞等待 → check 阶段: 3 (setImmediate)

如果在 readFile 的 I/O 回调中同时使用两者,顺序可能发生反转。

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
});

// 输出: immediate, timeout
// 原因: I/O 回调在 poll 阶段执行,poll 队列为空后直接进入 check 阶段,
//      此时 timers 尚未到期,因此 setImmediate 先执行

nextTick 的递归陷阱在实际项目中经常以隐蔽的形式出现。例如 ORM 库在保存模型后触发 afterSave 钩子,钩子中如果又更新了关联模型并再次触发钩子,若全部使用 nextTick 回写,可能在极短时间内耗尽事件循环的时间片。

// 危险模式:nextTick 递归可能导致事件循环饥饿
function dangerousFlush() {
    process.nextTick(() => {
        if (hasMoreWork()) {
            dangerousFlush(); // 下次事件循环的机会永远不会到来
        }
    });
}

正确的做法是将递归与工作负载绑定,每次执行固定批次后使用 setImmediate 让出控制权,或者在批次之间插入 await new Promise(setImmediate)。

1.3 poll 阶段的阻塞边界与 timer 漂移

poll 阶段的阻塞行为是 Event Loop 中最容易被误解的细节。当 poll 队列为空且没有 timer 到期时,poll 会阻塞等待内核通知新的 I/O 事件。阻塞时长由最短待处理 timer 的剩余时间决定:如果最短 timer 还有 200ms 到期,poll 最多阻塞 200ms,然后释放控制回到 timers 阶段。

这意味着 setTimeout(fn, 0) 的实际延迟并非严格为零。它至少需要经历当前阶段的结束、nextTick 和 microtask 队列的清空、timers 阶段的调度,加上因为 UV 内部最小时间精度导致的四舍五入误差(通常约为 1ms)。在 Windows 系统上,由于多媒体定时器的默认分辨率,setTimeout(fn, 0) 的实测延迟甚至可达 15ms 左右。

对于需要精确定时(如音视频同步、游戏帧率控制)的场景,不应依赖 setTimeout,而应使用基于 process.hrtime.bigint() 的高精度计时配合 setImmediate 轮询,或者直接使用 C++ Addon 绑定系统的高精度定时器。

1.3 libuv 线程池与 I/O 异步的真相

Node.js 部分 I/O 操作依赖 libuv 的线程池,默认线程数为 4。受线程池影响的 API 包括:dns.lookup、fs.*(除了 fs.watch)、加密相关的 crypto.pbkdf2 和 crypto.scrypt。当四个线程同时被占用时,后续的 fs.readFile 请求必须排队等待线程释放。

const os = require('os');

// 修改线程池大小(应在进程启动时尽早设置)
process.env.UV_THREADPOOL_SIZE = '16';

const { pbkdf2 } = require('crypto');
const start = Date.now();

for (let i = 0; i < 5; i++) {
    pbkdf2('secret', 'salt', 100000, 64, 'sha512', () => {
        console.log(`task ${i}: ${Date.now() - start}ms`);
    });
}

// UV_THREADPOOL_SIZE=4 时,第 5 个任务比前 4 个任务多出约 100ms 的等待

对于 DNS 解析,dns.resolve 直接使用 C-ares 异步解析(不占用线程池),而 dns.lookup 由于使用系统的 getaddrinfo,必须依赖线程池。在高并发 DNS 场景下,优先使用 dns.resolve 可避免线程池耗尽。

2. Worker Threads vs Cluster vs Child Process

Node.js 提供了三种突破单线程瓶颈的方案,但各自的适用场景截然不同。

2.1 Worker Threads:真正的多线程

Worker Threads 运行于独立的 V8 Isolate 中,通过 MessagePort 传递序列化数据,或通过 SharedArrayBuffer 直接共享内存。每个 Worker 有独立的 libuv 事件循环和 V8 堆,但共享进程的系统资源。

const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
    const buffer = new SharedArrayBuffer(4);
    const view = new Int32Array(buffer);

    const workerA = new Worker(__filename, { workerData: { buffer, delta: 1 } });
    const workerB = new Worker(__filename, { workerData: { buffer, delta: -1 } });

    setTimeout(() => {
        console.log('最终值:', view[0]);
        workerA.terminate();
        workerB.terminate();
    }, 1000);
} else {
    const { buffer, delta } = workerData;
    const view = new Int32Array(buffer);
    for (let i = 0; i < 1e6; i++) {
        Atomics.add(view, 0, delta);
    }
    parentPort.postMessage('done');
}

Worker Threads 适用于 CPU 密集型任务:图像处理、视频编码、密码学计算、大数据聚合。应避免将其用于 I/O 密集型场景,因为主线程的事件循环已经能高效处理异步 I/O,而创建 Worker 的上下文切换开销反而会增加延迟。

2.2 Cluster 模块:进程级负载均衡

Cluster 模块通过 child_process.fork 创建多个工作进程,所有子进程共享同一个端口。实现原理依赖操作系统内核的负载均衡或 Node.js 的轮询调度。在 Linux 系统上,Node.js 默认使用 round-robin 算法:主进程接收进来的连接,并通过 IPC 将 socket 句柄分发给空闲的工作进程。在非 Linux 系统上,则依赖操作系统的 shared handle 机制,内核自行决定将连接交给哪个进程。

round-robin 模式的优势在于主进程可以智能调度:如果某个 Worker 正在处理长连接请求,主进程可以选择跳过它,将新连接交给处理更快的 Worker。但这也意味着主进程成为了所有连接的单点瓶颈,极高并发场景下主进程的事件循环可能先于 Worker 成为瓶颈。shared handle 模式由操作系统内核直接分发连接,消除了主进程的调度开销,但失去了应用层的负载均衡透明度。

可以通过环境变量覆盖调度策略,但绝大多数情况下应信任 Node.js 的默认选择:

// 强制启用 round-robin(Linux 默认已启用)
cluster.schedulingPolicy = cluster.SCHED_RR;

// 强制使用操作系统 shared handle
cluster.schedulingPolicy = cluster.SCHED_NONE;

实际生产环境中,更常见的做法并非直接使用 Cluster 模块,而是将 Node.js 应用运行多个独立进程,由 Nginx 或 HAProxy 进行反向代理和负载均衡。这种方式的好处在于:每个进程完全独立,一个进程的崩溃或内存泄漏不会污染其他进程;Nginx 提供了更成熟的健康检查和静态文件服务;进程间的 session 共享可交由 Redis 统一管理。

const cluster = require('cluster');
const http = require('http');
const os = require('os');

if (cluster.isPrimary) {
    const numCPUs = os.cpus().length;

    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }

    cluster.on('exit', (worker) => {
        console.log(`Worker ${worker.process.pid} died, restarting...`);
        cluster.fork();
    });

    // 监控各 Worker 的负载
    setInterval(() => {
        for (const [id, worker] of Object.entries(cluster.workers)) {
            worker.send({ type: 'status' });
        }
    }, 5000);
} else {
    const server = http.createServer((req, res) => {
        res.writeHead(200);
        res.end(`Handled by Worker ${process.pid}\n`);
    });

    server.listen(3000);

    process.on('message', (msg) => {
        if (msg.type === 'status') {
            process.send({ pid: process.pid, memory: process.memoryUsage() });
        }
    });
}

2.3 Child Process:隔离性与灵活性

fork、spawn、exec 创建的子进程拥有独立的内存地址空间,崩溃不会影响主进程。适合执行外部命令、运行不受信任的脚本或需要完全资源隔离的场景。代价是 IPC 通信的序列化开销远大于 Worker Threads 的 MessageChannel。

维度Worker ThreadsClusterChild Process
进程/线程级别线程进程进程
内存模型共享 ArrayBuffer独立 V8 堆完全隔离
适用场景CPU 计算HTTP 负载均衡执行外部命令、沙箱
通信开销低(序列化)中(IPC + 句柄传递)高(IPC)
崩溃隔离性单个 Worker 崩溃影响整个进程单个 Worker 崩溃可被重启完全隔离

3. 并发控制与背压防护

在高并发数据流处理中,无限制地接受请求会导致内存爆炸和事件 loop 阻塞。Semaphore 模式通过许可计数控制并发度,是一种轻量级的背压策略。

3.1 信号量模式的实现与变体

基础信号量控制同时执行的任务数量,防止下游依赖因过载而雪崩。但在 HTTP 服务中,更常见的需求是将排队的请求返回 429 状态码而非无限制等待,因此需要增加超时控制:

class Semaphore {
    constructor(capacity) {
        this.capacity = capacity;
        this.queue = [];
    }

    async acquire(timeoutMs = 30000) {
        if (this.capacity > 0) {
            this.capacity--;
            return;
        }
        return new Promise((resolve, reject) => {
            const timer = timeoutMs ? setTimeout(() => {
                const idx = this.queue.indexOf(ticket);
                if (idx > -1) this.queue.splice(idx, 1);
                reject(new Error('Semaphore acquire timeout'));
            }, timeoutMs) : null;

            const ticket = () => {
                clearTimeout(timer);
                resolve();
            };
            this.queue.push(ticket);
        });
    }

    release() {
        if (this.queue.length > 0) {
            const next = this.queue.shift();
            next();
        } else {
            this.capacity++;
        }
    }
}

3.2 基于流的背压机制

Node.js 的 stream 模块内置了背压控制。当可写流的 write() 返回 false 时,表示内部缓冲区已满,可读流应暂停读取;当 drain 事件触发时,方可恢复读取。stream.pipeline 和 stream.pipeline(...) 会自动处理这种暂停与恢复的信号传递。

const { pipeline, Readable, Writable } = require('stream');
const fs = require('fs');

// 将大文件逐行处理写入数据库,自动处理背压
const source = fs.createReadStream('/var/log/large.log', { highWaterMark: 64 * 1024 });
const dbSink = new Writable({
    objectMode: true,
    write(chunk, encoding, callback) {
        // 模拟异步数据库写入
        db.insert(chunk).then(() => callback()).catch(callback);
    },
});

pipeline(source, lineParserTransform, dbSink, (err) => {
    if (err) console.error('Pipeline failed:', err);
    else console.log('Pipeline succeeded');
});

如果上游是 HTTP 请求流(如文件上传),而下游处理速度较慢,未经背压控制的 req.on('data', ...) 会将所有数据块压入内存,最终触发 OOM。应始终在处理原始请求流时使用 pipeline() 或手动检测 writable.write() 的返回值。

class Semaphore {
    constructor(capacity) {
        this.capacity = capacity;
        this.queue = [];
    }

    async acquire() {
        if (this.capacity > 0) {
            this.capacity--;
            return;
        }
        return new Promise(resolve => this.queue.push(resolve));
    }

    release() {
        if (this.queue.length > 0) {
            const next = this.queue.shift();
            next();
        } else {
            this.capacity++;
        }
    }
}

const sem = new Semaphore(5); // 最多并发 5 个任务

app.get('/heavy-task', async (req, res) => {
    await sem.acquire();
    try {
        await performHeavyTask();
        res.json({ status: 'ok' });
    } finally {
        sem.release();
    }
});

对于流式数据处理,可结合 stream.pipeline 与 highWaterMark 自动实现背压:当消费端速率低于生产端时,Node.js 会自动暂停上游读取,避免内存中堆积过多缓冲数据。

4. async_hooks 资源追踪

async_hooks 模块允许追踪异步资源的生命周期,从创建到销毁的完整链路。它在构建全链路追踪系统、内存泄漏检测和异步上下文传播中发挥关键作用。每个异步操作在 libuv 中创建时都会分配一个唯一的 asyncId,并通过 triggerAsyncId 关联到触发它的父级异步资源,这种父子关系构成了异步调用的完整调用链。

4.1 内存泄漏检测场景

异步资源若未被正确销毁(如 Promise 未 settle、事件监听器未移除、数据库连接未关闭),会导致内存持续增长。通过 init 和 destroy 钩子统计异步资源的存活数量,可以在测试阶段发现这类泄漏。

const async_hooks = require('async_hooks');
const fs = require('fs');

const asyncIds = new Map();

const hook = async_hooks.createHook({
    init(asyncId, type, triggerAsyncId) {
        asyncIds.set(asyncId, { type, triggerAsyncId, start: Date.now() });
    },
    destroy(asyncId) {
        const meta = asyncIds.get(asyncId);
        if (meta) {
            fs.writeSync(1, `Async ${asyncId} (${meta.type}) 存活 ${Date.now() - meta.start}ms\n`);
            asyncIds.delete(asyncId);
        }
    }
});

hook.enable();

Promise.resolve().then(() => {
    setTimeout(() => {}, 100);
});

在复杂系统中,可使用 AsyncLocalStorage(Node.js 13.10+)替代手动维护 async_hooks,将请求上下文隐式传递到异步调用栈的每一处。

const { AsyncLocalStorage } = require('async_hooks');

const storage = new AsyncLocalStorage();

function middleware(req, res, next) {
    storage.run({ requestId: req.headers['x-request-id'] || crypto.randomUUID() }, () => {
        next();
    });
}

function logger(msg) {
    const ctx = storage.getStore();
    console.log(`[${ctx?.requestId}] ${msg}`);
}

5. 阻塞事件循环的检测与规避

CPU 密集型同步计算会将整个事件 loop 冻结数十毫秒甚至数秒,所有 I/O 回调、HTTP 请求处理都会被延迟。诊断这类问题的核心工具包括 process.nextTick 延迟探测和 perf_hooks 采样分析。

5.1 事件循环延迟监控

const { monitorEventLoopDelay } = require('perf_hooks');

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

setInterval(() => {
    console.log(`平均延迟: ${h.mean / 1e6}ms, 最大延迟: ${h.max / 1e6}ms`);
    h.reset();
}, 5000);

// 当 mean > 50ms 时,说明事件 loop 中存在长时间运行的同步代码

5.2 拆解同步任务与 setImmediate 的限制

对于必须顺序执行的计算密集型逻辑,可通过 setImmediate 或 setTimeout(..., 0) 将任务切分为多个宏任务,让事件 loop 在间隙中处理 I/O。但 setImmediate 仅在 check 阶段执行,如果当前阶段已经是 check 阶段,它会排到下一轮 check,这意味着即使 CPU 负载不高,每个任务分片也会引入至少一个完整事件循环周期的延迟。

更精确的做法是使用 setTimeout(..., 0) 在 timers 阶段调度,或者使用 Node.js 18 引入的 scheduler.yield()。后者会立即执行当前阶段的所有微任务,然后将控制权交给事件循环,比 setImmediate 的延迟更可控。

async function chunkedProcess(items, chunkSize = 1000, processor) {
    let index = 0;

    return new Promise((resolve, reject) => {
        function processChunk() {
            const end = Math.min(index + chunkSize, items.length);
            for (let i = index; i < end; i++) {
                processor(items[i]);
            }
            index = end;

            if (index < items.length) {
                setImmediate(processChunk); // 让出事件 loop,允许 I/O 处理
            } else {
                resolve();
            }
        }
        processChunk();
    });
}

5.3 生产环境最佳实践

监控事件循环延迟应结合进程级指标统一采集,Prometheus 社区提供 prom-client 结合 perf_hooks 将事件循环指标导出为 /metrics 端点:

const client = require('prom-client');
const { monitorEventLoopDelay } = require('perf_hooks');

const eventLoopLag = new client.Gauge({
    name: 'nodejs_eventloop_lag_seconds',
    help: 'Event loop lag in seconds',
});

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

setInterval(() => {
    eventLoopLag.set(h.mean / 1e9);
    h.reset();
}, 5000);

事件循环延迟超过 100ms 时应触发告警。根本原因通常是同步 CPU 任务、阻塞性 I/O(如未使用 stream 的大文件读取)或内存 GC 导致的长时间停顿。将 UV_THREADPOOL_SIZE 从默认的 4 提高到 16-128(不超过 CPU 核心数的合理倍数),可以显著改善文件系统和 DNS 密集场景的 throughput。但是,线程池扩容增加了线程上下文切换开销,且线程池是进程级共享资源,Cluster 多进程模式下每个进程拥有独立的线程池,总线程数会线性增长。

对于必须顺序执行的计算密集型逻辑,可通过 setImmediate 或 setTimeout(..., 0) 将任务切分为多个宏任务,让事件 loop 在间隙中处理 I/O。

async function chunkedProcess(items, chunkSize = 1000, processor) {
    let index = 0;

    return new Promise((resolve, reject) => {
        function processChunk() {
            const end = Math.min(index + chunkSize, items.length);
            for (let i = index; i < end; i++) {
                processor(items[i]);
            }
            index = end;

            if (index < items.length) {
                setImmediate(processChunk); // 让出事件 loop
            } else {
                resolve();
            }
        }
        processChunk();
    });
}

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nodejs」更多文章

  1. Node.js 优雅停机与健康检查实战
  2. Node.js 内存泄漏诊断实战
  3. BullMQ 后台任务队列实战