JavaScript与异步编程
为了避免资源管理等复杂性的问题, javascript被设计为单线程的语言,即使有了html5 worker,也不能直接访问dom。javascript 设计之初是为浏览器设计的GUI编程语言,GUI编程的特性之一是保证UI线程一定不能阻塞,否则体验不佳,甚至界面卡死。一般安卓开发,会有一个界面线程,一个后台线程,保证界面的流畅。由于javascript是单线程,所以采用异步非阻塞的编程模式,javascript的绝大多数api都是异步api。关于JavaScript异步编程的详细内容可以参考我之前的一篇文章:什么是JavaScript异步编程?
总之,异步编程对 JavaScript 语言太重要。JavaScript 只有一根线程,如果没有异步编程,根本没法用,非卡死不可。而JavaScript 语言对异步编程的最早实现,就是回调函数。
回调函数
所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。它的英语名字 callback,直译过来就是"重新调用"。读取文件进行处理,是这样写的。
fs.readFile('/etc/passwd', function (err, data) {
if (err) throw err;
console.log(data);
});
上面代码中,readFile 函数的第二个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了 /etc/passwd 这个文件以后,回调函数才会执行。
一个有趣的问题是,为什么 Node.js 约定,回调函数的第一个参数,必须是错误对象err(如果没有错误,该参数就是 null)?
原因是执行分成两段,在这两段之间抛出的错误,程序无法捕捉,只能当作参数,传入第二段。
回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A文件之后,再读取B文件,再读取C文件,代码如下。
fs.readFile(fileA, function (err, data) {
if (err) throw err;
console.log(data)
fs.readFile(fileB, function (err, data) {
if (err) throw err;
console.log(data)
fs.readFile(fileC, function (err, data) () {
if (err) throw err;
console.log(data)
})
});
});
不难想象,如果依次读取多个文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。这种情况就称为“回调地狱”(callback hell)。
于是,Promise出现了。
Promise
要澄清一点,Promise
的概念并不是ES6
新出的,而是ES6
整合了一套新的写法。它不是新的语法功能,而是一种新的写法,允许将回调函数的横向加载,改成纵向加载。同样继续上面的例子,使用Promise
代码就变成这样了:
var readFile = require('fs-readfile-promise');
readFile(fileA)
.then(function(data){
console.log(data.toString());
})
.then(function(){
return readFile(fileB);
})
.then(function(data){
console.log(data.toString());
})
.then(function(){
return readFile(fileC);
})
.then(function(data){
console.log(data.toString());
})
.catch(function(err) {
console.log(err);
});
注意:上面代码使用了Node
封装好的Promise
版本的readFile
函数,它的原理其实就是返回一个Promise
对象,咱也简单地写一个:
var fs = require('fs');
var readFile = function(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) reject(err)
resolve(data)
})
})
}
module.export = readFile
而像这样的,将一个普通方法经过一系列骚操作最终变为一个Promise方法的行为,就是 Promise化
上面代码中,我使用了 fs-readfile-promise 模块,它的作用就是返回一个 Promise 版本的 readFile 函数。Promise 提供 then 方法加载回调函数,catch方法捕捉执行过程中抛出的错误。
但是,Promise
的写法只是回调函数的改进,使用then()
之后,异步任务的两段执行看得更清楚,除此之外并无新意。撇开优点,Promise
的最大问题就是代码冗余,原来的任务被Promise
包装一下,不管什么操作,一眼看上去都是一堆then()
,原本的语意变得很不清楚。
Generator
协程
在引入generator
之前,先介绍一下什么叫 协程。
所谓 "协程" ,就是多个线程相互协作,完成异步任务。协程有点像函数,又有点像线程。其运行流程大致如下:
- 第一步,协程A开始执行。
- 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
- 第三步,(一段时间后)协程B交还执行权。
- 第四步,协程A恢复执行。
function asyncJob() {
// ... 其他代码
var f = yield readFile(fileA);
// ... 其他代码
}
上面的asyncJob()
就是一个协程,它的奥妙就在于其中的yield
命令。它表示执行到此处执行权交给其他协程,换而言之,yield
就是异步两个阶段的分界线。协程遇到yield
命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点就是代码的写法非常像同步操作,如果除去 yield
命令,简直一模一样。
Generator概念
Generator
函数是协程在ES6中的实现,最大的特点就是可以交出函数的执行权(即暂停执行)。整个Generator
函数就是一个封装的异步任务,或者说就是异步任务的容器。
function* gen(x) {
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }
上面的代码中,调用Generator
函数,会返回一个内部指针(即遍历器)g,这是Generator
函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next()
方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield
语句。
换而言之,next()
方法的作用是分阶段执行Generator
函数。每次调用next()
方法,会返回一个对象,表示当前阶段的信息(value
属性和done
属性)。value
属性是yield
语句后面表达式的值,表示当前阶段的值;done
属性是一个布尔值,表示Generator
函数是否执行完毕,即是否还有一个阶段。
Generator函数的数据交换和错误处理
Generator
函数可以暂停执行和恢复执行,这是它封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的解决方案:函数体内外的数据交换和错误处理机制。
next()
方法返回值的value
属性,是Generator
函数向外输出的数据;next()
方法还可以接受参数,向Generator
函数体内输入数据。
function* gen(x) {
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }
上面的代码中,第一个next()
方法的value
属性,返回表达式x+2
的值(3)。第二个next()
方法带有参数2,这个参数可以传入Generator
函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收,因此这一步的value
属性返回的就是2(变量y的值)。
Generator
函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。
function* gen(x) {
try {
var y = yield x + 2
} catch(e) {
console.log(e)
}
return y
}
var g = gen(1);
g.next();
g.throw('出错了');
上面代码的最后一行,Generator
函数体外,使用指针对象的throw
方法抛出的错误,可以被函数体内的try...catch
代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。
Generator函数的用法
下面看看如何使用 Generator 函数,执行一个真实的异步任务。
var fetch = require('node-fetch');
function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}
上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了 yield 命令。
执行这段代码的方法如下。
var g = gen();
var result = g.next();
result.value
.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});
上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用 next 方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个next 方法。
可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。于是,就有了Co函数。
Co函数
co函数库是著名程序员 TJ Holowaychuk 于2013年6月发布的一个小工具,用于 Generator 函数的自动执行。
比如,有一个 Generator 函数,用于依次读取三个文件。
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
var f3 = yield readFile('/etc/services');
console.log(f1.toString());
console.log(f2.toString());
console.log(f3.toString());
}
co 函数库可以让你不用编写 Generator 函数的执行器。
var co = require('co');
co(gen).then(function (){
console.log('Generator 函数执行完成');
})
上面代码中,Generator 函数只要传入 co 函数,就会自动执行。co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。上面代码中,等到 Generator 函数执行结束,就会输出一行提示。
co 函数库的原理
为什么 co 可以自动执行 Generator 函数?前面文章说过,Generator 函数就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。两种方法可以做到这一点。
- 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
- Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交回执行权。
co 函数库其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个库。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或 Promise 对象。
至于什么是Thunk函数,这里就不展开描述了,因为在js中都是讲异步操作包装成Promise对象的,所以接下来着重描述co函数将异步操作包装成Promise对象的情况。当然如果又兴趣可以参考:Thunk 函数的含义和用法
co用promise的特性,将整个Generator函数包装在一个promise下,利用Generator的next链,循环调用co方法将不同的next任务分别包装为不同的子promise。根据next的状态来执行不同的resolve,进而实现自动执行。基本流程如下图
还是沿用上面的例子。首先,把 fs 模块的 readFile 方法 Promise化 一下。
var fs = require('fs');
var readFile = function (fileName){
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) reject(error);
resolve(data);
});
});
};
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
var f3 = yield readFile('/etc/services');
console.log(f1.toString());
console.log(f2.toString());
console.log(f3.toString());
};
然后,手动执行上面的 Generator 函数。
var g = gen();
g.next().value.then(function(data){
g.next(data).value.then(function(data){
g.next(data);
});
})
手动执行其实就是用 then 方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。
function Co(gen){
var g = gen();
function next(data){
var result = g.next(data);
if (result.done)
return result.value;
result.value.then(function(data){
next(data);
});
}
next();
}
Co(gen);
上面代码中,只要 Generator 函数还没执行到最后一步,next 函数就调用自身,以此实现自动执行。其实就是递归啦,原理很简单的。
co 函数库源码
co 就是上面那个自动执行器的扩展,它的源码只有几十行,非常简单。
首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。
function co(gen) {
var ctx = this;
return new Promise(function(resolve, reject) {});
}
在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为 resolved 。
function co(gen) {
var ctx = this;
return new Promise(function(resolve, reject) {
if (typeof gen === 'function')
gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function')
return resolve(gen);
});
}
接着,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulefilled 函数。这主要是为了能够捕捉抛出的错误。
function co(gen) {
var ctx = this;
return new Promise(function(resolve, reject) {
if (typeof gen === 'function')
gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function')
return resolve(gen);
onFulfilled();
function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
}
});
}
最后,就是关键的 next 函数,它会反复调用自身。
function next(ret) {
if (ret.done)
return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value))
return value.then(onFulfilled, onRejected);
return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "' + String(ret.value) + '"'));
}
})
上面代码中,next 函数的内部代码,一共只有四行命令。
- 第一行,检查当前是否为 Generator 函数的最后一步,如果是就返回。
- 第二行,确保每一步的返回值,是 Promise 对象。
- 第三行,使用 then 方法,为返回值加上回调函数,然后通过 onFulfilled 函数再次调用 next 函数。
- 第四行,在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为 rejected,从而终止执行。
终极解决Async/Await
异步操作是 JavaScript 编程的麻烦事,麻烦到一直有人提出各种各样的方案,试图解决这个问题。
从最早的回调函数,到 Promise 对象,再到 Generator 函数,每次都有所改进,但又让人觉得不彻底。它们都有额外的复杂性,都需要理解抽象的底层运行机制。
异步I/O不就是读取一个文件吗,干嘛要搞得这么复杂?异步编程的最高境界,就是根本不用关心它是不是异步。
async 函数就是隧道尽头的亮光,很多人认为它是异步操作的终极解决方案。
async 函数是什么
一句话,async 函数就是 Generator 函数的语法糖,同样是执行Generator函数,但是不用显式的调用Co函数了。
看例子:
var fs = require('fs');
var readFile = function (fileName){
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) reject(error);
resolve(data);
});
});
};
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
var f3 = yield readFile('/etc/services');
console.log(f1.toString());
console.log(f2.toString());
console.log(f3.toString());
};
function Co(gen){
var g = gen();
function next(data){
var result = g.next(data);
if (result.done)
return result.value;
result.value.then(function(data){
next(data);
});
}
next();
}
Co(gen);
写成 async 函数,就是下面这样。
var asyncReadFile = async function (){
var f1 = await readFile('/etc/fstab');
var f2 = await readFile('/etc/shells');
var f3 = await readFile('/etc/services');
console.log(f1.toString());
console.log(f2.toString());
console.log(f3.toString());
};
发现了吧,async
函数就是将Generator
函数的*
替换成了async
,将yield
替换成await
,除此之外,还对 Generator
做了以下四点改进:
- 内置执行器。
Generator
函数的执行比如靠执行器,所以才有了co
模块等异步执行器,而async
函数是自带执行器的。也就是说:async
函数的执行,与普通函数一模一样,只要一行:
var result = asyncReadFile();
- 上面的代码调用了
asyncReadFile()
,就会自动执行,输出最后结果。这完全不像Generator
函数,需要调用next()
方法,或者使用co
模块,才能得到真正执行,从而得到最终结果。 - 更好的语义。
async
和await
比起星号和yield
,语义更清楚。async
表示函数里有异步操作,await
表示紧跟在后面的表达式需要等待结果。 - 更广的适用性。
async
函数的await
命令后面可以是Promise
对象和原始类型的值(数值、字符串和布尔值,而这是等同于同步操作)。 - 返回值是
Promise
,这比Generator
函数返回的是Iterator
对象方便多了。你可以用then()
指定下一步操作。
不知道你看到这里,有没有一种...额...算了。反正从Promise => Generaor => Co => Async/Await。所有的代码都是为了异步编程能够优雅的实现,代码量也经历了一个从少到多再到精炼的过程。
async 函数的实现
async 函数的实现,就是将 Generator 函数和自动执行器,包装在一个函数里。
async function fn(args){
// ...
}
// 等同于
function fn(args){
return spawn(function*() {
// ...
});
}
所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器。下面给出 spawn 函数的实现,基本就是前文自动执行器的翻版。
function spawn(genF) {
return new Promise(function(resolve, reject) {
var gen = genF();
function step(nextF) {
try {
var next = nextF();
} catch(e) {
return reject(e);
}
if(next.done) {
return resolve(next.value);
}
Promise.resolve(next.value).then(function(v) {
step(function() { return gen.next(v); });
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() {
return gen.next(undefined);
});
});
}
async 函数的用法
同 Generator 函数一样,async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再接着执行函数体内后面的语句。下面是一个例子。
async function getStockPriceByName(name) {
var symbol = await getStockSymbol(name);
var stockPrice = await getStockPrice(symbol);
return stockPrice;
}
getStockPriceByName('goog').then(function (result){
console.log(result);
});
上面代码是一个获取股票报价的函数,函数前面的async关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个Promise对象。下面的例子,指定多少毫秒后输出一个值。
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value)
}
asyncPrint('hello world', 50);
上面代码指定50毫秒以后,输出"hello world"。
注意
await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try...catch 代码块中。
async function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}
// 另一种写法
async function myFunction() {
await somethingThatReturnsAPromise().catch(function (err){
console.log(err);
});
}
await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。
async function dbFuc(db) {
let docs = [{}, {}, {}];
// 报错
docs.forEach(function (doc) {
await db.post(doc);
});
}
上面代码会报错,因为 await 用在普通函数之中了。但是,如果将 forEach 方法的参数改成 async 函数,也有问题。
async function dbFuc(db) {
let docs = [{}, {}, {}];
// 可能得到错误结果
docs.forEach(async function (doc) {
await db.post(doc);
});
}
上面代码可能不会正常工作,原因是这时三个 db.post 操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用 for 循环。
async function dbFuc(db) {
let docs = [{}, {}, {}];
for (let doc of docs) {
await db.post(doc);
}
}
如果确实希望多个请求并发执行,可以使用 Promise.all 方法。
async function dbFuc(db) {
let docs = [{}, {}, {}];
let promises = docs.map((doc) => db.post(doc));
let results = await Promise.all(promises);
console.log(results);
}
// 或者使用下面的写法
async function dbFuc(db) {
let docs = [{}, {}, {}];
let promises = docs.map((doc) => db.post(doc));
let results = [];
for (let promise of promises) {
results.push(await promise);
}
console.log(results);
}