返回介绍

第一部分 类型和语法

第二部分 异步和性能

1.3 并行线程

发布于 2023-05-24 16:38:21 字数 4016 浏览 0 评论 0 收藏 0

术语“异步”和“并行”常常被混为一谈,但实际上它们的意义完全不同。记住,异步是关于现在将来 的时间间隙,而并行是关于能够同时发生的事情。

并行计算最常见的工具就是进程线程 。进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。

与之相对的是,事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。

并行线程的交替执行和异步事件的交替调度,其粒度是完全不同的。

举例来说:

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 。

如果共享同一数据的 JavaScript 事件并行执行的话,那么问题就变得更加微妙了。考虑 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 。

如果按照以下步骤执行,最终结果将会是什么样呢?

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 从不跨线程共享数据,这意味着不需要考虑这一层次的不确定性。但是这并不意味着 JavaScript 总是确定性的。回忆一下前面提到的,foo() 和 bar() 的相对顺序改变可能会导致不同结果(41 或 42 )。

可能目前还不是很明显,但并不是所有的不确定性都是有害的。这有时无关紧要,但有时又是要刻意追求的结果。关于这一点,本章和后面几章会给出更多示例。

完整运行

由于 JavaScript 的单线程特性,foo() (以及 bar() )中的代码具有原子性。也就是说,一旦 foo() 开始运行,它的所有代码都会在 bar() 中的任意代码运行之前完成,或者相反。这称为完整运行 (run-to-completion)特性。

实际上,如果 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 的特性 中,这种函数顺序的不确定性就是通常所说的竞态条件 (race condition),foo() 和 bar() 相互竞争,看谁先运行。具体来说,因为无法可靠预测 a 和 b 的最终结果,所以才是竞态条件。

如果 JavaScript 中的某个函数由于某种原因不具有完整运行特性,那么可能的结果就会多得多,对吧?实际上,ES6 就引入了这么一个东西(参见第 4 章),现在还不必为此操心,以后还会再探讨这一部分!

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文