你有没有遇到过这样的情况?打开一个网页,点了一下按钮,结果整个页面就像冻住了一样,点哪儿都没反应,连关闭标签页都要等半天。这种卡顿感,比看加载动画还难受。而且有时候不只卡一下,是隔几秒就卡一次,最后浏览器直接弹出“页面无响应”的提示。很多人第一反应是“网络慢”或者“电脑配置低”,但实际上,很可能是我们自己写的代码里边,多个后台任务在互相捣乱。今天咱们就从一个真实的小案例入手,看看这类问题到底是怎么发生的,又该怎么解决。

一、先看看问题长什么样

假设你给公司做了一个数据统计页面,页面上有个“开始计算”按钮,一点就把一堆数据拆成两半,分别交给两个 Workers 去处理。看起来挺合理吧?主线程不干活,页面应该很流畅。结果实际跑起来,页面卡成了幻灯片。打开任务管理器,发现浏览器CPU占用接近100%。你以为是计算量太大了,可是把数据量减半,依然卡。这就奇怪了。

其实,这类问题往往不是计算本身导致的,而是多个线程之间交互的姿势不对。就好比你开了两个厨师在厨房里做菜,本来很快,结果两个厨师不仅共用一口锅,还不停地喊你过去尝味道,你作为老板(主线程)跑来跑去,自然没时间接待客人了。页面卡顿,就是这么来的。

二、Web Workers 到底是啥

先简单介绍一下各位看官可能还不熟悉的 Web Workers。你可以把它理解成浏览器里的“小号”。主线程就像前台接待,管着页面上的按钮、点击、滚动这些交互。Worker 就像后厨的帮工,可以在后台做烤面包、切菜这些重活。你把任务丢给 Worker,前台就可以继续微笑服务。

Worker 和主线程之间怎么沟通呢?主要靠发消息来实现。Worker 干完活,就发一条消息回来;主线程收到消息后,再更新页面。这样一来,重活就不会堵在前台了。

但是,当你有多个 Worker 的时候,事情就变得微妙。Worker 之间不能直接聊天,都要通过主线程转达。如果消息特别多、特别频繁,主线程光是传话就累得不行。更麻烦的是,有时候为了让多个 Worker 协作,我们会给他们一块共享的内存区域(也就是 SharedArrayBuffer),大家一起在上面改数据。如果没约定好,就容易出现“谁改谁”的冲突,数据乱成一锅粥。

三、冲突排查:三步揪出元凶

遇到页面卡顿,别急着动手改代码,按下面三步来排查。

3.1 先排除主线程自己的大任务

第一步,打开浏览器的开发者工具,切到 Performance 面板,录一段卡顿时的过程。看看主线程的时间线是不是被一大片任务占满了。如果主线程本身忙得不可开交,那先别怪 Worker,可能是你自己的代码里某个大循环、某个频繁的 DOM 操作在作祟。先把主线程上的重活挪到 Worker 里,再继续查。

3.2 看看 Worker 和主线程之间的消息有多频繁

如果主线程很清闲,那就是消息风暴。举个例子,你的 Worker 每算出一个中间结果就发一条消息给主线程,主线程收到后就更新进度条。一秒钟发几百条甚至几千条,主线程光处理这些消息就累垮了。排查方法是,在发消息的前后打一个计数器,看看一段时间内发了多少条消息。如果数量大得吓人,就把消息攒一批再发,或者只在任务完成时发一条。

3.3 检查共享数据有没有在打架

如果消息也不多,那就要看看是不是用了 SharedArrayBuffer 这类共享内存。多个 Worker 同时往同一个数组的同一个位置写值,读了、加了、写回,但三步之间可能被另一个线程打断。结果数据就不对,而且因为数值一直在变,主线程要是隔一会儿读一次,看到的数字就在乱跳,视觉上也会感觉卡。具体怎么确定呢?把共享内存改成普通数据,或者给共享操作加上锁,如果问题消失,那就是它了。

四、完整示例:冲突现象与修复

纸上谈兵没意思,咱们上个真刀真枪的例子。这里我要用到一个叫 SharedArrayBuffer 的东西,它允许 Worker 和主线程共享同一块内存,读写都非常快。但快是快,容易撞车。下面这个例子会模拟两个 Worker 同时对同一块内存做“加一”的操作,同时主线程还时不时去读这块内存并显示出来。

4.1 场景说明

我们准备一个能装 8 个整数的共享数组,启动两个 Worker,每个 Worker 都把这个数组里的每一个元素加一,重复 10 万次。理论上,两个 Worker 各加 10 万次,每个元素最终应该变成 20 万。但是因为“读、加、写”不是原子操作,两个 Worker 会互相打乱,最终结果一定不是 20 万。页面上还会用一个定时器,每 16 毫秒把这个数组打印出来,模拟实时预览效果。这样一跑,页面卡顿现象就很明显了。

4.2 完整示例代码

技术栈:HTML + JavaScript(Web Workers + SharedArrayBuffer)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Web Workers 冲突与修复演示</title>
</head>
<body>
<button id="badBtn">启动冲突版(无原子操作 + 高频轮询)</button>
<button id="goodBtn">启动修复版(原子操作 + 消息通知)</button>
<pre id="output">结果会显示在这里</pre>

<script>
  // ---------- 技术栈:HTML + JavaScript(Web Workers + SharedArrayBuffer) ----------

  // 一块共享的内存,可以放 8 个整数
  const buffer = new SharedArrayBuffer(8 * Int32Array.BYTES_PER_ELEMENT);
  // 在主线程也建一个视图,方便读写
  const sharedArr = new Int32Array(buffer);

  let timer = null; // 定时器句柄,后面会用来停止高频轮询

  // 把数据显示到页面上
  function show(data) {
    document.getElementById('output').textContent = JSON.stringify(data);
  }

  // 创建 Worker。useAtomic 为 true 时使用 Atomics.add,false 时使用普通加法
  function createWorker(useAtomic) {
    // 这里直接把 Worker 要跑的脚本写在字符串里,省得单独建文件
    const workerCode = `
        self.onmessage = function(e) {
          const { buffer, rounds } = e.data;
          const arr = new Int32Array(buffer); // 在 Worker 里拿到同一块内存
          for (let r = 0; r < rounds; r++) {
            for (let i = 0; i < arr.length; i++) {
              // 如果是修复版,走原子加一,不会被打断
              // 如果是冲突版,走普通加法,读和写之间可能被对方插一脚
              ${useAtomic ? 'Atomics.add(arr, i, 1);' : 'arr[i] = arr[i] + 1;'}
            }
          }
          self.postMessage('done'); // 干完活通知主线程
        };
      `;
    const blob = new Blob([workerCode], { type: 'application/javascript' });
    const url = URL.createObjectURL(blob);
    const worker = new Worker(url);
    URL.revokeObjectURL(url); // 既然已经拿 worker 了,这个链接就没用了
    return worker;
  }

  // 停掉之前可能没关掉的定时器
  function stopPolling() {
    if (timer) {
      clearInterval(timer);
      timer = null;
    }
  }

  // ---- 冲突版按钮 ----
  document.getElementById('badBtn').addEventListener('click', function() {
    stopPolling();              // 先关上个任务
    sharedArr.fill(0);          // 把共享数组全部清零
    show('已重置,开始冲突版');

    // 两个 Worker 都不用原子操作
    const w1 = createWorker(false);
    const w2 = createWorker(false);
    const rounds = 100000;
    w1.postMessage({ buffer, rounds });
    w2.postMessage({ buffer, rounds });
    w1.onmessage = () => console.log('worker1 干完了');
    w2.onmessage = () => console.log('worker2 干完了');

    // 主线程每 16ms 读一次数据并更新页面,模拟“实时预览”
    timer = setInterval(() => {
      show(Array.from(sharedArr));
    }, 16);
  });

  // ---- 修复版按钮 ----
  document.getElementById('goodBtn').addEventListener('click', function() {
    stopPolling();     // 先停掉轮询
    sharedArr.fill(0);
    show('已重置,开始修复版');

    // 两个 Worker 都使用 Atomics.add
    const w1 = createWorker(true);
    const w2 = createWorker(true);
    const rounds = 100000;
    let completed = 0;

    // 等两个 Worker 都完成后,再统一更新一次页面
    function onDone() {
      completed++;
      if (completed === 2) {
        show(Array.from(sharedArr)); // 这时候数据是准确的,也不再需要轮询
      }
    }
    w1.onmessage = onDone;
    w2.onmessage = onDone;
    w1.postMessage({ buffer, rounds });
    w2.postMessage({ buffer, rounds });
  });
</script>
</body>
</html>

代码注释挺清楚了。注意,这个示例需要浏览器支持 SharedArrayBuffer,并且需要开启相关的隔离环境。正常本地直接打开文件可能不行,建议本地起个小服务器,或者用支持的环境跑。

4.3 运行结果对比

跑冲突版的时候,你会看到页面上的数字疯狂跳动,而且最终停下来后,数组里的值远远小于 200000。页面也会卡顿得厉害,因为主线程每 16ms 就在更新 DOM。修复版则几乎感觉不到卡顿,最终数值每个都是 200000,整整齐齐。

为什么修复版不卡?一,Atomics.add 保证了加法这个操作不会被另一个线程打断,数据正确了;二,不再用定时器频繁读内存,而是等两个 Worker 都干完活,只更新一次页面。这两个改动,把“冲突”和“打扰主线程”两大问题都解决了。

五、再深入聊聊 SharedArrayBuffer 和 Atomics

刚才的例子用到了 SharedArrayBuffer 和 Atomics,很多人可能还不太熟,这里再展开说说。

SharedArrayBuffer,直译就是“共享的数组缓冲区”。它和普通 ArrayBuffer 的区别在于:普通 ArrayBuffer 只能归某个线程自己用,而 SharedArrayBuffer 可以同时被多个线程读取和写入。就像多个厨师共用一个砧板,谁都能在上面放菜、拿菜,所以效率很高。

有共享,就有竞争。假设两个厨师同时伸手去拿同一块肉,谁拿到算谁的,最后肉上可能留下两个手印。为了防止这种事,JS 提供了 Atomics 对象。它可以执行原子操作。所谓“原子”,就是不可分割的最小操作。比如 Atomics.add(arr, i, 1),意思是从 arr[i] 中取出数值,加上 1,再放回去,整个过程一气呵成,别的线程在这中间插不上手。除了 Atomics.add,还有 Atomics.load、Atomics.store、Atomics.compareExchange、Atomics.wait 等,常用组合能实现锁,比如互斥锁。

但要注意,Atomics 并不能解决所有并发问题。它只是在单个操作层面保证了不会被打断。如果你需要的是一段连续的逻辑,比如先检查某个值,再修改另一个值,那就得用更复杂的锁机制,否则还是可能出问题。而且,用 SharedArrayBuffer 写得不好,很容易死锁或数据损坏,调试起来也头疼。所以,除非真的需要极高性能,否则最好优先考虑用消息传递来传递数据副本,副本之间的修改互不影响,虽然慢一点,但安全得多。

六、注意事项和最佳实践

最后分享几个实战中很有用的习惯,能帮你少踩很多坑。

第一,能不共享内存就不共享。默认情况下,用发消息的方式把数据发给 Worker 时,数据是被“复制”过去的,双方各改各的,互不干扰。虽然复制大对象有开销,但比调试并发竞争轻松一百倍。如果你追求性能,可以用 Transferable Objects 把数据的“所有权”转移给 Worker,转移后主线程就碰不到那块数据了,也不会冲突。

第二,如果必须共享内存,那就用 Atomics 把所有读写操作都压成原子操作。尤其是在多 Worker 同时写的情况下,一定要统一规矩:所有读写都走 Atomics 方法,不要混用普通索引和 Atomics。

第三,别在主线程里轮询 Worker 的数据。想知道 Worker 干到什么程度了,可以让 Worker 主动发消息,或者只等最后一条“干完了”。隔几毫秒去读一次共享内存,不仅浪费 CPU,还容易让页面掉帧、卡顿。

第四,控制消息的数量。如果 Worker 确实需要频繁汇报进度,可以攒一批再发,比如每完成 10% 发一条。或者用简单的节流函数,保证一秒最多发几次。

第五,别开太多 Worker。浏览器对线程数量有限制,而且线程本身也有内存开销。通常两三个就够用了。把一个任务拆成几百个小任务丢给几百个 Worker,只会让主线程调度到崩溃。

第六,测试时一定要在真实的浏览器和操作系统上跑,模拟各种网络环境和机器性能。低配机器上,冲突和卡顿会表现得更明显。

七、总结

页面卡顿的问题,很多时候不是某一个线程干太多活,而是多个线程之间协作的方式不对。遇到这种问题,先看主线程忙不忙,再看消息是否太密,最后检查共享数据有没有竞争。今天我们通过一个加一的小例子,把冲突和卡顿的成因演得明明白白。修复的办法也很简单:用 Atomics.add 保证操作不被打断,用“完成通知”代替“高频轮询”。记住一句话:多线程的世界里,安全比速度更重要。稍微慢一点点,换来页面不卡、数据不错,这笔买卖很划算。