这是 3 部分系列的第 3 篇文章
- 内存管理速成课程
- ArrayBuffers 和 SharedArrayBuffers 动画介绍
- 使用 Atomics 避免 SharedArrayBuffers 中的竞态条件
在上一篇文章中,我谈到了如何使用 SharedArrayBuffers 会导致竞态条件。这使得使用 SharedArrayBuffers 变得困难。我们不期望应用程序开发人员直接使用 SharedArrayBuffers。
但是,在其他语言中具有多线程编程经验的库开发人员可以使用这些新的低级 API 来创建更高级别的工具。然后,应用程序开发人员可以使用这些工具,而无需直接接触 SharedArrayBuffers 或 Atomics。
尽管您可能不应该直接使用 SharedArrayBuffers 和 Atomics,但我认为了解它们的工作原理仍然很有趣。因此,在本文中,我将解释并发可能带来的哪些类型的竞态条件,以及 Atomics 如何帮助库避免它们。
但首先,什么是竞态条件?
竞态条件:您可能见过的示例
当您有一个在两个线程之间共享的变量时,就会发生一个非常简单的竞态条件示例。假设一个线程想要加载一个文件,而另一个线程检查该文件是否存在。它们共享一个变量 fileExists
来进行通信。
最初,fileExists
被设置为 false。
只要线程 2 中的代码先运行,文件就会被加载。
但是,如果线程 1 中的代码先运行,那么它将向用户记录错误,说明文件不存在。
但这并不是问题。问题不是文件不存在。真正的问题是竞态条件。
许多 JavaScript 开发人员甚至在单线程代码中也遇到了这种竞态条件。您不必了解多线程,就能看出为什么这是一个竞态条件。
但是,有些类型的竞态条件在单线程代码中是不可能的,但当您使用多个线程以及这些线程共享内存进行编程时,这些竞态条件就会发生。
不同类型的竞态条件以及 Atomics 如何帮助解决
让我们探索在多线程代码中可能出现的不同类型的竞态条件,以及 Atomics 如何帮助防止它们。这并不涵盖所有可能的竞态条件,但应该让您了解为什么 API 提供了这些方法。
在我们开始之前,我要再次说明:您不应该直接使用 Atomics。编写多线程代码是一个众所周知的问题。相反,您应该使用可靠的库来处理多线程代码中的共享内存。
说完这些…
单个操作中的竞态条件
假设您有两个线程正在递增同一个变量。您可能认为,无论哪个线程先执行,最终结果都将相同。
但是,即使在源代码中,递增一个变量看起来像一个单独的操作,但当您查看编译后的代码时,它并不是一个单独的操作。
在 CPU 级别,递增一个值需要三个指令。这是因为计算机既有长期内存,也有短期内存。(我在另一篇文章中详细介绍了所有这些工作原理)。
所有线程共享长期内存。但短期内存(寄存器)在线程之间不共享。
每个线程都需要将内存中的值拉到其短期内存中。之后,它可以在短期内存中对该值运行计算。然后,它将该值从其短期内存写回到长期内存。
如果线程 1 中的所有操作都先发生,然后线程 2 中的所有操作都发生,那么我们将得到我们想要的结果。
但是,如果它们在时间上交错,线程 2 拉到其寄存器中的值将与内存中的值不同步。这意味着线程 2 不会考虑线程 1 的计算。相反,它只是用自己的值覆盖了线程 1 写入内存的值。
原子操作要做的一件事是将这些人们认为是单一操作的操作(但计算机认为是多个操作)变成计算机也将其视为单一操作的操作。
这就是为什么它们被称为原子操作。因为它们将通常具有多个指令的操作(其中指令可以暂停和恢复)变成似乎是瞬时发生的指令,就好像它是一个指令一样。就像一个不可分割的原子。
使用原子操作,递增的代码看起来会略有不同。
现在我们正在使用 Atomics.add
,递增变量所涉及的不同步骤不会在线程之间混淆。相反,一个线程将完成其原子操作,并阻止另一个线程开始。然后另一个线程将开始其自己的原子操作。
帮助避免此类竞态条件的 Atomics 方法是
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/add">Atomics.add</a>
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/sub">Atomics.sub</a>
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/and">Atomics.and</a>
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/or">Atomics.or</a>
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/xor">Atomics.xor</a>
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/exchange">Atomics.exchange</a>
您会注意到,此列表相当有限。它甚至不包括除法和乘法之类的东西。不过,库开发人员可以为其他事物创建类似原子的操作。
为此,开发人员将使用 <a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/compareExchange">Atomics.compareExchange</a>
。使用它,您可以从 SharedArrayBuffer 获取一个值,对其执行操作,并且只有在您首次检查后没有其他线程更新它时才将其写回到 SharedArrayBuffer。如果另一个线程更新了它,那么您可以获取该新值并重试。
跨多个操作的竞态条件
因此,这些原子操作有助于避免“单个操作”期间的竞态条件。但有时您希望更改对象上的多个值(使用多个操作),并确保没有其他人同时对该对象进行更改。基本上,这意味着在对对象的每次更改过程中,该对象都被锁定,其他线程无法访问它。
Atomics 对象不提供任何直接处理此问题的工具。但它确实提供了库作者可以使用来处理此问题的工具。库作者可以创建的是一个锁。
如果代码想要使用锁定的数据,它必须获取数据的锁。然后,它可以使用该锁来锁定其他线程。只有在锁处于活动状态时,它才能访问或更新数据。
要构建一个锁,库作者可以使用 <a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wait">Atomics.wait</a>
和 <a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wake">Atomics.wake</a>
,以及其他方法,例如 <a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/compareExchange">Atomics.compareExchange</a>
和 <a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/store">Atomics.store</a>
。如果您想了解这些方法的工作原理,请查看这个基本的锁实现。
在这种情况下,线程 2 将获取数据的锁,并将 locked
的值设置为 true。这意味着线程 1 无法访问数据,直到线程 2 解锁。
如果线程 1 需要访问数据,它将尝试获取锁。但由于锁已经被使用,它无法获取。然后,该线程将等待(因此它将被阻塞),直到锁可用。
一旦线程 2 完成,它将调用解锁。锁将通知一个或多个等待的线程,它现在已可用。
然后,该线程可以获取锁,并锁定数据以供自己使用。
锁库将使用 Atomics 对象上的许多不同方法,但对这种情况最重要的方法是
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wait">Atomics.wait</a>
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wake">Atomics.wake</a>
由指令重新排序引起的竞态条件
Atomics 处理了第三个同步问题。这一个可能会让人惊讶。
您可能没有意识到,但您正在编写的代码很可能没有按照您期望的方式运行。编译器和 CPU 都会重新排序代码,以使其运行得更快。
例如,假设您编写了一些代码来计算一个总数。您希望在计算完成后设置一个标志。
要编译它,我们需要决定为每个变量使用哪个寄存器。然后,我们可以将源代码转换为机器指令。
到目前为止,一切如预期。
如果您不了解计算机在芯片级别的运行方式(以及它们用来执行代码的管道的运行方式),那么不明显的是,代码中的第 2 行需要等待一会儿才能执行。
大多数计算机将运行指令的过程分解为多个步骤。这样可以确保 CPU 的所有不同部分始终处于忙碌状态,从而最大限度地利用 CPU。
以下是一个指令通过管道执行的步骤示例
- 从内存中获取下一条指令
- 确定指令告诉我们做什么(即解码指令),并从寄存器获取值
- 执行指令
- 将结果写回寄存器
所以这就是一条指令如何通过管道执行。理想情况下,我们希望第二个指令紧随其后。一旦它进入阶段 2,我们希望获取下一条指令。
问题是指令 #1 和指令 #2 之间存在依赖关系。
我们只需暂停 CPU,直到指令 #1 更新了寄存器中的 subTotal
。但这会降低速度。
为了提高效率,许多编译器和 CPU 会对代码进行重新排序。它们将查找不使用 subTotal
或 total
的其他指令,并将它们移动到这两行之间。
这样可以保持稳定的指令流通过管道。
因为第 3 行不依赖于第 1 行或第 2 行中的任何值,所以编译器或 CPU 认为将其重新排序是安全的。当你在单线程中运行时,无论如何,在整个函数完成之前,其他代码甚至看不到这些值。
但是,当你在另一个处理器上同时运行另一个线程时,情况并非如此。另一个线程不必等到函数完成才能看到这些更改。它几乎可以在它们被写回内存后立即看到它们。因此,它可以判断 isDone
是在 total
之前设置的。
如果你使用 isDone
作为 total
已被计算并准备好在另一个线程中使用的标志,那么这种重新排序将导致竞争条件。
原子操作试图解决其中的一些错误。当你使用原子写入时,就像在代码的两个部分之间放置了一个栅栏。
原子操作不会相对于彼此重新排序,并且其他操作也不会围绕它们移动。特别是,经常用于强制排序的两个操作是
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/load">Atomics.load</a>
<a href="https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/store">Atomics.store</a>
保证函数源代码中 Atomics.store
上方的所有变量更新都在 Atomics.store
完成将值写回内存之前完成。即使非原子指令相对于彼此重新排序,它们也不会被移动到 Atomics.store
调用下方,而 Atomics.store
调用位于源代码下方。
并且保证函数中 Atomics.load
之后的所有变量加载都在 Atomics.load
获取其值之后完成。同样,即使非原子指令重新排序,它们也不会被移动到 Atomics.load
上方,而 Atomics.load
在源代码中位于它们上方。
注意:我在这里显示的 while 循环被称为自旋锁,它非常低效。如果它在主线程上,它会导致你的应用程序停止。你几乎肯定不想在实际代码中使用它。
再次强调,这些方法并不是真正用于应用程序代码的直接使用。相反,库会使用它们来创建锁。
结论
编程多个共享内存的线程非常困难。有许多不同类型的竞争条件正等待着你。
这就是你不想直接在应用程序代码中使用 SharedArrayBuffers 和 Atomics 的原因。相反,你应该依赖于经验丰富的多线程开发人员创建的经过验证的库,这些开发人员已经花时间研究了内存模型。
SharedArrayBuffer 和 Atomics 仍然处于早期阶段。这些库还没有被创建。但是这些新的 API 提供了构建的基础。
关于 Lin Clark
Lin 在 Mozilla 的高级开发部门工作,专注于 Rust 和 WebAssembly。
5 条评论