JavaScript异步机制初探

851 阅读17分钟

在JavaScript的实现中,代码执行机制分为同步执行和异步执行两种。这里总结下我对这两种执行机制的初步理解。

同步机制

同步执行最符合人类大脑的思维模式,通常是:“先...然后...然后...”。

示例1:

const a = 1; //step1
const b = 2; //step2
const c = a + b; //step3 3
const d = a * b; //step4 4

就像示例一样,代码是一步一步线性执行的,理解最是简单、方便。

那么同步机制执行代码会带来的主要问题是:性能问题、在js引擎单线程情况下可能导致线程阻塞。

而js对于这些问题的解决方案是引入异步机制

异步机制

js引入异步机制,有效的解决了同步机制带来的性能和阻塞的问题。

异步概念

程序的一部分现在运行,另一部分将来运行。现在和将来直接有段间隙,在这段间隙中,程序没有活跃执行。

示例2:

//现在执行start
const a = 1; //step1
const b = 2; //step2
setTimeout(funciton(){
	//将来执行start
	const c = a + b; //step3 3
	const d = a * b; //step4 4
    //将来执行end
},1000);
dosomething();
//现在执行end

这段示例将同步执行的代码分成了两部分,第一部分声明a,b变量后,通知浏览器1000ms之后将第二部分推送给js引擎,之后执行dosomething函数。

1000ms后,浏览器推送第二部分给js引擎,js引擎执行代码

异步机制的实现

事件循环

js引擎实现异步机制的方案:事件循环。

事件循环一开始是宿主环境(浏览器、node)的实现,之后为了精细控制异步任务,js引入Promise,事件循环也被正式纳入js引擎的势力范围。

示例3:

let eventLoop=[];
let event;
while(true){
	//一次tick
    if(eventLoop.length>0){
        event=eventLoop.shift();
        try{
            event();
        }
        catch(err){
            reportError(err);
        }
    }
}

示例展示的伪代码有一个用while循环实现的持续运行的循环,一个消息队列eventLoop,每一次循环称为一个tick,每个tick都会执行消息队列中的一个任务。至于任务的添加,那是由事件驱动的。

宏任务macroTask

消息队列中的任务都是宏任务。宏任务的组成:

  1. JavaScript脚本执行事件;

  2. 用户交互事件(如鼠标点击、滚动页面、放大缩小等);

  3. 网络请求完成、文件读写完成事件等等;

  4. 渲染事件(解析DOM、计算布局、绘制等等)

微任务microTask

宏任务中有微任务队列。

产生微任务方式:

  1. 使用MutationObserver监听某个DOM节点,之后通过js来进行DOM操作,当这个节点发生变化,会产生记录DOM变化的微任务(也就是相关的回调函数或者默认的操作)
  2. Promise,当Promise决议的时候,会产生微任务。可以理解为new promise是定义一个事件,then和catch是对事件的监听函数,then和catch的参数(回调函数)就是微任务。

微任务执行时机:当前宏任务执行完毕,会检查微任务队列,并依次执行。如果在执行微任务期间又产生微任务,则追加到当前微任务队列。

示例4:

console.log(1);
new Promise(function pr(resolve,reject){
    console.log(2);
    var time1 = setTimeout(function callback1(){
        resolve(2);
	},10000);
	console.log(3);
}).then(function fulfilled(data){
    console.log(4);
    console.log("data",data);
});
console.log(5);
var time2=setTimeout(function callback2(){
	console.log(6);
},1000);
console.log(7);
//执行结果
//1
//2
//3
//5
//7
//6
//4
//data 2

一些术语

异步:关于程序现在和将来的时间间隙。程序一部分现在运行,一部分将来运行。

并行:关于能够同时发生的事情,可以分为运算级并行和任务级(进程级)并行。

并发:两个或者多个任务(进程)同时执行。并发是任务(进程)级别的并行。

多线程并行的问题:允许对内存的并行访问和修改,容易导致程序执行结果不符合预期,增加程序复杂度,程序的不确定性发生在运算级别。

单线程并行:单线程不允许对内存的并行访问和修改,而是通过和其他线程或者进程配合,同时执行任务,但是对任务的响应是通过事件循环一个一个执行。

js函数具有原子性,不会像多线程一样被中断,能够完整运行。但是多个函数运行的顺序不确定,因此js的不确定性发生在函数级别

异步交互

由于异步任务执行顺序的不确定性,如果多个异步任务之间是独立的,则不需要关心这种不确定性。如果多个异步任务需要对共同的内存进行访问和修改,那么这种函数级别的不确定性可能导致和预期不一致的结果。

竞态条件

js函数级别执行顺序的不确定性,就是竞态条件。

规避竞态条件的方法:根据异步任务响应的结果来协调对内存的访问和修改方式。

示例5:

var result=[];
function response(data){
    if(data.url==='url1'){
        result[0]=data.result;
    }
    if(data.url==='url2'){
		result[1]=data.result;
    }
}
ajax('url1',response);
ajax('url2',response);

并发协作

将长时间运行的单个异步任务分割成多个步骤或者多批任务,使得其他异步任务有机会将自己的运算插入到事件循环中交替执行。

异步模式

js实现的异步模式,从基础的事件回调,发展到Promise和生成器,再到进一步async...await。逐步的完善了js的异步机制。

回调

回调模式是js最基础的异步实现方式

示例6:

//A
setTimeout(funciton(){
	       //C
},1000);
//B

回调函数封装了程序的延续(将来执行的部分)。例如示例展示的:先执行A部分,设置1000ms定时器后执行B部分,定时器到时立刻执行C部分。

回调模式的问题:

  1. 回调函数执行的机制并非线性,执行步骤和代码顺序并不匹配,不符合思维习惯,增加了心智的负担。(正如示例展示的)
  2. 增加了任务链理解负担,也就是俗称的回调地狱。
  3. 信任问题:回调函数由第三方控制,容易产生异常。

回调地狱示例7:

doA(function(){
	doC();
    doD(function(){
        doE();
	});
    doF();
});
doB();

由于不确定这些函数是同步执行还是异步执行,执行顺序是有多种可能的,这需要开发人员自己去排查。而在实际项目中,由于项目的复杂性,回调模式会导致排查的困难。这就是回调模式容易导致地狱回调的原因。

Promise

Promise出现的原因主要是为了更好的管理异步执行机制,解决回调模式缺乏顺序性和回调产生的信任问题。

对Promise决议的监听是异步的,js引擎将监听函数设置在微任务队列中,js引擎保证了Promise的异步执行。

Promise是什么
  1. Promise是一种封装和组合未来值(未来值:异步任务的结果值)的易于复用的机制。
  2. 另一种理解是将Promise当成一个事件,then中的回调函数和catch中的回调函数视作对这个事件的成功、拒绝、异常这三种情况的监听处理函数。
Promise解决时序性

Promise是一种形如this...then...that的链式执行顺序,相比回调函数离散的分布导致的理解困难,链式流程控制机制已经是一种比较好的改进。

之所以Promise可以使用链式流程控制任务执行步骤,是因为Promise规范了异步机制,并封装了未来值,链式流程控制是Promise机制的附加好处,而不是主要目的。

then和catch都会返回一个Promise,then第一个回调函数返回值会被设置为then产生的Promise的resolve决议的值,通过这种关联可以链接Promise

示例8:

var p1 = new Promise(function(resolve,reject){
    resolve('success');
});
p1.then(function fulfilled(data){
    console.log('成功1',data);
    return new Promise(function(resolve,reject){
        setTimeout(function(){
            resolve(100);
		},100);
    });
},function reject(error){
	console.log('失败1',error)
}).then(function fulfilled(data){
    //延迟100ms执行
    console.log('成功2',data)
},function reject(error){
    //除非第一个then的两个回调处理函数异常,否则不会执行
	console.log('失败2',error)
})
Promise解决信任问题

Promise对于信任问题的解决,是通过第三方任务告知我们任务何时结束,返回值是什么,由我们自己的代码决定下一步的操作,是对回调函数的控制反转。

根据Promise的定义:

  1. Promise如果决议的话,只会决议一次,能够避免类似回调函数可能过少执行或者过多执行。

  2. Promise如果没有决议的话,可以通过race设置超时处理。

  3. Promise的决议是无法同步观测的,也就是说对决议的监听和异常处理也是异步的,这样能避免类似回调函数可能同步处理,可能异步处理导致的问题。

Promise异常处理

JavaScript中的异常处理方式通常是try...catch,但是这种方式无法在外部捕获异步的异常,只能在异步方法内部去捕获。

示例9:

function foo(){
    setTimeout(function(){
        throw new Error('setTimeout error')
    },100);
}
try{
    foo();
}catch(error){
    //无法捕获到异常
    console.log(error);
}
//正确方式
function bar(){
    setTimeout(function(){
        try{
            throw new Error('setTimeout error')
        }catch(error){
            console.log(error);
        }
    },100);
}

Promise的异常处理方式主要是:

  1. then(fulfilledFn,rejectedFn)的第二个拒绝回调,promise内部显式的拒绝决议和异常都会执行then中的rejectedFn。如果忽略第二个回调,那么then会执行默认回调,向Promise执行链抛出异常,由下一个监听函数出发。但是不会被外部try...catch捕获
  2. catch(cb),给Promise链最后追加catch捕获,那么整个Promise链如果没有处理的异常,最终会在这里捕获
异常忽略

Promise的异常还是可能被忽略的(即不被处理)。例如没有给then注册拒绝监听函数,也没有在最终进行catch捕获,或者在catch捕获中又触发异常。

Promise API
构造函数

Promise(function(resolve,reject){})

构造函数第一个参数是一个函数,该函数立刻执行,Promise决议可以调用resolve(可能是成功决议,可能是拒绝决议)或者reject(拒绝决议)。

示例10:

//构造一个Promise
var p = new Promise(function(resolve,reject){
    resolve(Promise.reject('异常')) //拒绝决议,返回拒绝理由
});
p.then(function fulfilled(){
	//不会执行到这里
},function rejected(err){
	console.log(err); // '异常'
})。
then(fulfilledFn,rejectedFn)

then函数返回一个Promise,fulfilledFn是处理then监听的Promise的成功回调,rejectedFn是对舰艇的Promise的拒绝回调。如果这两个回调函数参数内部拒绝决议或者异常报错,则then返回的Promise被拒绝;否则为成功决议,决议值是返回值,没有返回值是undefined;

catch(cb)

用于Promise异常捕获,给Promise链最后追加catch捕获,那么整个Promise链如果没有处理的异常,最终会在这里捕获

静态API
  1. Promise.race([Promise1,Promise2...]),Promise任务并发执行,第一个Promise决议则视为整体成功决议,全部决议失败否则视为决议失败。这个api可以用来实现Promise的超时处理
  2. Promise.all([Promise1,Promise2...]),Promise任务并发执行,所有Promise都决议成功则视为整体成功决议,否则视为决议失败
  3. Promise.resolve(param),返回一个已经决议的Promise,如果参数是一个Promsie或或者thenable,那么会异步展开这个对象,其决议值是最终的Promise的决议值;如果是一个普通变量,则决议值就是该值。
  4. Promise.reject(param),返回一个已经决议拒绝的Promise。决议值就是param,不会去做什么异步展开。
Promise的局限性
  1. 异常忽略,正如前文描述的异常处理的缺陷。
  2. 无法取消的Promise。一旦创建了一个Promise并为其注册了完成和拒绝处理函数,如果这个Promise没有决议的话,无法从外部停止这个Promise。
  3. 链式流程控制,依旧繁琐。

生成器

生成器构成的异步流程,是一种十分符合大脑思维模式的代码控制机制,很好的解决了回调模式导致的理解困难问题。相较于Promise的链式流程控制,生成器模式更加符合思维模式。

普通函数和生成器函数

JavaScript中普通函数的执行是无法打断的,会完整运行。但是生成器函数不同,它是可以中断一段时间再执行的,在这中间可以插入其他任务。不同于回调模式和Promise模式,这是异步模式的另一种实现。

示例11:

function *foo(){
    const a = 1;
    yield;
    const b = yield 2;
    const c = a + b;
}
const it = foo();
const step1 = it.next(); //step1 {value:undefined,done:false}
//doOtherthings();
const step2 = it.next(); //step2 {value:2,done:false}
const step3 = it.next(3); //step3 {value:4,done:true}

生成器函数前缀需要*修饰,执行生成器函数会返回一个迭代器对象,执行next方法可以对迭代器进行迭代,yield关键字终止程序执行,并可以想外传递消息和接受外部消息。

生成器函数可以多次执行,每次执行都会生成一个独立的迭代器。

生成器双向通信

yield表达式可以将消息传递到,yield表达式相当于将消息通知给调用方,并等待调用方返回表达式的结果值。

n个yield语句将生成器拆分成多个n+1个部分,需要进行n+1次迭代才能消耗完整个迭代器。

next函数对迭代器进行迭代,直到遇到yield,暂停执行,并将yield表达式的值传递到外层。每个next函数等待yield语句的值,最后一个next函数的值由return返回。

迭代器和iterable(可迭代)

迭代器:实现包含next函数的接口的对象。迭代器对象中的next方法返回值保存在value属性,done属性值代表迭代器是否消耗完毕。

iterable:一个包含可以在值上进行迭代的迭代器的对象。在ES6中,从一个iterable中提取迭代器的方法:iterable实现Symbol.iterator,调用这个函数会返回一个迭代器。

示例12:

function foo(){
    var value=1;
    var iterator = {
        next: function(){
            value = value * 2;
            return { value: value, done: false };
		}
    };
	return {
        [Symbol.iterator]:function(){
            return iterator;
        }
    };
};
var iterable = foo();
for(var i of iterable){
    console.log(i);
    //避免死循环
    if(i>10){
        break;
    }
}
生成器委托

示例13:

function *foo(){
    console.log('foo');
    yield 1;
    yield 2;
    yield 3;
	return 'foo end'
}
function *bar(){
    console.log('bar');
    yield 0;
    const value = yield *foo();
	console.log('value',value) 
    yield 4;
}
const it=bar();
for(let i of it){ //for...of会消耗iterable对象
    console.log(i);
}
//bar
//0
//foo
//1
//2
//3
//value foo end
//4

所谓生成器委托,其实就是将当前生成器代码的控制转移给委托的生成器函数,直到消耗完委托的生成器。此时调用委托生成器函数的yield的结果值是委托生成器的返回值,而不是通过next()传递进来的。

生成器委托主要用于代码的组织。类似于普通函数,将部分代码结构封装起来,供其他生成器调用。

异常处理

生成器异常处理这种看似同步的模式,在可读性和可理解性上是很符合我们的思维的。

  1. 将异常抛入迭代器内部:可以通过可迭代对象的throw方法,将异常抛入到迭代器内部处理,内部使用try...catch捕获
  2. 外部捕获:迭代器内部异常未处理,可以通过外部try...catch捕获
  3. 当使用生成器委托的时候,被委托的生成器如果发生异常,其处理方式同1、2

情况1示例14:

function ajax(){
    request('https://baidu.com',{
		success:function(data){
           console.log(data);
            it.next();
        },
		error:function(err){
            it.throw(err);// 异步任务发生异常,通过it.throw()方法将异常抛入到生成器中处理
        }
	})
}
function *foo(){
    try{
        const result = yield ajax(); //ajax是异步任务
    }catch(err){
        console.log(err);
    }
}
var it=foo();
foo.next();

情况2示例15:

function *foo(){
    console.log('foo');
    yield 1;
    throw new Error('foo err'); //生成器内部异常未捕获,抛出到外部。
    yield 2;
    yield 3;
    return 'foo end';
}
const it=foo();
try{
    for(let i of it){
        console.log(i);
    }
}catch(err){
	console.log(err)
}
Promise+生成器

示例16:

function ajax(){
	return request('https://baidu.com'); //request返回一个Promise
}
function *foo(){
    try{
        const result = yield ajax();
    }catch(err){
        console.log(err);
    }
}
//依旧是较为繁琐的调用
var it = foo();
const p = foo.next();
p.then(function filfulled(data){
    console.log(data);
    it.next();
},function rejected(msg){
    it.throw(msg);
})

Promise的可信任型和可组合型(链式调用),以及结合生成器的类似同步的执行机制。这是ES6之前的很好的异步模式解决方案。

当然,这里的问题是还需要自己去主动的调用生成器产生迭代器,手动控制迭代。而es6的async...await模式是js引擎在语法层面的解决方案,相当于自动执行Promsie+生成器的异步流程。

而在async...await方案还没有落地的时候,第三方库提供了一些自动执行生成器的方案(Thunk和co模块)。

自执行的生成器

示例17:

function run(gen){
    var args=[].slice.call(arguments,1);
    var it;
    //在当前上下文中初始化生成器
    it=gen.apply(this,args);
    //返回一个promise用于生成器完成
    return Promise.resolve().then(function handleNext(value){
        //对下一个yield出的值运行
        var next = it.next(value);
        return (function handleResult(next){
            //生成器运行完毕了吗?
            if(next.done){
                return next.value;
            }
            //否则继续运行
			else{
                return Promise.resolve(next.value).then(
                    //成功就回复异步执行,把决议的值返回给生成器
                    handleNext,
                    //如果value是被拒绝的promise,就把错误传回给生成器惊醒出错处理
                    function handleErr(err){
                        return Promsie.resolve(it.throw(err)).then(handleResult);
                    }
                )
            }
        })(next);
    });
}
run(foo); //foo即是示例16中定义的生成器函数

ES6异步模式async...await

可以理解为Promise+生成器+自执行机制的语法糖,在编译阶段就已经完成了对迭代器的消费方式。不必像生成器函数那样在外部编码控制迭代器的消耗。

类似生成器模式,实现了已同步方式编写异步代码。

async

async关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上。

async函数内部同Promise的执行器函数一样,内部是同步执行的。见示例18。

async函数返回值是Promise,如果显式返回非Promise的值,会被Promise.resolve()包装成Promise,如果程序异常或者内部有一个await跟着一个拒绝的决议Promise.reject,整个async函数返回拒绝决议的Promise。

示例18:

async function foo(){
    console.log(1);
}
foo();
console.log(2);
//1
//2
await

await是一种类似yield的处理机制,可以暂停和恢复异步代码的执行。

await是一元操作符,其后跟着的操作数如果是非Promise对象,则会由Promise.prototype.resolve包装成Promise。如果是Promsie,则会等待Promise决议完成。

重要的是,await操作总是异步执行的,哪怕操作数是基本对象。

await只能出现在async函数中。

示例19:

async function foo(){
    console.log(1);
    const a=await 2;
    console.log(2);
}
console.log(3);
foo();
console.log(4);
//3
//1
//4
//2
异常处理

async函数内部异常,处理方式和生成器函数不同,不会被外部捕获,而是和Promise的异常处理机制一样,需要由Promise.prototype.then或者Promise.prototype.catch 监听函数处理,原因是async内部的实现已经捕获了异常,并且以Promise.reject方式处理了,所以不会向外部抛出异常。

当然,也可以在async函数内部使用try...catch捕获异常。

示例20:

async function foo(){
    throw new Error(1);
}
async function bar(){
    const p = await new Promise((resolve,reject)=>{
        reject(2)
    });
    console.log(p)
}
//错误的异常监听 => 同步的异常处理无法捕获
try{
    foo()
}catch(err){
    console.log('foo',err)
}
try{
    bar()
}catch(err){
    console.log('bar',err)
}
//正确的异常监听
var p1=foo();
var p2=bar();
p1.catch(data=>{
    console.log('foo err',data)
});
//输出 foo err + 调用栈信息
p2.catch(data=>{
    console.log('bar err',data)
});
//输出bar err 2

//或者在async内部捕获,推荐使用这种异常处理机制
async function bar(){    
    try{
        const p=await new Promise((resolve,reject)=>{
            reject(2)
        });
        console.log(3,p); //不会执行
    }catch(err){
        console.log('catch',err); //异常捕获,输出catch 2
    }
}
bar();