Js 中 async-await 和 事件循环
上篇详细讲解了 js 中的生成器和迭代器. 包括迭代器, 可迭代协议, 可迭代对象, 生成器, 生成器协议, 生成器函数, 生成器对象, 生成器是特殊迭代器, 生成器可部分替代迭代器等诸多概念. 难点主要在概念理解上, 应用层面像 for...of, 展开运算符, 对象初始化, Array.from() 等都还算相对简单.
本篇将继续围绕迭代器和生成器, 再加上 Promise, 回调函数等知识, 推导出最优的异步代码解决方案, 即本篇的标题的 async-await 语法糖. 虽然平时也用过, 但却对它并不懂, 跟着瞎写一通, 能跑就行. 而本次则是希望从原理层面一步步推导出来, 这样才能真正掌握它.
异步代码处理方案
回顾之前模拟异步获取网络数据的的案例, 从最初的回调函数到用 Promise 改写:
// 异步代码
// 用户传入什么, 则服务器返回什么
function requestData(url) {
return new Promise((resolve, reject) => {
// 模拟网络请求
setTimeout(() => {
resolve(url)
}, 2000);
})
}
// 获取异步数据
requestData("youge").then(res => {
console.log("res: ", res) // youge
})
这时相对简单的异步任务处理情况, 现在开始有新的需求:
- 向服务器请求数据, 一共要发 多次请求
- 第二次请求的 url 依赖于第一次的结果
- 第三次请求的 url 依赖于第二次的结果
- ... 依此类推
// 需求升级
1. url: youge -> res: youge
2. url: res + 'aa' -> res: yougeaa
3. url: res + 'bb' -> res: yougeaabb
// ..
核心诉求就是 一个任务由多个异步任务组成, 后一个要等前一个异步任务拿到结果, 再请求 (异步任务队列).
// 需要多次发请求, 每次异步的, 后一次依赖前一次的结果.
user_id -> usr_info.department_id -> department_info
先来看第一种方案: 通过多次回调, 按照现在的 Promise 的写法实现:
// 方案一: 层层回调
requestData("youge").then(res => {
// 拿到第一次的结果够, 再发送第二次请求
requestData(res + 'aa').then(res => {
// 拿到第二次请求的结果, 再发送第三次请求
requestData(res + 'bb').then(res => {
console.log(res) // 等 6秒后, 输出: yougeaabb
})
})
})
这个阅读性和可维护性都极低, 就是加强版的 "回调地狱" . 这种方案肯定是不行的, 维护起来太难了.
来看第二种方案: 通过 Promise 中 then 的返回值 解决, 理由 then() 返回的也是 Promise 的链式调用特性
// 方案2: 通过 Promise 返回值, 也是 Promise 的特性
requestData("youge").then(res => {
return requestData(res + 'aa')
}).then(res => {
return requestData(res + 'bb')
}).then(res => {
console.log(res) // 等待 6秒 输出 yougeaabb
})
比回调地狱好一点, 但不多. 至少没有层层嵌套了, 但是一定要处理 res 的异常的话, 那这个代码也是难搞得很嘞.
Promise + Generator
来看第三种方案: Promise + Generator 实现, 通过生成器来控制回调函数的执行节奏.
// 方案3: Promise + Generator
function* getData() {
yield requestData('youge')
}
const generator = getData()
// 每调用一次 next(), 则 requestData() 执行一次
// 返回值形式是 { value, done }
generator.next().then(res => {
console.log(res)
})
- 首先是定义了一个生成器函数
getData(), 在yield时会调用请求函数, 其返回值是 Promise - 调用 getData() 则会返回一个生成器, 每次进行
.next()则会触发 yield , 则执行请求函数 - yield 同时会将请求函数的结果返回, 它是一个
{value, done}的结果, 这里value就是返回的 Promise - 因此通过
generator.next().value.then(res => {})能拿到异步请求的结果
拿到这个 res 之后, 我们传给 yield 的地方, 通过下一次的 .next(res)
// 上一次结果, 作为参数传递给下一次
function* getData() {
const res1 = yield requestData('youge')
console.log('执行第二段代码, 打印上一次的 res1 结果')
yield res1
}
const generator = getData()
generator.next().value.then(res => {
// next(res) 会传递给上面的 const res1 的地方, 然后在下一次的 yield 前能获取到
console.log(generator.next(res))
})
这样就实现了结果作为参数的传递, 通过 next() 传递给了 yield . 于是就形成这样了.
// 用户传入什么, 则服务器返回什么
function requestData(url) {
return new Promise((resolve, reject) => {
// 模拟网络请求
setTimeout(() => {
resolve(url)
}, 2000);
})
}
// 方案3: Promise + Generator
function* getData() {
const res1 = yield requestData('youge')
const res2 = yield requestData(res1 + 'aa')
const res3 = yield requestData(res2 + 'bb')
const res4 = yield requestData(res3 + "cc")
console.log(res4) // 等8秒后输出 yougeaabbcc
}
const generator = getData()
generator.next().value.then(res => {
generator.next(res).value.then(res => {
generator.next(res).value.then(res => {
generator.next(res).value.then(res => {
generator.next(res) // 比上面多一次
})
})
})
})
可以看到这种方式:
- 生成器中就没有
嵌套了, 从形式上看 res1, res2, res3, res4 非常像依次从上到下的顺序代码 - 但本质上 res1, res2 ... 是前后依赖的异步逻辑, 表现上很
亲民 - 下面的生成器部分, 虽然是层层嵌套, 但是它都是重复的, 有规律可以自动化
现在如果将生成器的部分, 能够实现从 手动挡 -> 自动挡 那就非常完美!
function execGenerator(genFn) {
const generator = genFn()
// 自动依次执行生成器函数
// 停止条件: 返回 undefined
// 不确定调多少次, 但有停止条件, 因此是一个递归
}
execGenerator(getData)
则这个递归函数实现如下:
// const generator = getData()
// generator.next().value.then(res => {
// generator.next(res).value.then(res => {
// generator.next(res).value.then(res => {
// generator.next(res).value.then(res => {
// generator.next(res)
// })
// })
// })
// })
function execGenerator(genFn) {
const generator = genFn()
// 调用生成器的 next() 方法并传入上一个结果 res
function exec(res) {
const ret = generator.next(res)
// 边界条件, yield 不返回值时
if (ret.done) {
return ret.value
}
ret.value.then(res => {
exec(res) // 递归调用
})
}
exec() // 立即调用
}
execGenerator(getData)
这个递归理解写起来有点无从下手, 但是看大佬写出来后, 阅读上还是有点感觉. 当然在实际中也不用自己写, 但凡我等小菜鸡能想到大多复杂的东西, 社区大神早就封装为工具类库了的. 就安心躺平调用 API 即可, 不要有心里负担, 因为也学不会.
通过这样的一番操作, 原来复杂的异步操作代码, 层层嵌套, 或者不断返回等方式, 最后从形式上变成了简洁的外观:
// 看着就像同步代码, 但实际上是异步代码
function* getData() {
const res1 = yield requestData('youge')
const res2 = yield requestData(res1 + 'aa')
const res3 = yield requestData(res2 + 'bb')
const res4 = yield requestData(res3 + "cc")
console.log(res4) // 等8秒后输出 yougeaabbcc
}
**其实最终推导的这个过程, 就是模拟 async-await 的本质, 它其实就是咱们上面推导执行过程的 语法糖. **
异步函数 async
编程里面的同步和异步, 和现实世界是 相反 的, 也不知道是翻译问题, 还是我理解不对, 难受.
- 异步
async: 表示同时做多件事. 我一边拉屎, 一边刷手机 - 同步
sync: 表示依次做多件事. 我先脱裤子, 然后在放屁.
// 异步函数, 写法就是在前面加个 async 关键字
async function foo1() { }
const foo2 = async () => { }
class Foo {
async bar() {
}
}
异步函数里面的代码执行过程基本和普通函数一致, 默认同步执行, 但当有返回值, 返回值 是 Promise 时这不同:
- 返回值是普通值, 则会被包裹在 Promise.resovle() 中
- 返回值是 Promise, 则 Promise 的状态将由 Promise 决定
- 返回是是实现了 thenable 的对象, 则会由对象的 then 方法决定
- 过程中出现异常, 不会像普通函数报错, 而是会作为 Promise 的 reject() 透传
// 异步函数执行流程, 默认和普通函数一致
async function foo() {
console.log('内部代码执行, 1')
console.log('内部代码执行, 2')
console.log('内部代码执行, 3')
}
console.log("start...")
foo()
console.log("end...")
start...
内部代码执行, 1
内部代码执行, 2
内部代码执行, 3
end...
无返回值情况下, 默认和普通函数并无区别.
有返回值时, 异步函数的返回值一定是一个 Promise
// 异步函数的返回值, 一定是 Promise
async function foo(params) {
console.log('foo start...')
console.log('foo end...')
// 1. 返回普通值
// return 111 // 默认 undefined
// 2. 返回 thenable
// return {
// then: function(resolve, reject) {
// resolve('okk')
// }
// }
// 3. 返回 Promise
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('err')
}, 2000);
})
}
foo().then(res => {
// 等 异步函数 return 时获取到值, 加入微任务队列
console.log(res, "Promise then 执行")
}).catch(err => {
console.log("err: ", err)
})
异步函数中出现异常会通过 Promise 的 reject 透传, 而不像同步函数直接程序崩溃.
// 同步函数中出现异常, 则直接崩盘
function foo() {
console.log('start')
console.log('doing')
throw new Error("异常");
console.log(end) // 不执行
}
console.log(111) // 111
foo()
console.log(222) // 不执行了, 前面就报错了
异步中就要求要处理异常.
// 异步函数中出现异常, 传递给 Promise 的 catch 处理
async function foo() {
console.log('start')
console.log('doing')
throw new Error("异常");
console.log(end) // 不执行
}
console.log(111)
foo().catch(err => console.log('处理异常')) // 最后才执行 (微任务)
console.log(222)
异步函数中 await
async 函数内部可以使用 await 关键字, 功能类似于前面生成器中的 yield, 用来控制异步函数执行顺序.
注意是要和 async 搭配使用, 不能单独用于普通函数.
- 写法上, await 后面会跟上一个表达式, 表达式会返回一个 Promise
- 执行上, await 会等 Promise 状态变为 fulfilled 后, 才会继续执行异步函数
// await 表达式
function requestData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(111)
}, 2000);
})
}
async function getData() {
// 看是同步, 其实是异步, 异步的部分在自动 generator
const res1 = await requestData()
console.log('相当于上面 Promise then 的代码 01', res1)
console.log('相当于上面 Promise then 的代码 02')
const res2 = await requestData()
console.log('res2 后面的代码执行', res2)
}
getData() // Promise
// 这里就是让异步代码,看起来像同步代码的 背后苦力
const generator = getData()
generator.next().value.then(res => {
generator.next(res).value.then(res => {
generator.next(res).value.then(res => {
generator.next(res).value.then(res => {
generator.next(res)
})
})
})
})
await 后面除了跟表达式外, 也可以跟普通值.
// await 普通值
async function foo() {
const res = await 123
return res
}
foo().then(res => {
console.log(res) // 123
})
await 也可以跟一个对象 (实现了 thenable )
// await 对象 thenable
async function foo() {
const res = await {
then: function(resolve, reject) {
resolve(111)
}
}
return res
}
foo().then(res => {
console.log(res) // 111
})
await 也可以跟一个 Promise, 注意点就是, 当 reject 的时候, 则后续不会再继续执行, 类似 Promise.all()
// await 对象 thenable
async function foo() {
const res = await new Promise((resolve, reject) => {
reject('err')
})
// 前面 reject 了, 后面是不会调用的, 有点像 Promise.all()
console.log(222)
return res
}
foo().then(res => {
console.log(res)
}).catch(err => {
console.log(err) // err
})
只要是理解了前面我们用 **generator + yield 来模拟 async + await ** 的过程, 以及对 Promise 的深刻理解, 肯定深刻, 前面我们都手写 Promise A+ 了, 这些东西就轻轻松松. 后面直接无心智负担, 复制粘贴就行.
事件循环
要引出 事件循环, 这可需要先补充一下计算机的基本常识. 尤其像我这种没学过计算机的, 还是得补一下为好.
进程, 线程, 协程
- 进程 (Process): 它是操作系统管理程序的一种方式, 一个程序的运行, 至少有一个进程
- 线程 (Thread) : 一个程序进程, 至少包含一个线程, 它是操作系统能运行调度的最小单位
- 协程 (Coroutine): 它是用户程序或者运行时库的轻量级并发单元, 大多建在单个线程上, 也可多个线程上
可以更直观去理解, 当我们去启动一个程序, 则至少会默认启动一个进程, 或者多个进程;
在每个进程中, 都至少会会启动一个线程来执行程序中的代码, 也称为 主线程, 也可以说,进程是线程的容器
而对于协程来说, 它并非是由操作系统调度的, 而是有程序或者运行时库, 比如我比较熟悉的 Go 和 Python:
| 概念 | 线程 (Thread) | 协程 (Coroutine) |
|---|---|---|
| 管理方 | 操作系统内核 (Kernel) | 用户程序或运行时库 (Runtime) |
| 切换开销 | 高 (涉及内核态/用户态切换、保存大量寄存器) | 极低 (纯用户态函数调用,只保存少量上下文) |
| 创建成本 | 高 (内存占用大,通常几MB) | 极低 (内存占用小,通常几KB) |
| 数量限制 | 有限 (通常几百到几千) | 极多 (可轻松创建成千上万甚至百万) |
| 调度 | 抢占式 (Preemptive) - OS 决定何时切换 | 协作式 (Cooperative) - 协程主动让出执行权 |
| 阻塞影响 | 一个线程阻塞,不会影响同进程内其他线程 | **一个协程阻塞,可能阻塞其所在的整个线程 ** |
Go 语言将协程称为 Goroutine:
- Go 运行时 (Runtime) 实现了一个复杂的 M:N调度器, M 个协程, 会被映射到 N 个线程上去运行
- Goroutine 是轻量的, go func() 语法即可启动一个协程, 开销极小, 可以轻松创建几千几万, 天然高并发
- 单个 Goroutine **不会阻塞所在的 OS 线程 **, 并发效率极度提高
Python 语言将协程, 则通过 async/await 和 asyncio 等库进行实现
- 它大多采用的是 1:N模型, 在单个线程上创建 N 个协程, 通过事件循环来进行任务调度
- 它表现为一个任务, 通过 async def 定义协程函数, 返回协程对象, 然后等待被事件循环调度执行
- 它的阻塞是致命的, 遇到阻塞任务 (查数据库, CPU密集计算等) 会阻塞整个事件循环所在的 OS线程.
而且 Python 还是全局锁, 就用起来是超级简单, 但是性能是真的垃圾.
Go 让你感觉像是在创建“轻量线程”,运行时帮你处理了线程和协程的复杂映射。
而Python 的 async/await 是一种编程范式,让你在单个线程内通过“暂停-恢复”机制实现并发, 还要人工运维
OS 的进程调度
我们平时用手机或者电脑时, 可以同时一边听歌, 一边回工作消息, 一边上网等, 这看上去是同时进行的, 其实并不是!
因为 CPU的运算速度非常快, 以至于我们根本察觉不到它其实在多个进程中来回速度切换!
当进程中的线程获取到时间片时, 就可以快速执行我们编写的代码, 这个量级的速度, 用户根本无感知.
所以嘛, 还是这句老话: "天下武功, 唯快不破".
操作系统进程调度是一个复杂的权衡过程:
-
目标: 公平、高效、低延迟、高吞吐量。
-
手段: 通过抢占式调度和复杂的算法(如 MLFQ, CFS)来实现.
-
趋势: 从简单的算法发展到自适应、多层次的调度器,能够智能地识别交互式任务(如 GUI、shell)并给予高优先级,同时保证后台任务也能获得资源.
-
关键: 现代调度器(如 Linux CFS)不再追求“平均”,而是追求“公平”,并利用数据结构(红黑树)和动态调整来优化性能.
过多的就不补充了, 还是回到 js 这里来, 不然就跑偏了
浏览器中的 js 线程
首先来说浏览器, 以 Chrome 为例, 它本身也是一个用 C++ 编写的程序, 当启动浏览器时, 它会同时启动多个进程.
比如我们新开一个 tab 页面, 就会开启一个新进程, 当前页面卡死了, 并不会造成所有其他 tab 页面无法响应, 而造成浏览器崩溃.
它每个进程中又有很多个线程, 这些线程中, 就包含有 执行 js 代码的线程.
我们常说 js 核心执行环境是单线程的, 但是 js 的线程是有自己的容器进程: 浏览器或者 Node. (js 要依赖这俩老哥的环境)
- 单线程对于 js 来说, 在同一个时刻只能做一件事情, 若这件事是耗时的, 则当前线程就会阻塞.
- 但是 js 的执行环境是 浏览器 或者 Node , 他们是多线程, 多进程的, 可以创建额外的线程来执行 js
先说结论, js 是单线程语言, 但可以运行在多线程!
| 问题 | 回答 |
|---|---|
| JS 主线程是单线程的吗? | 是的。 默认的执行上下文只有一个主线程。 |
| JS 代码能运行在多线程上吗? | 是的! 通过 Web Workers (浏览器) 和 Worker Threads (Node.js),你可以创建额外的线程来运行 JavaScript 代码。 |
async/await 是多线程吗? |
不是。 它是单线程事件循环上的异步编程,依赖后台线程池处理 I/O,但 JS 逻辑本身仍在主线程执行。 |
所以真正耗时的操作, 如网络请求, 定时器, IO 读写等, 实际上并非要由 js 主线程执行的, 完全可以借助它的浏览器爸爸和 Node 爸爸的强大能力, 去跪求一些别的线程来异步完成多任务.
JavaScript 的核心执行环境是单线程的,但它提供了强大的 API(Web Workers / Worker Threads)来创建额外的线程,从而让 JavaScript 代码能够真正地在多线程上并行运行,以应对现代应用的性能需求.
这样就可以理解 js 这门语言和 java 或者 go 的本质区别了. 就 js 是一个寄生的, 需要跪舔它的浏览器爸爸, 或者 Node 爸爸, 不然没法存活. 同时执行代码环境也是单线程的, 轻量化, 适合客户端 好处就是数据流唯一, 不好就是不支持并发. 但因为有两个强大的爸爸, 也能通过跪舔能获的一些爸爸的资源来搞一些额外的线程, 如 web worker 等实现异步的任务执行.
而像 java 或者 go 这种, 他们是更相对独立的个体, 有自己能去直接操作 OS 的能力, 这不就是可以进程, 线程随便搞, 自由得很嘞! 因而更适用于重量级系统程序, 服务端程序等场景, 搞客户端就有点大材小用了.
浏览器的事件循环
js 代码执行是单线程的, 则意味着同一个时间段只能执行一个任务, 那比如:
- setTimeout()
- axios.get(url)
- Promise.then(() => { })
- onclick = function()
- ...
等这些可能会阻塞主线程的任务, 它的执行顺序又该是怎样的呢? 比如这个 setTimeout, 假设我们设定 3秒钟之后再执行一个任务, 那谁来进行计时? 显然不能是这个当前的主线程, 不然就停住了. 则必然是去找它的浏览器或者 Node 爸爸搞点别的线程来支援一下.
于是, 当主线程遇到这些可能会阻塞的任务, 通过跪舔浏览器的其他线程来支援后, 如何将其他线程的结果, 回调回主线程, 则这个调度的机制, 就称为 浏览器事件循环.
// 事件循环
console.log('main start')
// 计时操作, 是由别的线程完成的, 不阻塞主线程
// 会将回调函数保存在其他地方, 开始计时
setTimeout(() => {
console.log('回调函数执行')
}, 1000);
console.log('main end')
main start
main end
// 等1秒后
回调函数执行
这个存在别地方, 这个地方就是浏览器在维护的一个 事件任务队列, 队列是先进先出的哈.
- 主线程遇到 setTimeout (() => {}, 1000), 是阻塞任务, 给它加到队列中去
- 继续往后执行主线程代码
- 等主线程执行完后, js 引擎从事件队列中, 去取出刚入队的任务, 进行执行
宏任务 与 微任务
在事件循环中, 浏览器其实维护的是 两个队列
- 宏任务队列: ajax, setTimeout, setInterval, Dom 监听, UI 渲染等
- 微任务队列: Promise.then(), queueMiscrotask() 等
他们和主线程之间的执行顺序是这样:
- 先执行主线程中的代码先执行 (顶层的 js 代码)
- 再检查微任务队列中是否有任务要执行, 有则先执行微任务
- 等微任务清空后, 再从宏任务队列中, 去执行宏任务
宏任务执行之前, 必须保证微任务队列是空的
若微任务不为空, 则优先执行微任务队列中的任务 (回调)
// 宏任务与微任务
// 宏任务: 会被添加到宏队列, 最后执行
setTimeout(() => {
console.log('宏任务开始: setTimeout')
}, 1000);
// 微任务: 会被添加到微队列, 在宏任务前执行
queueMicrotask(() => {
console.log('微任务开始: queueMicrotask')
})
// main script, 这些会在主线程执行
function foo() {
console.log('main-foo')
}
function bar() {
console.log('main-bar')
foo()
}
bar()
console.log('main-其他代码')
main-bar
main-foo
main-其他代码
微任务开始: queueMicrotask
宏任务开始: setTimeout
就记住规范就好了, main > 微任务 > 宏任务, 最常用的 Promise 是微, setTimeout , AJAX, UI是宏
Promise 相关练习题
case 01:
// 001
setTimeout(function () {
console.log("setTimeout1");
new Promise(function (resolve) {
resolve();
}).then(function () {
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then4");
});
console.log("then2");
});
});
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("then1");
});
setTimeout(function () {
console.log("setTimeout2");
});
console.log(2);
queueMicrotask(() => {
console.log("queueMicrotask1")
});
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then3");
});
看上去有点复杂, 但只要按 main > 微 > 宏 逐个分析出代码的执行顺序即可
| main | 微任务队列 | 宏任务队列 |
|---|---|---|
| promise1 | then1 | setTimeout1 |
| log(2) | queueMicrotask1 | setTimeout2 |
| then3 |
查看执行打印过程:
主: promise1, 2, then1, queueMicrotask1, then3, setTimeout1,then2, then4, setTime2
这里比较难的地方是这个 setTimeout1, 往下遇到 Promise.resovle(), 则到了 then(), 又遇到 Promise.resovle(), 则继续到 then(), 但此时并不会执行时 then4 哦, 而是加到 微任务, 而外层的 log(then2) 和 上一个 then 是平级的, 会先执行 then2, 才是 then4.
case 02:
async function async1 () {
console.log('async1 start')
await async2();
console.log('async1 end')
}
async function async2 () {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
async1();
new Promise (function (resolve) {
console.log('promise1')
resolve();
}).then (function () {
console.log('promise2')
})
console.log('script end')
做之前还是先回顾一下异步函数 aysnc 的特点是, 默认情况下, 和同步函数一样执行. await 才是 Promise 的
// 复习
async function foo() {
console.log(111)
await bar()
console.log(333) // 微任务
}
function bar() {
console.log(222)
return new Promise((resolve, reject) => {
resolve()
})
}
foo()
console.log(444)
// 执行顺序: 111, 222, 444, 333
现在来正式分析 case2 的情况 :
| main | 微任务队列 | 宏任务队列 |
|---|---|---|
| script start | aysnc1 end | setTimeout |
| async1 start | promise2 | |
| async2 | ||
| promise1 | ||
| script end |
因此最终顺序就是:
script start, async1 start, async2, promise1, script end, aysnc1 end, promise2, setTimeout
这个题相比第一个例子还是要简单一些, 没有那么多的 Promise 嵌套, 注意: then() 一定要加入 微队列
练习题就不再继续做了, 对我而已主要是区分理解宏任务和微任务相关代码执行顺序, 尤其这个 Promise 是可以出很多较难的题目的, 我并不是搞前端的, 因此大致了解即可, 不用太深入了.
至此, 关于异步函数 async-await 的用 generator + yield 的推导过程, 语法糖的使用, 以及浏览器的事件循环机制, 还有宏任务队列, 微任务队列等知识就差不多了, 本篇重要的是理解为主哈.

浙公网安备 33010602011771号