1. 异步
Javascript中程序是分块执行的,块的最常见单位是函数,在Javascript引擎执行的时候,通常最少存在一个现在正在执行的块和一个将要执行的块,对于异步的分块执行,最简单的方式就是回调。
function f1() {
console.log(1);
}
function f2() {
console.log(2);
}
// 当开始执行f1的时候,f1就是当前执行块,setTimeout(f1,0)和f2()就是将来要执行的块
f1();
// 使用了setTimeout并将f1作为了回调函数,创建了一个异步的块
setTimeout(f1, 0);
f2();
1.1 事件循环
事件循环是Javascript引擎用于处理多块程序执行的机制。主要实现是在Javascript中存在一个事件循环队列,然后会将需要运行的块加入到该队列中,之后等待触发运行,同时存在一个无限循环监听该队列的变化,并触发方法执行。
// 先进先出的队列
var eventLoop = [];
var event;
while(true) {
if (eventLoop.length > 0) {
// 获取队列中的某个事件
event = eventLoop.shift();
// 事件执行
try {
event();
} catch(e) {
doError(e);
}
}
}
1.2 并行
并行是时间点的概念,是指某个时间点多个事情同时执行,通常多线程才存在并行的能力,事情的运行结果存在不确定性,即两个或者多个线程同时操作同一份数据,那么数据结果就会不确定。
var a = 1;
function f1() {
a = a + 1;
}
function f2() {
a = a + 2;
}
// ajax是某个异步函数
ajax(f1);
ajax(f2);
// 如果f1和f2是两个线程同时并行
线程1(X和Y是临时内存地址)
f1:
a. 把a的值保存在X
b. 把1的值保存在Y
c. 执行X+Y
d. 把结果保存到a
线程2(X和Y是临时内存地址)
f1:
a. 把a的值保存在X
b. 把2的值保存在Y
c. 执行X+Y
d. 把结果保存到a
/* 由于多线程并行,所以线程1中和线程2中的步骤是可以按照任意方式组合
于是对于操作同一份数据a就存在了不确定性
*/
// 例如顺序1:
1a -> 1b -> 1c-> 1d -> 2a -> 2b -> 2c -> 2d 那么最后a的结果是4
// 如果假设顺序2:
1a -> 2a -> 1b -> 1c -> 1d -> 2b -> 2c -> 2d 那么最后a的结果就是3
// 当然还有其他很多顺序组合
由于Javascript是单线程的,所以在函数运行的时候具有原子性和完整性,也就是说在回调函数f1
和f2
执行的时候,如果f1
开始执行,那么在f1
执行完成之前,f2
不会进行执行,所以不存在多线程导致的不确定性。
但是Javascript同样存在不确定性,其不确定性来自异步的回调函数执行时间,这种不确定性也称为竞态条件,也就是说对于上面的例子,这里只存在先执行f1
,还是先执行f2
导致的结果不确定性。
1.3 并发
并发是时间间隔的概念,是指某个时间间隔内可以处理多件事情的能力。
知乎上有一个很通俗的例子关于并行和并发的区别:
非并发:吃饭的时候接到电话,需要先把饭吃完,才能接电话
并发:吃饭的时候接到电话,中断吃饭接电话,接完电话吃饭
并行:吃饭的时候接到电话,一边吃饭一边接电话(快速切换上下文,其实并不能算是很严格的并行,看似吃饭和打电话同时进行)
并发有三种常见的情况
1.3.1 非交互
两个运行函数之间没有任何关联,独自运行不会对结果产生影响
var a,b;
function f1() {
a = 1;
}
function f2() {
b = 1;
}
ajax(f1)
ajax(f2)
1.3.2 交互
两个运行函数之间存在关联,运行顺序对结果会产生影响
// 由于回调的不确定性,所以最后a的值可能是1,可能是2
var a ;
function f1() {
a = 1;
}
function f2() {
a = 2;
}
ajax(f1);
ajax(f2);
通常为了控制结果,会设置竞态条件
var a,b ;
function f1() {
a = 1;
foo(); // 如果直接输出,可能此时b未被赋值,所以会返回NaN
}
function f2() {
b = 2;
foo(); // 如果直接输出,可能此时a未被赋值,所以会返回NaN
}
function foo() {
console.log(a + b);
}
ajax(f1);
ajax(f2);
// 使用竞态条件确保输出结果
var a,b ;
function f1() {
a = 1;
// 添加竞态条件
if(a&&b) {
foo();
}
}
function f2() {
b = 2;
// 添加竞态条件
if(a&&b) {
foo();
}
}
function foo() {
console.log(a + b);
}
ajax(f1);
ajax(f2);
1.3.3 协作
Javascript的单线程操作具有原子性,那么当某个方法执行可能会持续占用引擎,于是我们通常会考虑执行一部分以后释放资源,使事件循环队列中的其他内容可以先执行,不断切换执行上下文。利用setTimeout函数,可以让我们实现这样的效果
function res(datas) {
for(let i = 0; i< datas.length; i++) {
if (i === 1000) {
// 释放当前资源,尝试将回调重新加入事件循环队列尾部
setTimeout(_ => {res(datas.slice(0,1000))} , 0);
break;
}
}
}
1.4 任务队列
任务队列是建立在事件循环队列基础上,区别在于事件循环队列每次只能将事件添加到队列的尾部;任务队列则是在一个事件循环队列触发下一次tick前执行,可以不断在插入内容,从而使得事件循环的下一次tick延迟。
2. 回调
2.1 回调的执行
Javascript中实现异步分块执行的最简单的方式就是回调。当异步操作结束的时候,回调函数会被放到事件循环队列中,注意,这里不是异步操作结束的时候执行回调函数,而是将其放到事件循环队列中等待执行。
也就是说,当异步结束的时候,回调函数并非是立即执行,而是根据Javascript事件循环机制来进行执行,其具体运行时间不可预知。
function f1() {
console.log(1);
}
// 这里设置1000ms是指在1000ms后将f1方法放到事件队列中,并不是1000ms后就立即执行回调方法
setTimeout(f1, 1000);
2.2 缺陷
回调很简单,但是回调在处理Javascript操作的时候存在一些缺陷
2.2.1 回调地狱
由于异步的不确定性,当我们需要依次使用回调结果的时候,不可避免的就必须要使用嵌套的方式
ajax(url1, function(url2){
ajax(url2, function(url3)){
ajax(url3, function(data){
// 做某些内容
})
})
});
这种嵌套一方面带来的问题是代码上阅读的困难,另一方面没办法对某些操作进行统一的处理,例如:异常处理,日志操作等
2.2.2 信任问题
在使用第三方异步方法的时候,由于只能进行回调函数的传递,那么我们不能确保第三方异步方法如何对回调进行调用,可能存在多次调用回调的情况,从而导致结果和预期不相符
function f() {
console.log(1);
}
ajaxF(f);
// 第三方提供的ajaxF,可能我们并不知道第三方调用回尝试重连
// 所以可能导致我们的回调调用多次,当然这个问题可以通过沟通解决
var i = 0;
function ajaxF(fn) {
ajax(data=>{
// 失败时且请求次数小于3尝试重新请求
if (data.fail && i<3){
i++;
// 多次调用回调
fn(data.fail);
ajaxF(fn);
} else {
fn(data.success);
}
})
}
2.3 优化
回调自身存在一些缺陷,我们通过一些处理手段可以提升回调的可读性,但是这种优化并不能从根本上解决目前回调带来的困境,我们需要使用新的异步方案来替代回调,当然回调仍然是最简单的Javascript异步处理方法
优化一:将异常和成功回调分离
function fail() {
console.log('fail')
}
function succ() {
console.log('succ');
}
ajax(succ, fail);
优化二:first-error风格回调
function foo(err, data) {
if(err) {
// 处理错误逻辑
} else {
// 处理正常逻辑
}
}
ajax(foo);
3. 小结
Javascript异步最基础的内容是事件循环,所以最基本的是了解事件循环机制,在了解的基础上再去处理所有和异步相关的问题都会变的简单。
回调是最简单的Javascript异步解决方案,但是其自身存在一些不方便使用的地方,在较复杂的时候,我们需要更好的异步方案来替代。
4. 参考
《你不知道的Javascript(中篇)》