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

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 建立起来的时序直觉,是完全通用的。

相关推荐
AlienZHOU3 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc6 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师7 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen8 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒8 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow8 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林8188 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800788 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明8 小时前
第 6 章 组件进阶
前端·vue.js
江华森8 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端