JS异步编程

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

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

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

为了解决以上问题,就可以使用异步编程。

Javascript异步编程方法

ES 6以前:

* 回调函数
* 事件监听(事件发布/订阅)
* Promise对象

ES 6:

* Generator函数(协程coroutine)

ES 7:

* async和await

PS:如要运行以下例子,请安装node v0.11以上版本,在命令行下使用 node [文件名.js] 的形式来运行,有部分代码需要开启特殊选项,会在具体例子里说明。

1.回调函数

回调函数在Javascript中非常常见,一般是需要在一个耗时操作之后执行某个操作时可以使用回调函数。

example 1:

 1 //一个定时器 2 function timer(time, callback){ 3 setTimeout(function(){ 4 callback(); 5 }, time); 6 } 7 8 timer(3000, function(){ 9 console.log(123);10 })

example 2:

1 //读文件后输出文件内容2 var fs = require(‘fs‘);3 4 fs.readFile(‘./text1.txt‘, ‘utf8‘, function(err, data){5 if (err){6 throw err;7 }8 console.log(data);9 });

example 3:

1 //嵌套回调,读一个文件后输出,再读另一个文件,注意文件是有序被输出的,先text1.txt后text2.txt2 var fs = require(‘fs‘);3 4 fs.readFile(‘./text1.txt‘, ‘utf8‘, function(err, data){5 console.log("text1 file content: " + data);6 fs.readFile(‘./text2.txt‘, ‘utf8‘, function(err, data){7 console.log("text2 file content: " + data);8 });9 });

example 4:

 1 //callback hell 2 3 doSomethingAsync1(function(){ 4 doSomethingAsync2(function(){ 5 doSomethingAsync3(function(){ 6 doSomethingAsync4(function(){ 7 doSomethingAsync5(function(){ 8 // code... 9 });10 });11 });12 });13 });

通过观察以上4个例子,可以发现一个问题,在回调函数嵌套层数不深的情况下,代码还算容易理解和维护,一旦嵌套层数加深,就会出现“回调金字塔”的问题,就像example 4那样,如果这里面的每个回调函数中又包含了很多业务逻辑的话,整个代码块就会变得非常复杂。从逻辑正确性的角度来说,上面这几种回调函数的写法没有任何问题,但是随着业务逻辑的增加和趋于复杂,这种写法的缺点马上就会暴露出来,想要维护它们实在是太痛苦了,这就是“回调地狱(callback hell)”。
一个衡量回调层次复杂度的方法是,在example 4中,假设doSomethingAsync2要发生在doSomethingAsync1之前,我们需要忍受多少重构的痛苦。

回调函数还有一个问题就是我们在回调函数之外无法捕获到回调函数中的异常,我们以前在处理异常时一般这么做:

example 5:

1 try{2 //do something may cause exception..3 }4 catch(e){5 //handle exception...6 }

在同步代码中,这没有问题。现在思考一下下面代码的执行情况:

example 6:

 1 var fs = require(‘fs‘); 2 3 try{ 4 fs.readFile(‘not_exist_file‘, ‘utf8‘, function(err, data){ 5 console.log(data); 6 }); 7 } 8 catch(e){ 9 console.log("error caught: " + e);10 }

你觉得会输出什么?答案是undefined。我们尝试读取一个不存在的文件,这当然会引发异常,但是最外层的try/catch语句却无法捕获这个异常。这是异步代码的执行机制导致的。

Tips: 为什么异步代码回调函数中的异常无法被最外层的try/catch语句捕获?

异步调用一般分为两个阶段,提交请求和处理结果,这两个阶段之间有事件循环的调用,它们属于两个不同的事件循环(tick),彼此没有关联。

异步调用一般以传入callback的方式来指定异步操作完成后要执行的动作。而异步调用本体和callback属于不同的事件循环。

try/catch语句只能捕获当次事件循环的异常,对callback无能为力。

也就是说,一旦我们在异步调用函数中扔出一个异步I/O请求,异步调用函数立即返回,此时,这个异步调用函数和这个异步I/O请求没有任何关系。

 

2.事件监听(事件发布/订阅)

事件监听是一种非常常见的异步编程模式,它是一种典型的逻辑分离方式,对代码解耦很有用处。通常情况下,我们需要考虑哪些部分是不变的,哪些是容易变化的,把不变的部分封装在组件内部,供外部调用,需要自定义的部分暴露在外部处理。从某种意义上说,事件的设计就是组件的接口设计。

example 7:

 1 //发布和订阅事件 2 3 var events = require(‘events‘); 4 var emitter = new events.EventEmitter(); 5 6 emitter.on(‘event1‘, function(message){ 7 console.log(message); 8 }); 9 10 emitter.emit(‘event1‘, "message for you");

这种使用事件监听处理的异步编程方式很适合一些需要高度解耦的场景。例如在之前一个游戏服务端项目中,当人物属性变化时,需要写入到持久层。解决方案是先写一个订阅方,订阅‘save‘事件,在需要保存数据时让发布方对象(这里就是人物对象)上直接用emit发出一个事件名并携带相应参数,订阅方收到这个事件信息并处理。

 

3.Promise对象

ES 6中原生提供了Promise对象,Promise对象代表了某个未来才会知道结果的事件(一般是一个异步操作),并且这个事件对外提供了统一的API,可供进一步处理。
使用Promise对象可以用同步操作的流程写法来表达异步操作,避免了层层嵌套的异步回调,代码也更加清晰易懂,方便维护。

3.Generator函数

Generator函数是协程在ES 6中的实现,最大特点就是可以交出函数的执行权(暂停执行)。
注意:在node中需要开启--harmony选项来启用Generator函数。
整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。

协程的运行方式如下:

第一步:协程A开始运行。
第二步:协程A执行到一半,暂停,执行权转移到协程B。
第三步:一段时间后,协程B交还执行权。
第四步:协程A恢复执行。

上面的协程A就是异步任务,因为分为两步执行。

比如一个读取文件的例子:

example 16:

1 function asnycJob() {2 // ...其他代码3 var f = yield readFile(fileA);4 // ...其他代码5 }

asnycJob函数是一个协程,yield语句表示,执行到此处执行权就交给其他协程,也就是说,yield是两个阶段的分界线。协程遇到yield语句就暂停执行,直到执行权返回,再从暂停处继续执行。这种写法的优点是,可以把异步代码写得像同步一样。

看一个简单的Generator函数例子:

example 17:

 1 function* gen(x){ 2 var y = yield x + 2; 3 return y; 4 } 5 6 var g = gen(1); 7 var r1 = g.next(); // { value: 3, done: false } 8 console.log(r1); 9 var r2 = g.next() // { value: undefined, done: true }10 console.log(r2);

需要注意的是Generator函数的函数名前面有一个"*"。
上述代码中,调用Generator函数,会返回一个内部指针(即遍历器)g,这是Generator函数和一般函数不同的地方,调用它不会返回结果,而是一个指针对象。调用指针g的next方法,会移动内部指针,指向第一个遇到的yield语句,上例就是执行到x+2为止。
换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有下一个阶段。

Generator函数的数据交换和错误处理

next方法返回值的value属性,是Generator函数向外输出数据;next方法还可以接受参数,这是向Generator函数体内输入数据。

example 18:

 1 function* gen(x){ 2 var y = yield x + 2; 3 return y; 4 } 5 6 var g = gen(1); 7 var r1 = g.next(); // { value: 3, done: false } 8 console.log(r1); 9 var r2 = g.next(2) // { value: 2, done: true }10 console.log(r2);

第一个next的value值,返回了表达式x+2的值(3),第二个next带有参数2,这个参数传入Generator函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收,因此这一阶段的value值就是2。

Generator函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

example 19:

 1 function* gen(x){ 2 try { 3 var y = yield x + 2; 4 } 5 catch (e){ 6 console.log(e); 7 } 8 return y; 9 }10 11 var g = gen(1);12 g.next();13 g.throw(‘error!‘); //error!

下面是一个读取文件的真实异步操作的例子。

example 20:

 1 var fs = require(‘fs‘); 2 var thunkify = require(‘thunkify‘); 3 var readFile = thunkify(fs.readFile); 4 5 var gen = function* (){ 6 var r1 = yield readFile(‘./text1.txt‘, ‘utf8‘); 7 console.log(r1); 8 var r2 = yield readFile(‘./text2.txt‘, ‘utf8‘); 9 console.log(r2);10 };11 12 //开始执行上面的代码13 var g = gen();14 15 var r1 = g.next();16 r1.value(function(err, data){17 if (err) throw err;18 var r2 = g.next(data);19 r2.value(function(err, data){20 if (err) throw err;21 g.next(data);22 });23 });

这就是一个基本的Generator函数定义和执行的流程。可以看到,虽然这里的Generator函数写的很简洁,和同步方法的写法很像,但是执行起来却很麻烦,流程管理比较繁琐。

在深入讨论Generator函数之前我们先要知道Thunk函数这个概念。

求值策略(即函数的参数到底应该何时求值)

(1) 传值调用
(2) 传名调用

Javascript是传值调用的,Thunk函数是编译器“传名调用”的实现,就是将参数放入一个临时函数中,再将这个临时函数放入函数体,这个临时函数就叫做Thunk函数。
举个栗子就好懂了:

example 21:

 1 function f(m){ 2 return m * 2; 3 } 4 var x = 1; 5 f(x + 5); 6 7 //等同于 8 var thunk = function (x) { 9 return x + 5;10 };11 12 function f(thunk){13 return thunk() * 2;14 }

Thunk函数本质上是函数柯里化(currying),柯里化进行参数复用和惰性求值,这个是函数式编程的一些技巧,在js中,我们可以利用**高阶函数**实现函数柯里化。

JavaScript语言的Thunk函数

在JavaScript语言中,Thunk函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。

example 22:

 1 var fs = require(‘fs‘); 2 3 // 正常版本的readFile(多参数版本) 4 fs.readFile(fileName, callback); 5 6 // Thunk版本的readFile(单参数版本) 7 var readFileThunk = Thunk(fileName); 8 readFileThunk(callback); 9 10 var Thunk = function (fileName){11 return function (callback){12 return fs.readFile(fileName, callback);13 };14 };

任何函数,只要参数有回调函数,就能写成Thunk函数的形式。以下是一个简单的Thunk函数转换器:

example 23:

1 var Thunk = function(fn){2 return function (){3 var args = Array.prototype.slice.call(arguments);4 return function (callback){5 args.push(callback);6 return fn.apply(this, args);7 }8 };9 };

从本质上说,我们借助了Javascript高阶函数来抽象了异步执行流程。

使用上面的转换器,生成fs.readFile的Thunk函数。

example 24:

1 var readFileThunk = Thunk(fs.readFile);2 readFileThunk(‘./text1.txt‘, ‘utf8‘)(function(err, data){3 console.log(data);4 });

可以使用thunkify模块来Thunk化任何带有callback的函数。

我们需要借助Thunk函数的能力来自动执行Generator函数。

下面是一个基于Thunk函数的Generator函数执行器。

example 25:

 1 //Generator函数执行器 2 3 function run(fn) { 4 var gen = fn(); 5 6 function next(err, data) { 7 var result = gen.next(data); 8 if (result.done) return; 9 result.value(next);10 }11 12 next();13 }14 15 run(gen);

我们马上拿这个执行器来做点事情。

example 26:

 1 var fs = require(‘fs‘); 2 var thunkify = require(‘thunkify‘); 3 var readFile = thunkify(fs.readFile); 4 5 var gen = function* (){ 6 var f1 = yield readFile(‘./text1.txt‘, ‘utf8‘); 7 console.log(f1); 8 var f2 = yield readFile(‘./text2.txt‘, ‘utf8‘); 9 console.log(f2);10 var f3 = yield readFile(‘./text3.txt‘, ‘utf8‘);11 console.log(f3);12 };13 14 function run(fn) {15 var gen = fn();16 17 function next(err, data) {18 var result = gen.next(data);19 if (result.done) return;20 result.value(next);21 }22 23 next();24 }25 26 run(gen); //自动执行

现在异步操作代码的写法就和同步的写法一样了。实际上,Thunk函数并不是自动控制Generator函数执行的唯一方案,要自动控制Generator函数的执行过程,需要有一种机制,自动接收和交还程序的执行权,回调函数和Promise都可以做到(利用调用自身的一些特性)。

yield *语句

普通的yield语句后面跟一个异步操作,yield *语句后面需要跟一个遍历器,可以理解为yield *后面要跟另一个Generator函数,讲起来比较抽象,看一个实例。

example 27:

 1 //嵌套异步操作流 2 var fs = require(‘fs‘); 3 var thunkify = require(‘thunkify‘); 4 var readFile = thunkify(fs.readFile); 5 6 var gen = function* (){ 7 var f1 = yield readFile(‘./text1.txt‘, ‘utf8‘); 8 console.log(f1); 9 10 var f_ = yield * gen1(); //此处插入了另外一个异步流程11 12 var f2 = yield readFile(‘./text2.txt‘, ‘utf8‘);13 console.log(f2);14 15 var f3 = yield readFile(‘./text3.txt‘, ‘utf8‘);16 console.log(f3);17 };18 19 var gen1 = function* (){20 var f4 = yield readFile(‘./text4.txt‘, ‘utf8‘);21 console.log(f4);22 var f5 = yield readFile(‘./text5.txt‘, ‘utf8‘);23 console.log(f5);24 }25 26 function run(fn) {27 var gen = fn();28 29 function next(err, data) {30 var result = gen.next(data);31 if (result.done) return;32 result.value(next);33 }34 35 next();36 }37 38 run(gen); //自动执行

上面这个例子会输出
1
4
5
2
3
也就是说,使用yield *可以在一个异步操作流程中直接插入另一个异步操作流程,我们可以据此构造可嵌套的异步操作流,更为重要的是,写这些代码完全是同步风格的。

目前业界比较流行的Generator函数自动执行的解决方案是co库,此处也只给出co的例子。顺带一提node-fibers也是一种解决方案。

顺序执行3个异步读取文件的操作,并依次输出文件内容:

example 28:

 1 var fs = require(‘fs‘); 2 var co = require(‘co‘); 3 var thunkify = require(‘thunkify‘); 4 var readFile = thunkify(fs.readFile); 5 6 co(function*(){ 7 var files=[ 8 ‘./text1.txt‘, 9 ‘./text2.txt‘,10 ‘./text3.txt‘11 ];12 13 var p1 = yield readFile(files[0]);14 console.log(files[0] + ‘ ->‘ + p1);15 16 var p2 = yield readFile(files[1]);17 console.log(files[1] + ‘ ->‘ + p2);18 19 var p3 = yield readFile(files[2]);20 console.log(files[2] + ‘ ->‘ + p3);21 22 return ‘done‘;23 });

并发执行3个异步读取文件的操作,并存储在一个数组中输出(顺序和文件名相同):

example 29:

 1 var fs = require(‘fs‘); 2 var co = require(‘co‘); 3 var thunkify = require(‘thunkify‘); 4 var readFile = thunkify(fs.readFile); 5 6 co(function* () { 7 var files = [‘./text1.txt‘, ‘./text2.txt‘, ‘./text3.txt‘]; 8 var contents = yield files.map(readFileAsync); 9 10 console.log(contents);11 });12 13 function readFileAsync(filename) {14 return readFile(filename, ‘utf8‘);15 }

co库和我们刚才的run函数有点类似,都是自动控制Generator函数的流程。

ES 7中的async和await

async和await是ES 7中的新语法,新到连ES 6都不支持,但是可以通过Babel一类的预编译器处理成ES 5的代码。目前比较一致的看法是async和await是js对异步的终极解决方案。

async函数实际上是Generator函数的语法糖(js最喜欢搞语法糖,包括ES 6中新增的“类”支持其实也是语法糖)。

配置Babel可以看:配置Babel

如果想尝个鲜,简单一点做法是执行:

1 sudo npm install --global babel-cli

async_await.js代码如下:

 1 var fs = require(‘fs‘); 2 3 var readFile = function (fileName){ 4 return new Promise(function (resolve, reject){ 5 fs.readFile(fileName, function(error, data){ 6 if (error){ 7 reject(error); 8 } 9 else {10 resolve(data);11 }12 });13 });14 };15 16 var asyncReadFile = async function (){17 var f1 = await readFile(‘./text1.txt‘);18 var f2 = await readFile(‘./text2.txt‘);19 console.log(f1.toString());20 console.log(f2.toString());21 };22 23 asyncReadFile();

接着执行 babel-node async_await.js

输出:

1

 

 

转载自https://nullcc.github.io/

相关文章