「译」内存管理碰撞课程

翻译自:Avoiding race conditions in SharedArrayBuffers with Atomicsgit

这是图解 SharedArrayBuffers 系列的第三篇:github

  1. 内存管理碰撞课程
  2. 图解 ArrayBuffers 和 SharedArrayBuffers
  3. 用 Atomics 避免 SharedArrayBuffers 竞争条件

译者注:文中会屡次出现“线程(threads)”,这个翻译其实并不许确,但不会妨碍理解编程

上篇文章我介绍了什么状况下使用 SharedArrayBuffers 会致使竞争条件,这让使用 SharedArrayBuffers 变得很困难,咱们并不但愿应用开发者直接就这么使用 SharedArrayBuffers安全

可是在多线程编程方面经验丰富的库开发者可使用这些底层 API 创造出高级的工具,应用开发者能够直接使用这些工具而不用去直接接触 SharedArrayBuffers 和 Atomics多线程

即便你工做中不须要直接接触 SharedArrayBuffers 和 Atomics,我以为去理解它的工做原理也是颇有意思的。所以,在这篇文章里我会解释下哪些竞争条件会产生,以及 Atomics 是如何解决这些问题的函数

可是,首先,什么是竞争条件呢?工具

竞争条件:以前看过的例子

若是有两个线程使用同一个变量,那么就有可能产生竞争条件,这是最简单的状况。再具体点,假设一个线程要加载一个文件,而另外一个线程要检查这个文件是否存在(译者注:这里应该是检查并设置存在标志位),它们会使用到同一个变量 fileExists 去通讯atom

初始的时候,fileExists 被设置为 false线程

一旦线程 2 先运行,文件就会被加载翻译

可是若是线程 1 先运行,就会向用户抛一个错误,说文件不存在

可是这不是问题的关键,文件存在与否问题不大,真正的问题在于竞争条件

即便在单线程代码里,许多 JavaScript 开发者也会遇到这类竞争条件,你不须要理解多线程就能搞明白为何会竞争

然而,有些竞争条件在单线程里就无法发生,只可能在有内存共享的多线程里发生

不一样类型的竞争条件以及 Atomics 是如何解决的

如今说点多线程里不一样类型的竞争条件,看看如何用 Atomics 解决的。这个并无覆盖全部状况,可是却会给你提供一些思路去理解为何 Atomics 的 API 会提供这些方法

开始以前,须要再次重申:你不该该直接使用 Atomics!写多线的代码原本就是个很苦难的事情,你应该直接使用可靠的库去处理多线程中共享内存问题

单个运算的竞争条件

假设有两个线程同时增长某个变量的值,你可能认为,不管哪一个线程先运行,最终的结果是同样的

在代码里,即便增长一个变量这种操做看起来像是一个操做,但若是看到编译后的代码,会发现并非

从 CPU 层面看,增长一个变量值须要三条指令,这是由于计算机同时有长期存储器和短时间存储器(这个在其它文章里会说)

全部的线程共享同一个长期存储器(内存),可是短时间存储器(寄存器)并非共享的

每一个线程须要把值先从内存搬到寄存器,以后就能够在寄存器上进行计算了,再而后会把计算后的值写回内存

若是线程 1 的全部的操做都先执行,以后执行全部线程 2 的操做,最终会获得咱们的预期的结果

可是,若是它们间隔着执行,从线程 2 的里移到寄存器的值就没法与内存的值同步了,这意味着线程 2 会没法用到线程 1 的计算结果。相反,它线程 2 会用覆盖掉线程 1 写回内存的值

原子操做作的一件事就是在多线程中让计算机按照人所想的单操做方式工做

这就是为何被叫作原子操做,由于它可让一个包含多条指令(指令能够暂停和恢复)的操做执行起来像是一会儿就完了,就好像一条指令,相似一个不可分割的原子

使用原子操做会让加法变得有点不同

如今,咱们可使用 Atomics.add 了,加法执行过程当中不会由于多线程而被打乱。一个线程在执行完原子操做前会阻止其它线程执行,以后其它线程才会执行本身的原子操做

Atomics 中帮助避免竞争的方法有:

你会发现这个列表数量颇有限,甚至没有除法和乘法。不过,库的开发者会提供相似这些常见原子操做的

库的开发者会借助 Atomics.compareExchange 从 SharedArrayBuffer 拿到值,应用相应的操做,而后只有在自上次检查到如今没有其它线程更新的状况下才会去写回。若是期间有其它线程更新了,则会先拿到新的值从新运算一次

多运算的竞争条件

这些 Atomic 运算符成功避免了“单运算”中的竞争条件。可是,有时你会同时改变一个对象上的多个值(使用多个运算),在此期间,你并不但愿有其它的任务也在修改这个对象。简单说,就是在你修改这个对象期间,这个对象是处于禁闭状态,其它线程不能够访问

Atomics 没有提供任何方法去作这个事,可是却为库开发者提供了相应的方案,库开发者能够经过锁来达到目的

若是代码想使用某个被锁住的数据,首先它须要去请求锁,以后它会用这个锁把其它线程锁在外面,只有它能够访问和更新这块数据

库开发者会经过使用 Atomics.waitAtomics.wake,以及可选的 Atomics.compareExchangeAtomics.store 建立一个锁。想了解更多能够看下这篇文章 简单锁的实现

这种状况下,线程 2 会请求到锁,并把值设置为 true,这意味着直到线程 2 交出锁前,线程 1 是没法访问的

若是线程 1 想要访问这块数据,它会试图请求锁。可是由于锁处于被使用状态,它没法拿到,它因而只能出于等待状态直到锁可用

一旦线程 2 结束了,它会调用 unlock,锁会通知其它等待的线程本身空出来啦

那个线程就会拿起锁,锁住数据供本身使用

实现一个锁可能须要依赖不少 Atomics 的方法,可是用的最多的是下面两个:

指令重排致使的竞争条件

这里还有第三种同步问题须要用 Atomics 处理,这类问题可能会很神奇

你可能感受不到,你写的代码极可能根本没按你指望的顺序执行,由于编译器和 CPU 会尝试重排指令使得代码更快地运行

好比,你写了一些代码去计算总和,你想的是计算完了要设置一个标记

编译的时候须要决定每一个变量该用哪一个寄存器,以后就能够把代码翻译成机器的指令了

目前为止,一切都在掌握中

若是你对计算机芯片级的原理不理解的话,可能你没发现到第 2 行须要等待下才能执行

大多数的计算机会把一个指令拆分为多个步骤,这使得 CPU 能够被充分利用

下面是一个指令执行步骤的例子:

  1. 从内存里拿到下一个指令
  2. 指令解码,从寄存器拿值
  3. 执行指令
  4. 结果写回寄存器




这就是指令如何像流水线工人同样工做,理想的状况是第二个指令会牢牢地跟着第一个指令,当第一个指令进行到步骤 2 的时候,第二个指令进行步骤 1

问题是,指令 1 和指令 2 存在依赖

CPU 须要一直等待直到指令 1 更新了寄存器里的 subTotal,可是这就使执行变慢了

为了让这一切更加高效,不少编译器和 CPU 会记录好代码,找到不依赖 subTotaltotal 的指令,而后移到两个指令之间

这会让指令执行保持着一个很稳定的流水线

由于第三行不依赖任何前两行的值,编译器和 CPU 认为它是安全的。在单线程里运行时,直到运行完不会有其它代码看到这些

可是当有另外一个 CPU 上的线程也在同时运行,状况就不妙了。其它线程不须要一直等到函数执行完毕,只要值写到内存里它就能够看到,所以,它会认为 isDone 是在 total 前设置的

若是你用 isDone 做为 total 被计算好用于其它线程的标记,这里就会产生竞争条件

Atomics 试图去解决这些问题,使用 Atomic 的时候就像在代码块上加了个围栏

Atomic 操做相互之间不会重排,其它操做也不会移动到它们的周围。其中,有两个常常用到的操做:

Atomics.store 以前的代码能够保证在 Atomics.store 以前运行完并把值写回内存。即便非原子指令相互之间重排了,也不会移到 Atomics.store 的下面

全部 Atomics.load 后面的变量能够保证只会在 Atomics.load 后面取得值。即便非原子指令重排了,也不会有指令会移到 Atomics.load 上面

提示:这里我写的一个 while 循环使用了自旋锁,很低效。若是它在主线程上运行的话,会让你的应用程序有无响应一段时间,你不该该在实际代码里用

再次提醒,这些方法不建议直接在应用程序里使用,库开发者会用这些创造锁供使用

总结

有内存共享的多线程编程是很困难的,有太多竞争条件的陷进等着你往里跳

这就是为何你不会喜欢直接在应用程序里使用 SharedArrayBuffers 和 Atomics。相反,你应该使用一个由多线程方面经验丰富的开发者开发的可靠的库,他确定会内存模型研究很透彻

SharedArrayBuffer 和 Atomics 才出来没多久,这样的库尚未呢,可是新的 API 已经足够去构建这些

By Cody

相关文章
相关标签/搜索