并行线程

“异步”与“并行”两个词经常被混为一谈,但它们实际上是十分不同的。记住,异步是关于 现在稍后 之间的间隙。但并行是关于可以同时发生的事情。

关于并行计算最常见的工具就是进程与线程。进程和线程独立地,可能同时地执行:在不同的处理器上,甚至在不同的计算机上,而多个线程可以共享一个进程的内存资源。

相比之下,一个事件轮询将它的工作打碎成一系列任务并串行地执行它们,不允许并行访问和更改共享的内存。并行与“串行”可能以在不同线程上的事件轮询协作的形式共存。

并行线程执行的穿插,与异步事件的穿插发生在完全不同的粒度等级上:

比如:

  1. function later() {
  2. answer = answer * 2;
  3. console.log( "Meaning of life:", answer );
  4. }

虽然later()的整个内容将被当做一个事件轮询队列的实体,但当考虑到将要执行这段代码的线程时,实际上也许会有许多不同的底层操作。比如,answer = answer * 2首先需要读取当前answer的值,再把2放在某个地方,然后进行乘法计算,最后把结果存回到answer

在一个单线程环境中,线程队列中的内容都是底层操作真的无关紧要,因为没有什么可以打断线程。但如果你有一个并行系统,在同一个程序中有两个不同的线程,你很可能会得到无法预测的行为:

考虑这段代码:

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

在JavaScript的单线程行为下,如果foo()bar()之前执行,结果a42,但如果bar()foo()之前执行,结果a将是41

如果JS事件共享相同的并列执行数据,问题将会变得微妙得多。考虑这两个假想代码段,它们分别描述了运行foo()bar()中代码的线程将要执行的任务,并考虑如果它们在完全相同的时刻运行会发生什么:

线程1(XY是临时的内存位置):

  1. foo():
  2. a. `a`的值读取到`X`
  3. b. `1`存入`Y`
  4. c. `X``Y`相加,将结果存入`X`
  5. d. `X`的值存入`a`

线程2(XY是临时的内存位置):

  1. bar():
  2. a. `a`的值读取到`X`
  3. b. `2`存入`Y`
  4. c. `X``Y`相乘,将结果存入`X`
  5. d. `X`的值存入`a`

现在,让我们假定这两个线程在并行执行。你可能发现了问题,对吧?它们在临时的步骤中使用共享的内存位置XY

如果步骤像这样发生,a的最终结果什么?

  1. 1a (将`a`的值读取到`X` ==> `20`)
  2. 2a (将`a`的值读取到`X` ==> `20`)
  3. 1b (将`1`存入`Y` ==> `1`)
  4. 2b (将`2`存入`Y` ==> `2`)
  5. 1c (把`X``Y`相加,将结果存入`X` ==> `22`)
  6. 1d (将`X`的值存入`a` ==> `22`)
  7. 2c (把`X``Y`相乘,将结果存入`X` ==> `44`)
  8. 2d (将`X`的值存入`a` ==> `44`)

a中的结果将是44。那么这种顺序呢?

  1. 1a (将`a`的值读取到`X` ==> `20`)
  2. 2a (将`a`的值读取到`X` ==> `20`)
  3. 2b (将`2`存入`Y` ==> `2`)
  4. 1b (将`1`存入`Y` ==> `1`)
  5. 2c (把`X``Y`相乘,将结果存入`X` ==> `20`)
  6. 1c (把`X``Y`相加,将结果存入`X` ==> `21`)
  7. 1d (将`X`的值存入`a` ==> `21`)
  8. 2d (将`X`的值存入`a` ==> `21`)

a中的结果将是21

所以,关于线程的编程十分刁钻,因为如果你不采取特殊的步骤来防止这样的干扰/穿插,你会得到令人非常诧异的,不确定的行为。这通常让人头疼。

JavaScript从不跨线程共享数据,这意味着不必关心这一层的不确定性。但这并不意味着JS总是确定性的。记得前面foo()bar()的相对顺序产生两个不同的结果吗(4142)?

注意: 可能还不明显,但不是所有的不确定性都是坏的。有时候它无关紧要,有时候它是故意的。我们会在本章和后续几章中看到更多的例子。

运行至完成

因为JavaScript是单线程的,foo()(和bar())中的代码是原子性的,这意味着一旦foo()开始运行,它的全部代码都会在bar()中的任何代码可以运行之前执行完成,反之亦然。这称为“运行至完成”行为。

事实上,运行至完成的语义会在foo()bar()中有更多的代码时更明显,比如:

  1. var a = 1;
  2. var b = 2;
  3. function foo() {
  4. a++;
  5. b = b * a;
  6. a = b + 3;
  7. }
  8. function bar() {
  9. b--;
  10. a = 8 + b;
  11. b = a * 2;
  12. }
  13. // ajax(..) 是某个包中任意的Ajax函数
  14. ajax( "http://some.url.1", foo );
  15. ajax( "http://some.url.2", bar );

因为foo()不能被bar()打断,而且bar()不能被foo()打断,所以这个程序根据哪一个先执行只有两种可能的结果——如果线程存在,foo()bar()中的每一个语句都可能被穿插,可能的结果数量将会极大地增长!

代码块儿1是同步的(现在 发生),但代码块儿2和3是异步的(稍后 发生),这意味着它们的执行将会被时间的间隙分开。

代码块儿1:

  1. var a = 1;
  2. var b = 2;

代码块儿2 (foo()):

  1. a++;
  2. b = b * a;
  3. a = b + 3;

代码块儿3 (bar()):

  1. b--;
  2. a = 8 + b;
  3. b = a * 2;

代码块儿2和3哪一个都有可能先执行,所以这个程序有两个可能的结果,正如这里展示的:

结果1:

  1. var a = 1;
  2. var b = 2;
  3. // foo()
  4. a++;
  5. b = b * a;
  6. a = b + 3;
  7. // bar()
  8. b--;
  9. a = 8 + b;
  10. b = a * 2;
  11. a; // 11
  12. b; // 22

结果2:

  1. var a = 1;
  2. var b = 2;
  3. // bar()
  4. b--;
  5. a = 8 + b;
  6. b = a * 2;
  7. // foo()
  8. a++;
  9. b = b * a;
  10. a = b + 3;
  11. a; // 183
  12. b; // 180

同一段代码有两种结果仍然意味着不确定性!但是这是在函数(事件)顺序的水平上,而不是在使用线程时语句顺序的水平上(或者说,实际上是表达式操作的顺序上)。换句话说,他比线程更具有 确定性

当套用到JavaScript行为时,这种函数顺序的不确定性通常称为“竞合状态”,因为foo()bar()在互相竞争看谁会先运行。明确地说,它是一个“竞合状态”因为你不能可靠地预测ab将如何产生。

注意: 如果在JS中不知怎的有一个函数没有运行至完成的行为,我们会有更多可能的结果,对吧?ES6中引入一个这样的东西(见第四章“生成器”),但现在不要担心,我们会回头讨论它。