写点什么

图说前端 - 使用 Atomics 避免 SharedArrayBuffers 中的 race conditions(3/3)

用户头像
梦见君笑
关注
发布于: 2020 年 07 月 11 日
图说前端-使用Atomics避免SharedArrayBuffers中的race conditions(3/3)

本系列共三篇



上一篇文章 ,我谈到了使用 SharedArrayBuffers 如何导致竞态条件(race conditions)。这使得使用 SharedArrayBuffers 变得困难。我不建议应用程序开发人员直接使用 SharedArrayBuffers。



但是有多线程编程经验的library开发人员可以使用这些新的底层api来创建封装的工具。然后,应用程序开发人员可以使用这些工具,而无需直接接触 SharedArrayBuffers 或 Atomics 。





即使不直接使用 SharedArrayBuffers 和 Atomics ,我认为理解它们是如何工作的仍然很重要。因此,在本文中,我将解释并发会带来什么类型的竞态条件(race conditions),以及 Atomics 如何避免它们。



但是首先,什么是竞态条件?





竞态条件: 你以前可能见过的一个例子

当您有一个变量需要在两个线程之间共享时,可能会发生一个非常简单的竞态条件(race conditions)示例。假设一个线程想要加载一个文件,另一个线程检查它是否存在。他们共享一个变量 fileExists 进行交流。

最初, fileExists 设置为false。





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





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





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



许多JavaScript开发人员已经遇到了这种竞态条件(race conditions),即使在单线程代码中也是如此。你不需要理解任何关于多线程的东西就能明白这是一场竞争。



即使有些竞态条件(race conditions)在单线程代码中是不存在的,但是当你使用多个线程编程并且这些线程共享内存时,这种情况就会发生。

不同类别的竞态条件以及 Atomics 工作原理

让我们来探索一下多线程代码中可能存在的一些不同类型的竞态条件(race conditions),以及 Atomics 如何避免竞态条件。这并没有涵盖所有的竞态条件(race conditions),但是应该能够让你知道为什么API提供了它所提供的方法。

在我们开始之前,我想再强调一遍: 你不应该直接使用 Atomics ,编写多线程代码是一个已知的难题。相反,您应该使用可靠的library来处理多线程代码中的共享内存。





别自讨苦吃…

1、单个操作中的竞态条件(race conditions)

假设您有两个线程递增相同的变量。你可能会认为最终结果是一样的,不管哪个线程先走。





但是,在源代码中,递增变量看起来像一个单独的操作,但是当您查看编译的代码时,它不是一个单独的操作。

在CPU中,递增一个值需要三个指令。原因是因为电脑既有长期存储器(long-term memory又有短期存储器(short-term memory





所有线程共享长期存储器,但是短期存储器 -- 寄存器 -- 并不会在线程之间共享的。



每个线程都需要将值从内存中提取到其寄存器中。之后,它可以在寄存器中运行该值的计算。然后,它将该值从其短期存储器写回长期存储器。





如果线程 1 中的所有操作首先发生,其次线程 2 中的所有操作发生,我们将得到预期的结果。





但是如果它们在时间上交错,线程 2 拉入寄存器的值与内存中的值不同步。这意味着线程 2 不考虑线程 1 的计算结果。而是直接用自己的值来覆盖线程1写入内存中的值。





Atomics操作做的一件事就是把这些“人类认为是单一操作,但计算机认为是多重操作,并使计算机也把它们看作是单一操作”。



这就是为什么它们被称为 Atomics 操作。因为他们采取了一个通常会有多个指令的操作 -- 在那里指令可以被暂停和恢复 -- 这使得它们看起来都在瞬间发生,好像这是一个指令。它就像一个不可分割的原子 。





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





现在我们正在使用 Atomics.add ,增加变量所涉及的不同步骤不会在线程之间混淆。相反,一个线程将完成其 Atomics 操作,并阻止另一个线程启动。然后另一个将开始自己的 Atomics 操作。





有助于避免这种竞态的 Atomics 方法有:

  • Atomics.add

  • Atomics.sub

  • Atomics.and

  • Atomics.or

  • Atomics.xor

  • Atomics.exchange

你会注意到这个列表相当有限。它甚至不包括除法和乘法。不过,library开发人员可以按照自己的需求创建类似 Atomics 的操作。



为了实现这个功能,开发人员可以使用 Atomics.compareExchange 。这样,您就可以从SharedArrayBuffer中获取一个值,对其执行操作,并且只有在您第一次检查后没有其他线程更新它的情况下,才将其写回SharedArrayBuffer。如果另一个线程更新了它,那么您将获取新值并重新计算。



2、跨多个操作的竞态条件(race conditions)

这些 Atomics 操作有助于避免 “单一操作” 期间的竞态条件(race conditions)。但是有时你想改变一个对象上的多个值 (使用多个操作),并确保没有其他人同时对该对象进行改变。这意味着在对该对象的每次更改过程中,该对象处于锁定状态,其他线程无法访问。



Atomics对象不提供任何直接处理此问题的方法。但它提供了用来处理这一问题的工具--创建一个锁。





如果代码想要使用锁定的数据,它必须获取数据的锁。然后它可以使用锁来锁定其他线程。只有当锁处于活动状态时,它才能访问或更新数据。

为了建立锁,library的作者可以使用 Atomics.waitAtomics.wake , 还有其他的比如Atomics.compareExchangeAtomics.store 。如果你想知道这些是如何工作的,看看这个 基本锁实现

在这种情况下,线程 2 将获取数据的锁,并设置 locked 为true。这意味着线程 1 在线程 2 解锁之前无法访问数据。





如果线程 1 需要访问数据,它将尝试获取锁。但是由于锁已经在使用,所以它不能操作。然后线程将处于等待状态--因此它将被阻止--直到锁可用。





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





然后,等待中的一个线程可以获取锁并锁定数据以供自己使用。





提供锁的library将在Atomics对象上使用许多不同的方法,最重要的两个方法是:

  • Atomics.wait

  • Atomics.wake

3、指令重新排序引起的竞态条件(race conditions)

Atomics 处理第三个同步问题,可能会令人惊讶。



你可能没有意识到这一点,但是你正在编写的代码很有可能没有按照你期望的顺序运行。编译器和cpu都会重新排序代码以使其运行更快。



假设您已经编写了一些代码来计算总数。希望在计算完成时设置一个标志。





为了编译这个,我们需要决定每个变量使用哪个寄存器。然后我们可以把源代码翻译成机器的指令。





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



以下是指令所经历步骤的一个例子:

  1. 从内存中获取下一个指令

  2. 找出指令告诉我们要做什么 (也就是解码指令),并从寄存器中获取值

  3. 执行指令

  4. 将结果写回寄存器











这就是一条指令通过管道的方式。理想情况下,我们希望第二条指令紧随其后。一旦它进入第2阶段,我们就要获取下一条指令。问题是指令1和指令2之间存在依赖关系。





我们可以暂停CPU,直到指令1更新了寄存器中的subTotal。但这会让CPU运行变慢。



为了使CPU运行更有效,许多编译器和cpu都会做代码重新排序。他们会寻找其他不使用 subTotaltotal 的指令,并将它们移到这两行之间。





这使得指令流在管道中源源不断,持续保持有指令执行



因为第 3 行不依赖于第 1 行或第 2 行中的任何值,编译器或CPU认为像这样重新排序是安全的。当您在单个线程中运行时,在完成整个函数之前,其他代码都不会看到这些值。



但是当您在另一个处理器上同时运行另一个线程时,情况就不是这样了。另一个线程不必等到函数完成后才能看到这些更改。它几乎一写回内存的同时就能看到它们。所以可以看出isDone是在total之前设置的。



如果您使用isDone作为一个标志,表示总数已经计算出来,并且可以在另一个线程中使用,那么这种重新排序将导致竞态条件(race conditions)



Atomics 试图解决其中一些缺陷。当您使用 Atomics 写入时,就像在代码的两个部分之间设置栅栏一样。

Atomics 操作不会相对于彼此重新排序,其他操作也不会围绕它们移动。通常用于强制排序的两个操作是:

  • Atomics.load

  • Atomics.store



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



和之后的所有变量加载 Atomics.load 在一个函数中,保证在 Atomics.load 获取其值一样。即使非 Atomics 指令被重新排序,它们都不会被移动到 Atomics.load 在源代码中高于它们。





注意: 我在这里展示的while循环被称为spinlock(自循环),效率非常低。如果它在主线程上,会导致你的应用程序停止运行。你肯定不想在真实的代码中使用它。

再说一次,这些方法并不是要直接在应用程序代码中使用。相反,library将使用它们来创建锁。

结论

编写共享内存的多个线程是很困难的。有许多不同的竞态条件的坑等着你去踩。





这就是为什么不建议在你的应用程序中直接使用 SharedArrayBuffers 和 Atomics 。而是推荐你去使用经验丰富的多线程开发人员以及花费时间研究内存模型的开发人员开发的library。



对于SharedArrayBuffer和Atomics来说,还处于早期阶段。那些library还没有创建。但是这些新的api提供了构建的基础。



用户头像

梦见君笑

关注

还未添加个人签名 2018.03.28 加入

还未添加个人简介

评论

发布
暂无评论
图说前端-使用Atomics避免SharedArrayBuffers中的race conditions(3/3)