写在前面
最近看了Lynda上的一个课程叫做Rethinking Asynchronous Javascript。 在此写一写整理一下自己的心得。
异步与并发
其实谈到异步编程我们基本上会讲到并发(Parallel)和异步(Async)。从很高的角度来看,他们说的是同一个事情。然而他们在实际工作中一般各有所指。
从理念上看,并发指的是优化,资源同时的得到执行,加快了时间。比如玩过山车,如果每次只让一个人上车,那么其他人都要等待。如果一次上去30个人,那么这30个人就是同时享受到了服务。从计算机的角度看,那就是线程和多核,内核最终支持多少个线程并发,就是类似于过山车一次可以装多少个人上去。所以说一般谈到并发,讲的是资源的最大利用率和效率的问题,最终让请求或者任务得到快速的响应。
谈到异步,则是从编程的角度来看,其实也就是说的非阻塞。如果说到Javascript中我们最最基本的应该就是Callback了。通常我们会在一段代码中有一些需要XHR或者SetTimeout类似的工作,然后我们可以注册一个Callback,然后在等这个注册的函数在任务完成后执行。然而注册这个Callback并不会影响他之后代码的执行。
传统的Web服务器,例如Apache,是多线程的。每一个用户的请求都会编程一个线程,在服务器端执行,只有在服务器完成了用户请求后才可以返回。这样,如果访问的人越来越多,那么线程也会越来越多。所以并发和多线程在这种设计情景下,就是要最大限度的利用服务器的资源。
有关Javascript,我们都知道JS是单线程的执行的,因为它一开始设计得时候就是为了操作浏览器中的DOM元素,单线程也是最简单的设计。我们也都知道Android和iOS程序所有修改UI的操作都需要在主线程也就是UI线程进行。所以JS一定是顺序执行的。而所有的阻塞操作,都是通过回调的方式进行的。还有一个话题就是JS的Event Loop。这个放一放。
Nodejs的服务器是单线程的,因为用了Chrome V8的JS Engine。也是因为js的单线程特性,所以js的代码必须是异步的,也就是说是非阻塞的。如果包含例如文件读写,网络请求等,那么这些操作
程都会被这个线程接收,然后转到后台的MicroTask任务执行。线程只负责转发这个任务而不是真正执行。只有当任务完成后,这个线程又会得到消息,再把这个结果返回给用户。这样,这个线程所做的工作就比较简单。
Callback
Callback,不用多说,js最最原始的异步调用方式。每注册一次Callback会产生两片代码区域。他们的执行是异步的。看下边的code中的setTimeout注册了一个回调,一段代码在callback函数外边,包括了doSomething,setTimeout的调用和doOtherThing,另一段代码是setTimeout里的回调函数,console.log。第一段代码不知道第二段代码是否已经执行,何时执行。第二段代码也不会知道第一段代码执行到了哪里。1
2
3
4
5doSomething();
setTimeout(function(){
console.log("callback!");
}, 1000);
doOtherThings();
Callback Hell
下边的代码就是所谓的Callback Hell。1
2
3
4
5
6
7
8
9setTimeout(function(){
console.log("One");
setTimeout(function(){
console.log("Two");
setTimeout(function(){
console.log("Three");
}, 1000);
}, 1000);
}, 1000);
Callback Hell,大家都知道的一点叫Indentation Nesting。就是层数越多,缩进越多,代码越难看,越难以维护。
其实这个只是表象,我们甚至可以改写上边的代码,如下1
2
3
4
5
6
7
8
9
10
11
12
13
14function one(cb){
console.log("one");
setTimeout(cb, 1000);
}
function two(cb){
console.log("two");
setTimeout(cb, 1000);
}
function three(cb){
console.log("three");
}
one(function(){
two(three);
});
这样我们没有之前代码那样的缩进,但是这个代码还是看着很别扭。
一个模拟的实际问题
再看下边一个稍实际一点例子(之后会继续用这个例子)。
需求有三点:
- 并行的请求3个文件
- 尽快的输出文件内容,但是不要等所有请求都完成再去输出
- 输出是有顺序的,file1内容先输出,之后file2,最后输出file3
1 | function fakeAjax(url,cb) { |
我们该怎么办?因为要满足并发,在代码最后三行我们同时调用了getFile。那么如何让结果及时输出并有序呢?那么一定是在fakeAjax里的回调函数中处理。而我们并不知道每一个请求到底要花多少时间才能返回,所以我们需要在回调函数中通过一些变量或者状态,知道目前这三个请求的状态,才能顺序输出。例如,如果我们file3最先返回了结果,但是file1或者file2还没有返回,那么我们并不能立即输出file3的内容,而要等到file1和file2的内容都已经输出了再输出。所以我们的回调函数中,需要知道几乎所有的事情。那么我们看看我们该怎么做:
- 我们需要定义输出顺序,也就是file1 > file2 > file3。
- 我们需要知道每一个文件请求结果,以及这个内容是否已经输出过
实现参考以下代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63function fakeAjax(url,cb) {
var fake_responses = {
"file1": "The first text",
"file2": "The middle text",
"file3": "The last text"
};
var randomDelay = (Math.round(Math.random() * 1E4) % 8000) + 1000;
console.log("Requesting: " + url);
setTimeout(function(){
cb(fake_responses[url]);
},randomDelay);
}
function output(text) {
console.log(text);
}
// **************************************
// The old-n-busted callback way
function getFile(file) {
fakeAjax(file,function(text){
fileReceived(file,text);
});
}
function fileReceived(file,text) {
// haven't received this text yet?
if (!responses[file]) {
responses[file] = text;
}
var files = ["file1","file2","file3"];
// loop through responses in order for rendering
for (var i=0; i<files.length; i++) {
// response received?
if (files[i] in responses) {
// response needs to be rendered?
if (responses[files[i]] !== true) {
output(responses[files[i]]);
responses[files[i]] = true;
}
}
// can't render yet
else {
// not complete!
return false;
}
}
output("Complete!");
}
// hold responses in whatever order they come back
var responses = {};
// request all files at once in "parallel"
getFile("file1");
getFile("file2");
getFile("file3");
可以看到我们的回调函数中的逻辑还是比较复杂的。 我们遍历了输出文件顺序的files数组,然后依次查看responses有没有返回信息,以及目前正在遍历到的file有没有被输出过。如果结果还没有返回,那么就退出,把这个输出的调用留到下一个回调函数触发的时候。如果返回了,那么还要看输出过了没,如果输出过了,那就过掉。
我们的回调函数中有了不少代码,跟这个回调本身该做的事情没有关系。这些代码,都是跟时间或者说状态有关的。输出的顺序,输出过的状态。而我们人脑的思维方式是习惯于顺序思考的。比如上面这个例子如果不考虑阻塞什么的,写伪代码的话我们大概会这么写1
2
3
4
5
6
7
8
9fetch( file1 );
fetch( file2 );
fetch( file3 );
var file1Result = getFileContent ("file1");
var file2Result = getFileContent ("file2");
var file3Result = getFileContent ("file3");
output(file1Result);
output(file2Result);
output(file3Result);
上面的伪代码,是我们一般喜欢的思考方式,代码读起来也很容易。
反观Callback Hell,我们把本该做纯粹输出的Callback函数加了很多他本不应该具备的功能,比如,把程序运行的控制逻辑放入了Callback函数中。造成了Non-Local, Non-Sequential的代码。课程中,把这个叫做Inversion Of Control。这里的IOC只是指这种我们刚才描述现象,而跟其他我们一般说起的IOC不想关,比如Java的一些设计模式,以及流行的框架Spring。
Thunk
我们再看一个叫做Thunk的模式。Thunk出现的非常早,比JavaScript语言出现要早很多。
阮一峰的博客里讲到了thunk
从同步的角度来看Thunk是一种不需要你传入任何参数的函数,不用传入任何状态,任何时候你调用他,你可以得到相同的结果。1
2
3
4
5
6
7
8
9function add(x, y){
return x + y;
}
var thunk = function(){
return add(11, 31);
}
thunk();//42
Thunk从上面的例子看出,我们创建了一个Thunk,在里边Hard Code了调用add函数的参数。只要调用这个Thunk,他都会返回相同的值42。在这个Thunk中,我们用它包裹了一个状态的集合,之后我们不管把这个thunk传到哪里,只要调用他就能得到一个确定的值。我并不需要传递任何状态的值,我只需要这个Wrapper,并且调用他就可以得到我需要的值。这其实也是Promise设计的重要基础之一。而Promise要更为的高级,复杂。
我们可以把thunk从同步的模式,转换到异步的模式。那么什么是一个异步的thunk?其实这也没有标准答案。按照之前我们同步版thunk的特点,我们的thunk是一个状态和值的wrapper。那么,比较直接的想法,一个异步的thunk,是我们不传入任何跟值或者状态有关的东西,而只需要把回调的函数传入。1
2
3
4
5
6
7
8
9
10
11
12
13function addAsync(x, y, cb){
setTimeout(function(){
cb(x + y);
}, 1000);
}
var thunk = function(cb){
addSync(11, 31, cb);
}
thunk(function(sum){
sum;//42
});
每一次调用thunk,把Callback传入,我们知道我们会得到相应的结果。外部的代码并不知道,也不需要关心thunk里的参数或者计算倒地是什么细节。从thunk内部来说,他里边可能有复杂的计算,或者ajax call;他甚至可以记录计算后的结果,当你第二次调用他的时候立即返回这个结果。但是外部的代码并不需要关心这些,只要你调用thunk,就可以期待一个值在Callback中返回。
创建一个异步thunk,就是创建了一个返回值的wrapper,而且这个值是多少,与调用的时间无关。而时间,是程序代码中最复杂的状态因子。管理好程序执行的时机,是最难的工作之一。
Thunk就是一个value的wrapper,我不管把他传到哪里,我想知道这个value是,传入Callback,都会得到结果。
这其实跟promise的概念很相似。理解了这一点也就很容易理解promise。promise更为的复杂。
Lazy Thunk, Active Thunk
我们之前的例子1
2
3
4
5
6
7
8
9
10
11
12
13function addAsync(x, y, cb){
setTimeout(function(){
cb(x + y);
}, 1000);
}
var thunk = function(cb){
addSync(11, 31, cb);
}
thunk(function(sum){
sum;//42
});
只有在调用Thunk时,才回去请求addAsync函数。再联系到我们之前Callback最后那个题目的情景,如果我们要同时、并发去请求文件,这样的thunk是做不到的。我们起个名字,把这种叫Lazy Thunk。
那怎么实现我们的并发需求呢?Lazy Thunk似乎并不能满足我们的并发需求。我再把之前的例子贴过来1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27function fakeAjax(url,cb) {
var fake_responses = {
"file1": "The first text",
"file2": "The middle text",
"file3": "The last text"
};
var randomDelay = (Math.round(Math.random() * 1E4) % 8000) + 1000;
console.log("Requesting: " + url);
setTimeout(function(){
cb(fake_responses[url]);
},randomDelay);
}
function output(text) {
console.log(text);
}
// **************************************
function getFile(file) {
// what do we do here?
}
// request all files at once in "parallel"
// ???
我们的getFile应该返回一个接受一个Callback参数的thunk,所以我们最后的代码应该有这样一段,前三个调用创建三个thunk,并且创建时就去请求,最后顺序输出结果。1
2
3
4
5
6
7
8
9
10
11
12
13var th1 = getFile('file1');
var th2 = getFile('file2');
var th3 = getFile('file3');
th1(function(file1){
output(file1);
th2(function(file2){
output(file2);
th3(function(file3){
output(file3);
output('Compeleted!');
})
})
})
下面是重点getFile怎么实现。而要满足并发这个需求,我们必须要在getFile中,第一时间去调用fakeAjax。1
2
3
4
5
6
7
8function getFile(file) {
fakeAjax(file, function(response){
//Do something
});
return function(cb){
//Do other thing
}
}
下面有一个时机问题,我们的fakeAjax的回调函数执行时间,与thunk里传入的回调函数,倒地哪个先执行?
- fakeAjax中的cb先执行。
那么我们需要把response存在一个local变量中,等thunk里的cb执行时,直接调用cb,传入这个变量存好的值就可以了 - thunk传入的cb先执行。
这个看上去不是很直接,我们的返回值还没有,怎么调用这个cb?其实我们要做的事情跟前边的一样,只需要把这个cb存到local的变量中,等fakeAjax返回,再去执行这个cb,把返回的response传入。
最后的参考代码可能看上去还不大习惯,但是这个代码风格还是很实用的。这个就是Active Thunk。1
2
3
4
5
6
7
8
9
10
11
12
13
14function getFile(file) {
var content, fn;
fakeAjax(file, function(response){
if(fn){
fn(response);
}else{
content = response;
}
});
return function(cb){
if(content) cb(content)
else fn = cb;
}
}
Thunk并不能解决我们之前在Callback中遇到的大部分缺陷,比如IOC,我们还是会有callback,callback hell的问题。但是这个模式比callback那个代码清晰许多,代码可读性,可维护性都增加了不少。
这个用纯js代码实现的异步工具,十分的实用,利用这样的代码可以构建很多有用的工具方法。理解了这个,也就更容易理解promise。
Promise
Promise,大家肯定都知道了。Promise也成了ES6的标准,主流浏览器的都支持。可以参考MDN,Promise。
Promise中比较重要的一点是一个Promise只能Resolve一次。举个例子,你做了一个电商网站,专门卖电视。其中有下单,给用户信用卡扣款的流程。如果使用callback,可能的代码是这样的。其中trackCheckout返回一个listener,你注册一个时间监听comletion,之后完成扣款。但是这个方法,可能来自于一个别人写的Module,甚至有可能是一个第三方的lib。如果这个模块中有bug,同一个订单的completion在某些情况下被触发了两次,那么这样的代码下,会扣两次款。这样的callback形式的代码对这种bug特别敏感,要处理这样的情况也需要做很多的额外代码。1
2
3
4
5
6
7
8
9
10
11
12
13functiion finish(){
chargeCreditCard(purchaseInfo);
showThankYouPage();
}
function error(err){
logStatsError(err);
finish();
}
var listener = trackCheckout(purchaseInfo);
listener.on('completion', finish);
listener.on('error', error);
如果使用Promise,让trackCheckout返回一个promise,利用promise只能resolve一次的特点,就不会有个问题。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15function trackCheckout(purchaseInfo){
return new Promise(function(resolve, reject){
//call resolve when task complete
//call rejct when task encounter error
});
}
//
var prommise = trackCheckout(purchageInfo);
promise.then(
finish,
error
);
不管你在trackCheckout中调用resolve多少次,then只会执行一次。
Promise保证
- only resolve once
- either success or error
- messages are kept/ passed
- exceptions treat as error
- immutable once resolved
Promise可以理解成一个callback manager,让我们的callback调用更有保证,更清晰明白,
Flow Control
下面看看我们一般从外边资料学习到的promise是哪些内容
一般我们写的js代码做一些复杂的事情,比如同时发request,等得到结果了以后做什么事情。就跟我们之前做的callback,thunk的练习一样。这就是flow control。我们看看promise做我们的flow control
Chaining Promise
一个重要的特点,也是设计Promise API的最重要的点。就是Chaining。Promise A代表步骤A, Promise B代表步骤B,…1
2
3
4
5
6
7
8
9
10
11doFirstThing()
.then(function(){
return doSecondThing();
})
.then(function(){
return doThirdThing():
})
.then(
complete,
error
);
Promise不仅是flow control,也是data flow的体现。
练习
跟之前的例子一样,我们看看如何用promise完成输出。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24function fakeAjax(url,cb) {
var fake_responses = {
"file1": "The first text",
"file2": "The middle text",
"file3": "The last text"
};
var randomDelay = (Math.round(Math.random() * 1E4) % 8000) + 1000;
console.log("Requesting: " + url);
setTimeout(function(){
cb(fake_responses[url]);
},randomDelay);
}
function output(text) {
console.log(text);
}
// **************************************
function getFile(file) {
// what do we do here?
}
我们需要在getFile中立马发出ajax请求,并且返回一个promise。
我们需要按顺序输出,这里应该要用到chaining.如果用过promise这个代码应该很容易写出。
1 | function fakeAjax(url,cb) { |
有一些问题
- 我们需要自己new 一个Promise吗?
一般的Library都会把异步操作封装好,直接返回一个promise。但是我们有时候还是需要和上面的代码一样,做一个异步操作的封装,封装的有可能是一个callback或者其他的调用。 - 能不能把output写到function里面?
可以,但是function应该有一个单一的功能,能分开的功能尽量分开 - output的chain后面的promise.then的传入参数是什么?
传入一个新的promise,传入的参数是output的返回值。 - resolve是哪里来的?
来自Promise库函数。 - catch逻辑是怎么样的?
Promise Hell
Promise一定要用对,不然很容易写成promise hell。如果你发现自己陷入了嵌套迭代很深的缩进时,说明你的promise代码很可能写错了。1
2
3
4
5
6
7
8
9
10
11p1
.then(function(text){
output(text);
p2.then(function(text){
output(text);
p3.then(function(text){
output(text);
output('completed!');
} )
})
});
上边的练习里边我们有三个文件要去请求,那么如果我们的请求的文件数目增加我们是不是要写很多的.then呢?有没有更好的办法?
可以用map和reduce方法来减少我们的重复代码。
我们可以把任意个file的list转换成promise的list,再用reduce函数一一处理。
1 | function fakeAjax(url,cb) { |
Promise 其他的API
Promise.all
如果任何一个里面的promise reject,那么all立刻reject1
2
3
4
5
6
7
8
9
10
11
12
13
14Promise.all([
doTask1a(),
doTask1b(),
doTask1c()
])
.then(function (results){
return doTask2(
Math.max(
results[0],
results[1],
results[2],
);
);
});
Promise.race
只要有一个promise完成,resolve或者reject1
2
3
4
5
6
7
8
9
10
11
12
13var p = trySomeAsyncThing();
Promise.race([
p,
new Promise(function(_, reject){
setTimeout(function(){
reject('timeout!');
}, 3000)
})
])
.then(
success,
error
);