前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >高并发 Javascript: 存在的!(下)

高并发 Javascript: 存在的!(下)

作者头像
疯狂的技术宅
发布2019-03-27 15:24:27
6960
发布2019-03-27 15:24:27
举报
文章被收录于专栏:京程一灯京程一灯

Transition Thread Locality(TTL) 与 Flat Butterflies

Segmented butterflies 只有在这些时候是必要的:

  • 除了分配对象以外的某个线程,试图 transition 对象的时候
  • 被分配对象的线程尝试 transition 该对象,而且其它线程可能在写入的时候

我们可以用整块的 butterfly (flat butterfly) ———— 我们现在的对象模型 ———— 在我们知道这些可能性还未发生的时候。这部分会讲一种混合的对象模型,它使用 flat 或 segmented butterfly,取决于我们是否检测到可能的写 transition 的竞争(write-transition races)。这种对象模型也让我们可以在执行多次 transition 避免锁机制。

在 64 位系统上,butterfly 指针有 48 个位的指针信息,在高 16 位上都是 0。16 位足够用来存:

  • 分配对象的线程 ID。我们把它叫做 butterfly TID。
  • 有一个位用来表示,除了分配线程以外,是否还有其它线程尝试向 butterfly 发起写操作。我们把它叫做 butterflyshared-write(SW) 位

一些系统有不止 48 个指针位. 在之后的部分,我们会讲即便我们能用的位更少,怎么让这种做法起作用。而且,我们会把 butterfly 的分配限制到低于 248 个地址,如果我们真的想要 16 个多余位的话。

假设被以如下方式编码:

代码语言:javascript
复制
static const uint16_t mainThreadTID = 0; // 某些情况下,我们可以对主线程优化地更多一些
static const uint16_t notTTLTID = 0x7fff; // 对于线程不再是 TTL 的情形

static inline uint64_t encodeButterflyHeader(uint16_t tid, bool sharedWrite)
{
    ASSERT(tid <= notTTLTID); // Only support 2^15 tids.
    return (static_cast<uint64_t>(tid) << 48)
         | (static_cast<uint64_t>(sharedWrite) << 63);
}

static inline uint64_t encodeButterfly(Butterfly* butterfly, uint16_t tid, bool sharedWrite)
{
    return static_cast<uint64_t>(bitwise_cast<uintptr_t>(butterfly))
         | encodeButterflyHeader(tid, sharedWrite);
}

我们可以使用 flat butterfly ———— 现有的对象模型 ———— 在任何 TID 匹配当前线程的时候。我们设置了 SW 位,而且使用了一个 magic TID 值(所有的位都被设置了)来表明 butterfly 被 segmented 了。这部分会展示如何使用这些位,可以在任何我们知道的没有 transition 竞争发生的时间,来容许 flat butterfly 的使用。我们把叫做 transition thread locality 推断。

任何时候,当一个线程尝试写入 butterfly,并且 TID 匹配当前线程的时候,它可以简单地向 butterfly 写入,而不用做任何特别的事情。

任何时候,当一个线程尝试写入 butterfly,TID 不匹配,但 SW 位被设定的时候,它也可以简单地向 butterfly 写入。

任何时候,当一个线程尝试读取 butterfly,它只需要检查 TID 以确认该读取是否应该被 segmented (额外间接指令)。

任何时候,当读写操作遇到 TID = notTTLTID,且 SW = true 的时候,它知道该使用 segemnted 对象模型了。

下面的情况需要特殊处理:

  • 某个线程,除了被 TID 标识的那个,尝试写入 butterfly,且 SW 位还未设定,这种情况下,它需要设定 SW 位。这并不意味着 butterfly 需要被 segement,这只是说 SW 位必须被设定,因此任何未来从运行线程(owning thread) transition butterfly 的尝试都会触发向 segmented butterfly 对象模型的 transition。
  • 某个线程,除了被 TID 标识的那个,尝试 transition butterfly。这种情况下,它需要设定 SW 位和所有的 TID 位,并进行一次 segmented butterfly transition。我们会在下面具体描述该过程。
  • 某个线程尝试 transition 设定好 SW 位的 butterfly,这也会强制做一次 segmented butterfly transition。
  • 即使是 TID = current 且 SW = false 的 transition 也需要锁,以确保在 transition 过程中假设不会被违背。

接下来的部分会更进一步展示更大程度上减小开销的提炼内容。首先,我们考虑怎么避免在每次操作中都去检查 TID 和 SW 位。然后我们展示怎么使用 structure 检测点来避免在最常用的 transition 上的锁。接着展示怎么处理数组重设大小。最后我们会解释 flat butterfly 是如何能被 transition 成 segmented 的更多细节。

快速检查 TID 和 SW 位

这部分将展示如何通过简单地扩展 JavascriptCore 已经使用的优化方案,让并发 JS 和串行 JS,在大多数重要情形下一样快。

JSC 使用内联缓存来优化访问堆的代码。 我们使用内联缓存来访问命名属性(像 o.f)和访问数组(像 a[i])。

在相同偏移量下,有同样属性的对象通过会共享同一个 structure,它通过对象类型头部的 32 位来识别。倘若一个属性访问可能看到同样的 structure,那么我们会为这次属性访问生成新的机器码,它会检查对象是否有期待的 structure,然后直接访问属性,错误的判断会引发内联缓存的重新编译。如果内联缓存在很长一段时间保持稳定(没有重编译太多次),并且包含它的函数符合优化的 JIT 编译条件,那么最佳 JIT 编译器也许会在它的 IR 上直接表达成内联缓存的代码,这会有两种结果:如果失败(引起优化代码执行的突然终止),structure 检查会被分流到 OSR 出口,并且所有内联缓存的代码(structure 检查、内存访问、其他步骤)都会具有用我们的 DFG 和 B3 JIT 编译器 pipeline 来低级编译的资格。我们的编译器擅长出色地执行类型稳定的 Javascript 的属性访问。

数组访问使用类似的技术来检测对象是否有数组元素,如果有,它们是怎样被格式化的。我们支持数组元素的多种存储,这取决于它们被使用的方式。内联缓存检测每个数组正在访问哪种数组,然后,我们可以发送推断出该种数组访问的代码。

另一种我们已经在使用的技术是虚拟内存。举个例子,我们的 WebAssembly 实现方案 使用虚拟内存技巧来免费地检查线性内存访问的边界。我们可以用 POSIX 信号处理器,或 Mach 异常来抓取页面错误。这个处理器会在发生错误的时点上知道确切的机器状态,也有能力转化执行成它喜欢的任何状态。WebAssembly 会用这种方式来抛出异常,但我们可以用它来把控制流转换到缓慢路径上去,这些缓慢可以处理内存访问的一般情形。从本质上说,这意味着我们可以在安全检查上保存周期,如果我们可以把检查条件表达出来的话,这里的条件是指某样能引起虚拟内存系统发布页面错误的地方。并发 JS 需要内联缓存和虚拟内存技巧相结合来使得 TID 和 SW 检查代价不要太大。

内联缓存指的是对于每个属性访问发射不同的代码,然后当我们了解到这个属性访问可以做什么的信息的时候,可能会多次重新编译每个每个属性访问。内联缓存能够每个属性访问行为的极为精确的信息,这是因为我们问题传输一个快速路径访问,这条路径仅会处理那么我们至今为止看到的实际例子。我们通过记录了解的这条路径上失败的所有内容来获取新信息。访问失败的完整日志之后会被 LUB (least-upper-bounded) 以创建一个 AccessCase 的最小集合。我们可以给新型的属性访问实现优化 ———— (这些属性访问)如必须检查 TID 和 SW 位的那些 ———— (要实现这种优化)我们应该考虑一个特定的访问地址可能以何种方式显得特殊而要专门去优化它。下面是一张我们可能遇到的列表,里面列出了内联缓存是如何测试这个条件并处理它的策略:

  • 可能很多对象访问问题会发现 TID = current 且 SW = false。这些访问只要在进行之前从 butterfly 里扣掉 encodeButterflyHeader(currentTID,0)这部分就好。如果推断出错了,虚拟内存子系统会因为非零高位而发布一个页面错误。我们可以捕获这个错误,将其作为一个 Mach 异常或是 POSIX 信号,并将执行转移到慢路径上。这个常量的扣除经常经常会直接编码在后面的 butterfly 访问指令里,因此,这些类型的访问,相较于目前有的东西来说,不会体会到任何并发 JS 中的过多开销。注意,这种优化手段不会应用到 transition,当原子化地声明没有不好的情况发生的时候,transition 必须安装一个新 butterfly ———— 我们会在下面进一步考虑那些问题。注意,这种优化会稍稍有利于主线程(以及所有单线程的程序),因为如果 currentTID = 0,那么我们不用添加任何东西。
  • 可能很多对象访问会总是看到 TID = current 且 SW = true。这些问题可以和前一种情形采用同样的优化策略。我们可以从很多线程中向我们已经知道的对象状态进行写入,但那只会在最后一次当前线程 transition 对象之后发生。
  • 可能很多对象的读操作会发现 TID != notTTLTID 且 SW = 任意值的情况。这种情况下,我们只需要去检查 butterfly 的高位不是 notTTLTID,这可以由单一的 compare/branch 来完成。除此以外,它们也可以继续我们在当前引擎里执行的方式。
  • 可能很多对象的写操作会发现 TID != notTTLTID 且 SW = true 的情况。这意味着对象正在被很多线程写入,我们也在对它写入,但我们不需要设置 SW 位因为它已经设置好了。这个检查同样可以由 compare/branch 来完成。和上面的读操作优化一起,这表明共享对象的 JS 程序通常只会遇到一个给 butterfly 访问的额外周期(这个周期是给融合的 compare/branch 的)。
  • 有时候,一个对象的写操作会发现 TID != notTTLTID 且 SW = false。这表示需要使用 DCAS 来设定 SW 位,DCAS 会在声明类型头部不改变的时候设定 SW 位。这些访问会比它们的朋友有更大的代价,但这只会在第一次任何线程写入共享对象的时候发生。我们内联缓存设施可能会这么做,以使得写入会是 branchy 的,这里的写入有时会看到 SW = false,有时会看到 SW = true(而且不一定会看到 TID = current,但决不会是 notTTLTID):会有一条快速路径给 SW= true,以及稍微慢一点但是内联的路径给 SW = false。在下一节,我们会描述需要唤醒 structure 上函数的优化方式,在该 structure 的任意对象的 SW 位被首次设定的时候。因为内联缓存在生成的时候知道其 structure,所以它能保证 structure 已经被告知了其类型的对象可能要设定 SW 位。
  • 可能很多对象访问会发现 TID = notTTLTID 且 SW = true。照常理来讲,我们觉得不太可能发生 TID = notTTLTID 且 SW = false 的情况(在我们的 VM 内部,transition 一个对象而不"writing"它在技术上是可能的,但我们假装认为它们是写入)。这部分访问会在进行前从 butterfly 里扣除 encodeButterflyHeader(notTTLTID,true),然后它们必须在从 butterfly 读取的时候执行一次额外的加载,这次额外的加载是必要的,因为 segmented 对象模型:butterfly 指针指向一个 spine,我们可以用这个 spine 来查询包含我们关心的值的 fragment。这比一次额外加载的开销稍微多些,因为它引入了一个 load-load 依赖。

这些优化留下了未解决的问题:transitions。transition 现在需要请求和释放锁。我们必须在所有我们添加任何属性的时候都能这样做。下一节,我们会展示怎么使用watchpoints解决这个问题。然后我们会讲怎么实现需要创建在 flat butterfly 之外的 segmented butterfly 的 transition 的方法。

Watchpoint 优化

transition 很难优化。每一个 transition,包括那些发现 TID = current 的 transition,需要请求对象的内部锁,以确认设置了 butterfly,调整了对象的类型头部,且在一个原子操作步中存放了新的属性值。幸运的是,我们可以通过 使用我们的引擎 structure watchpointing 设施来极大地提升 transition 的性能。

每个对象都有一个 structure。很多对象会共享同一个 structure。大部分内联缓存优化从一个检查开始,它会去检视对象的 structure 是否匹配内联缓存的期望。这表示当内联缓存被编译的时候,它有一个指针指向当前对象的 Structure。每个 structure 都有任意数目的 watchpoint 集。一个 watchpoint 集就是一个 bool 型的字段(从 valid 开始,变成 invalid),和一组 watchpoint。当这个集合被触发的时候(字段从 valid 变成 invalid),所有的 watchpoint 都被唤醒。我们可以向 Structure 中添加两组 watchpoint 集:

  • transitionThreadLocal. 只要所有含有这个 structure 的对象有 TID != notTTLTID,这就能保持有效
  • writeThreadLocal. 只要所有含有这个 structure 的对象有 SW = false,这就能保持有效

这使得下面的这种优化在内联缓存的优化策略中是上面描述的最佳策略:

  • 满足 TID = currrent 和 SW = false 的 transition 可以像它们在现有的引擎里那样工作,只有 structure 的 transitionThreadLocalwriteThreadLocal watchpoint 集都还是有效的。这就是说,在做 transition 对象的时候,不用任何额外的锁或 CAS,即使其他线程正在并发地读取对象的时候,这也可以正常工作。甚至在组装一个最终会被读写的对象的时候,这也是有用的,因为在这种情况下, writeThreadLocal watchpoint 集只会触发用来组装对象的最终 transition 目标的 structure。最后的 transition 可以继续工作而不用锁,因为 transition 源的 watchpoint 集仍旧是有效的
  • 如果 structure 的 transitionThreadLocal watchpoint 集仍旧有效,且一个 watchpoint 已经安装的话,任何检查 TID != notTTLTID 的部分都可以删除
  • 如果 structure 的 writeThreadLocal watchpoint 集仍旧有效,且 watchpoint 已安装的话,所有检查 SW == false 的部分都可以删除

对于可以删掉 TID != notTTLTID 和 SW == false 检查这样的结果来说,这表示对这些对象的读写实际上不用检查任何东西,它们只需要屏蔽掉 butterfly 的高位就可以了

最重要的是,这意味着只要包含的 structure 具有有效的 transitionThreadLocalwriteThreadLocalwatchpoint 集,那么发生在被分配了对象的同一个线程上的 transition,不需要任何锁机制。structure 是由我们的引擎来动态推断的,这是为了能够紧贴创建那些对象的代码。因此,如果你写了一个 constructor,它往 this 加了很多字段但并没有摆脱 this,那么发生在 constructor 里的 transition 链的相应 structure 都会有一个有效的 transitionThreadLocalwriteThreadLocal watchpoint 集。为了确保对象仍然拥有 flat butterfly,你要么别往对象里动态添加属性,要么别往不是构造它的线程上写入对象。遵循这些规则的对象会感受到几乎没有并发负载,因为属性访问将在快速路径上至多只有一条额外指令(一个掩码)

watchpoint 和引起它们触发的操作,会在安全点下执行:如果我们执行了一些操作,它发现必须要让一个 watchpoint 失效,它会在其他线程都停下以后再去操作,就像一个垃圾回收一样。如果某块优化代码正在做含有 structure 的非原子化 transition,同时还有其他线程尝试写入或 transition 使用那些 structure 的对象,那么直到优化代码触及了一个安全点且被无效化后,它才会去实际执行写入。大多数时候,watchpoint 集会告诉我们它们已经被无效化了,甚至会在优化 JIT 编译器试图安装任何 watchpoint 之前。我们希望在稳定态下几乎不会有 watchpoint 无效化。

总结一下,如果我们的优化子能够猜到你会在分配的时候往对象里添加哪些属性,那么对象访问的代价模型根本不会改变,因为内联属性可以免费地获取并发能力。如果你真的有外部属性,那么只要对象只被创建它的线程写入(任意线程读取也一样),或者不在创建之后向对象里添加新的属性(这种情形下可以被任何线程读写),它们会几乎和现有的执行方式一模一样(偶尔,一个额外的算术指令会涉及到计算 butterfly 的读写)。倘若你违反了这种模式,那么 transition 的速度会慢 7 倍,而且所有其它对象的访问都会慢 2 倍。这种衰减是目标非常明确的:只有涉及到 segmented butterfly 的访问会感受到速度减缓。这个系统被设计成能让你动态、并发地往对象里加内容,并且初衷是希望如果你适当地这么做,你会发现没有太多性能上的改变。

Arrays

数组元素访问会从 TTL 获准,类似于命名访问做的事情:

  • 对于 TTL 数组的访问和现在的速度一样
  • 对于非 TTL 的数组会需要一条额外的间接指令

我们处理数组 transition 的方式会有一点特殊。很多 JavascriptCore 里数组 transition 已经用了锁,因为它们需要避免和垃圾回收的竞争。对剩余的数组 transition 加锁可能会引起性能问题,因此这部分考虑替代方案。

对数组重新分配大小以应对超出边界的存储或是类似 array.push的东西是最常见的数组 transition。幸运的是,这种 transition 不会改变对象头部的任何内容 ———— 它只会改变 butterfly 指针。butterfly 是否有数组元素会反映在 structure 上。因此,我们可以只用 butterfly 指针上的 CAS(compare-and-swap),然后制定一条规则,它规定任何对于有数组的对象 butterfly 指针的修改都需要做 CAS 即使对象锁已经在那了。由于数组 structure 的 transitionThreadLocalwriteThreadLocal watchpoints 不太可能是完整的(任何共享数组的使用都会使其无效,因为数组共享 structure),我们希望即使在 TTL 数组上的 transition 也需要在一般情况下使用 CAS。这个 butterfly 指针 CAS 足以确保使得非 TLL 数组不会和改变大小的操作混淆,且是同步的尝试。

一个数组改变大小的 CAS 可能对于实际来说代价足够小了。CAS 可能对于当前改变大小的操作来说开销小一点,毕竟目前的改变大小的操作包括了分配、复制数据,以及初始化新分配的内存。

同时有命名属性和数组元素的对象现在在某些涉及命名属性的 transition 上会都有锁和 CAS。还好,具有数组元素和命名属性的对象足够特殊,从而使我们可能避免增长其命名属性 transition 的一点点代价。

从 Flat 到 segemented 的快速 transition

把 flat butterflye 转变成 segmented butterfly 需要一种特殊的 transition。幸运的是,这种 transition 代价不大。butterfly fragment 只有数据,它们看上去像 flat butterfly 负载的 fragment(固定大小的切片)。因此,我们可以通过分配一个 spine 并将其 fragment 指针指向原始的 flat butterfly 来把一个 flat butterfly transition 成一个 segmented butterfly。

当 butterfly 被从 flat 转成 segemented 的时候,任何对于这块 flat butterfly 的读写对于 segmented butterfly 的使用者来说像是发生在 fragments 上一样。也就是说,尽管某些线程可能会错误地认为 butterfly 仍旧是 flat 的,但它们访问 butterfly 仍然是安全的即使在 butterfly 已被 segmented 之后。

让这部分正常工作需要确认 segmented butterfly 的 fragment 要和转化源头的 flat butterfly 一样,共享相同的内存布局。由于这个原因,每一个数组 fragment 包含了公共长度和旧的向量长度;它将记入 flat butterfly 一直使用的向量长度,以及将会出现在 segmented butterfly spine 里真实向量长度。这保证了如果在 flat butterfly 数组访问和 flat-to-segmented transition 之间存在竞争,那么 flat butterfly 的访问会正确地知道 flat butterfly 的大小,因为它的向量长度不会改变。为了整理对象模型,我人也会在外部 fragment 里倒序存储外部属性,以匹配 flat butterfly 做的事。

只要 flat butterfly 访问在 transition 发生前就加载 butterfly 指针,那么这种机制就会起作用。如果 flat butterfly 访问加载其指针的时机晚了一点,那么访问的 TID 检查就会失败,可能是在访问 butterfly 的时候,以一种页面错误的形式。

删除与字典查询

JavaScriptCore 想让对象在任何可能的时候共享 structure。这只需要 structure 有两个特性:

  • Structures 是不可变的
  • 当需要一个新的 structure 的时候,我们一般会 hash cons 它。

如果它真的可以让对象重用 structure 的话,这是很棒的优化。但有些对象会有其特定的 structure,无论 VM 想对它做什么。JavascriptCore 试图检测这种情况发生的时机,然后把对象放到字典模式里面去。在字典模式里,structure 有一个 1-1 的到对象的映射。而且,structure 也会变成可变的。添加和删除属性意味着先后编辑 structure 和对象。

删除和字典模式联系很紧密,因为删除会立即把对象放到字典模式里。

对字典的修改需要保持 structure 锁的状态,这已经是既成事实了。为了支持并发 JIT 和并发垃圾回收,这是必要的。

为了支持并发 JS,我们只需要做以下改动:

  1. 我字典读取需要保持 structure 锁的状态,以防其他某个线程修改字典
  2. 对象进入字典模式前添加的属性必须被删除特殊处理

我们不担心获取字典所有的读操作锁的性能问题,相对于读取一个字典来说,其代价会大一点。

现有的安全地 transition 对象的设施会很自然地支持对字典的 transition。通常来说,字典 transition 不包括删除属性。当我们发现程序正在往对象添加巨多属性,以至于它可能比字典表现性能更佳的时候,删除才会发生。在这种情况下,其他某个线程也许正在访问这个对象的过程中而没有保持任何锁,这无关紧要。对于非字典对象来说,我们只需要 transition 有锁就可以了。读写包括了检查对象类型、载入 butterfly、然后访问 butterfly 的诸多独立步骤。但如果没有属性在字典 transition 被删除之前添加进来的话,那么其他线程在访问旧属性的时候发起竞争是没问题的。我们把这种现象叫做迟缓访问(tardy access)。即使 tardy access 不做任何的锁,在对象变成一个字典前,它们是正确的,基于这个相同的原因,它们都是正确的。这个问题取决于 butterfly 对象模型,它要么是 flat 的,要么是 segment 的,具体是哪个在于对象是否仍旧是 TTL 的。字典模式正交地操作(原文,operates orthogonally to TTL inference and butterflies) 于 TTL 推断和 butterfly。

但如果在字典 transition 被删除之前添加任何属性的话,那我们必须要特殊处理这种删除情形。正常来说,删除会导致被删掉的 slot 变成可复用的。我们不能在这里这么做,因为对某个被删除的属性 f做迟缓读取 (tardy read) 可能会导致覆盖某个新加的属性 g的值。我们可以通过简单地不去复用删除属性后多余出的空间来预防这些非预期的结果,如果那些属性已经在字典 transition 之前添加的话。

这不会导致使用无界内存。当垃圾回收在它的安全点的时候,它已经知道所有的内存访问都完成了。因此,最简单的实现方法是当垃圾回收访问对象的时候,让它改变那些删除属性的状态。一旦垃圾回收在安全点的时候标识了那些属性 slot,之后的属性添加可以复用这些 slot。

内联缓存

一旦我们让并发生效以后,重新编译一块内联缓存会更加困难,因为你需要在修改当前可能执行在另一个 CPU 上的代码的时候仔细一点。我们计划布置一个分层的防御方案以防止这个问题:我们会推测代码的 thread-locality 以使用尽可能和现在相同的内联缓存,我们会在任何安全的时候缓冲内联缓存,最后如果某个内联缓存迫切需要被重置的时候,我们会依靠安全点(safepoint)。

我们希望在很多程序里,内联缓存会在代码执行在一个以上的线程上之前,达到稳定状态。因此,我们计划让每块代码追踪其 thread-locality。只要它只在一个线程上执行过,它会记住并且在入口检查这种情况。如果结果是 true 的话,任何那块代码里的内联缓存都可以被修改而不用任何额外的同步。我们可以在喜好的粒度层次上追踪 thread-locality,例如它甚至可以是每块基础代码块。如果我们用 JavascriptCore 的 CodeBlock记号作为粒度层次,这可能是最自然的;这大致上相当于源码里的函数层级。

一旦代码的 thread-locality 被打破了,我们可以开始缓冲内联缓存修改。我们的 PolymorphicAccess内联缓存设施已然缓冲了修改,因为即使我们不做代价昂贵的同步,这也是最有效率的。对于可能全局执行的内联缓存,我们可以全局缓冲修改。例如,VM 会执行每微秒一次的安全点(safepoint)以把修改刷新到全局内联缓存中。

有时候,我们需要立即修改内联缓存,在这个时候,我们会用 safepoint 系统的能力 ———— 即在某一点上停掉所有线程,在这个点上,我们可以处理每一个线程状态。在有许多线程的时候,这可不是一项快速操作。Java 虚拟机已经需要为类层级失效、锁偏向做一些类似的事情。设计上来说,这些迫切的无效化不太可能发生。我们只去做会导致迫切无效化的优化,如果我们有测量数据表明可能性不大的话。

本地锁优化工具

目前为止,这个算法需要能够把 16 位的信息偷偷地放到 butterfly 指针的高位里去。某些硬件不允许我们这么做,比如说,少于 16 个高指针位可能会被用来做虚拟内存里的全零检查。如果系统只允许 8 个选中的高位,那我们将只能支持 127 个线程。即使并发 Javascript 具有严格的 127 个线程的上限,它可能还是有实用价值的,但这是一个需要在语言层面上加强的重要下限。这部分展示如何克服这个限制。

如果对象模型只能处理 127 个线程的 thread-locality,那我们可以选择让第 127 个线程不做 thread-locality 推断,或者可以尝试着把一个以上的逻辑线程映射到同一个线程识别器上。映射到同一个 TID 上的线程将不能够在互相之间并发执行。为了实现这个目的,我们可以借鉴 python 里的 GIL (global interpreter lock) 的想法。这种 CPython 实现只允许单个本地线程单次地解释 python 代码,通过用一个保护解释器的锁(所谓的 GIL),CPython 达到了这个目的。这个锁会被阶段性地释放然后重新请求,这能显现出并发的现象。因为我们也可以在任何潜在阻塞的操作中释放锁,所以我们可以甚至避免由于不足并发的所引起死锁。我们可以把这种技术应用在这里:如果我们最多有 127 个线程,那我们可以有 127 个锁来保护 JS 的执行。只要有 127 个或者少于 127 个线程,那锁什么都不做;任何我们想要释放它的时候,我们什么都不用做因为锁会告诉我们没有人在等待获取这个锁。"释放和重新获取"锁的确是代价低廉的加载和 branch,因此没必要释放它。

这个锁对于线程池来说是局部的而不是对于全引擎全局的,而且它会保护我们所有的优化路径,而不是仅仅保护解释器。因此有了这个名字:局部锁优化工具,或者简称 LOL(Local Optimizer Lock)。

线程不安全的对象

DOM 对象像 Javascript 对象的行为一样,但实际上是一个以 C++ 实现的复杂逻辑的代理。那部分逻辑通常不是线程安全的。支持 DOM 传递的本地代码使用了很多本地 API,这些 API 只会被主线程使用,要确保 DOM 完全是线程安全的会花很多工夫。我们的 strawman 提案只需要让 DOM 全局对象能够处理自己属性的查询,我们需要它来允许线程像 ObjectThread一样访问全局 Javascript 属性。

在 Webkit 中, JSDOMWindow上的变量解析和自属性解析很大程度上遵循一种依赖现有 JS 属性查询机制的模式,在 window 有一个 null 的 frame 的时候,它使用外部行为。我们已经在非 null 的 frame上安装了 watchpoint。因此,通过使用现有的 wathcpoint 集,给 frame特殊情形的时候,我们可以支持对 JSDOMWindow属性的快速并发访问,这意味着 JSDOMWindow的部分慢路径会需要锁,但这是可以接受的,因为大多数全局对象访问是内联缓存的。

想要深入并发 JS 的原生应用也可能希望限制共享其中的某些类。对象访问的线程关系检查会需要由 VM 自己实现,以将优化的能力赋予编译器,这表示将功能暴露给 C/Object-C API 客户端是可能的。

总结

我们认为如果提供了在开发者使用这项特性时能够足够满意的性能时,我们可以在 Webkit 里并发地执行 Javascript。这项技术的核心是 segmented butterfly、transition TTL 推断、以及很多性能优良的单对象锁。只要程序遵守某些特定的行为,那它们就能体验到极小的开销:属性访问和目前的开销差不多,而且对象不需要任何额外的内存。如果某些对象不遵守我们的规则,它们的代价会稍高一些。

相关工作

segmented butterfly 是一种 Schism 垃圾回收中的数组对象模型和我们在 JavascriptCore 中使用了很长时间的 butterfly 对象模型的简单结合。

TTL 推断是受到了锁偏向工作的启发。在 HotSpot biased locking 计划 里,我们使用类型来判断我们是否能够保证 thread locality。但和那份计划不一样的是,我们不依赖逆优化来打破线程-对象的关系。我们用来告知其他线程某个对象不再是 TTL 的主要机制是在 butterfly 指针里保持不同的标识 bit。

segmented butterfly、TTL 推断,以及在出现奇怪现象时,这两套计划在单对象锁上的回退机制,这三者的结合在之前我们没有见到过。大多数面向对象且允许并发的系统实现避免这些现象的方式,要么是通过一种对象模型,它可以自然地避免解析代价过大的竞争,像 Java 里的方式那样,或者是通过使用某些机制来限制语言中的并发数量。

例如,CPython 用一个全局的解释器锁(GIL —— global interpreter lock) 来确保解释器永远不会和自己竞争。这是一种强大的技术,它抓住了并发的某些核心:一旦 I/O 或其他类型的阻塞发生的时候,你希望你的其他线程能够在此时做一些有用的工作。GIL 机制就会允许该种可能的发生,因此它能让线程对于很多类型的应用来说都是有用的。可惜的是,它并不能让程序发展出并行特性。GIL 最出色的特性是易于实现。举例来说,它可以用来实现在任何 JS 引擎下我们所希望的线程语义。本文的内容全部是关于为 64 位平台去做优化的全并发。

还有一种尝试,叫做 Gilectomy,目前正在进行中,它试图移除 CPython 的 GIL 并将其替代为细粒度的锁和智能化的原子化算法。Gilectomy 尚未能在速度上匹及目前的 CPython;单线程和多线程程序在 Gilectomy 里都会运行得很慢。性能问题貌似和分配器(我们计划使用 thread-local 的分配缓冲)与引用计数器(我们没有引用计数)里的锁有关。Gilectomy 不会从对象访问里删掉锁。像 Javascript 的对象一样,Python 的对象是能动态重新分配大小的字典。我们提案中的大部分内容是关于在多线程读取同一个对象的时候,如何快速访问这些对象的。

PyPy 也有一个正在进行中的删除 GIL 的尝试,但他们没有说太多关于计划如何处理除使用锁以外的同步对象访问。我们也会有锁的,但我们也考虑到了怎么去做优化才能在大多数情况下避免锁。

另一个方法是 SpiderMonkey 的title locking计划,它已经被删除了。那个计划也包括了单线程锁,但没有做我们在大多数情况下避免锁的那部分优化。

Cohen, Tal, 和 Petrank 最近引入了 layout lock(布局锁),它容许对象的快速读写,那些对象可能会使得自己的布局被改变。读取需要一次额外的加载(它必须是隔离的或是有所依赖的),写入必须拿到读取的锁(它的代价和一对隔离的读取和分流差不多小),transition 必须拿到写入锁并做一些额外的簿记(book-keeping)。我们怀疑这和 segmented butterfly 的代价模型相近,除了写入的部分。segmented butterfly 只需要一块额外的用来写入的依赖性加载,它比请求读取锁的开销稍低一些,读取锁会需要两次围绕某个 store 的加载。围绕 store 的加载不太方便;比如说强制让一个 store 在 x86 的加载之前发生会需要一条给 store 的 xchg指令,它比只做一次 mov的开销要大。在 ARM 上,它需要使用 load 和 store 的 acq/rel 变量。另一方面,写入 segmented butterfly 的额外依赖性加载不需要额外的在 x86 或是 ARM 上的围栏(fencing)。transition 和读取可能在使用 segmented butterfly 和布局锁的时候速度相同,但写入对我们很重要。写入发生的次数足够频繁,以至于 segmented butterfly 可能比 Javascript 对象模型的布局锁要明显地快一些。而且,segmentation 的方法不太会重现在很多不同类型的数据结构中。因其更具适用性,布局锁对于像 SparseArrayValueMapMap/ WeakMap/ Set这样更复杂的数据 JSC 数据结构来说可能挺有用的。那些数据结构也许对于 segmentation 来说太复杂了,但对于布局锁来说不太复杂。像 segmented butterfly 一样,布局锁可能会和 TTL 推断结合起来使用以避免任何的锁,直到需要用它来保护竞争的 transition。

结论

本文展示了 Webkit 的 Javascript 实现是通过何种方式的修正来支持线程的。我们的计划允许所有的 Javascript 都可以共享,我们的计划避免在快速路径上的锁,如属性访问的快速路径。我们希望它能够用较低的开销实现这个计划。序列化代码会体验到零开销,并发代码只会在它往对象里添加属性的时候体验到较大开销,前提是这个对象正在被多个线程写入。

这个狂野想法的下一步是尝试着实现它,然后看看有没有用。我们会在 bug 174276 底下跟踪进度。



往期精选文章

使用虚拟dom和JavaScript构建完全响应式的UI框架

扩展 Vue 组件

使用Three.js制作酷炫无比的无穷隧道特效

一个治愈JavaScript疲劳的学习计划

全栈工程师技能大全

WEB前端性能优化常见方法

一小时内搭建一个全栈Web应用框架

干货:CSS 专业技巧

四步实现React页面过渡动画效果

让你分分钟理解 JavaScript 闭包



小手一抖,资料全有。长按二维码关注京程一灯,阅读更多技术文章和业界动态。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-10-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 京程一灯 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Transition Thread Locality(TTL) 与 Flat Butterflies
    • 快速检查 TID 和 SW 位
      • Watchpoint 优化
        • Arrays
          • 从 Flat 到 segemented 的快速 transition
          • 删除与字典查询
          • 内联缓存
          • 本地锁优化工具
          • 线程不安全的对象
          • 总结
          • 相关工作
          • 结论
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档