前面几篇我们学了 JavaScript 的语法、函数、数据结构和刷题技巧,这些内容已经够你在 OJ 上刷算法题了。这篇是选读内容,聊一聊 JavaScript 中非常有特色的异步编程模型。
为什么说"有特色"?因为大多数编程语言(Java、Python、C++、Go)默认都是同步执行的,想搞并发就开多线程。而 JavaScript 从设计之初就是单线程的,它通过事件循环和异步回调来实现并发,思路完全不同。理解异步编程,对于学习 Node.js 后端开发和前端开发都非常重要。
同步、异步、并行、并发
在开始之前,我们先用一个做饭的比喻把这四个容易混淆的概念理清楚。
假设你今天要做三件事:煮饭、炒菜、煲汤。
同步就是最朴素的做法:你站在灶台前盯着电饭煲,等饭完全煮好了,再去洗菜、切菜、炒菜,炒完再去煲汤。一件做完才做下一件,中间干等着也不干别的。程序里的同步也是这样,代码一行一行往下跑,上一行没执行完,下一行就得等着。
异步就聪明多了:你按下电饭煲的开关,不傻等了,利用煮饭的时间去炒菜。炒菜的间隙如果汤的料备好了,你就把汤锅也架上。等电饭煲"叮"一声提醒你饭好了,你回来盛饭。注意,自始至终只有你一个人在厨房里忙活,你并没有同时做两件事,只是利用"等待"的空档切换到别的任务去了。
这就是异步的本质:一个人(单线程),通过在等待间隙切换任务,让多件事同时推进。
并行是真正意义上的"同时":你叫了两个朋友来帮忙,三个人各守一个灶台,一个煮饭、一个炒菜、一个煲汤,同一时刻真的有三件事在同时发生。在计算机里,并行需要多个 CPU 核心,每个核心同时执行不同的代码。
并发是一个比较宽泛的概念:只要多件事在同一时间段内都在推进,就叫并发。你一个人在厨房里异步切换任务,这是并发;三个人并行做饭,这也是并发。所以并发是目标,而异步和并行是实现并发的两种手段。
理解了这四个概念,JavaScript 的模型就很好理解了:JavaScript 是单线程 + 事件循环,它通过异步实现并发,但永远不会并行。同一时刻只有一段 JS 代码在执行,永远不会出现"两段 JS 代码同时跑"的情况。
那你可能会问,只有一个线程,不会很慢吗?其实大部分时候瓶颈不在"计算"而在"等待":等网络请求返回、等文件读完、等定时器到期。这些等待操作由浏览器或 Node.js 底层的 C++ 模块去处理了,JS 线程只需要发起操作、注册回调函数,等结果回来时再处理。
所以单线程+异步的模型对 I/O 密集型场景非常高效。
事件循环机制
JavaScript 的异步能力靠的是事件循环(Event Loop)。要理解事件循环,先认识两个关键部件:调用栈和任务队列。
调用栈(Call Stack) 就是当前正在执行的代码。函数调用时压栈,执行完后出栈。JavaScript 引擎每次只从栈顶取一个任务来执行,这就是"单线程"的体现。
任务队列(Task Queue) 是一个等候区。异步操作(比如 setTimeout、网络请求)完成后,它的回调函数不会立刻执行,而是被放进任务队列排队。只有当调用栈清空了(当前代码执行完了),事件循环才会从队列里取出下一个任务放进调用栈执行。
这就解释了一个经典问题:setTimeout(fn, 0) 为什么不是"立即执行"?
虽然延迟设成了 0 毫秒,但 setTimeout 的回调不会插队。执行流程是这样的:
console.log("1: 开始")直接执行,输出1: 开始setTimeout把回调函数放进任务队列,注意不是立刻执行console.log("3: 结束")直接执行,输出3: 结束- 调用栈清空了,事件循环从任务队列取出回调,执行输出
2: setTimeout 回调
所以 setTimeout(fn, 0) 的含义不是"0 秒后执行",而是"等当前代码全部跑完后,尽快执行"。来看一个更明显的例子:
虽然 setTimeout 设的是 100 毫秒,但因为同步代码(while 循环)阻塞了主线程约 300 毫秒,回调必须等调用栈清空后才能执行,所以实际等待时间远超 100 毫秒。
这就是事件循环的核心规则:同步代码优先,异步回调排队等。
回调函数
理解了事件循环,我们来看 JavaScript 中最原始的异步编程方式:回调函数(Callback)。
思路很简单:你把一个函数当参数传进去,等异步操作完成后,由系统调用这个函数,把结果传给你。
看到了吗?fetchData 调用后不会阻塞,代码继续往下跑,输出了"请求已发出"。等 100 毫秒后,回调函数被调用,输出了结果。
回调函数简单直接,但有一个致命的问题:当多个异步操作有依赖关系(第二个操作依赖第一个的结果,第三个依赖第二个),回调就会一层套一层,形成所谓的回调地狱(Callback Hell):
才三层嵌套就已经很难读了,如果有五六层呢?再加上错误处理,代码会变成一个向右缩进的"金字塔",可读性和可维护性都很差。这就是回调地狱的问题,也是 Promise 被发明出来的原因。
Promise
Promise 是 ES6 引入的异步编程方案,专门为了解决回调地狱。Promise 这个词是"承诺"的意思:它代表一个异步操作的最终结果,承诺你未来一定会拿到值(或者告诉你出错了)。
一个 Promise 对象有三种状态:
- pending(进行中):刚创建,操作还没完成
- fulfilled(已完成):操作成功,有了结果
- rejected(已拒绝):操作失败,有了错误
状态只能从 pending 变成 fulfilled 或 rejected,一旦改变就不会再变。
Promise 最大的优势是支持链式调用。.then() 返回的还是一个 Promise,所以可以一个接一个地串下去,把回调地狱的嵌套结构变成扁平的链式结构:
和前面回调地狱的版本对比一下,逻辑完全一样,但代码从"金字塔"变成了"直梯",每一步的缩进都在同一层级,读起来清晰多了。关键在于 .then() 的回调里 return 了一个新的 Promise,下一个 .then() 会等这个 Promise 完成后再执行。
除了顺序执行,Promise 还提供了两个非常实用的静态方法来处理多个异步操作:
Promise.all 接收一个 Promise 数组,等所有 Promise 都 fulfilled 后,把结果按原顺序收集到数组里返回。注意观察输出:虽然任务B最先完成,但结果数组里的顺序仍然是 A, B, C,和传入顺序一致。如果其中任何一个 Promise 被 rejected,Promise.all 就会立即被 rejected。
还有一个 Promise.race,它只等最快的那个,谁先完成就返回谁的结果:
Promise.race 的典型应用场景是给异步操作设置超时:把实际操作和一个定时器放在一起 race,如果定时器先到就当作超时处理。
async / await
Promise 链虽然比回调地狱好多了,但一长串 .then() 写起来还是有些啰嗦。ES2017 引入了 async/await 语法,让异步代码写起来和同步代码几乎一样。
async 关键字加在函数声明前面,表示这个函数是一个异步函数。异步函数的返回值会自动被包装成 Promise。await 关键字只能在 async 函数里使用,它会暂停函数的执行,等待 Promise 完成后拿到结果。
和前面 Promise 链的版本比较一下:逻辑完全一样,但代码写起来就像普通的同步代码一样,一行一行往下读就行,没有嵌套、没有 .then()。await 就像是在说"在这里等一等,拿到结果再继续"。
本质上,async/await 是 Promise 的语法糖。await step("第一步") 和 step("第一步").then(result => ...) 做的是同一件事,只是写法不同。编译器会把 async/await 转换成 Promise 链来执行。
错误处理方面,async/await 可以直接用 try/catch。try 块中的代码如果出错(抛出异常),程序不会崩溃,而是跳到 catch 块执行,err 就是错误对象:
当 await 的 Promise 被 rejected 时,就会抛出异常,被 catch 捕获。这比 Promise 链的 .catch() 更直观,特别是在有多个 await 的场景下,你不用给每个 .then() 都挂一个 .catch(),一个 try/catch 就能兜住所有错误。
async/await 和 Promise.all 配合使用也非常自然:
注意一个容易犯的错误:如果你写成依次 await,那三个任务就变成串行的了,总耗时是三个任务时间之和。用 Promise.all 则是并发发起,总耗时约等于最慢那个任务的时间。
JS 能"并行"吗?
前面一直在说 JavaScript 是单线程的,但用了 Promise.all 之后好像多个任务"同时"在跑,这到底算不算并行?
答案是:不算并行,这是并发。
回忆一下做饭的比喻:Promise.all 就像你一个人同时开了三个灶(按下电饭煲、架上汤锅、起火炒菜),然后等所有灶都做完。三口锅确实在"同时"煮着东西,但负责操作的始终只有你一个人。你只是把等待时间利用起来了,并不是真的有三个人在同时干活。
在 JavaScript 里也是这样。Promise.all 同时发起了多个异步操作(比如网络请求、定时器),这些操作的"等待"由浏览器/Node.js 底层并行处理,但 JavaScript 代码本身还是一行一行跑的。当回调函数执行时,它们依然要排队,一个跑完下一个才能跑。
那如果你有一个计算密集型任务,比如要算一个巨大的斐波那契数列,它会一直占着调用栈不放,整个 JS 线程就被阻塞了,其他异步回调都得等着。来看一个例子:
在计算 fib(35) 的这段时间里,如果有其他的 setTimeout 回调或网络请求回调在排队,它们都只能干等着。在浏览器里,这甚至会导致页面卡死、无法响应用户操作。
那 JavaScript 真的完全不能并行吗?也不是。Node.js 提供了 Worker Threads(工作线程),浏览器提供了 Web Workers,可以开一个新线程来跑计算密集型任务。
但这些 Worker 和主线程之间只能通过消息传递来通信,不能共享变量,用法和传统多线程编程差别很大。对于大多数场景来说,单线程 + 异步的模型就够用了,Worker 只在计算密集型场景下才需要考虑。
小结
JavaScript 的异步编程经历了三代进化:回调函数 -> Promise -> async/await。每一代都是在解决上一代的痛点:回调地狱太难读,Promise 链让它变扁平,async/await 让它看起来和同步代码一样自然。
核心要点回顾:
- JavaScript 是单线程的,通过事件循环实现异步并发,但不是并行
setTimeout(fn, 0)不是立即执行,而是"等当前代码跑完后尽快执行"- 回调函数是最原始的异步方式,多层嵌套会形成回调地狱
- Promise 用
.then()/.catch()链式调用,Promise.all并发等多个结果,Promise.race只等最快的 async/await是 Promise 的语法糖,让异步代码看起来像同步,用try/catch处理错误Promise.all是并发发起,不是并行执行。计算密集型任务仍然会阻塞主线程