原文: 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()
过早执行(a
和 b
都还是 unfined
),但 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(..) 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)
条件语句通常被称为一个 “门”,因为不确定 a
和b
的到达顺序,我们要在开门之前(调用 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(..)
。尽管这很方便(通常性能也更好),但还没有直接的方法(至少现在还没有)可以跨所有环境来保证异步事件顺序。我们会在下一节深入讨论这个话题。