同步与异步
单线程
由于 JavaScript
是一门单线程的语言,因此 JavaScript
在同一个时间只能做一件事。单线程意味着,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务,会造成运行阻塞,严重的话会造成页面长时间无响应,js执行时间过长等等。
JavaScript
之所以采用单线程,而不是多线程,跟历史有关系。JavaScript
从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。
单线程的好处
- 实现起来比较简单
- 执行环境相对单纯
单线程的坏处
- 坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行
如果排队是因为计算量大,CPU
忙不过来,倒也算了,但是很多时候 CPU
是闲着的,因为 IO
操作(输入输出)很慢(比如 Ajax
操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript
语言的设计者意识到,这时 CPU
完全可以不管 IO
操作,挂起处于等待中的任务,先运行排在后面的任务。等到 IO
操作返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是 JavaScript
内部采用的 “事件循环” 机制(Event Loop
)。
单线程虽然对 JavaScript
构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果用得好,JavaScript
程序是不会出现堵塞的,这就是为什么 Node
可以用很少的资源,应付大流量访问的原因。
为了利用多核 CPU
的计算能力,HTML5
提出 Web Worker
标准,允许 JavaScript
脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM
。所以,这个新标准并没有改变 JavaScript
单线程的本质。
同步
同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。
同步操作的例子
简单的数学计算:
let xhs = 3
xhs = xhs + 4
在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。等到最后一条指定执行完毕,存储在 xhs
的值就立即可以使用。
首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态。
异步
异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。
异步操作的例子
在定时回调中执行一次简单的数学计算:
let xhs = 3
setTimeout(() => (xhs = xhs + 4), 1000)
这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道 xhs 值何时会改变,因为这取决于回调何时从消息队列出列并执行。
异步代码不容易推断。虽然这个例子对应的低级代码最终跟前面的例子没什么区别,但第二个指令块(加操作及赋值操作)是由系统计时器触发的,这会生成一个入队执行的中断。到底什么时候会触发这个中断,这对 JavaScript
运行时来说是一个黑盒,因此实际上无法预知(尽管可以保证这发生在当前线程的同步代码执行之后,否则回调都没有机会出列被执行)。无论如何,在排定回调以后基本没办法知道系统状态何时变化。
为了让后续代码能够使用 xhs
,异步执行的函数需要在更新 xhs
的值以后通知其他代码。如果程序不需要这个值,那么就只管继续执行,不必等待这个结果了。
异步的应用场景
- 定时任务:setTimeout、setInterval
- 网络请求:ajax请求、动态创建img标签的加载
- 事件监听器:addEventListener
任务队列和事件循环
JavaScript
运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue
),里面是各种需要当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。为了方便理解,这里假设只存在一个队列)。
首先,主线程会去执行所有的同步任务。等到同步任务全部执行完,就会去看任务队列里面的异步任务。如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。
异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,也就是说,不会重新进入主线程,因为没有用回调函数指定下一步的操作。
JavaScript
引擎怎么知道异步任务有没有结果,能不能进入主线程了呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环(Event Loop
)。
异步操作的模式
回调函数
回调函数是异步操作最基本的方法。但是回调函数有一个非常严重的缺点,那就是回调地狱(Callback Hell)问题。
Callback Hell
回调函数例子
下面是两个函数 f1
和 f2
,代码的意图是 f2
必须等到 f1
执行完成,才能继续执行。
function f1() { // ... }
function f2() {
// ...
}
f1()
f2()
上面代码的问题在于,如果 f1
是异步操作,f2
会立即执行,不会等到 f1
结束再执行。 这时,可以考虑改写 f1
,把 f2
写成 f1
的回调函数。
回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(coupling
),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。
Promise
Promise
作为典型的微任务之一,它的出现可以使JS
达到异步执行的效果。
Promise函数例子
const promise = new Promise((resolve, reject) => {
resolve('a');
});
promise
.then((arg) => { console.log(`执行resolve,参数是${arg}`) })
.catch((arg) => { console.log(`执行reject,参数是${arg}`) })
.finally(() => { console.log('结束promise') });
如果,我们需要嵌套执行异步代码,相比于回调函数来说,Promise
的执行方式如下列代码所示:
const promise = new Promise((resolve, reject) => {
resolve(1);
});
promise.then((value) => {
console.log(value);
return value * 2;
}).then((value) => {
console.log(value);
return value * 2;
}).then((value) => {
console.log(value);
}).catch((err) => {
console.log(err);
});
即通过 then
来实现多级嵌套(链式调用),每个 Promise
都会经历的生命周期是:
-
进行中(
pending
) - 此时代码执行尚未结束,所以也叫未处理的(unsettled
) -
已处理(
settled
) - 异步代码已执行结束 已处理的代码会进入两种状态中的一种:- 已完成(
fulfilled
) - 表明异步代码执行成功,由 resolve()
触发 - 已拒绝(
rejected
)- 遇到错误,异步代码执行失败 ,由 reject()
触发
- 已完成(
因此,pending
,fulfilled
,rejected
就是 Promise
中的三种状态。在 Promise
中,要么包含 resolve()
来表示 Promise
的状态为 fulfilled
,要么包含 reject()
来表示 Promise
的状态为 rejected
。不然我们的 Promise
就会一直处于 pending
的状态,直至程序崩溃~。
除此之外,Promise
不仅很好的解决了链式调用的问题,它还有很多神奇的操作:
-
Promise.all(promises)
:接收一个包含多个Promise
对象的数组,等待所有都完成时,返回存放它们结果的数组。如果任一被拒绝,则立即抛出错误,其他已完成的结果会被忽略 -
Promise.allSettled(promises)
:接收一个包含多个Promise
对象的数组,等待所有都已完成或者已拒绝时,返回存放它们结果对象的数组。每个结果对象的结构为{status: 'fulfilled' // 'rejected', value // reason}
-
Promise.race(promises)
:接收一个包含多个Promise
对象的数组,等待第一个有结果(完成 / 拒绝)的Promise
,并把其result / error
作为结果返回
function getPromises() { return [ new Promise(((resolve, reject) => setTimeout(() => resolve(1), 1000))), new Promise(((resolve, reject) => setTimeout(() => reject(new Error('2')), 2000))), new Promise(((resolve, reject) => setTimeout(() => resolve(3), 3000))), ]; }
Promise.all(getPromises()).then(console.log);
Promise.allSettled(getPromises()).then(console.log);
Promise.race(getPromises()).then(console.log);
Generator
Generator
是 ES6
提出的一种异步编程的方案。因为手动创建一个 iterator
十分麻烦,因此 ES6
推出了 Generator
,用于更方便的创建 iterator
。即 Generator
就是一个返回值为 iterator
对象的函数。
iterator是什么
在讲 Generator
之前,我们先来看看 iterator
是什么?
iterator
中文名叫迭代器。它为 JS
中各种不同的数据结构 (Object、Array、Set、Map)提供统一的访问机制。任何数据结构只要部署了 Iterator
接口,就可以完成遍历操作。 因此 iterator
也是一种对象,不过相比于普通对象来说,它有着专为迭代而设计的接口。
iterator
的作用
- 为各种数据结构提供一个统一和简便的访问接口
- 使得数据结构的成员能够按某种次序排列
-
ES6
创造了一种新的遍历命令for…of
循环,Iterator
接口主要供for…of
消费
iterator的结构
iterator
有 next
方法,该方法返回一个包含 value
和 done
两个属性的对象(我们假设叫 result
)。value
是迭代的值,后者是表明迭代是否完成的标志。true
表示迭代完成,false
表示没有。iterator
内部有指向迭代位置的指针,每次调用 next
,自动移动指针并返回相应的 result
。
原生具备iterator接口的数据结构如下:
- Array
- Map
- Set
- String
- TypedArray
- 函数里的 arguments 对象
- NodeList 对象
这些数据结构都有一个 Symbol.iterator 属性,可以直接通过这个属性来直接创建一个迭代器。也就是说,Symbol.iterator 属性只是一个用来创建迭代器的接口,而不是一个迭代器,因为它不含遍历的部分。
使用 Symbol.iterator 接口生成 iterator 迭代器来遍历数组的过程的例子:
let arr = ['a','b','c'];
let iter = arrSymbol.iterator;
iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }
for ... of 的循环内部实现机制其实就是 iterator,它首先调用被遍历集合对象的 Symbol.iterator 方法,该方法返回一个迭代器对象,迭代器对象是可以拥有 .next()
方法的任何对象,然后,在 for ... of 的每次循环中,都将调用该迭代器对象上的 .next
方法。然后使用 for i of
打印出来的 i
也就是调用 .next 方法后得到的对象上的 value 属性。
对于原生不具备 iterator 接口的数据结构,比如 Object,我们可以采用自定义的方式来创建一个遍历器。
let obj = {a: "hello", b: "world"}; // 自定义迭代器 function createIterator(items) { let keyArr = Object.keys(items); let i = 0; return { next: function () { let done = (i >= keyArr.length); let value = !done ? items[keyArr[i++]] : undefined; return { value: value, done: done, }; } }; }
let iterator = createIterator(obj);
console.log(iterator.next()); // "{ value: 'hello', done: false }"
console.log(iterator.next()); // "{ value: 'world', done: false }"
console.log(iterator.next()); // "{ value: undefined, done: true }"
接下来,来聊聊Generator。
通过一个例子来看看Gnerator的特征:
function* createIterator() { yield 1; yield 2; yield 3; }
// generators可以像正常函数一样被调用,不同的是会返回一个 iterator
let iterator = createIterator();
console.log(iterator.next().value); // 1
console.log(iterator.next().value); // 2
console.log(iterator.next().value); // 3
Generator 函数是 ES6 提供的一种异步编程解决方案。形式上,Generator 函数是一个普通函数,但是有两个特征:
- function 关键字与函数名之间有一个星号
- 函数体内部使用 yield 语句,定义不同的内部状态
Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象(Iterator Object)。
打印看看 Generator 函数返回值的内容,发现 Generator 函数的返回值的原型链上确实有 iterator 对象该有的 next,这充分说明了 generator 的返回值是一个 iterator。除此之外还有函数该有的 return 方法和 throw 方法。
在普通函数中,我们想要一个函数最终的执行结果,一般都是 return 出来,或者以 return 作为结束函数的标准。运行函数时也不能被打断,期间也不能从外部再传入值到函数体内。但在 generator 中,就打破了这几点,所以 generator 和普通的函数完全不同。当以 function*
的方式声明了一个 Generator 生成器时,内部是可以有许多状态的,以 yield 进行断点间隔。期间我们执行调用这个生成的 Generator,他会返回一个遍历器对象,用这个对象上的方法,实现获得一个 yield 后面输出的结果。
function* generator() { yield 1 yield 2 };
let iterator = generator();
iterator.next() // {value: 1, done: false}
iterator.next() // {value: 2, done: false}
iterator.next() // {value: undefined, done: true}
yield 和 return 的区别:
- 都能返回紧跟在语句后面的那个表达式的值
- yield 相比于 return 来说,更像是一个断点。遇到yield,函数暂停执行,下一次再从该位置继续向后执行,而 return 语句不具备位置记忆的功能
- 一个函数里面,只能执行一个 return 语句,但是可以执行多次 yield 表达式
- 正常函数只能返回一个值,因为只能执行一次 return;Generator 函数可以返回一系列的值,因为可以有任意多个 yield
语法注意点:
- yield 表达式只能用在 Generator 函数里面
- yield 表达式如果用在另一个表达式之中,必须放在圆括号里面
- yield 表达式用作函数参数或放在赋值表达式的右边,可以不加括号
- 如果 return 语句后面还有 yield 表达式,那么后面的 yield 完全不生效
使用Generator的其余注意事项:
- yield 不能跨函数。并且 yield 需要和 * 配套使用,别处使用无效。2、箭头函数不能用做 generator
- 箭头函数不能用做 generator
function* createIterator(items) {
items.forEach(function (item) {
// 语法错误
yield item + 1;
});
}
那么 Generator 到底有什么用呢?
- 因为 Generator 可以在执行过程中多次返回,所以它看上去就像一个可以记住执行状态的函数,利用这一点,写一个 generator 就可以实现需要用面向对象才能实现的功能
- Generator 还有另一个巨大的好处,就是把异步回调代码变成“同步”代码。这个在 ajax 请求中很有用,避免了回调地狱
async/await
async / await 是 ES7 提出的关于异步的终极解决方案。互联网上关于 async / await 是谁的语法糖这块有两个版本,这两个版本的所发都没有错。
- 第一个版本说 async / await 是 Generator 的语法糖
- 第二个版本说 async / await 是 Promise 的语法糖
关于第一个版本的 async / await 是 Generator 的语法糖:
所谓 Generator 语法糖,表明的就是 aysnc / await 实现的就是 generator 实现的功能。但是 async / await 比 generator 要好用。因为 generator 执行 yield 设下的断点采用的方式就是不断的调用 iterator 方法,这是个手动调用的过程。针对 generator 的这个缺点,后面提出了co这个库函数来自动执行 next,相比于之前的方案,这种方式确实有了进步,但是仍然麻烦。而 async 配合 await 得到的就是断点执行后的结果。因此 async / await 比 generator 使用更普遍。
总结下来,async 函数对 Generator 函数的改进,主要体现在以下三点:
- 内置执行器:Generator 函数的执行必须靠执行器,因为不能一次性执行完成,所以之后才有了开源的 co 函数库。但是,async 函数和正常的函数一样执行,也不用 co 函数库,也不用使用 next 方法,而 async 函数自带执行器,会自动执行
- 适用性更好:co 函数库有条件约束,yield 命令后面只能是 Thunk 函数或 Promise 对象,但是 async 函数的 await 关键词后面,可以不受约束
- 可读性更好:async和 await,比起使用 * 号和 yield,语义更清晰明了
关于 async / await 是 Promise 的语法糖:
如果不使用 async / await 的话,Promise 就需要通过链式调用来依次执行 then 之后的代码:
function counter(n) { return new Promise((resolve, reject) => { resolve(n + 1); }); }
function adder(a, b) {
return new Promise((resolve, reject) => {
resolve(a + b);
});
}function delay(a) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(a), 1000);
});
}// 链式调用写法
function callAll() {
counter(1)
.then((val) => adder(val, 3))
.then((val) => delay(val))
.then(console.log);
}
callAll(); // 5
虽然相比于回调地狱来说,链式调用确实顺眼多了。但是其呈现仍然略繁琐了一些。而async/await的出现,就使得我们可以通过同步代码来达到异步的效果:
async function callAll(){
const count = await counter(1);
const sum = await adder(count + 3);
console.log(await delay(sum));
}
callAll();// 5
复制代码
由此可见,Promise搭配async/await的使用才是正解!
小结
- promise 让异步执行看起来更清晰明了,通过 then 让异步执行结果分离出来
- async / await 其实是基于Promise的。async 函数其实是把 promise 包装了一下。使用 async 函数可以让代码简洁很多,不需要 promise 一样需要些 then,不需要写匿名函数处理promise的resolve值,也不需要定义多余的 data 变量,还避免了嵌套代码
- async函数是Generator函数的语法糖。async函数的返回值是 promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。同时,我们还可以用 await 来替代 then 方法指定下一步的操作
- 感觉 Promise + async 的操作最为常见。因为 Generator 被 async 替代了呀
异步操作的流程控制
串行执行
我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。这就叫串行执行。
并行执行
即所有异步任务同时执行,等到全部完成以后,才执行final函数。