Node.js教程:理解异步编程与事件循环

生活资讯5小时前发布 jdkjadmin
0 00
Node.js教程:理解异步编程与事件循环

Node.js教程:理解异步编程与事件循环

Node.js 经常被描述为“单线程、异步、非阻塞”,这句话容易让人误以为所有任务都会自动并行,或者耗时操作不会影响程序响应。实际情况更具体:JavaScript 通常在一个线程上执行;Node.js 通过事件循环调度回调,并由操作系统或 libuv 在线程池中处理部分 I/O 和计算任务。异步只表示调用者不必在当前执行位置等待结果,并不意味着任务没有成本,也不保证任务一定更快。

理解这套机制,能帮助我们判断什么时候使用异步 I/O、怎样组织 Promise、为什么定时器并不准时,以及何时应该把计算移出主线程。本文以常见 Node.js API 为例,代码可保存为 .js 文件后运行。不同 Node.js 版本、操作系统和负载状况可能影响具体调度表现,因此示例中的顺序说明会区分稳定保证与实现细节。

一、同步、异步与非阻塞分别是什么

同步代码会占住当前 JavaScript 执行线程

同步函数必须返回后,当前代码才能继续执行。例如:

const fs = require('node:fs');

console.log('开始');
const text = fs.readFileSync('./input.txt', 'utf8');
console.log(text);
console.log('结束');

文件读取期间,当前 JavaScript 执行线程无法处理其他 JavaScript 回调。文件很小、只在启动阶段读取一次配置时,同步 API 有时简单直接;但如果在服务器请求处理中频繁使用同步文件操作,其他请求的回调可能被迫等待。同步 API 也可能抛出异常,需要用 try/catch 处理。

异步调用先返回,结果稍后通过回调或 Promise 交付

例如使用基于 Promise 的文件 API:

const fs = require('node:fs/promises');

async function main() {
  console.log('开始');
  const text = await fs.readFile('./input.txt', 'utf8');
  console.log(text);
  console.log('结束');
}

main().catch((error) => {
  console.error('读取失败:', error);
});

调用 readFile 后,JavaScript 可以继续处理其他工作;读取完成后,Promise 才会兑现或拒绝,await 后的代码再恢复执行。这里的 await 不会把整个 Node.js 进程冻结,但它会暂停当前 async 函数,直到 Promise 有结果。

“非阻塞”也不是“完全不等待”。await 会让当前函数等待结果,只是不以同步阻塞的方式占住 JavaScript 调用栈。若程序只有这一项工作,仍然要等它完成;异步的优势主要体现在等待期间事件循环还能处理其他可运行的任务。

二、Node.js 的事件循环在做什么

JavaScript 函数开始执行时,会进入调用栈。当前栈上的代码必须运行完,事件循环才能继续处理后续回调。异步 API 完成后,其回调通常会被安排到相应队列;当当前代码退出、调用栈清空后,Node.js 才有机会运行这些回调。

可以把过程简化为以下步骤:

  1. 执行当前同步 JavaScript,直到调用栈清空。
  2. 处理已排队的 Promise 回调等微任务。
  3. 事件循环继续处理计时器、I/O 回调、检查阶段等工作。
  4. 某项异步工作完成时,对应回调被安排执行;回调执行期间仍会占用 JavaScript 线程。

这只是帮助理解的模型,不应被当作每个回调都对应某个固定线程的承诺。Node.js 的事件循环由运行时与 libuv 等组件共同实现,具体阶段和实现会随版本演进。

事件循环的常见阶段

在 Node.js 的常见说明中,事件循环包含多个阶段:timers 阶段处理到期的 setTimeout 和 setInterval 回调;poll 阶段处理 I/O 相关回调,并可能等待新的 I/O;check 阶段处理 setImmediate 回调;此外还有处理关闭事件等阶段。阶段的存在有助于解释行为,但不代表回调会在设定时间点准时执行。

例如 setTimeout(callback, 10) 表示回调不会在约 10 毫秒之前执行(实际触发还受参数规则、计时精度和事件循环状态影响),并不表示恰好在第 10 毫秒执行。如果同步代码运行了 100 毫秒,计时器回调只能等同步代码结束后才有机会运行。

const start = Date.now();

setTimeout(() => {
  console.log('计时器回调,经过约', Date.now() - start, '毫秒');
}, 10);

const busyUntil = Date.now() + 100;
while (Date.now() < busyUntil) {
  // 故意阻塞 JavaScript 线程
}

console.log('同步代码结束');

输出中“同步代码结束”会先于计时器回调。计时器已经到期,也不能抢占正在执行的 JavaScript。

setImmediate 与 setTimeout 的顺序不能随意假定

setImmediate 的回调在 check 阶段执行。setTimeout(fn, 0) 则会在计时器满足触发条件后执行。两者在顶层代码中谁先运行,可能取决于启动时机和运行环境,不适合写依赖某一固定顺序的逻辑。若在 I/O 回调中安排二者,常见情况下 setImmediate 会先于 setTimeout(callback, 0),但不应把这个观察扩展成所有场景下的绝对规则。

const fs = require('node:fs');

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

可以运行这段代码观察结果。关键不是记住某个输出顺序,而是不要用它实现正确性依赖;若需要明确的业务顺序,应直接串联函数或 Promise。

三、回调、Promise 与 async/await

回调适合接口边界,但要明确错误参数

Node.js 传统回调 API 常采用 error-first 形式:第一个参数是错误,没有错误时通常为 null。例如:

const fs = require('node:fs');

fs.readFile('./input.txt', 'utf8', (error, text) => {
  if (error) {
    console.error('读取失败:', error);
    return;
  }

  console.log(text);
});

回调适合与既有接口集成,但多层嵌套会让控制流难以阅读。还要注意,回调里的 throw 通常不会被外层发起调用的 try/catch 捕获,因为回调可能在之后的事件循环中运行。错误应该通过 API 约定的 error 参数或 Promise rejection 传递。

Promise 表达异步结果和失败

Promise 有 pending、fulfilled 和 rejected 等状态。then 处理成功结果,catch 处理拒绝;每次调用 then、catch 或 finally 都会产生一个新的 Promise,因此返回值和异常会继续传递到后续链条。

const fs = require('node:fs/promises');

fs.readFile('./input.txt', 'utf8')
  .then((text) => {
    console.log(text);
    return text.length;
  })
  .then((length) => {
    console.log('字符数:', length);
  })
  .catch((error) => {
    console.error('操作失败:', error);
  });

常见错误是忘记 return 内层 Promise,导致外层链条不再等待它;另一个错误是写了 catch 后又把错误吞掉,让调用者误以为操作成功。若错误无法在当前层恢复,应继续抛出或返回拒绝的 Promise。

async/await 让异步控制流更接近同步写法

async 函数总是返回 Promise。函数内抛出的异常会让返回的 Promise 拒绝;await 会取得 Promise 的兑现值,若 Promise 拒绝,则会在当前位置抛出异常。

const fs = require('node:fs/promises');

async function loadText(path) {
  try {
    return await fs.readFile(path, 'utf8');
  } catch (error) {
    console.error('无法读取文件:', error.message);
    throw error;
  }
}

loadText('./input.txt')
  .then((text) => console.log(text))
  .catch(() => {
    console.log('上层调用者处理了失败');
  });

try/catch 只捕获其词法范围内、同步抛出的异常以及被 await 的 Promise 拒绝。未 await、未 return 的异步任务不属于当前控制流,外层 try/catch 无法替它兜底。

async function incorrect() {
  try {
    Promise.reject(new Error('稍后失败'));
    console.log('try 结束');
  } catch (error) {
    console.log('捕获不到这个拒绝');
  }
}

上例创建了一个被拒绝但没有处理的 Promise。应当 await 或 return 它,并为最外层调用提供错误处理:

async function correct() {
  try {
    await Promise.reject(new Error('操作失败'));
  } catch (error) {
    console.error('已处理:', error.message);
  }
}

correct().catch(console.error);

四、微任务与 process.nextTick

Promise 的 then、catch、finally 回调属于微任务。Node.js 还提供 process.nextTick,它使用 Node.js 自己的 next-tick 队列,通常会在当前 JavaScript 操作结束后、事件循环继续处理其他阶段前执行。它不是浏览器的 queueMicrotask 的别名,运行时对队列的安排也有差别。

console.log('同步开始');

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

console.log('同步结束');

在常见 Node.js 环境中,这段顶层代码会先输出两条同步日志,再处理 nextTick 队列,随后处理 Promise 与 queueMicrotask 的回调。不要仅靠记忆队列顺序来协调业务任务;对业务顺序使用显式的 await、Promise 链或队列更可靠。

不应在循环中无止境地递归安排 process.nextTick、Promise 微任务或 queueMicrotask。持续产生高优先级任务会让事件循环无法及时进入 I/O、计时器等阶段,造成 I/O 饥饿。nextTick 适合少量、短小的兼容性或收尾逻辑,不适合作为通用任务调度器。

五、并发执行与串行执行

顺序依赖的任务使用 await 串行执行

若第二项操作必须使用第一项结果,逐步 await 通常最清楚:

async function runSerially() {
  const user = await fetchUser();
  const orders = await fetchOrders(user.id);
  return { user, orders };
}

这种写法会等待第一项完成后才启动第二项。如果两项操作彼此独立,串行等待可能浪费时间,可以并发启动:

async function runTogether() {
  const [profile, settings] = await Promise.all([
    fetchProfile(),
    fetchSettings()
  ]);

  return { profile, settings };
}

Promise.all 在所有输入 Promise 成功时兑现;任意一个拒绝时,返回的 Promise 会以该错误拒绝。它不会自动取消其他已经启动的操作,也不能保证操作回滚。若需要失败后仍收集每项结果,可以考虑 Promise.allSettled。

并发不等于无限制地启动任务

把数万个请求一次性放进 Promise.all,可能造成内存占用、连接耗尽、服务端限流或文件描述符压力。实际服务应根据外部服务限制、资源预算和延迟目标控制并发数量,例如用任务队列或并发限制库;超时和取消策略也要与底层 API 支持情况相匹配。

Promise.all 的“并发”是指这些异步操作可以重叠等待,不代表 JavaScript 在多个 CPU 核心上同时执行。若传入的函数在创建 Promise 之前就做了大量同步计算,仍会占住主线程。

六、阻塞主线程的 CPU 密集型工作

Node.js 主线程上的长时间计算会延迟请求回调、计时器和 Promise 回调。典型场景包括大型 JSON 转换、复杂正则表达式、加密计算、压缩和图像处理。异步语法本身不能让同步计算变快:

function expensiveCalculation() {
  let total = 0;

  for (let i = 0; i < 1_000_000_000; i++) {
    total += i;
  }

  return total;
}

setTimeout(() => console.log('计时器'), 0);
console.log(expensiveCalculation());

计时器必须等循环结束才能运行。对于持续占用 CPU 的工作,可以考虑 worker_threads,把计算放到工作线程;child_process 适用于需要进程隔离或运行独立程序的情况。采用哪种方式取决于任务大小、数据传输成本、隔离需求和部署环境。线程池也不是无限资源,提交过多任务会带来排队和内存开销。

某些 Node.js API 虽然以异步形式提供,但底层可能使用 libuv 线程池,而不是由操作系统直接完成全部工作。例如部分文件系统操作、DNS 查询、加密和压缩 API 会使用线程池。线程池容量有限,某类任务大量占用线程可能影响同池内其他工作。对于高负载服务,应该通过基准测试观察吞吐、延迟和资源使用,而不是假设“异步 API 等于无限并行”。

七、流式处理:避免一次性读入大文件

readFile 会在 Promise 兑现前读取完整文件并把内容放入内存。对于很大的文件,逐块读取的流通常更合适。流使用背压机制协调生产者和消费者:当下游处理不过来时,数据生产可以暂缓,避免不受控制地积累。

const fs = require('node:fs');
const { pipeline } = require('node:stream/promises');
const { createGzip } = require('node:zlib');

async function compressFile() {
  await pipeline(
    fs.createReadStream('./large.log'),
    createGzip(),
    fs.createWriteStream('./large.log.gz')
  );
}

compressFile().catch((error) => {
  console.error('压缩失败:', error);
});

pipeline 会连接流,并在错误时完成适当的错误传播和清理。处理网络上传或用户提供的文件时,还需要设置合理的大小上限、超时和访问权限;流式处理能降低峰值内存,并不自动保证数据可信或资源消耗有界。

八、定时器、取消与超时

setTimeout 和 setInterval 适用于延迟执行或周期性检查,不是实时调度器。事件循环繁忙时,回调会延后。setInterval 的回调若执行时间过长,也可能出现任务重叠或节奏不符合预期;对于依赖上一次结果的轮询,常用“完成后再安排下一次”的递归 setTimeout。

async function poll() {
  try {
    await checkStatus();
  } catch (error) {
    console.error('检查状态失败:', error);
  } finally {
    setTimeout(poll, 1000);
  }
}

poll();

上例保证下一次检查在本次调用结束后才安排,但它没有提供停止机制。长生命周期任务应保存定时器句柄,并在服务关闭时清理。对于支持 AbortSignal 的 API,可用 AbortController 请求取消:

const controller = new AbortController();

const timer = setTimeout(() => controller.abort(), 3000);

fetch('https://example.com/data', {
  signal: controller.signal
})
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 状态:${response.status}`);
    }
    return response.text();
  })
  .then((text) => console.log(text))
  .catch((error) => {
    if (error.name === 'AbortError') {
      console.error('请求被取消或超时');
    } else {
      console.error('请求失败:', error);
    }
  })
  .finally(() => clearTimeout(timer));

该示例使用 Node.js 内置 fetch;较旧版本可能没有全局 fetch,需要使用支持该 API 的 Node.js 版本或合适的 HTTP 客户端。AbortSignal 能否真正中止底层操作取决于具体 API 的支持。对不支持取消的操作,调用 abort 不一定能停止它;所谓超时也可能只是让调用方停止等待,底层工作仍在继续。

九、异步代码中的安全与可靠性边界

不要把异步错误留成未处理拒绝

每个启动的异步任务都应该有明确的结果归属:等待它、返回它,或在适当位置处理它。未处理的 Promise rejection 在不同 Node.js 版本和启动配置下可能产生警告,甚至导致进程以非零状态退出。不要依赖全局 unhandledRejection 监听器充当正常错误处理流程;它适合监控和诊断,不代替局部处理。

对外部输入设置资源边界

异步并不会消除拒绝服务风险。攻击者可能提交巨大请求体、触发昂贵的正则表达式、要求并行访问大量资源,或让服务等待永不返回的外部依赖。应结合请求体大小限制、并发上限、超时、速率限制和输入校验。对正则表达式尤其要避免可能造成灾难性回溯的模式,并在实际输入范围内测试。

文件路径和命令参数需要独立校验

不要把用户输入直接拼接成文件路径或 shell 命令。路径应限制在预期目录内,并检查规范化后的路径;执行外部进程时优先使用参数数组和 shell: false,避免把不可信字符串拼成命令行。异步运行并不会降低路径穿越或命令注入风险。

取消任务不等于撤销副作用

请求被取消时,远端服务可能已经完成写入,数据库事务也可能已经提交。取消信号只能通知支持它的操作尽量停止,不能自动撤销已发生的副作用。涉及支付、创建订单等操作,应设计幂等键、事务或补偿流程,不要把客户端断开连接等同于业务操作未发生。

十、常见错误清单

  • 在服务器请求路径里做长时间同步计算或同步 I/O:会阻塞主线程,降低其他请求的响应能力。
  • 认为 setTimeout(fn, 0) 会立刻运行:它只能在当前同步代码结束、事件循环有机会处理时执行。
  • 在 async 函数中启动 Promise 却不 await 或 return:外层代码无法等待它,也可能漏掉拒绝。
  • 用 await 写彼此独立的操作,却期待并发:逐条 await 会串行启动;先创建 Promise,再用 Promise.all 汇总。
  • 一次性并发启动没有上限的任务:可能耗尽内存、连接或线程池资源。
  • 认为 Promise.all 会取消失败后的其他任务:它只汇总结果,不提供自动取消或回滚。
  • 在 nextTick 或微任务中无限递归:可能饿死 I/O 和计时器。
  • 假定 setImmediate 与零延迟定时器有固定顺序:不要让业务正确性依赖这种顺序。
  • 把异步 API 当成必然并行:JavaScript 回调仍在主线程运行,底层线程池也有容量限制。
  • 认为 abort 一定能停止操作:取消能力取决于 API,已经发生的副作用通常无法自动撤销。

十一、一个完整的小例子:有界并发读取文件

下面的示例使用简单工作队列,最多同时读取两个文件。它依赖 Node.js 内置 fs/promises,不需要安装第三方包。将代码保存为 batch-read.js,并在同目录准备 a.txt、b.txt 和 c.txt 后运行:

const fs = require('node:fs/promises');

async function mapWithLimit(items, limit, worker) {
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError('limit 必须是正整数');
  }

  const results = new Array(items.length);
  let nextIndex = 0;

  async function runWorker() {
    while (true) {
      const index = nextIndex++;
      if (index >= items.length) return;

      results[index] = await worker(items[index], index);
    }
  }

  const workerCount = Math.min(limit, items.length);
  await Promise.all(
    Array.from({ length: workerCount }, () => runWorker())
  );

  return results;
}

async function main() {
  const files = ['a.txt', 'b.txt', 'c.txt'];

  const contents = await mapWithLimit(files, 2, (file) =>
    fs.readFile(file, 'utf8')
  );

  contents.forEach((text, index) => {
    console.log(files[index], '字符数:', text.length);
  });
}

main().catch((error) => {
  console.error('批处理失败:', error);
  process.exitCode = 1;
});

这个例子保留输入顺序作为结果顺序,但任务完成顺序可能不同。任一 worker 失败时,Promise.all 会拒绝;其他已经启动的读取不一定自动取消。真实应用还应决定失败策略:是立即停止分派新任务、尽量完成已启动任务,还是收集每个文件的独立结果。示例中的 limit 只限制本批任务的并发数,不限制文件大小,也不提供超时,因此不能替代完整的资源治理。

十二、如何选择合适的方式

场景通常考虑需要留意
读取小型启动配置同步读取或启动阶段的异步读取不要在高频请求路径中反复同步读取
网络请求、常规文件操作Promise 或 async/await处理错误、超时、取消和并发上限
大型文件传输或转换流与 pipeline处理背压、错误、大小和权限
CPU 密集型任务worker_threads 或独立进程评估数据传输成本、隔离和资源配额
多个互不依赖的 I/O 操作并发启动并用 Promise.all 汇总任务数量大时采用有界并发
周期性轮询完成后再安排下一次的定时器提供退出清理、失败策略和停止条件

总结

Node.js 异步编程的核心不是“让代码自动并行”,而是让程序在等待 I/O 时有机会处理其他工作。JavaScript 回调仍由事件循环所在的执行线程运行;长时间同步计算会阻塞它,底层线程池和系统资源也有明确容量。Promise 与 async/await 能让异步控制流更清晰,但只有正确等待和传递 Promise,错误处理才完整。

写异步代码时,可以依次检查:当前操作是否会阻塞主线程;任务之间是否有依赖;并发数量是否受控;拒绝和取消是否有明确处理;输入和资源消耗是否有边界。掌握这些边界,比死记事件循环的输出顺序更有助于写出可靠的 Node.js 程序。

© 版权声明

相关文章