《ArrayBuffers和SharedArrayBuffers的介绍》一文中,我谈到了在使用SharedArrayBuffers时是如何可能导致竞争条件的,这使得大家很难使用SharedArrayBuffers。另外在文章的最后我还提到不希望应用程序开发人员直接使用SharedArrayBuffers。

但是,具有其他语言的多线程编程经验的库开发人员可以使用这些新的低级API来创建更高级别的工具。如果是这样,那么应用程序开发人员则可以直接使用这些工具而不用接触SharedArrayBuffers或Atomics。

即使你可能用不到SharedArrayBuffers或Atomics,但我认为了解它们是如何工作的,仍然会有助于你日后的程序开发。所以在本文中,我将介绍并发可以带来什么样的竞争条件,以及Atomics如何帮助库避免它们。

但首先,需要知道什么是竞争条件?

竞争条件指多个线程或者进程在读写一个共享数据时结果依赖于它们执行的相对时间的情形,竞争条件发生在当多个进程或者线程在读写数据时,其最终的结果依赖于多个进程的指令执行顺序。假设两个进程P1和P2共享了变量a。在某一执行时刻,P1更新a为1,在另一时刻,P2更新a为2。因此两个任务竞争地写变量a。在这个例子中,竞争的“失败者”(最后更新的进程)决定了变量a的最终值。多个进程并发访问和操作同一数据且执行结果与访问的特定顺序有关,称为竞争条件。

简单的说,就是当一个变量在两个线程之间共享时,就很可能发生一个非常简单的竞争情境。假设一个线程想加载一个文件,另一个线程则要检查它是否存在。他们共享一个变量file_exists函数来检查文件或目录是否存在,以方便进行通信。

最初,fileExists设置为false。

只要线程2中的代码首先运行,文件将被加载。

但如果线程1中的代码首先运行,那么它将向用户记录一个错误,表示该文件不存在。

但这不是问题的所在,不是文件不存在,真正的问题是竞争条件。

许多JavaScript开发人员经常会遇到这种竞争条件,即使是在单线程代码的情境之下。你不必理解有关多线程的任何内容,只需看看为什么会发生竞争条件。

但是,有些类型的竞争条件在单线程代码中是不可能发生的,但是当你使用多个线程进行编程并且这些线程共享内存时,可能会发生这种情况。

Atomics是如何处理不同类别的竞争条件的?

让我来介绍一些不同类型的竞争条件以及你如何多线程代码中使用Atomics防止竞争条件,不过,这不包括所有可能的竞争条件,但通过我的介绍,你应该给能对API的做法有所了解。

在开始之前,我想再说一遍,你不应该直接使用Atomics。编写多线程代码是一个众所周知的难题。所以,你应该使用可靠的库来使用多线程代码中的共享内存。

单一线程操作中的竞争条件

假设你有两个线程增加相同的变量,无论哪个线程首先运行,你可能都会认为最终的结果将是一样的。 

但即使在源代码中,增加一个变量看起来就像一个操作,当你查看编译的代码时,它其实不是一个单一的操作。

在CPU级别,增加一个值需要三条指令,这是因为计算机具有长期记忆和短期记忆。 

所有线程共享长期记忆,但基于寄存器的短期记忆是不会在线程之间共享的。

每个线程都需要将内存中的值从其内存中取出来,之后,可以在短期记忆中对该值进行计算。然后它将这个值从短期记忆回溯到长期记忆。

如果线程1中的所有操作首先发生,然后线程2中的所有操作都会发生,你将最终得到想要的结果。

但是如果它们在时间上是交错的,则线程2已经被拉入其寄存器的值与内存中的值不同步。这意味着线程2不考虑线程1的计算值,也就是说,它只是消除了线程1,用自己的值写入内存的值。

Atomics操作所做的一件事是将人们认为是单一操作而计算机视为多个操作的这些操作,让计算机统一将它们视为单个操作。这个操作之所以被称为Atomics操作的原因是因为他们采取的操作通常会有多个指令,指令可以暂停和恢复,并且可以使它们全部发生在瞬间,就像是一个指令一样,这就像一个不可分割的Atomics。

使用Atomics操作,增量代码看起来有点不同。

现在我们使用的是Atomics.add,递增变量所涉及的不同步骤不会在线程之间混合。相反,会按着顺序,当一个线程进行其Atomics操作时,会阻止另一个线程启动。然后等操作完成后,另一个将开始自己的Atomics操作。

避免竞争条件发生的Atomics方法有:

· Atomics.add

· Atomics.sub

· Atomics.and

· Atomics.or

· Atomics.xor

· Atomics.exchange

你会注意到这个列表是相当有限的,它甚至不包括分割和倍增的办法等。不过,库开发人员可以为其他情境创建类似Atomics的操作。

为此,开发人员将使用Atomics.compareExchange。这样,你可以从SharedArrayBuffer获取值,对其执行操作,并且只有在你首次检查后,确认没有其他线程已更新的情况下才将其写回SharedArrayBuffer。如果另一个线程已更新,那么你可以获得新值,然后重试。

多个线程操作中的竞争条件

通过上面的介绍,你已经了解了这些Atomics操作有助于在单次操作期间避免竞争条件。但有时你想要更改对象上的多个值即使用多个操作,并确保没有其他人同时对该对象进行更改。基本上,这意味着在对对象的每次更改通过期间,该对象都处于锁定状态,而其他线程无法访问。

Atomics对象虽然不提供任何工具来直接处理,但它确实提供了库作者可以用来处理这个问题的工具。库作者可以创建一个锁。

如果代码想要使用锁定的数据,它必须对该数据进行解锁。同样它也可以使用锁来锁定其他线程。只有在解锁成功时,才能访问或更新数据。

为了构建一个锁,库作者将使用Atomics.wait和Atomics.wake,加上其他的工具,如Atomics.compareExchange和Atomics.store。如果你想看看它们是如何工作的,看看这个

在这种情况下,线程2将获取数据锁,并将锁定值设置为true。这意味着线程1无法访问数据,直到线程2解锁。

如果线程1需要访问数据,它将尝试获取锁。但是由于锁已经在使用,所以不能重复使用。这时线程就会处于等待状态,所以它将被阻止,直到被解锁。

一旦线程2完成,它将调用解锁,该锁将通知一个或多个等待线程现在可用。

这样使用锁的线程就会锁定数据供自己独自使用:

锁库将使用Atomics对象上的许多不同方法,其中最重要的方法是:

· Atomics.wait;

· Atomics.wake;

由指令重新排序引起的竞争条件

经过测试,Atomics可以同时处理三个同步问题,这确实非常令人惊讶。

你可能没有意识到这一点,但是你写的代码并不是按照你期望的顺序来运行。编译器和CPU都会重新排序你编写的代码,使其运行速度更快。

例如,假设你已经编写了一些计算总和的代码,你想在计算结束时设置一个标志。

为了编译这个代码,你需要决定每个变量使用哪个寄存器。之后,你可以将源代码转换为该设备的说明。

到目前为止,一切都如预期。

如果你不了解计算机在芯片级别的工作原理以及它们用于执行代码工作的流水线,那你的代码中的第2行需要稍等一下才能执行。

大多数计算机将运行指令的过程分解成多个步骤,这样可以确保CPU的所有不同部分始终处于运行状态,这样才能充分利用CPU的性能。

以下是我在处理一个实际案例时的步骤:

1.从内存中读取下一条指令;

2.找出告诉我要做什么的指令,也就是解码指令,并从寄存器获取该值;

3.执行指令;

4.将结果写回寄存器;

这是一条指令如何通过管道的步骤,理想情况下,我希望直接遵循第二条指令,即一旦进入第二阶段,我们就能要获取下一条指令。

问题是指令#1和指令#2之间存在依赖关系。

你可以暂停CPU,直到指令#1更新了寄存器中的subTotal,但这会减慢运行速度。

为了使运行更有效率,很多编译器和CPU将做的是重新排序代码,它们将寻找不使用subTotal或total的其他指令,并将它们移动到两行之间。

这么做就保持了稳定的指令流通过管道,因为第3行不依赖于第1行或第2行中的任何值,所以编译器或CPU表明可以像这样重新排序。当你运行在单个线程中时,不管发生什么,在整个函数完成之前,其他代码甚至不会看到这些值。

但是当另一个线程在另一个处理器上同时运行时,情况并非如此。另一个线程不需要等到函数完成才能看到这些更改。一旦它们被记录回来,它就可以看到这些值,所以该情况可以说是一个被设置在总和之前的isDone运行。

如果你使用isDone来作为计算总和的方法并准备在其他线程中使用,则这种重新排序将创建竞争条件。

Atomics试图解决一些这些错误,当你使用Atomic写入时,就像将代码放在两个部分之间。

Atomics操作相对于彼此不重新排序,其他操作也不会在其周围移动。特别是,经常用于强制排序的两个操作是:

· Atomics.load;

· Atomics.store;

在Atomics.store完成将其值并重写回内存之前,函数源代码中的Atomics.store之上的所有变量更新都将得到保证。即使非Atomics指令相对于彼此重新排序,它们都不会被移动到源代码下面的Atomics.store。

在保证Atomics.load获取其值后,在函数中的Atomics.load之后的所有值都将变为可变负载。之后,即使非Atomics指令被重新排序,它们也不会被移动到源代码之前的Atomics.load。

注意:这里显示的while循环称为自旋锁,效率非常低,自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分。如果它在主线程上,它可以使你的应用程序停止,几乎可以肯定你不想在实践中的编码中使用它。

另外,这些方法并不意味着直接用在应用程序代码中。相反,库将使用它们来创建锁。

总结

编程共享内存的多个线程很难,有很多不同种类的竞争条件会阻碍你。

这就是为什么你不想直接在应用程序代码中使用SharedArrayBuffers和Atomics。所以,你应该依赖具有多线程经验的开发人员的经过验证的库,以及经过实践验证的内存模型。

由于SharedArrayBuffer和Atomics还处于早期研发阶段,所以有很多库还尚未创建。

本文翻译自:https://hacks.mozilla.org/2017/06/avoiding-race-conditions-in-sharedarraybuffers-with-atomics/如若转载,请注明原文地址: http://www.4hou.com/technology/5636.html
源链接

Hacking more

...