vlambda博客
学习文章列表

js异步编程的解决方案全解析

js异步编程的解决方案全解析

理解什么是同步和异步?

Javascript语言的执行环境是"单线程"(single thread)。

所谓"单线程",就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。

"同步模式"就是上一段的模式,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;"异步模式"则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

"异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,"异步模式"甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。


js异步编程的发展进化:

异步最早的解决方案是回调函数,如ajax,事件的回调,setInterval/setTimeout中的回调。但是回调函数有回调地狱的问题;

    为了解决回调地狱的问题,社区提出了Promise解决方案,ES6将其写进了语言标准。Promise一定程度上解决了回调地狱的问题,但是Promise也存在一些问题,如错误不能被try catch,而且使用Promise的链式调用,其实并没有从根本上解决回调地狱的问题,只是换了一种写法。

    ES6中引入 Generator 函数,Generator是一种异步编程解决方案,Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权,Generator 函数可以看出是异步任务的容器,需要暂停的地方,都用yield语句注明。但是 Generator 使用起来较为复杂。

    ES7又提出了新的异步解决方案:async/await,async是 Generator 函数的语法糖,async/await 使得异步代码看起来像同步代码,异步编程发展的目标就是让异步逻辑的代码看起来像同步一样。

理解什么是协程?

进程和线程
众所周知,进程和线程都是一个时间段的描述,是CPU工作时间段的描述,不过是颗粒大小不同,进程是 CPU 资源分配的最小单位,线程是 CPU 调度的最小单位。

其实协程(微线程,纤程,Coroutine)的概念很早就提出来了,可以认为是比线程更小的执行单元,但直到最近几年才在某些语言中得到广泛应用。

那么什么是协程呢?
子程序,或者称为函数,在所有语言中都是层级调用的,比如 A 调用 B,B 在执行过程中又调用 C,C 执行完毕返回,B 执行完毕返回,最后是 A 执行完毕,显然子程序调用是通过栈实现的,一个线程就是执行一个子程序,子程序调用总是一个入口,一次返回,调用顺序是明确的;而协程的调用和子程序不同,协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

了解了上面这些,我们来一起总结一下异步解决方案:

1. 回调函数(Callback)

回调函数是异步操作最基本的方法。

ajax(url, () => {
// 处理逻辑
})

回调函数有一个致命的弱点,就是容易写出回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:

ajax(url, () => {
// 处理逻辑
ajax(url1, () => {
// 处理逻辑
ajax(url2, () => {
// 处理逻辑
})
})
})



回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。此外它不能使用 try catch 捕获错误,不能直接 return。


2. 事件监听

这种方式采用事件驱动模式。任务的执行不取决于代码的顺序,而取决于某个事件是否发生。
下面是两个函数f1和f2,编程的意图是f2必须等到f1执行完成,才能执行。首先,为f1绑定一个事件(这里采用的jQuery的写法)

f1.on('done', f2);

上面这行代码的意思是,当f1发生done事件,就执行f2。然后,对f1进行改写:

function f1() {
setTimeout(function () {
// ...
f1.trigger('done');
}, 1000);
}

上面代码中,f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2。

这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以"去耦合",有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程。

3. 发布/订阅

我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式"(publish-subscribe pattern),又称"观察者模式"(observer pattern)。

这个模式有多种实现,下面采用的是Ben Alman的Tiny Pub/Sub,这是jQuery的一个插件。

首先,f2向"信号中心"jQuery订阅"done"信号。

jQuery.subscribe("done", f2);

然后,f1进行如下改写:

function f1(){

setTimeout(function () {

// f1的任务代码

jQuery.publish("done");

}, 1000);

}

jQuery.publish("done")的意思是,f1执行完成后,向"信号中心"jQuery发布"done"信号,从而引发f2的执行。

此外,f2完成执行后,也可以取消订阅(unsubscribe)。

jQuery.unsubscribe("done", f2);

这种方法的性质与"事件监听"类似,但是明显优于后者。因为我们可以通过查看"消息中心",了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

4.Promises对象

Promises对象是CommonJS工作组提出的一种规范,目的是为异步编程提供统一接口。

简单说,它的思想是,每一个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数。

Promise的三种状态
Pending----Promise对象实例创建时候的初始状态
Fulfilled----可以理解为成功的状态
Rejected----可以理解为失败的状态




我们来观察一下Promise是如何解决回调地狱问题的。

function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, 'utf-8', function(err, data) {
if (err) reject(err);
resolve(data);
});
});
}
read('A').then(data => {
return read('B');
}).then(data => {
return read('C');
}).then(data => {
return read('D');
}).catch(err => {
console.log(err);
});

Promise虽然解决了回调地狱问题,但也存在一些缺点:
1)无法取消 Promise。
2)错误需要通过回调函数捕获。
3)当处于pending状态时,无法得知目前进展到哪一个阶段。

Promise对象需要注意的几点问题:
1)resolve将Promise对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果作为参数传递出去。reject也可以将Promise对象的状态结束,但是是变成“失败”(即从 pending 变为 rejected)。
2)then方法的第一个参数是resolved状态的回调函数,第二个参数(可选)是rejected状态的回调函数。一般then只用来接收resolve状态。rejected状态由catch捕获执行。
3)resolve()、reject()、then()和cacth()方法的返回值都是新的Promise对象。所以可以在后面接着使用then/catch,链式调用。
4)立即resolve()的 Promise 对象,是在本轮“事件循环”(event loop)的结束时执行,而不是在下一轮“事件循环”的开始时。

Promise给我们提供了 Promise.all 的方法:
Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

/**
* 将 fs.readFile 包装成promise接口
*/
function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, 'utf-8', function(err, data) {
if (err) reject(err);
resolve(data);
});
});
}
/**
* Promise.all 可以实现多个异步并行执行,同一时刻获取最终结果的问题
*/
Promise.all([
read(A),
read(B),
read(C)
]).then(data => {
console.log(data);
}).catch(err => console.log(err));

5. 生成器Generators/ yield

Generator 函数是 ES6 提供的一种异步编程解决方案,整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。

1)语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。
2)
Generator 函数除了状态机,还是一个遍历器对象生成函数。

3)可暂停函数, yield可暂停,next方法可启动,每次返回的是yield后的表达式结果。
4)yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。

function* generator() {
let a = yield 111;
console.log(a);
let b = yield 222;
console.log(b);
let c = yield 333;
console.log(c);
let d = yield 444;
console.log(d);
}
let t = generator();
// next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值
t.next(1); // 第一次调用next函数时,传递的参数无效
t.next(2); // 输出2
t.next(3); // 3
t.next(4); // 4
t.next(5); // 5

手动迭代Generator 函数很麻烦,实现逻辑有点绕,而实际开发一般会配合 co 库去使用。co是一个为Node.js和浏览器打造的基于生成器的流程控制工具,借助于Promise,你可以使用更加优雅的方式编写非阻塞代码。

安装co库只需:


npm install co



6. async/await

ES7中引入了 async/await 概念。async 其实是一个语法糖,它的实现就是将 Generator函数和自动执行器(co),包装在一个函数中。

async/await 的优点是代码清晰,不用像 Promise 写很多 then 链,就可以处理回调地狱的问题。并且错误可以被try catch。

使用async/await有如下特点:
1)async/await是基于Promise实现的,它不能用于普通的回调函数。

2)async/await与Promise一样,是非阻塞的。

3)async/await使得异步代码看起来像同步代码,这正是它的魔力所在。

一个函数如果加上 async ,那么该函数就会返回一个 Promise:



async function async1() {
return "1"
}
console.log(async1()) // -> Promise {<resolved>: "1"}


async/await可以说是异步终极解决方案了:

(1) async/await函数相对于Promise,优势体现在:
处理 then 的调用链,能够更清晰准确的写出代码

并且也能优雅地解决回调地狱问题。


当然async/await函数也存在一些缺点,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低,代码没有依赖性的话,完全可以使用 Promise.all 的方式。


(2) async/await函数对 Generator 函数的改进,体现在以下三点:

内置执行器。
Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。
更广的适用性。 
co 函数库约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
更好的语义。 
async 和 await,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。

异步的并发限制和超时控制:

异步解决方案成熟的第三方库有async、Step等。

Node中的异步调用有时需要控制并发数量,防止底层系统的性能出问题,一种思路是创建一个队列,每个异步调用顺序存入。设定最大并发数,如果当前活跃的异步调用数量小于最大并发数,直接取出执行,如果大于最大数量,则暂存在队列中,顺序取出调用。
超时控制可以给异步调用设置一个时间阈值,如果异步调用没有在规定时间内完成,则提示超时。

参考资料:
1. http://www.ruanyifeng.com/blog/2012/12/asynchronous%EF%BC%BFjavascript.html
2. https://www.jianshu.com/p/d7f6077a0dd2