JavaScript异步编程
js为什么是单线程的
JavaScript单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户交互,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。
比如,假定JavaScript同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript 就是单线程的。
js单线程会遇到哪些问题
js是单线程的,所以它执行代码时是顺序执行的,这样不可避免的会产生一些问题:当一段代码耗时特别长的时候,js线程会被阻塞,出现卡死的情况(这是因为js是单线程的,它要将这段耗时的代码执行完成之后才会执行后面的代码)。比如说io操作,请求数据这些情况。
对于这个问题,js使用了同步,异步,回调函数来解决。
异步vs同步&回调函数
- 同步: 同步就是代码的顺序执行,比如这样:
console.log('我是同步代码1');
console.log('我是同步代码2');
console.log('我是同步代码3');
它会按照代码编写的顺序,依次执行下去。
- 异步: 不是同步的就是异步的。这样说有点抽象,具体来讲就是:js同步执行的过程中,碰到一段异步代码,它会将这段异步代码交给浏览器 引擎
择机执行
,js会跳过这段异步代码,接着执行后面的同步代码。就像这样:
console.log('我是同步代码1');
console.log('我是同步代码2');
// js会将下面这段异步代码交给浏览器引擎,跳过这段异步代码,接着执行后面的同步代码。
(function(){
xxxxxxxxxxxxxx
console.log('我是异步代码');
})();
console.log('我是同步代码3');
- 回调函数:js将异步代码交给浏览器引擎
择机执行
,执行完成之后呢,会发生什么,就是说如果这段代码执行失败了要干什么,执行成功了要干什么,这个干什么
就是回调函数。就像这样:
console.log('我是同步代码1');
console.log('我是同步代码2');
// js会将下面这段异步代码交给浏览器引擎,跳过这段异步代码,接着执行后面的同步代码。
(function(successFun, failseFun){
xxxxxxxxxxxxxx
console.log('我是异步代码');
if(异步代码执行成功){
successFun(); // 这个就是回调函数
}else{
failseFun(); // 这个也是回调函数
}
})();
console.log('我是同步代码3');
上面的解释有些不太严肃,我们看一下比较严肃的描述:
javascript是单线程,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。于是就有一个概念——任务队列。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。于是JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有等主线程任务执行完毕,"任务队列"开始通知主线程,请求执行任务,该任务才会进入主线程执行。
具体来说,异步运行机制如下:
- (1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- (2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- (3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- (4)主线程不断重复上面的第三步。
回调地狱
js单线程,同步任务,异步任务,回调函数这四者组合起来当然可以解决js世界中所有的问题,但是会带来一些麻烦,这个麻烦就是:我在回调函数中继续执行异步任务。
按照我们的理解,回调函数式这样工作的:
console.log('我是同步任务1');
asynFun(callBack);
console.log('我是同步任务2');
这当然很好,可以完美解决我们的问题,但是如果这样呢:
console.log('我是同步任务1');
asynFun(function callBack() {
asynFun2(function callBack2() {
asynFun3(function callBack3(){
asynFun4...
});
});
});
console.log('我是同步任务2');
看,发生了什么,我们会发现这种场景下会无限套娃
。这种情况就是回调地狱
。
回调地狱虽然能解决我们的问题,但是它并不符合我们的阅读习惯,我们不习惯一层又一层的去阅读代码,我们还是比较习惯顺序的去阅读代码,为了解决回调地狱的问题,promise应运而生。
promise是怎么产生的
promise的产生就是为了解决回调地狱的,它是commonjs标准,然后被收录到es5中。promise之所以受到欢迎,是因为它可以链式调用,就像这样:
console.log('我是同步任务1');
asynFun().then( () => {
return asynFun2();
}).then( () => {
return asynFun3()
}).then...
console.log('我是同步任务2');
是不是这样更符合我们的阅读习惯。
js的执行时序
写到这里,js异步编程,就只有一个问题没有解释清楚了,择机执行
,到底是怎样择机的,看下面的代码:
console.log('start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise( (resolve, reject) => {
console.log('promise'),
resolve(1);
}).then((res) => {
console.log('promise value: ', res);
});
console.log('end');
上面这段代码中,promise和setTimeout都是异步任务,那么js应该先执行谁?上面这段代码的执行结果是什么?
为了解决这个问题,我们先来探究下消息队列与事件循环。
消息队列与事件循环
JavaScript 确实只有一个线程(由js引擎维护),这个线程用来解释和执行 JavaScript 代码,我们称之为“主线程”。
浏览器中还存在其它线程,例如:处理ajax、dom、定时器等,我们称他们为“工作线程”。同时浏览器还维护了一个消息队列,主线程会将执行过程中遇到的异步请求,发送给消息队列,等到主线程空闲时再来执行消息队列中的任务。
js异步执行过程中的任务队列就是消息队列。
主线程在执行过程中遇到了异步任务,就发起函数或者称为注册函数,通过event loop通知相应的工作线程,同时主线程继续往后执行,不会等待。等到工作线程完成了任务,event loop 会将消息添加到消息队列中,如果此时调用栈为空,就执行消息队列中排在最前面的消息,依次执行。
事件循环,就是主线程重复从消息队列中取消息、执行的过程。
宏任务与微任务
一个线程中,事件循环是唯一的,但任务队列可以拥有多个。任务队列由分为“宏任务”和“微任务”。
宏任务大概包括:script(整体代码)、setTimeout、setInterval、setImmediate、I/O、UI Rendering;
微任务大概包括:process.nextTick、Promise、MutationObserver(H5新特性)
举一个形象的类比: 银行业务办理
银行中一堆人在排队办理业务,按照排队的顺序柜台工作人员依次给每个人办理业务,每个人要办理的业务就是一个宏任务;
当轮到你办理业务了,你要存钱,存完之后你又想起你还想办个信用卡,顺便转个账,这个额外的业务就是一个微任务。
所以:
宏任务执行过程中产生的新的任务可以作为一个新的宏任务插入到宏任务队列(消息队列)中等待被执行;
也可以作为当前任务的微任务插入到当前任务的微任务队里中,等待当前任务结束后依次执行当前任务的微任务队列中的微任务。