并发、并行、竞态的区别梳理

1、从一个 bug 说起

先看一段几乎每个前端都写过的代码:

javascript 复制代码
getMenuList() {
  request('/api/menu').then(res => {
    this.menuList = res.data
  })
  // 注意:没有 return
}

async refresh() {
  await this.getMenuList()
  this.rebuildExposure()   // 用 menuList 重建曝光埋点
}

上线后偶发反馈:曝光埋点上报的菜单是旧的。你盯着 await 看了十分钟,觉得没毛病------不是等它执行完了才往下走吗?

问题在于 getMenuList 没有 return,返回的是 undefinedawait undefined 并不会真的等待,它只是把后面的代码推迟到下一个微任务。而真正的网络请求还在路上,要几十上百毫秒才回来。

于是形成了两条赛道:一条是 rebuildExposure() 拿着旧数据往前跑,另一条是接口返回后给 menuList 赋新值。谁先冲线,决定了最终结果。 这就是竞态。

要真正理解这个 bug 为什么会发生、以及它的同类还有哪些,得先把三个概念摆清楚:并发、并行、竞态。它们经常被混着说,但其实是三个不同层面的东西。

2、并发:一个人应付很多事

想象一家只有一个店员的咖啡店。

顾客 A 点了手冲,店员开始注水,水要慢慢渗。这三十秒里店员不会傻站着------他转身收了顾客 B 的钱,又给顾客 C 递了个可颂,然后回来接着注水。

从顾客的视角看,三个人"同时"被服务着。但店员始终只有一双手,任何一个瞬间他只在做一件事。他做的是切换:在某件事进入等待期时,把注意力挪到另一件事上。

这就是并发(Concurrency) :多个任务在时间上重叠推进,但不要求同一时刻真的一起执行。它描述的是程序的结构------你的代码有没有能力"同时应对多件事"。

Rob Pike 有一句被引用烂了的话,大意是:并发关乎如何"应对"很多事,并行关乎如何"执行"很多事。前者是组织方式,后者是执行方式。

JavaScript 主线程就是这个店员。单线程、一个调用栈,但靠事件循环把网络请求、定时器、用户交互这些"等待期"利用起来,交替推进一大堆任务。所以:JS 是并发的

less 复制代码
// 三个请求"同时"在飞,但主线程从没有分身
const [a, b, c] = await Promise.all([
  fetch('/api/a'),
  fetch('/api/b'),
  fetch('/api/c'),
])

这里主线程做的事很少:发出三个请求(几微秒),然后把自己交还给事件循环去干别的。等待是浏览器的网络线程在做,不占用你的 JS 执行权。

3、并行:真的有很多个人

现在这家店生意好了,老板雇了三个店员,三台机器。三杯手冲在同一个物理时刻被冲着。

这是并行(Parallelism) :多个任务在同一时刻真正同时执行。它需要硬件支持------多核 CPU,或者多台机器。

关键区别:并发是一种设计,并行是一种执行。并发不一定并行(单核也能并发),并行必然需要并发的结构。

前端里真正的并行长这样:

javascript 复制代码
// worker.js
self.onmessage = (e) => {
  const result = heavyCompute(e.data)   // 跑在另一个线程,真·并行
  self.postMessage(result)
}

// main.js
const worker = new Worker('worker.js')
worker.postMessage(hugeArray)
worker.onmessage = (e) => console.log(e.data)

Web Worker、Service Worker、以及浏览器内部的渲染/合成/网络线程,这些才是并行。你日常写的 async/awaitPromisesetTimeout------全都是并发,不是并行

这个区分有实际后果。如果你的页面卡顿是因为一个 200ms 的同步计算(比如大数组排序、图片像素处理),那把它包进 PromisesetTimeout 是没用的------单线程该卡还是卡,你只是把卡顿挪了个位置。这种情况只有 Worker 能救。反过来,如果卡顿来自等接口,那是并发问题,Worker 一点忙都帮不上。

一句话记忆:并发是"这三十秒我别闲着",并行是"我多雇几个人"。

4、竞态:并发的副产品

前两个是并列关系,竞态不是。竞态(Race Condition)是并发带来的一类 bug,是并发的代价,不是第三种执行模型。

它的定义可以浓缩成一句:程序的正确性依赖于多个操作的完成顺序,而这个顺序你控制不了。

比喻很好找:你在两家店各下了一单,A 店先下、B 店后下。你的收纳逻辑写的是"后到的放上层"。但快递不保证先下单的先到------B 可能先到。于是你的柜子时对时错,取决于当天的路况。

注意"时对时错 "这四个字。竞态最难缠的地方就在这里:它不是稳定复现的逻辑错误,而是概率性的时序错误。本地网速快的时候可能一切正常,测试环境跑一百遍都没事,一到用户手机的弱网环境就翻车。

4.1、JS 的竞态和多线程的竞态不是一回事

这里有个容易踩的认知坑。经典教材里讲的竞态是数据竞争(data race) :两个线程同时读改写同一块内存,i++ 被拆成读-加-写三步,交错执行导致丢更新。所以需要锁、原子操作、临界区。

JS 单线程,不存在数据竞争i++ 不会被打断,你永远不需要 mutex。

但 JS 有逻辑竞态(logical race) :只要一个操作的"发起"和"完成"是分离的(发请求 → 拿响应),完成顺序就不受你控制,依赖顺序的逻辑就会出错。

所以:JS 免疫内存级竞态,但完全不免疫时序级竞态。开头那个 bug 就是后者。

5、前端竞态的四种高发形态

理解了原理,接下来是能直接用的部分。前端的竞态基本跑不出这四种,各有固定解法。

5.1、 await 了一个假 Promise

就是开头那个。await 一个非 thenable 的值,等于什么都没等,只是推迟了一个微任务。

javascript 复制代码
// ❌ 函数体没有 return,await 形同虚设
getMenuList() {
  request('/api/menu').then(res => { this.menuList = res.data })
}

// ✅ 把 Promise 交出去
getMenuList() {
  return request('/api/menu').then(res => { this.menuList = res.data })
}

自查习惯 :每次写 await someFn(),反问一句"someFn 到底返没返回 Promise?"。这类 bug 在 Vue 的 methods、Pinia 的 action、以及任何封装了请求的工具函数里最常见------因为函数名叫 getXxx,看起来就该返回点什么,很容易默认它返回了。

5.2、 请求乱序覆盖

用户快速切 tab:先点 A,再点 B。A 的请求慢,B 的快,B 先返回渲染了,然后 A 姗姗来迟,把 B 的结果覆盖了。用户点的是 B,看到的是 A。

搜索框输入联想是重灾区,因为每敲一个字符就发一次请求。

javascript 复制代码
// ✅ 方案一:请求序号,只认最新那次
let reqId = 0
async function loadTab(tab) {
  const id = ++reqId
  const data = await fetchTab(tab)
  if (id !== reqId) return    // 已经不是最新请求了,丢弃
  render(data)
}

// ✅ 方案二:AbortController,直接掐掉旧请求
let controller
async function loadTab(tab) {
  controller?.abort()
  controller = new AbortController()
  try {
    const data = await fetch(url, { signal: controller.signal })
    render(data)
  } catch (e) {
    if (e.name === 'AbortError') return
    throw e
  }
}

两者的差别:序号法让请求跑完只是丢弃结果,AbortController 会真的取消掉网络请求、省流量。能用后者就用后者。

5.3、 组件卸载后写状态

请求发出去了,用户返回上一页,组件销毁,然后响应回来往一个已经不存在的组件上赋值。轻则内存泄漏,重则报错。

csharp 复制代码
// Vue 3
let alive = true
onUnmounted(() => { alive = false })

async function load() {
  const data = await fetchData()
  if (!alive) return
  list.value = data
}

React 的 useEffect cleanup、Vue 的 onUnmounted,本质上就是为这件事准备的。

5.4、 并发触发同一个副作用

页面三个组件初始化时都调了 fetchUserInfo(),于是发了三个一模一样的请求,三份响应互相覆盖。

csharp 复制代码
// ✅ in-flight 复用:同一时刻只有一个真实请求
let pending = null
function fetchUserInfo() {
  if (pending) return pending
  pending = request('/api/user').finally(() => { pending = null })
  return pending
}

顺带说一句:如果你用 SWR、React Query、VueUse 这类数据请求库,上面这四种它们基本都内置处理了(dedupe、取消、卸载保护)。这不是巧合------这些库存在的主要理由之一,就是帮你收拾竞态。

6、三者关系的最终图景

是什么 前端对应 判断标志
并发 多任务交替推进的结构 事件循环、Promise、async/await 有等待期就切走
并行 多任务同刻执行的能力 Web Worker、多核 需要额外线程
竞态 并发带来的bug 请求乱序、假 await 结果依赖不可控的顺序

关系可以这样串起来:

并发让你能同时应付很多事 → 但"完成顺序"就此脱离掌控 → 任何依赖顺序的逻辑都可能出竞态。并行是另一个维度的事,它解决的是算力,不是等待。

落到日常的三个动作

第一,看到 await 就问一句"等的是什么"。 等的是有返回值的 Promise 吗?还是一个假装自己是 Promise 的 undefined?这一个反问能干掉一大半这类 bug。

第二,凡是"可以被快速重复触发"的请求,默认加上竞态防护。 tab 切换、搜索联想、分页、筛选器------这几个场景不要等 bug 出现了再补,写的时候就带上序号或 AbortController。

第三,分清卡顿的来源再选工具。 等接口是并发问题,加 Worker 没用;算得慢是算力问题,包 Promise 也没用。

最后一点,多线程那套锁和临界区的知识,在单线程 JS 里确实用不上。但如果你以后写 Node 服务、处理数据库并发写入、或者碰上分布式的最终一致性,它会原封不动地回来找你。到那时候你会发现,今天为一个 await undefined 建立起来的时序直觉,是完全通用的。

相关推荐
水煮白菜王2 小时前
商用地图全面收费?从天地图到开源生态的替代路线
前端·javascript·高德地图·amap·开源地图
程序员黑豆2 小时前
鸿蒙应用开发:网络请求三种方式详解(http / rcp / axios)
前端·harmonyos
黄敬峰2 小时前
前端路由到底是个啥?React Router v7 从零到一,大白话一次讲透
前端·面试
先吃饱再说2 小时前
从多页到单页:前端路由的演进与 React Router
前端·react.js·前端框架
北墨NoLimit2 小时前
TRAE Work实战:把办公Agent竞品调研从2-3天压到32分钟,有完整指令模板
前端·人工智能·数据可视化
颜进强2 小时前
Calude Code - 25 CodeGraph:让 AI 真正读懂你的代码库
前端·后端·ai编程
七牛开发者2 小时前
Agent 小知识|长任务不重来:Agent 状态保存的工程设计
前端·javascript·后端
weixin_431600443 小时前
做 Agent 会用到的 Node API(3):异步与流
前端·学习·ai·agent·ai编程
程序员黑豆3 小时前
鸿蒙应用开发之生命周期方法完全指南
前端·harmonyos