【译】异步:现在与将来(并发篇)

Apr 05 2017 前端

原文: https://github.com/getify/You-Dont-Know-JS/blob/master/async%20%26%20performance/ch1.md#concurrency
译者:熊贤仁

让我们想象有个网站,展示状态更新的列表(类似社交网络信息流),当用户下滑列表时,渐进式地加载数据。为了让这一特性正确工作,(至少)两个独立的 “进程” 需要同时执行(也就是说,在同一段窗口时间下,而不必是同一时刻)。

注意: 我们使用了给 “进程” 加了引号,因为它们不是计算机科学意义上的真正的操作系统级别的进程。它们是虚拟进程,或者说任务,用来表示一系列逻辑相关的连续的操作。我们简单地使用了 “进程” 而不是 “任务”,因为这个术语更加匹配我们正在探讨的定义。

当用户向下滚动页面后,第一个 “进程” 会响应 onscroll 事件(发起 Ajax 请求去获取新内容)。第二个 “进程” 会接收 Ajax 返回的响应(去渲染内容到页面上)。

显然,如果用户向下滚动页面足够快的话,你可能在页面接收到第一个响应并处理时,看到不止一个 onscroll 事件被触发。因此你将得到快速触发并交错执行的onscroll 事件和 Ajax 响应事件。

并发是在同一段时间内两个或多个 “进程” 同时运行,不管它们的独立构成的运算是否 “并行” (多个独立的处理器或核心同一时刻)执行。你可以把并发看作是 “进程” 级(或者任务级)的并行化,这与运算级的并行化(多个独立处理器线程)是截然相反的。

注意: 并发也引入了这些相互作用的 “进程”间的一个可选的概念。我们将随后介绍它。

对于给定的一段窗口时间(用户滚动页面的几秒内),我们把每个独立的 “进程” 看作是一系列事件或运算:

“进程” 1(onscroll 事件):

onscroll, request 1
onscroll, request 2
onscroll, request 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
onscroll, request 7

“进程” 2(Ajax 响应事件):

response 1
response 2
response 3
response 4
response 5
response 6
response 7

onscroll 事件和 Ajax 响应事件很可能在同一时刻准备执行。举个例子,让我们设想这些事件在一个时间线上:

onscroll, request 1
onscroll, request 2 response 1
onscroll, request 3 response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6 response 4
onscroll, request 7
response 6
response 5
response 7

但是,回到我们之前章节讲过的事件循环的概念,JS 同一时刻只能处理一个事件,onscroll 事件也不例外。要么请求 2 先执行,要么响应 1 先执行,但它们字面上不能同时执行。就像学校食堂的孩子们,不管门外挤进了多少人,他们也必须排队取餐。

我们设想所有这些事件在事件循环队列中交替执行。
事件循环队列:

onscroll, request 1 <--- Process 1 starts
onscroll, request 2
response 1 <--- Process 2 starts
onscroll, request 3
response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
response 4
onscroll, request 7 <--- Process 1 finishes
response 6
response 5
response 7 <--- Process 2 finishes

“进程 1” 和 “进程 2” 并发运行(任务级并发),而他们独立的事件在事件循环队列中按序运行。

顺便说一下,注意到响应 6 和响应 5 那令人意外的顺序了吗?

基于单线程的事件循环是一种并发的形式(当然还有其他的,我们后面讲介绍)。

非交互

当两个或多个 “进程” 在同一个程序内并发地交替执行它们的步骤或事件,如果彼此任务不相关,它们不必有交互。如果它们没有交互,程序的不确定性将相当低。

比如:

var res = {};

function foo(results) {
res.foo = results;
}

function bar(results) {
res.bar = results;
}

// ajax(..) is some arbitrary Ajax function given by a library
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(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

并发 “进程” 是两个用于处理 Ajax 响应而调用的 response() 方法。它们任意一个都可以先执行。

我们假设期待的行为是,res[0] 存放调用 "http://some.url.1" 的结果,res[1]存放调用 "http://some.url.2" 的结果。但有时并非如此,这取决于哪个调用先结束。这种不确定性很可能就是一个 “竞态条件” 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(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

无论先返回哪个 Ajax 响应,我们都要通过检查 data.url (当然,假设其中一个返回自服务器)来确定响应数据应该放在 res 数组的哪个位置。 res[0] 总是保存 "http://some.url.1" 返回的结果,res[1] 总是保存 "http://some.url.2" 的结果。通过简单的调整,我们排除了 “竞态条件” 不确定性。

如果调用了多个并发函数,这些函数通过共享内存产生交互,上面场景下的论证也可以得到应用。比如其中一个更新一个 <div> 的内容,另一个更新 <div> 的样式或属性(比如,一旦 DOM 元素有内容,就使其可见)。你可能不想在 DOM 元素有内容之前展示它,那么调整必须保证正确的交互顺序。

若没有调整交互顺序,有些并发场景总是会出错(并非偶尔),考虑:

var a, b;

function foo(x) {
a = x * 2;
baz();
}

function bar(y) {
b = y * 2;
baz();
}

function baz() {
console.log(a + b);
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在这个例子中,不管是 foo() 还是 bar() 先执行,总是会造成 baz() 过早执行(ab 都还是 unfined),但 baz() 的第二次调用会正常工作,因为 ab 都已被初始化了。

有多种方法可以处理此类情况。这里有一个简单的办法:

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(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

包裹着 baz() 调用的 if (a && b) 条件语句通常被称为一个 “门”,因为不确定 ab 的到达顺序,我们要在开门之前(调用 baz())等待这两者的到达。

另一种可能遇到的并发交互条件有时被称为 “竞态”(race),更准确的说应该是 “门闩”(latch)。它的特征是 “先到先赢” 行为。这里,不确定性是可接受的,因为你明确说明了 “竞态” 中为了成为那个唯一的赢家,需要竞争到终点。

考虑这段有问题的代码:

var a;

function foo(x) {
a = x * 2;
baz();
}

function bar(x) {
a = x / 2;
baz();
}

function baz() {
console.log( a );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

无论哪个(foo() 或者 bar())先执行,不仅 a 的赋值会被另一个覆盖,而且还会重复调用 baz() (很可能并不希望这样)。

所以,我们可以通过一个简单的门闩来调整交互过程,以只让第一个通过:

var a;

function foo(x) {
if (a == undefined) {
a = x * 2;
baz();
}
}

function bar(x) {
if (a == undefined) {
a = x / 2;
baz();
}
}

function baz() {
console.log( a );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

if (a == undefined) 条件只会让 foo()bar() 中的第一个通过,第二个调用(事实上是任何后续调用的)的会被忽略。第二名并不光荣!

注意: 在以上所有场景中,我们都使用了全局变量来做简单的示范,但对于我们的论证来说,这样做并不是必要的。只要问题中的函数可以访问变量(通过作用域),它们都会正常工作。依赖于词法作用域变量(见本书的作用域和闭包部分),以上例子中的全局变量,事实上对于那些并发调整来说是一个明显的负面因素。随着后面章节的展开,我们会看到更多种干净得多的调整方式。

协作

协调并发的另一个形式被称为 “协作式并发”。在这里,我们要关注的点不再是通过共享作用域内的值来进行交互(尽管这显然也是可以的)。通过使用一个长期运行的 “进程” ,并把 “进程” 分成多个步骤或批次,并发 “进程” 可以在事件循环队列中交叉执行它们的各种操作。

举个例子,考虑一个 Ajax 响应处理器,它需要遍历一长串结果列表来转换值。我们会使用 Array.map(..) 来简化代码:

var res = [];

// `response(..)` receives array of results from the Ajax call
function response(data) {
// add onto existing `res` array
res = res.concat(
// make a new transformed array with all `data` values doubled
data.map( function(val){
return val * 2;
} )
);
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

如果 "http://some.url.1" 先得到返回结果,整个列表立即会被 map 进 res。如果只有几千或者更少的数据,通常问题不大。但如果说有个一千万条数据,这需要运行好一会儿(性能不错的笔记本上要几秒种,移动设备上会久得多,等待)。

当这样一个 “进程” 在运行,页面中的其他任务就可以去一边呆着了,包括没有其他的response(…) 函数调用,没有任何 UI 更新,甚至像滚动、输入、按钮点击这样的用户事件也没法工作了。这相当痛苦。

所以,为了实现一个协作更好的并发系统,它更友好,而且不会霸占事件循环队列,你可以异步地批处理这些结果,每次 “yielding” 后返回事件循环,让其他等待事件执行。

这里有个很简单的方法:

var res = [];

// `response(..)` receives array of results from the Ajax call
function response(data) {
// let's just do 1000 at a time
var chunk = data.splice( 0, 1000 );

// add onto existing `res` array
res = res.concat(
// make a new transformed array with all `chunk` values doubled
chunk.map( function(val){
return val * 2;
} )
);

// anything left to process?
if (data.length > 0) {
// async schedule next batch
setTimeout( function(){
response( data );
}, 0 );
}
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

我们把数据集处理成一些最大容量为 1000 的块。这样做可以保证 “进程” 运行时间比较短,即使这样也意味着会有更多后续的 “进程”,因为事件循环队列的交叉执行会提升网站/APP 的响应性能。

当然,我们没有针对这些 “进程” 的执行顺序做任何的交互调整,因此 res 中结果的顺序将是不确定的。如果要求保证顺序,你需要使用像我们前面讨论过的交互技巧,或者本书后面章节要介绍的技术。

为了实现异步调度,我们使用了 setTimeout(..0)(hack),基本上,这里的意思是 “把这个函数插入到事件循环队列的末尾”。

注意: 严格来说,setTimeout(..0) 并没有直接往事件循环队列插入处理函数。定时器将在有机会的情况下插入事件。比如说,两个相继的 setTimeout(..0) 调用并不能严格保证按照调用顺序处理,所以可以各种不同的情况都可能发生,比如定时器漂移,这时这些事件是不可预测的。Node.js 中,一个相似的方法是 process.nextTick(..)。尽管这很方便(通常性能也更好),但还没有直接的方法(至少现在还没有)可以跨所有环境来保证异步事件顺序。我们会在下一节深入讨论这个话题。

JavaScript, 翻译