JavaScript 并行处理

下面这篇文章,将尽量用通俗+深入的方式,围绕 “JavaScript 并行处理”setTimeout(fn, 0) 的真实含义与用法” 这两个主题做一个系统性讲解,并配以示例代码来帮助理解。


一、JavaScript 并行处理:从单线程到多线程

1.1 单线程、事件循环和异步

JavaScript 语言本质上是单线程的,这条主线程上所有的代码都按照**事件循环(Event Loop)**的机制来调度。

  • 单线程:JS 只有一个主线程来执行脚本,如果某个操作非常耗时(例如复杂循环),就会阻塞整个页面(浏览器端)或阻塞后续任务(Node.js)。
  • 事件循环:主线程会处理一个个“任务”(Task),当我们执行异步操作(如 Ajax 请求、定时器、文件读写)时,实际工作会交给浏览器或 Node.js 的其他线程或系统 API 来处理,一旦有结果,就把对应的回调推入主线程的任务队列,等待“事件循环”调度执行。

这意味着:在 JS 里,“并发”或“并行”大多数时候是并发地处理 I/O,而非真正让单线程脚本自己同时执行多段 CPU 指令。

1.2 “并行”其实可能有两层含义

  1. 并行发起或并发处理 I/O(网络请求、文件读写等)。
    • 这在 JS 中十分常见,也是我们主要关心的“并发”。例如同时请求多个接口、同时读取多个文件,不必一个完了再发下一个请求。
    • 从代码角度看,我们在同一时刻发出多个异步操作,然后共同等待它们的结果。
  2. 真正的多线程并行(CPU 多核并行)。
    • JS 单线程要想利用多核 CPU,需要借助 Web Worker(浏览器)或 Worker Threads / Child Process(Node.js)在子线程或子进程中执行计算任务。
    • 只有这样,才能让 CPU 同时(物理级别)做多段繁重运算,而不阻塞 JS 主线程的执行或 UI 渲染。

接下来我们会从以上两个角度,分别给出一些案例和具体实现方式。


二、并发 I/O:Promise 并行 & 代码示例

大部分“并行”需求,是指同时发起多个异步请求(I/O 操作)。在现代 JavaScript 中,我们常用 Promise 及其相关方法(Promise.all(), Promise.race() 等)来处理。

2.1 基础示例:Promise.all() 实现多请求并发

2.1.1 什么是 Promise.all()

  • 传入一个“Promise 数组”,会并行执行这些 Promise。
  • 只有当全部成功时,才会进入 .then();只要有任意一个报错,就会进入 .catch()
  • Promise.all() 返回的结果数组顺序与传入顺序一一对应,不因谁先完成而打乱。

2.1.2 代码示例

假设我们想同时请求两个数据接口(/api/user/api/posts),等待它们都返回后再进行处理:

function fetchUser() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 这里假设成功返回
      resolve({ name: 'Alice', age: 20 });
    }, 1000); // 模拟接口耗时 1s
  });
}

function fetchPosts() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 这里假设成功返回
      resolve([
        { id: 1, title: 'Post A' },
        { id: 2, title: 'Post B' }
      ]);
    }, 1500); // 模拟接口耗时 1.5s
  });
}

// 并发请求
Promise.all([fetchUser(), fetchPosts()])
  .then(([user, posts]) => {
    console.log('用户信息:', user);
    console.log('帖子列表:', posts);
  })
  .catch(err => {
    console.error('请求出错:', err);
  });

在这段代码里:

  • fetchUser()fetchPosts() 同时发起,在 1s 和 1.5s 后各自拿到结果。
  • Promise.all() 要等都成功后,返回数组 [userData, postData]
  • 若任何一个请求失败,就会走 .catch() 分支。

2.1.3 处理错误 & 保证顺序

  • 如果只想在它们全部完成后拿到“谁成功、谁失败”的详情,可以用 Promise.allSettled()
  • 如果想在第一个成功/失败时立刻返回,则可用 Promise.race()
  • “顺序”并不会乱,因为 Promise.all() 的结果数组与最初传入的顺序对应,但它们发起请求的时机是一并开始的,所以时间上是“并发”的。

2.2 借助 async/await 语法糖

如果你习惯 async/await

async function fetchDataInParallel() {
  try {
    // 并行发起
    const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
    console.log('用户:', user, '帖子:', posts);
  } catch (error) {
    console.error('并行请求出错:', error);
  }
}

fetchDataInParallel();
  • 只要把多个 Promise 放进 Promise.all(),就能享受并发带来的效率提升。
  • 注意不要写成:
    // 会先请求 user 等完再请求 posts,无法并发
    const user = await fetchUser();
    const posts = await fetchPosts();
    
    这样就是串行了,后一个要等前一个完全结束才能启动。

三、真正的多线程并行:Web Workers & Worker Threads

如前所述,“并发 I/O”并不是真的多线程。如果你要同时做CPU 密集型的计算(例如处理图像、加解密大文件等),在主线程里做,就会卡住页面或阻塞事件循环。此时可以通过“开子线程”来获得多核并行。

3.1 浏览器端:Web Worker 示例

  1. worker.js(子线程脚本):

    self.onmessage = function (event) {
      const { upperBound } = event.data;
      let sum = 0;
      for (let i = 0; i < upperBound; i++) {
        sum += i;
      }
      // 把结果发回主线程
      self.postMessage({ sum });
    };
    
  2. 主线程脚本

    const worker = new Worker('worker.js');
    
    // 监听 Worker 的返回结果
    worker.onmessage = function (event) {
      console.log('Worker 计算结果:', event.data.sum);
    };
    
    // 发送任务给 Worker
    worker.postMessage({ upperBound: 1e8 });
    

这样,大循环就跑在 Worker 线程里,不会阻塞主线程的 UI 响应。

  • 对于复杂的算法或耗时操作,多开 Worker 可以利用多核 CPU 并行运算。
  • Worker 不能直接访问主线程 DOM,需要通过 postMessage 通信。

3.2 Node.js:Worker Threads

Node.js 也有类似的 Worker 机制(v10.5+),或你也可以使用多进程(child_process.fork()cluster)来做并行计算或分布式部署。


四、setTimeout(fn, 0) 在实际开发中的用途

现在谈谈另一个常被问到的话题:setTimeout(fn, 0)。在很多项目里,都能看到有人写 setTimeout(() => { ... }, 0),或使用非常小的延迟,比如 setTimeout(fn, 1)setTimeout(fn, 4)它究竟是干什么用的?是否真的是“立即执行”?

4.1 代码不会“马上执行”,只是“尽快异步”

setTimeout(() => {
  console.log('回调执行');
}, 0);
  • 虽然设定了 0 毫秒,但浏览器仍有最小时间分辨率(通常 4ms 左右),且属于 “宏任务 (macrotask)” 的下一轮事件循环执行。
  • 这意味着并不是真的 0ms 后执行,而是等主线程空闲后、进入下一次循环时,再执行这段回调。

4.2 实际开发中常见的用法

  1. 将某些操作推迟到下一次事件循环

    • 例如你在函数里想先更新一些状态,然后等这段函数结束后再执行另一段逻辑,“脱离”当前调用栈。
    • 这可以防止阻塞或避免和当前同步逻辑“抢”资源。
  2. 出栈

    • 如果递归调用太深或调用栈快爆了,可以通过 setTimeout(fn, 0) 暂时“跳”出当前栈,下一个事件循环再继续。(当然,现代 JS 也有更好的结构方式。)
  3. 类似语义:queueMicrotask()Promise.then()

    • 如果你要比 setTimeout(fn, 0) 更快(在同一轮事件循环里)执行回调,可以用微任务 (microtask) 机制,比如 Promise.resolve().then(...)queueMicrotask(...)
    • 它们会在当前宏任务结束后、下一个渲染前执行,不需要等到下一轮事件循环。

一个直观对比

console.log('start');

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

Promise.resolve().then(() => console.log('promise.then'));

console.log('end');

常见输出顺序:

start
end
promise.then
setTimeout
  • promise.then(微任务)在同一轮事件循环里、宏任务之后立即执行;
  • setTimeout(fn, 0)(宏任务)要等到下一轮事件循环才执行。

五、实战案例:并行处理 + 保持顺序 + “延后执行”

下面给出一个更贴近实战的示例场景,把上面讲的要点结合起来:

5.1 需求场景

  1. 有一批“站点”信息(sites 数组),每个站点对应一个接口请求,获取后再生成 HTML 插入页面。
  2. 我们想并行请求所有站点的数据,等都拿到后再插入页面,这样比一个个串行请求更快。
  3. 插入页面时必须保持与原数组顺序一致
  4. 插入完毕后,我们再用 setTimeout(fn, 0) 做点其他小事,比如写个日志,不想阻塞插入过程。

5.2 代码实现

// 假设这是一批站点数据
const sites = [
  { id: 1, name: 'Site A' },
  { id: 2, name: 'Site B' },
  { id: 3, name: 'Site C' }
];

// 异步请求函数,模拟每个站点 API
function fetchSiteData(site) {
  return new Promise((resolve, reject) => {
    // 随机耗时
    const delay = Math.random() * 2000;
    setTimeout(() => {
      // 简单返回
      resolve({
        siteId: site.id,
        content: `${site.name} 的一些内容`
      });
    }, delay);
  });
}

async function buildAllSites() {
  // 1. 并行请求
  const promises = sites.map(site => fetchSiteData(site));

  // 2. 保持顺序:Promise.all 返回的数组,顺序与 sites 一一对应
  const results = await Promise.all(promises);

  // 3. 一次性插入 DOM,保证和 sites 同一顺序
  let htmlSimple = '';
  let htmlFull = '';

  results.forEach(result => {
    htmlSimple += `<div>[简] ${result.siteId} - ${result.content}</div>`;
    htmlFull   += `<div>[全] ${result.siteId} - 详细内容... </div>`;
  });

  document.querySelector('#siteGridSimple').innerHTML = htmlSimple;
  document.querySelector('#siteGrid').innerHTML = htmlFull;

  // 4. 其他操作延后处理
  setTimeout(() => {
    console.log('所有站点已完成渲染:', results);
  }, 0);
}

// 页面加载后执行
buildAllSites();

5.2.1 流程解析

  1. fetchSiteData(site) 是异步的,会在后台并行处理各个站点 API 请求。
  2. Promise.all(promises) 等所有请求完后,依照 sites 的顺序返回结果数组 results
  3. 我们把全部内容一次性插入到 #siteGridSimple#siteGrid 中,以减少频繁 DOM 操作。
  4. 最后,通过 setTimeout(..., 0) 在主线程的下一个宏任务中执行日志或其他收尾逻辑,不阻塞前面的渲染。

5.2.2 若想“先返回先展示”?

  • 上面的例子是“都准备好后一次插入”。
  • 如果你想让用户尽快看到部分返回内容,但又必须在视觉上保持顺序,可先在页面上按顺序插入“占位容器”,等每个请求完成后再填充对应占位区。这也是另一种常见方案。
  • 当然,具体如何展示取决于产品需求。

六、总结

  1. JavaScript 并发 I/O

    • 主要通过 Promise / async/await + Promise.all() 来实现,直接并发发起多个请求或操作,大幅提升效率。
    • 一定要避免不必要的串行 await(一个结束才发下一个),否则浪费时间。
  2. 真正的多线程并行

    • JS 主线程是单线程,如果要做 CPU 密集运算,可以用 Web Worker(浏览器)或 Worker Threads(Node.js)开子线程,避免主线程卡死。
    • 子线程与主线程通过消息传递来交互。
  3. setTimeout(fn, 0) 用法

    • 并非“立即执行”,而是“推到事件循环的下一次宏任务”执行,浏览器通常有最小延时 4ms 左右。
    • 常用于“异步化”某段逻辑,脱离当前调用栈;若要更快时机,可考虑 queueMicrotask()Promise.then()(微任务)。
  4. 实战要点

    • 并行请求:注意错误处理(catchPromise.allSettled())并保持结果顺序(Promise.all 自带序保持)。
    • CPU 计算:务必考虑 Web Worker/Worker Threads,否则阻塞 UI 或阻塞后续异步。
    • “延后执行” 通常用 setTimeout(fn, 0) 或微任务,以控制执行顺序与避免阻塞。

希望通过这篇博文,你能更透彻地掌握:

  • JavaScript 是如何在单线程模型下实现“并行处理”(主要指并发 I/O 或多线程子进程)。
  • setTimeout(fn, 0) 的实际意义(宏任务调度),以及和微任务 (Promise.then, queueMicrotask) 的区别。

在实际项目中,综合这些方法,就能够写出既高效又清晰的异步代码,最大化地发挥 JavaScript 的能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值