并行线程
“异步”与“并行”两个词经常被混为一谈,但它们实际上是十分不同的。记住,异步是关于 现在 与 稍后 之间的间隙。但并行是关于可以同时发生的事情。
关于并行计算最常见的工具就是进程与线程。进程和线程独立地,可能同时地执行:在不同的处理器上,甚至在不同的计算机上,而多个线程可以共享一个进程的内存资源。
相比之下,一个事件轮询将它的工作打碎成一系列任务并串行地执行它们,不允许并行访问和更改共享的内存。并行与“串行”可能以在不同线程上的事件轮询协作的形式共存。
并行线程执行的穿插,与异步事件的穿插发生在完全不同的粒度等级上:
比如:
function later() {
answer = answer * 2;
console.log( "Meaning of life:", answer );
}
虽然later()
的整个内容将被当做一个事件轮询队列的实体,但当考虑到将要执行这段代码的线程时,实际上也许会有许多不同的底层操作。比如,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
。
如果JS事件共享相同的并列执行数据,问题将会变得微妙得多。考虑这两个假想代码段,它们分别描述了运行foo()
和bar()
中代码的线程将要执行的任务,并考虑如果它们在完全相同的时刻运行会发生什么:
线程1(X
和Y
是临时的内存位置):
foo():
a. 将`a`的值读取到`X`
b. 将`1`存入`Y`
c. 把`X`和`Y`相加,将结果存入`X`
d. 将`X`的值存入`a`
线程2(X
和Y
是临时的内存位置):
bar():
a. 将`a`的值读取到`X`
b. 将`2`存入`Y`
c. 把`X`和`Y`相乘,将结果存入`X`
d. 将`X`的值存入`a`
现在,让我们假定这两个线程在并行执行。你可能发现了问题,对吧?它们在临时的步骤中使用共享的内存位置X
和Y
。
如果步骤像这样发生,a
的最终结果什么?
1a (将`a`的值读取到`X` ==> `20`)
2a (将`a`的值读取到`X` ==> `20`)
1b (将`1`存入`Y` ==> `1`)
2b (将`2`存入`Y` ==> `2`)
1c (把`X`和`Y`相加,将结果存入`X` ==> `22`)
1d (将`X`的值存入`a` ==> `22`)
2c (把`X`和`Y`相乘,将结果存入`X` ==> `44`)
2d (将`X`的值存入`a` ==> `44`)
a
中的结果将是44
。那么这种顺序呢?
1a (将`a`的值读取到`X` ==> `20`)
2a (将`a`的值读取到`X` ==> `20`)
2b (将`2`存入`Y` ==> `2`)
1b (将`1`存入`Y` ==> `1`)
2c (把`X`和`Y`相乘,将结果存入`X` ==> `20`)
1c (把`X`和`Y`相加,将结果存入`X` ==> `21`)
1d (将`X`的值存入`a` ==> `21`)
2d (将`X`的值存入`a` ==> `21`)
a
中的结果将是21
。
所以,关于线程的编程十分刁钻,因为如果你不采取特殊的步骤来防止这样的干扰/穿插,你会得到令人非常诧异的,不确定的行为。这通常让人头疼。
JavaScript从不跨线程共享数据,这意味着不必关心这一层的不确定性。但这并不意味着JS总是确定性的。记得前面foo()
和bar()
的相对顺序产生两个不同的结果吗(41
或42
)?
注意: 可能还不明显,但不是所有的不确定性都是坏的。有时候它无关紧要,有时候它是故意的。我们会在本章和后续几章中看到更多的例子。
运行至完成
因为JavaScript是单线程的,foo()
(和bar()
)中的代码是原子性的,这意味着一旦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哪一个都有可能先执行,所以这个程序有两个可能的结果,正如这里展示的:
结果1:
var a = 1;
var b = 2;
// foo()
a++;
b = b * a;
a = b + 3;
// bar()
b--;
a = 8 + b;
b = a * 2;
a; // 11
b; // 22
结果2:
var a = 1;
var b = 2;
// bar()
b--;
a = 8 + b;
b = a * 2;
// foo()
a++;
b = b * a;
a = b + 3;
a; // 183
b; // 180
同一段代码有两种结果仍然意味着不确定性!但是这是在函数(事件)顺序的水平上,而不是在使用线程时语句顺序的水平上(或者说,实际上是表达式操作的顺序上)。换句话说,他比线程更具有 确定性。
当套用到JavaScript行为时,这种函数顺序的不确定性通常称为“竞合状态”,因为foo()
和bar()
在互相竞争看谁会先运行。明确地说,它是一个“竞合状态”因为你不能可靠地预测a
与b
将如何产生。
注意: 如果在JS中不知怎的有一个函数没有运行至完成的行为,我们会有更多可能的结果,对吧?ES6中引入一个这样的东西(见第四章“生成器”),但现在不要担心,我们会回头讨论它。