你不知道的JavaScript(中卷)|异步

分块的程序
可以把JavaScript程序写在单个.js文件中,但是这个程序几乎一定是由多个块构成的。这些快中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。
考虑:

// ajax(..)是某个库中提供的某个Ajax函数
var data = ajax( "http://some.url.1" );
console.log( data );
// 啊哦!data通常不会包含Ajax结果

你可能已经了解,标准Ajax请求不是同步完成的,这意味着ajax(..)函数还没有返回任何值可以赋给变量data。如果ajax(..)能够阻塞到响应返回,那么data=..赋值就会正确工作。
但是我们并不是这么使用Ajax的。现在我们发出一个异步ajax请求,然后在将来才能得到返回的结果。
从现在到将来的“等待”,最简单的方法(但绝对不是唯一的,甚至也不是最好的!)是使用一个通常称为回调函数的函数:

// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", function myCallbackFunction(data){
    console.log( data ); // 耶!这里得到了一些数据!
} );

可能你已经听说过,可以发送同步Ajax请求。尽管技术上说是这样,但是,在任何情况下都不应该使用这种方式,因为它会锁定浏览器UI(按钮、菜单、滚动条等),并阻塞所有的用户交互。这是一个可怕的想法,一定要避免。

// 现在:
function now() {
    return 21;
}
function later() { .. }
var answer = now();
setTimeout(later, 1000);
// 将来:
answer = answer * 2;
console.log("Meaning of life:", answer);

现在这一块在程序运行之后就会立即执行。但是,setTimeout(..)还设置了一个事件(定时)在将来执行,所以函数later()的内容会在之后的某个时间(从现在起1000毫秒之后)执行。
任何时候,只要把一段代码包装成一个函数,并制定它在响应某个事件(定时器、鼠标点击、Ajax响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。

异步控制台
并没有什么规范或一组需求指定console.*方法族如何工作——它们并不是JavaScript正式的一部分,而是由宿主环境添加到JavaScript中的。因此,不同的浏览器和JavaScript环境可以按照自己的意愿来实现,有时候这会引起混淆。尤其要提出的是,在某些条件下,某些浏览器的console.log(..)并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是JavaScript)中,I/O是非常低速的阻塞部分。所以,(从页面/UI的角度来说)浏览器在后台异步处理控制台I/O能够提高性能,这时用户甚至可能根本意识不到其发生。

var a = {
    index: 1
};
// 然后
console.log(a); // ??
// 再然后
a.index++;

我们通常认为恰好在执行到console.log(..)语句的时候会看到a对象的快照,打印出类似于{index:1}这样的内容,然后在下一条语句a.index++执行时将其修改,这句的执行会严格在a的输出之后。
多数情况下,前述代码在开发者工具的控制台中输出的对象表示与期望是一致的。但是,这段代码运行的时候,浏览器可能会认为需要把控制台I/O延迟到后台,在这种情况下,等到浏览器控制台输出对象内容时,a.index++可能已经执行,因此会显示{index:2}。
到底什么时候控制台I/O会延迟,甚至是否能够被观察到,这都是游移不定的。如果在调试的过程中遇到对象在console.log(..)语句之后被修改,可你却看到了意料之外的结果,要意识到这可能是这种I/O的异步化造成的。

如果遇到这种少见的情况,最好的选择是在JavaScript调试器中使用断点,而不要依赖控制台输出。次优的方案是把对象序列化到一个字符串中,以强制执行一次“快照”,比如通过JSON.stringify(..)。

并行线程
术语“异步”和“并行”常常被混为一谈,但实际上它们的意义完全不同。记住,异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。
并行计算最常见的工具就是进程和线程。进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。
与之相对的是,事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。
多线程编程是非常复杂的。因为如果不通过特殊的步骤来防止这种中断和交错运行的话,可能会得到出乎意料的、不确定的行为,通常这很让人头疼。
JavaScript从不跨线程共享数据,这意味着不需要考虑这一层次的不确定性。但是这并不意味着JavaScript总是确定性的。

完整运行
由于JavaScript的单线程特性,foo()(以及bar())中的代码具有原子性。也就是说,一旦foo()开始运行,它的所有代码都会在bar()中的任意代码运行之前完成,或者相反。这称为完整运行特性。

var a = 1;
var b = 2;
function foo() {
    a++;
    b = b * a;
    a = b + 3;
}
function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}
// ajax(..)是某个库中提供的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

由于foo()不会被bar()中断,bar()也不会被foo()中断,所以这个程序只有两个可能的输出,取决于这两个函数哪个先运行——如果存在多线程,且foo()和bar()中的语句可以交替运行的话,可能输出的数目将会增加不少!
块1是同步的(现在运行),而块2和块3是异步的(将来运行),也就是说,它们的运行在时间上是分隔的。

//块1:
var a = 1;
var b = 2;
//块2(foo()):
a++;
b = b * a;
a = b + 3;
//块3(bar()):
b--;
a = 8 + b;
b = a * 2;

块2和块3哪个先运行都有可能。
同一段代码有两个可能输出意味着还是存在不确定性!但是,这种不确定性是在函数(事件)顺序级别上,而不是多线程情况下的语句顺序级别(或者说,表达式运算顺序级别)。换句话说,这一确定性要高于多线程情况。
在JavaScript的特性中,这种函数顺序的不确定性就是通常所说的竞态条件,foo()和bar()相互竞争,看谁先运行。具体来说,因为无法可靠预测a和b的最终结果,所以才是竞态条件。

并发
现在让我们来设想一个展示状态更新列表(比如社交网络新闻种子)的网站,其随着用户向下滚动列表而逐渐加载更多内容。要正确地实现这一特性,需要(至少)两个独立的“进程”同时运行(也就是说,是在同一段时间内,并不需要在同一时刻)。
举例来说,假设这些事件的时间线是这样的:

onscroll, 请求1
onscroll, 请求2 响应1
onscroll, 请求3 响应2
响应3
onscroll, 请求4
onscroll, 请求5
onscroll, 请求6 响应4
onscroll, 请求7
响应6
响应5
响应7

但是,前面已经介绍过,JavaScript一次只能处理一个事件,所以要么是onscroll,请求2先发生,要么是响应1先发生,但是不会严格地同时发生。
下面列出了事件循环队列中所有这些交替的事件:

onscroll, 请求1 <--- 进程1启动
onscroll, 请求2
响应1 <--- 进程2启动
onscroll, 请求3
响应2
响应3
onscroll, 请求4
onscroll, 请求5
onscroll, 请求6
响应4
onscroll, 请求7 <--- 进程1结束
响应6
响应5
响应7 <--- 进程2结束

“进程”1和“进程”2并发运行(任务级并行),但是它们的各个事件是在事件循环队列中依次运行的。
单线程事件循环是并发的一种形式。

非交互
两个或多个“进程”在同一个程序内并发地交替运行它们的步骤/事件时,如果这些任务彼此不相关,就不一定需要交互。如果进程间没有相互影响的话,不确定性是完全可以接受的。

var res = {};
function foo(results) {
    res.foo = results;
}
function bar(results) {
    res.bar = results;
}
// ajax(..)是某个库提供的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

foo()和bar()是两个并发执行的“进程”,按照什么顺序执行是不确定的。但是,我们构建程序的方式使得无论按哪种顺序执行都无所谓,因为它们是独立运行的,不会相互影响。
这并不是竞态条件bug,因为不管顺序如何,代码总会正常工作。

交互
更常见的情况是,并发的“进程”需要相互交流,通过作用域或DOM间接交互。正如前面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。
下面是一个简单的例子,两个并发的“进程”通过隐含的顺序相互影响,这个顺序有时会被破坏:

var res = [];
function response(data) {
    res.push(data);
}
// ajax(..)是某个库中提供的某个Ajax函数
ajax("http://some.url.1", response);
ajax("http://some.url.2", response);

这种不确定性很可能就是竞态条件bug。在这些情况下,你对可能做出的假定要持十分谨慎的态度。比如,开发者可能会观察到对"http://some.url.2"的响应速度总是显著慢于对"http://some.url.1"的响应,这可能是由它们所执行任务的性质决定的(比如,一个执行数据库任务,而另一个只是获取静态文件),所以观察到的顺序总是符合预期。即使两个请求都发送到同一个服务器,也总会按照固定的顺序响应,但对于响应返回浏览器的顺序,也没有人可以真正保证。
所以,可以协调交互顺序来处理这样的竞态条件:

var res = [];
function response(data) {
    if (data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if (data.url == "http://some.url.2") {
        res[1] = data;
    }
}
// ajax(..)是某个库中提供的某个Ajax函数
ajax("http://some.url.1", response);
ajax("http://some.url.2", response);

有些并发场景如果不做协调,就总是(并非偶尔)会出错:

var a, b;
function foo(x) {
    a = x * 2;
    baz();
}
function bar(y) {
    b = y * 2;
    baz();
}
function baz() {
    console.log(a + b);
}
// ajax(..)是某个库中的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

在这个例子中,无论foo()和bar()哪一个先被触发,总会使baz()过早运行(a或者b扔处于未定义状态);但对baz()的第二次调用就没有问题,因为这时候a和b都已经可用了。
要解决这个问题有很多种方法。这里给出了一种简单方法:

var a, b;
function foo(x) {
    a = x * 2;
    if (a && b) {
        baz();
    }
}
function bar(y) {
    b = y * 2;
    if (a && b) {
        baz();
    }
}
function baz() {
    console.log(a + b);
}
// ajax(..)是某个库中的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

另一种可能遇到的并发交互条件有时称为竞态,但是更精确的叫法是门闩。它的特性可以描述为“只有第一名取胜”。在这里,不确定性是可以接受的,因为它明确指出了这一点是可以接受的:需要“竞争”到终点,且只有唯一的胜利者。

var a;
function foo(x) {
    a = x * 2;
    baz();
}
function bar(x) {
    a = x / 2;
    baz();
}
function baz() {
    console.log(a);
}
// ajax(..)是某个库中的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

所以,可以通过一个简单的门闩协调这个交互过程,只让第一个通过:

var a;
function foo(x) {
    if (!a) {
        a = x * 2;
        baz();
    }
}
function bar(x) {
    if (!a) {
        a = x / 2;
        baz();
    }
}
function baz() {
    console.log(a);
}
// ajax(..)是某个库中的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

条件判断if(!a)使得只有foo()和bar()中的第一个可以通过,第二个(实际上是任何后续的)调用会被忽略。也就是说,第二名没有任何意义!

协作
还有一种并发合作方式,称为并发协作。这里的重点不再是通过共享作用域中的值进行交互(尽管显然这也是允许的!)。这里的目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。

var res = [];
// response(..)从Ajax调用中取得结果数组
function response(data) {
    // 添加到已有的res数组
    res = res.concat(
        // 创建一个新的变换数组把所有data值加倍
        data.map(function (val) {
            return val * 2;
        })
    );
}
// ajax(..)是某个库中提供的某个Ajax函数
ajax("http://some.url.1", response);
ajax("http://some.url.2", response);

如果"http://some.url.1"首先取得结果,那么整个列表会立刻映射到res中。如果记录有几千条或更少,这不算什么。但是如果有像1000万条记录的话,就可能需要运行相当一段时间了(在高性能笔记本上需要几秒钟,在移动设备上需要更长时间,等等)。
这样的“进程”运行时,页面上的其他代码都不能运行,包括不能有其他的response(..)调用或UI刷新,甚至是想滚动、输入、按钮点击这样的用户事件。这是非常痛苦的。
这里给出一个非常简单的方法:

var res = [];
// response(..)从Ajax调用中取得结果数组
function response(data) {
    // 一次处理1000个
    var chunk = data.splice(0, 1000);
    // 添加到已有的res组
    res = res.concat(
        // 创建一个新的数组把chunk中所有值加倍
        chunk.map(function (val) {
            return val * 2;
        })
    );
    // 还有剩下的需要处理吗?
    if (data.length > 0) {
        // 异步调度下一次批处理
        setTimeout(function () {
            response(data);
        }, 0);
    }
}
// ajax(..)是某个库中提供的某个Ajax函数
ajax("http://some.url.1", response);
ajax("http://some.url.2", response);

我们把数据集合放在最多包含1000条项目的块中。这样,我们就确保了“进程”运行事件会很短,即使这意味着需要更多的后续“进程”,因为事件循环队列的交替运行会提高站点/App的响应(性能)。
这里使用setTimeout(..0)(hack)进行异步调度,基本上它的意思就是“把这个函数插入到当前事件循环队列的结尾处”。

严格说来,setTimeout(..0)并不直接把项目插入到事件循环队列。定时器会在有机会的时候插入事件。举例来说,两个连续的setTimeout(..0)调用不能保证会严格按照调用顺序处理,所以各种情况都有可能出现,比如定时器漂移,在这种情况下,这些事件的顺序就不可预测。在Node.js中,类似的方法是process.nextTick(..)。尽管它们使用方便(通常性能也高),但并没有(至少到目前为止)直接的方法可以适应所有环境来确保异步事件的顺序。

任务
在ES6中,有一个新的概念建立在事件循环队列上,叫做任务队列。这个概念给大家带来的最大影响可能是Promise的异步特性。
我认为对于任务队列最好的理解方式是,它是挂在事件循环队列的每个tick之后的一个队列。在事件循环的每个tick中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目(一个任务)。
这就像是在说:“哦,这里还有一件事将来要做,但要确保在其他任何事情发生之前就完成它。”
一个任务可能引起更多任务被添加到同一个队列末尾。所以,理论上说,任务循环可能无限循环,进而导致程序的饿死,无法转移到下一个事件循环tick。从概念上看,这和代码中的无限循环(就像while(true)..)的体验几乎是一样的。
任务和setTimeout(..0) hack的思路类似,但是其实现方式的定义更加良好,对顺序的保证性更强:尽可能早的将来。
设想一个调度任务(直接地,不要hack)的API,称其为schedule(..):

console.log("A");
setTimeout(function () {
    console.log("B");
}, 0);
// 理论上的"任务API"
schedule(function () {
    console.log("C");
    schedule(function () {
        console.log("D");
    });
});

可能你认为这里会打印出A B C D,但实际打印的结果是A C D B。因为任务处理是在当前事件循环tick结尾处,且定时器触发是为了调度一下个事件循环tick(如果可用的话!)。

语句顺序
代码中语句的顺序和JavaScript引擎执行语句的顺序并不一定要一致。

var a, b;
a = 10;
b = 30;
a = a + 1;
b = b + 1;
console.log( a + b ); // 42

这段代码中没有显式的异步(除了前面介绍过的很少见的异步I/O!),所以很可能它的执行过程是从上到下一行进行的。
但是,JavaScript引擎在编译这段代码之后可能会发现通过(安全地)重新安排这些语句的顺序有可能提高执行速度。重点是,只要这个重新排序是不可见的,一切都没问题。
比如,引擎可能会发现,其实这样执行会更快:

var a, b;
a = 10;
a++;
b = 30;
b++;
console.log( a + b ); // 42

或者这样:

var a, b;
a = 11;
b = 31;
console.log( a + b ); // 42

或者甚至这样:

// 因为a和b不会被再次使用
// 我们可以inline,从而完全不需要它们!
console.log( 42 ); // 42

前面的所有情况中,JavaScript引擎在编译期间执行的都是安全的优化,最后可见的结果都是一样的。
但是这里有一种场景,其中特定的优化是不安全的,因此也是不允许的(当然,不用说这其实也根本不能称为优化):

var a, b;
a = 10;
b = 30;
// 我们需要a和b处于递增之前的状态!
console.log( a * b ); // 300
a = a + 1;
b = b + 1;
console.log( a + b ); // 42

还有其他一些例子,其中编译器重新排雷会产生可见的副作用(因此必须禁止),比如会产生副作用的函数调用(特别是getter函数),或ES6代理对象。

function foo() {
    console.log(b);
    return 1;
}
var a, b, c;
// ES5.1 getter字面量语法
c = {
    get bar() {
        console.log(a);
        return 1;
    }
};
a = 10;
b = 30;
a += foo(); // 30
b += c.bar; // 11
console.log(a + b); // 42

如果不是因为代码片段中的语句console.log(..)(只是作为一种方便的形式说明可见的副作用),JavaScript引擎如果愿意的话,本来可以自由地把代码重新排序如下:

// ...
a = 10 + foo();
b = 30 + c.bar;
// ...

尽管JavaScript语义让我们不会见到编译器语句重排序可能导致的噩梦,这是一种幸运,但是代码编写的方式(从上到下的模式)和编译后执行的方式之间的联系非常脆弱,理解这一点也非常重要。
编译器语句重排序几乎就是并发和交互的微型隐喻。作为一个一般性的概念,清楚这一点能够使你更好地理解异步JavaScript代码流问题。

小结
实际上,JavaScript程序总是至少分为两个快:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累计的修改之上进行的。
一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO和定时器会向事件队列中加入事件。
任意时刻,一次只能从队列中处理一个事件。执行事件的时候,可能直接或间接地引发一个或多个后续事件。
并发是指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时在运行(尽管在任意时刻只处理一个事件)。
通常需要对这些并发执行的“进程”(有别于操作系统中的进程概念)进行某种形式的交互协调,比如需要确保执行顺序或者需要防止竞态出现。这些“进程”也可以通过把自身分割为更小的块,以便其他“进程”插入进来。

11.jpg
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容