JavaScript异步:现在与未来

事实上,程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

分块的程序

在开发中,会把程序拆分成多个.js文件,这种拆分,意味着只有一个是现在执行的,其余的要等到将来才能执行。
考虑使用Ajax请求:

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

标准的Ajax请求不是同步完成的,意味着 ajax(..) 函数还没有返回 任何值可以赋给变量 data; 如果ajax(..)能够阻塞到响应返回,那么这种赋值是可以工作的。实际上并不是。

使用回调函数的方式:

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

异步控制台

尤其要提出的是,在某些条件下,某些浏览器的 console.log(..) 并不会把传入的内容立 即输出。出现这种情况的主要原因是,在许多程序(不只是 JavaScript)中,I/O 是非常低 速的阻塞部分。

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

事件循环

JavaScript 引擎本身所做的只 不过是在需要的时候,在给定的任意时刻执行程序中的单个代码块!!

JavaScript进入了其他环境,比如nodejs, 在这些环境中都用一个共同的点(thread,线程)即它们都提供了一种机制来处理程序中多个块的执行,且执行每 块时调用 JavaScript 引擎,这种机制被称为事件循环。

简单代码模式事件循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// eventLoop是一个用作队列的数组 //(先进,先出)
var eventLoop = [ ];
var event;
//“永远”执行 while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件 event = eventLoop.shift();
// 现在,执行下一个事件 try {
event(); }
catch (err) {
reportError(err);
}
}
}

循环的每一轮称为一个 tick。 对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这 些事件就是你的回调函数。

并行线程

异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。

并行计算最常见的工具就是进程和线程。进程和线程独立运行,并可能同时运行:在不同
的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。

在程序中多线程的共享内容,同时访问同一块内容,会造成程序的中断和交错运行,可能得到出乎意料、不确定的行为。

在JavaScript中不允许跨线程共享数据,这意味着不需要考虑因为线程竞争内存引起的中断和交错行为。当不同的先后执行顺序(例如异步函数的执行先后顺序不同)也会导致结果的不同。

完整运行

举例说明什么是完整性:

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

函数foo()开始执行后,它的所有代码都会在bar() 中的任意代码运行之前完成,或者相反。这被称为完整性运行(run-to-completion)特性

但是这不能消除全都的不确定性,依旧有两种可能的执行结果。

但是,这种不确定性是在函数(事 件)顺序级别上,而不是多线程情况下的语句顺序级别(

在 JavaScript 的特性中,这种函数顺序的不确定性就是通常所说的竞态条件(race condition),foo() 和 bar() 相互竞争,看谁先运行。具体来说,因为无法可靠预测 a 和 b 的最终结果,所以才是竞态条件。

并发

这里的“进程”之所以打上引号,是因为这并不是计算机科学意义上的真正 操作系统级进程。这是虚拟进程,或者任务,表示一个逻辑上相关的运算序 列。

非交互

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

交互

更常见的情况是,并发的“进程”需要相互交流,通过作用域或 DOM 间接交互。正如前 面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。

协作

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

1
2
3
4
5
6
7
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 );

这里的问题在于如果返回的data数组很庞大,那么需要一段时间用于执行res数组写入操作,这过程中发生了阻塞。

那么,可以创建一种协调性更友好且不会霸占时间循环队列的并发系统,这样可以异步处理这些结果,每次处理之后返回事件循环,让其他事件有机会进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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):可以模拟异步调度,但并不直接把项目插入到事件循环队列。当有两个定时器为0时,并不能顺序处理。

任务

在ES6中,有一个新的概念建立在事件循环队列之上,叫作任务队列(job queue)。这个
概念给大家带来的最大影响可能是 Promise 的异步特性。

while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。 对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这 些事件就是你的回调函数。

任务队列的概念:

在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件 添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个项目(一个任务)。

Promise 的异步特性是基于任务的

语句顺序

由于JavaScript引擎需要编译代码,所以语句的执行顺序并不总是按顺序的,在正常情况下,这种优化对于程序的执行并没有影响。

编译器语句重排序几乎就是并发和交互的微型隐喻。

这可以理解为最小单元的异步执行。

小结

尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访 问,所以对状态的修改都是在之前累积的修改之上进行的。

一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个 tick。用户交互、IO 和定时器会向事件队列中加入事件。

任意时刻,一次只能从队列中处理一个事件。执行事件的时候,可能直接或间接地引发一 个或多个后续事件。

并发是指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时 在运行(尽管在任意时刻只处理一个事件)。

通常需要对这些并发执行的“进程”(有别于操作系统中的进程概念)进行某种形式的交 互协调,比如需要确保执行顺序或者需要防止竞态出现。这些“进程”也可以通过把自身 分割为更小的块,以便其他“进程”插入进来。

项目总结

在实际开发中,经常遇到数据重复改写的问题,这种状态的竞争往往很难预见,处理对业务逻辑的梳理之外,还需要对语言的底层有较好的理解。以便于修复一些难以解决的BUG。