3月阅读周·你不知道的JavaScript | 异步编程,桥接程序运行的现在与未来
背景
去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。
没有计划的阅读,收效甚微。
新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。
这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。
《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。
已读完书籍:《架构简洁之道》、《深入浅出的Node.js》。
当前阅读周书籍:《你不知道的JavaScript(上卷)》、《你不知道的JavaScript(中卷)》。
异步编程
异步
分块的程序
可以把JavaScript程序写在单个.js文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。
从现在到将来的“等待”,最简单的方法是使用一个通常称为回调函数的函数:
// ajax(..)是某个库中提供的某个Ajax函数
ajax('http://some.url.1', function myCallbackFunction(data) {
console.log(data); // 耶!这里得到了一些数据!
});
任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。
事件循环
程序通常分成了很多小块,在事件循环队列中一个接一个地执行。
下面是一段伪代码:
// eventLoop是一个用作队列的数组
//(先进,先出)
var eventLoop = [];
var event;
// “永远”执行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift();
// 现在,执行下一个事件
try {
event();
} catch (err) {
reportError(err);
}
}
}
有一个用while循环实现的持续运行的循环,循环的每一轮称为一个tick。对每个tick而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是回调函数。
setTimeout(..)并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把回调函数放在事件循环中,这样,在未来某个时刻的tick会摘下并执行这个回调。
并行线程
事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。
并行线程的交替执行和异步事件的交替调度,其粒度是完全不同的。
function later() {
answer = answer * 2;
console.log('Meaning of life:', answer);
}
考虑到这段代码是运行在一个线程中,实际上可能有很多个不同的底层运算。比如,answer =answer * 2需要先加载answer的当前值,然后把2放到某处并执行乘法,取得结果之后保存回answer中。
在单线程环境中,线程队列中的这些项目是底层运算确实是无所谓的,因为线程本身不会被中断。但如果是在并行系统中,同一个程序中可能有两个不同的线程在运转,这时很可能就会得到不确定的结果。
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);
上面的代码,根据JavaScript的单线程运行特性,如果foo()运行在bar()之前,a的结果是42,而如果bar()运行在foo()之前的话,a的结果就是41。
并发
两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运算是否并行执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作“进程”级(或者任务级)的并行,与运算级的并行(不同处理器上的线程)相对。
1、非交互
两个或多个“进程”在同一个程序内并发地交替运行它们的步骤/事件时,如果这些任务彼此不相关,就不一定需要交互。如果进程间没有相互影响的话,不确定性是完全可以接受的。
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()是两个并发执行的“进程”,按照什么顺序执行是不确定的。
2、交互
并发的“进程”需要相互交流,通过作用域或DOM间接交互。正如前面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。
可以协调交互顺序来处理这样的竞态条件:
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);
3、协作
还有一种并发合作方式,称为并发协作。目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。
举例来说,考虑一个需要遍历很长的结果列表进行值转换的Ajax响应处理函数。
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的响应(性能)。
语句顺序
JavaScript引擎在编译这段代码之后可能会发现通过(安全地)重新安排这些语句的顺序有可能提高执行速度。重点是,只要这个重新排序是不可见的,一切都没问题。
比如,引擎可能会发现,其实这样执行会更快:
var a, b;
a = 10;
a++;
b = 30;
b++;
console.log( a + b ); // 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
回调
回调是编写和处理JavaScript程序异步逻辑的最常用方式。
continuation
回调函数包裹或者说封装了程序的延续(continuation)。
// A
ajax( "..", function(..){
// C
} );
// B
// A和// B表示程序的前半部分(也就是现在的部分),而// C标识了程序的后半部分(也就是将来的部分)。前半部分立刻执行,然后是一段时间不确定的停顿。在未来的某个时刻,如果Ajax调用完成,程序就会从停下的位置继续执行后半部分。
顺序
listen('click', function handler(evt) {
setTimeout(function request() {
ajax('http://some.url.1', function response(text) {
if (text == 'hello') {
handler();
} else if (text == 'world') {
request();
}
});
}, 500);
});
这段代码得到了三个函数嵌套在一起构成的链,其中每个函数代表异步序列(任务,“进程”)中的一个步骤。
这种代码常常被称为回调地狱(callback hell),有时也被称为毁灭金字塔(pyramid of doom,得名于嵌套缩进产生的横向三角形状)。
回调方式最主要的缺陷:对于它们在代码中表达异步的方式,我们的大脑需要努力才能同步得上。
尝试挽救回调
有一种常见的回调模式叫作“error-first风格”(有时候也称为“Node风格”,因为几乎所有Node.js API都采用这种风格),其中回调的第一个参数保留用作错误对象(如果有的话)。如果成功的话,这个参数就会被清空/置假(后续的参数就是成功数据)。不过,如果产生了错误结果,那么第一个参数就会被置起/置真(通常就不会再传递其他结果):
function response(err, data) {
// 出错?
if (err) {
console.error(err);
}
// 否则认为成功
else {
console.log(data);
}
}
ajax('http://some.url.1', response);
这并没有涉及阻止或过滤不想要的重复调用回调的问题。
总结
我们来总结一下本篇的主要内容:
- JavaScript程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。
- 一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO和定时器会向事件队列中加入事件。
- 并发是指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时在运行(尽管在任意时刻只处理一个事件)。
- 回调函数是JavaScript异步的基本单元。但是随着JavaScript越来越成熟,对于异步编程领域的发展,回调已经不够用了。
作者介绍
非职业「传道授业解惑」的开发者叶一一。
《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。
如果看完文章有所收获,欢迎点赞👍 | 收藏⭐️ | 留言📝。
- 点赞
- 收藏
- 关注作者
评论(0)