分类 技术 下的文章

可以很公平地说,JavaScript 是当下软件工程中最重要的技术。对于那些深入接触过编程语言、编译器和虚拟机的人来说,这仍然有点令人惊讶,因为在语言设计者们看来,JavaScript 不是十分优雅;在编译器工程师们看来,它没有多少可优化的地方;甚至还没有一个伟大的标准库。这取决于你和谁吐槽,JavaScript 的缺点你花上数周都枚举不完,而你总会找到一些你从所未知的奇怪的东西。尽管这看起来明显困难重重,不过 JavaScript 还是成为了当今 web 的核心,并且还(通过 Node.js)成为服务器端和云端的主导技术,甚至还开辟了进军物联网领域的道路。

那么问题来了,为什么 JavaScript 如此受欢迎?或者说如此成功?我知道没有一个很好的答案。如今我们有许多使用 JavaScript 的好理由,或许最重要的是围绕其构建的庞大的生态系统,以及现今大量可用的资源。但所有这一切实际上是发展到一定程度的后果。为什么 JavaScript 变得流行起来了?嗯,你或许会说,这是 web 多年来的通用语了。但是在很长一段时间里,人们极其讨厌 JavaScript。回顾过去,似乎第一波 JavaScript 浪潮爆发在上个年代的后半段。那个时候 JavaScript 引擎加速了各种不同的任务的执行,很自然的,这可能让很多人对 JavaScript 刮目相看。

回到过去那些日子,这些加速使用了现在所谓的传统 JavaScript 基准进行测试——从苹果的 SunSpider 基准(JavaScript 微基准之母)到 Mozilla 的 Kraken 基准 和谷歌的 V8 基准。后来,V8 基准被 Octane 基准 取代,而苹果发布了新的 JetStream 基准。这些传统的 JavaScript 基准测试驱动了无数人的努力,使 JavaScript 的性能达到了本世纪初没人能预料到的水平。据报道其性能加速达到了 1000 倍,一夜之间在网站使用 <script> 标签不再是与魔鬼共舞,做客户端不再仅仅是可能的了,甚至是被鼓励的。

性能测试,JS 基准的简史

(来源: Advanced JS performance with V8 and Web Assembly, Chrome Developer Summit 2016, @s3ththompson。)

现在是 2016 年,所有(相关的)JavaScript 引擎的性能都达到了一个令人难以置信的水平,web 应用像原生应用一样快(或者能够像原生应用一样快)。引擎配有复杂的优化编译器,通过收集之前的关于类型/形状的反馈来推测某些操作(例如属性访问、二进制操作、比较、调用等),生成高度优化的机器代码的短序列。大多数优化是由 SunSpider 或 Kraken 等微基准以及 Octane 和 JetStream 等静态测试套件驱动的。由于有像 asm.jsEmscripten 这样的 JavaScript 技术,我们甚至可以将大型 C++ 应用程序编译成 JavaScript,并在你的浏览器上运行,而无需下载或安装任何东西。例如,现在你可以在 web 上玩 AngryBots,无需沙盒,而过去的 web 游戏需要安装一堆诸如 Adobe Flash 或 Chrome PNaCl 的特殊插件。

这些成就绝大多数都要归功于这些微基准和静态性能测试套件的出现,以及与这些传统的 JavaScript 基准间的竞争的结果。你可以对 SunSpider 表示不满,但很显然,没有 SunSpider,JavaScript 的性能可能达不到今天的高度。好吧,赞美到此为止。现在看看另一方面,所有的静态性能测试——无论是 微基准 micro-benchmark 还是大型应用的 宏基准 macro-benchmark ,都注定要随着时间的推移变成噩梦!为什么?因为在开始摆弄它之前,基准只能教你这么多。一旦达到某个阔值以上(或以下),那么有益于特定基准的优化的一般适用性将呈指数级下降。例如,我们将 Octane 作为现实世界中 web 应用性能的代表,并且在相当长的一段时间里,它可能做得很不错,但是现在,Octane 与现实场景中的时间分布是截然不同的,因此即使眼下再优化 Octane 乃至超越自身,可能在现实世界中还是得不到任何显著的改进(无论是通用 web 还是 Node.js 的工作负载)。

基准与现实世界的时间分布对比

(来源:Real-World JavaScript Performance,BlinkOn 6 conference,@tverwaes)

由于传统 JavaScript 基准(包括最新版的 JetStream 和 Octane)可能已经背离其有用性变得越来越远,我们开始在 2016 年初寻找新的方法来测量现实场景的性能,为 V8 和 Chrome 添加了大量新的性能追踪钩子。我们还特意添加一些机制来查看我们在浏览 web 时的时间究竟开销在哪里,例如,是脚本执行、垃圾回收、编译,还是什么地方?而这些调查的结果非常有趣和令人惊讶。从上面的幻灯片可以看出,运行 Octane 花费了 70% 以上的时间去执行 JavaScript 和垃圾回收,而浏览 web 的时候,通常执行 JavaScript 花费的时间不到 30%,垃圾回收占用的时间永远不会超过 5%。在 Octane 中并没有体现出它花费了大量时间来解析和编译。因此,将更多的时间用在优化 JavaScript 执行上将提高你的 Octane 跑分,但不会对加载 youtube.com 有任何积极的影响。事实上,花费更多的时间来优化 JavaScript 执行甚至可能有损你现实场景的性能,因为编译器需要更多的时间,或者你需要跟踪更多的反馈,最终在编译、垃圾回收和 运行时桶 Runtime bucket 等方面开销了更多的时间。

测速表

还有另外一组基准测试用于测量浏览器整体性能(包括 JavaScript 和 DOM 性能),最新推出的是 Speedometer 基准。该基准试图通过运行一个用不同的主流 web 框架实现的简单的 TodoMVC 应用(现在看来有点过时了,不过新版本正在研发中)以捕获更真实的现实场景的性能。上述幻灯片中的各种测试 (Angular、Ember、React、Vanilla、Flight 和 Backbone)挨着放在 Octane 之后,你可以看到,此时此刻这些测试似乎更好地代表了现实世界的性能指标。但是请注意,这些数据收集在本文撰写将近 6 个月以前,而且我们优化了更多的现实场景模式(例如我们正在重构垃圾回收系统以显著地降低开销,并且 解析器也正在重新设计)。还要注意的是,虽然这看起来像是只和浏览器相关,但我们有非常强有力的证据表明传统的峰值性能基准也不能很好的代表现实场景中 Node.js 应用性能。

Speedometer 和 Octane 对比

(来源: Real-World JavaScript Performance, BlinkOn 6 conference, @tverwaes.)

所有这一切可能已经路人皆知了,因此我将用本文剩下的部分强调一些具体案例,它们对关于我为什么认为这不仅有用,而且必须停止关注某一阈值的静态峰值性能基准测试对于 JavaScript 社区的健康是很关键的。让我通过一些例子说明 JavaScript 引擎怎样来玩弄基准的。

臭名昭著的 SunSpider 案例

一篇关于传统 JavaScript 基准测试的博客如果没有指出 SunSpider 那个明显的问题是不完整的。让我们从性能测试的最佳实践开始,它在现实场景中不是很适用:bitops-bitwise-and.js 性能测试

bitops-bitwise-and.js

有一些算法需要进行快速的 AND 位运算,特别是从 C/C++ 转译成 JavaScript 的地方,所以快速执行该操作确实有点意义。然而,现实场景中的网页可能不关心引擎在循环中执行 AND 位运算是否比另一个引擎快两倍。但是再盯着这段代码几秒钟后,你可能会注意到在第一次循环迭代之后 bitwiseAndValue 将变成 0,并且在接下来的 599999 次迭代中将保持为 0。所以一旦你让此获得了好的性能,比如在差不多的硬件上所有测试均低于 5ms,在经过尝试之后你会意识到,只有循环的第一次是必要的,而剩余的迭代只是在浪费时间(例如 loop peeling 后面的死代码),那你现在就可以开始玩弄这个基准测试了。这需要 JavaScript 中的一些机制来执行这种转换,即你需要检查 bitwiseAndValue 是全局对象的常规属性还是在执行脚本之前不存在,全局对象或者它的原型上必须没有拦截器。但如果你真的想要赢得这个基准测试,并且你愿意全力以赴,那么你可以在不到 1ms 的时间内完成这个测试。然而,这种优化将局限于这种特殊情况,并且测试的轻微修改可能不再触发它。

好吧,那么 bitops-bitwise-and.js 测试彻底肯定是微基准最失败的案例。让我们继续转移到 SunSpider 中更逼真的场景——string-tagcloud.js 测试,它基本上是运行一个较早版本的 json.js polyfill。该测试可以说看起来比位运算测试更合理,但是花点时间查看基准的配置之后立刻会发现:大量的时间浪费在一条 eval 表达式(高达 20% 的总执行时间被用于解析和编译,再加上实际执行编译后代码的 10% 的时间)。

string-tagcloud.js

仔细看看,这个 eval 只执行了一次,并传递一个 JSON 格式的字符串,它包含一个由 2501 个含有 tagpopularity 属性的对象组成的数组:

([
  {
    "tag": "titillation",
    "popularity": 4294967296
  },
  {
    "tag": "foamless",
    "popularity": 1257718401
  },
  {
    "tag": "snarler",
    "popularity": 613166183
  },
  {
    "tag": "multangularness",
    "popularity": 368304452任何
  },
  {
    "tag": "Fesapo unventurous",
    "popularity": 248026512
  },
  {
    "tag": "esthesioblast",
    "popularity": 179556755
  },
  {
    "tag": "echeneidoid",
    "popularity": 136641578
  },
  {
    "tag": "embryoctony",
    "popularity": 107852576
  },
  ...
])

显然,解析这些对象字面量,为其生成本地代码,然后执行该代码的成本很高。将输入的字符串解析为 JSON 并生成适当的对象图的开销将更加低廉。所以,加快这个基准测试的一个小把戏就是模拟 eval,并尝试总是将数据首先作为 JSON 解析,如果以 JSON 方式读取失败,才回退进行真实的解析、编译、执行(尽管需要一些额外的黑魔法来跳过括号)。早在 2007 年,这甚至不算是一个坏点子,因为没有 JSON.parse,不过在 2017 年这只是 JavaScript 引擎的技术债,可能会让 eval 的合法使用遥遥无期。

--- string-tagcloud.js.ORIG     2016-12-14 09:00:52.869887104 +0100
+++ string-tagcloud.js  2016-12-14 09:01:01.033944051 +0100
@@ -198,7 +198,7 @@
                     replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(:?[eE][+\-]?\d+)?/g, ']').
                     replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) {

-                j = eval('(' + this + ')');
+                j = JSON.parse(this);

                 return typeof filter === 'function' ? walk('', j) : j;
             }

事实上,将基准测试更新到现代 JavaScript 会立刻会性能暴增,正如今天的 V8 LKGR 从 36ms 降到了 26ms,性能足足提升了 30%!

$ node string-tagcloud.js.ORIG
Time (string-tagcloud): 36 ms.
$ node string-tagcloud.js
Time (string-tagcloud): 26 ms.
$ node -v
v8.0.0-pre
$

这是静态基准和性能测试套件常见的一个问题。今天,没有人会正儿八经地用 eval 解析 JSON 数据(不仅是因为性能问题,还出于严重的安全性考虑),而是坚持为最近五年写的代码使用 JSON.parse。事实上,使用 eval 解析 JSON 可能会被视作产品级代码的的一个漏洞!所以引擎作者致力于新代码的性能所作的努力并没有反映在这个古老的基准中,相反地,而是使得 eval 不必要地更智能复杂化,从而赢得 string-tagcloud.js 测试。

好吧,让我们看看另一个例子:3d-cube.js。这个基准测试做了很多矩阵运算,即便是最聪明的编译器对此也无可奈何,只能说执行而已。基本上,该基准测试花了大量的时间执行 Loop 函数及其调用的函数。

3d-cube.js

一个有趣的发现是:RotateXRotateYRotateZ 函数总是调用相同的常量参数 Phi

3d-cube.js

这意味着我们基本上总是为 Math.sinMath.cos 计算相同的值,每次执行都要计算 204 次。只有 3 个不同的输入值:

  • 0.017453292519943295
  • 0.05235987755982989
  • 0.08726646259971647

显然,你可以在这里做的一件事情就是通过缓存以前的计算值来避免重复计算相同的正弦值和余弦值。事实上,这是 V8 以前的做法,而其它引擎例如 SpiderMonkey 目前仍然在这样做。我们从 V8 中删除了所谓的 超载缓存 transcendental cache ,因为缓存的开销在实际的工作负载中是不可忽视的,你不可能总是在一行代码中计算相同的值,这在其它地方倒不稀奇。当我们在 2013 和 2014 年移除这个特定的基准优化时,我们对 SunSpider 基准产生了强烈的冲击,但我们完全相信,为基准而优化并没有任何意义,并同时以这种方式批判了现实场景中的使用案例。

3d-cube 基准

(来源:arewefastyet.com

显然,处理恒定正弦/余弦输入的更好的方法是一个内联的启发式算法,它试图平衡内联因素与其它不同的因素,例如在调用位置优先选择内联,其中 常量叠算 constant folding 可以是有益的,例如在 RotateXRotateYRotateZ 调用位置的案例中。但是出于各种原因,这对于 Crankshaft 编译器并不可行。使用 IgnitionTurboFan 倒是一个明智的选择,我们已经在开发更好的内联启发式算法

垃圾回收(GC)是有害的

除了这些非常具体的测试问题,SunSpider 基准测试还有一个根本性的问题:总体执行时间。目前 V8 在适当的英特尔硬件上运行整个基准测试大概只需要 200ms(使用默认配置)。 次垃圾回收 minor GC 在 1ms 到 25ms 之间(取决于新空间中的存活对象和旧空间的碎片),而 主垃圾回收 major GC 暂停的话可以轻松减掉 30ms(甚至不考虑增量标记的开销),这超过了 SunSpider 套件总体执行时间的 10%!因此,任何不想因垃圾回收循环而造成减速 10-20% 的引擎,必须用某种方式确保它在运行 SunSpider 时不会触发垃圾回收。

driver-TEMPLATE.html

就实现而言,有不同的方案,不过就我所知,没有一个在现实场景中产生了任何积极的影响。V8 使用了一个相当简单的技巧:由于每个 SunSpider 套件都运行在一个新的 <iframe> 中,这对应于 V8 中一个新的本地上下文,我们只需检测快速的 <iframe> 创建和处理(所有的 SunSpider 测试每个花费的时间小于 50ms),在这种情况下,在处理和创建之间执行垃圾回收,以确保我们在实际运行测试的时候不会触发垃圾回收。这个技巧运行的很好,在 99.9% 的案例中没有与实际用途冲突;除了时不时的你可能会受到打击,不管出于什么原因,如果你做的事情让你看起来像是 V8 的 SunSpider 测试驱动程序,你就可能被强制的垃圾回收打击到,这有可能对你的应用导致负面影响。所以谨记一点:不要让你的应用看起来像 SunSpider!

我可以继续展示更多 SunSpider 示例,但我不认为这非常有用。到目前为止,应该清楚的是,为刷新 SunSpider 评分而做的进一步优化在现实场景中没有带来任何好处。事实上,世界可能会因为没有 SunSpider 而更美好,因为引擎可以放弃只是用于 SunSpider 的奇淫技巧,或者甚至可以伤害到现实中的用例。不幸的是,SunSpider 仍然被(科技)媒体大量地用来比较他们眼中的浏览器性能,或者甚至用来比较手机!所以手机制造商和安卓制造商对于让 SunSpider(以及其它现在毫无意义的基准 FWIW) 上的 Chrome 看起来比较体面自然有一定的兴趣。手机制造商通过销售手机来赚钱,所以获得良好的评价对于电话部门甚至整间公司的成功至关重要。其中一些部门甚至在其手机中配置在 SunSpider 中得分较高的旧版 V8,将他们的用户置于各种未修复的安全漏洞之下(在新版中早已被修复),而让用户被最新版本的 V8 带来的任何现实场景的性能优势拒之门外!

Galaxy S7 和 S7 Edge 的评价:三星的高光表现

(来源:www.engadget.com

作为 JavaScript 社区的一员,如果我们真的想认真对待 JavaScript 领域的现实场景的性能,我们需要让各大技术媒体停止使用传统的 JavaScript 基准来比较浏览器或手机。能够在每个浏览器中运行一个基准测试,并比较它的得分自然是好的,但是请使用一个与当今世界相关的基准,例如真实的 web 页面;如果你觉得需要通过浏览器基准来比较两部手机,请至少考虑使用 Speedometer

轻松一刻

我一直很喜欢这个 Myles Borins 谈话,所以我不得不无耻地向他偷师。现在我们从 SunSpider 的谴责中回过头来,让我们继续检查其它经典基准。

不是那么显眼的 Kraken 案例

Kraken 基准是 Mozilla 于 2010 年 9 月 发布的,据说它包含了现实场景应用的片段/内核,并且与 SunSpider 相比少了一个微基准。我不想在 Kraken 上花太多口舌,因为我认为它不像 SunSpider 和 Octane 一样对 JavaScript 性能有着深远的影响,所以我将强调一个特别的案例——audio-oscillator.js 测试。

audio-oscillator.js

正如你所见,测试调用了 calcOsc 函数 500 次。calcOsc 首先在全局的 sine Oscillator 上调用 generate,然后创建一个新的 Oscillator,调用它的 generate 方法并将其添加到全局的 sine Oscillator 里。没有详细说明测试为什么是这样做的,让我们看看 Oscillator 原型上的 generate 方法。

audio-oscillator-data.js

让我们看看代码,你也许会觉得这里主要是循环中的数组访问或者乘法或者 Math.round 调用,但令人惊讶的是 offset % this.waveTableLength 表达式完全支配了 Oscillator.prototype.generate 的运行。在任何的英特尔机器上的分析器中运行此基准测试显示,超过 20% 的时间占用都属于我们为模数生成的 idiv 指令。然而一个有趣的发现是,Oscillator 实例的 waveTableLength 字段总是包含相同的值——2048,因为它在 Oscillator 构造器中只分配一次。

audio-oscillator-data.js

如果我们知道整数模数运算的右边是 2 的幂,我们显然可以生成更好的代码,完全避免了英特尔上的 idiv 指令。所以我们需要获取一种信息使 this.waveTableLengthOscillator 构造器到 Oscillator.prototype.generate 中的模运算都是 2048。一个显而易见的方法是尝试依赖于将所有内容内嵌到 calcOsc 函数,并让 load/store 消除为我们进行的常量传播,但这对于在 calcOsc 函数之外分配的 sine oscillator 无效。

因此,我们所做的就是添加支持跟踪某些常数值作为模运算符的右侧反馈。这在 V8 中是有意义的,因为我们为诸如 +*% 的二进制操作跟踪类型反馈,这意味着操作者跟踪输入的类型和产生的输出类型(参见最近的圆桌讨论中关于动态语言的快速运算的幻灯片)。当然,用 fullcodegenCrankshaft 挂接起来也是相当容易的,MODBinaryOpIC 也可以跟踪右边已知的 2 的冥。

$ ~/Projects/v8/out/Release/d8 --trace-ic audio-oscillator.js
[...SNIP...]
[BinaryOpIC(MOD:None*None->None) => (MOD:Smi*2048->Smi) @ ~Oscillator.generate+598 at audio-oscillator.js:697]
[...SNIP...]
$

事实上,以默认配置运行的 V8 (带有 Crankshaft 和 fullcodegen)表明 BinaryOpIC 正在为模数的右侧拾取适当的恒定反馈,并正确跟踪左侧始终是一个小整数(以 V8 的话叫做 Smi),我们也总是产生一个小整数结果。 使用 --print-opt-code -code-comments 查看生成的代码,很快就显示出,Crankshaft 利用反馈在 Oscillator.prototype.generate 中为整数模数生成一个有效的代码序列:

[...SNIP...]
                  ;;; <@80,#84> load-named-field
0x133a0bdacc4a   330  8b4343         movl rax,[rbx+0x43]
                  ;;; <@83,#86> compare-numeric-and-branch
0x133a0bdacc4d   333  3d00080000     cmp rax,0x800
0x133a0bdacc52   338  0f85ff000000   jnz 599  (0x133a0bdacd57)
[...SNIP...]
                  ;;; <@90,#94> mod-by-power-of-2-i
0x133a0bdacc5b   347  4585db         testl r11,r11
0x133a0bdacc5e   350  790f           jns 367  (0x133a0bdacc6f)
0x133a0bdacc60   352  41f7db         negl r11
0x133a0bdacc63   355  4181e3ff070000 andl r11,0x7ff
0x133a0bdacc6a   362  41f7db         negl r11
0x133a0bdacc6d   365  eb07           jmp 374  (0x133a0bdacc76)
0x133a0bdacc6f   367  4181e3ff070000 andl r11,0x7ff
[...SNIP...]
                  ;;; <@127,#88> deoptimize
0x133a0bdacd57   599  e81273cdff     call 0x133a0ba8406e
[...SNIP...]

所以你看到我们加载 this.waveTableLengthrbx 持有 this 的引用)的值,检查它仍然是 2048(十六进制的 0x800),如果是这样,就只用适当的掩码 0x7ff(r11 包含循环感应变量 i 的值)执行一个位操作 AND ,而不是使用 idiv 指令(注意保留左侧的符号)。

过度特定的问题

所以这个技巧酷毙了,但正如许多基准关注的技巧都有一个主要的缺点:太过于特定了!一旦右侧发生变化,所有优化过的代码就失去了优化(假设右手始终是不再处理的 2 的冥),任何进一步的优化尝试都必须再次使用 idiv,因为 BinaryOpIC 很可能以 Smi * Smi -> Smi 的形式报告反馈。例如,假设我们实例化另一个 Oscillator,在其上设置不同的 waveTableLength,并为 Oscillator 调用 generate,那么即使我们实际上感兴趣的 Oscillator 不受影响,我们也会损失 20% 的性能(例如,引擎在这里实行非局部惩罚)。

--- audio-oscillator.js.ORIG    2016-12-15 22:01:43.897033156 +0100
+++ audio-oscillator.js 2016-12-15 22:02:26.397326067 +0100
@@ -1931,6 +1931,10 @@
 var frequency = 344.53;
 var sine = new Oscillator(Oscillator.Sine, frequency, 1, bufferSize, sampleRate);

+var unused = new Oscillator(Oscillator.Sine, frequency, 1, bufferSize, sampleRate);
+unused.waveTableLength = 1024;
+unused.generate();
+
 var calcOsc = function() {
   sine.generate();

将原始的 audio-oscillator.js 执行时间与包含额外未使用的 Oscillator 实例与修改的 waveTableLength 的版本进行比较,显示的是预期的结果:

$ ~/Projects/v8/out/Release/d8 audio-oscillator.js.ORIG
Time (audio-oscillator-once): 64 ms.
$ ~/Projects/v8/out/Release/d8 audio-oscillator.js
Time (audio-oscillator-once): 81 ms.
$

这是一个非常可怕的性能悬崖的例子:假设开发人员编写代码库,并使用某些样本输入值进行仔细的调整和优化,性能是体面的。现在,用户读过了性能说明开始使用该库,但不知何故从性能悬崖下降,因为她/他正在以一种稍微不同的方式使用库,即特定的 BinaryOpIC 的某种污染方式的类型反馈,并且遭受 20% 的减速(与该库作者的测量相比),该库的作者和用户都无法解释,这似乎是随机的。

现在这种情况在 JavaScript 领域并不少见,不幸的是,这些悬崖中有几个是不可避免的,因为它们是由于 JavaScript 的性能是基于乐观的假设和猜测。我们已经花了 大量 时间和精力来试图找到避免这些性能悬崖的方法,而仍提供了(几乎)相同的性能。事实证明,尽可能避免 idiv 是很有意义的,即使你不一定知道右边总是一个 2 的幂(通过动态反馈),所以为什么 TurboFan 的做法有异于 Crankshaft 的做法,因为它总是在运行时检查输入是否是 2 的幂,所以一般情况下,对于有符整数模数,优化右手侧的(未知的) 2 的冥看起来像这样(伪代码):

if 0 < rhs then
  msk = rhs - 1
  if rhs & msk != 0 then
    lhs % rhs
  else
    if lhs < 0 then
      -(-lhs & msk)
    else
      lhs & msk
else
  if rhs < -1 then
    lhs % rhs
  else
    zero

这产生更加一致和可预测的性能(使用 TurboFan):

$ ~/Projects/v8/out/Release/d8 --turbo audio-oscillator.js.ORIG
Time (audio-oscillator-once): 69 ms.
$ ~/Projects/v8/out/Release/d8 --turbo audio-oscillator.js
Time (audio-oscillator-once): 69 ms.
$

基准和过度特定化的问题在于基准可以给你提示可以看看哪里以及该怎么做,但它不告诉你应该做到什么程度,不能保护合理优化。例如,所有 JavaScript 引擎都使用基准来防止性能回退,但是运行 Kraken 不能保护我们在 TurboFan 中使用的常规方法,即我们可以将 TurboFan 中的模优化降级到过度特定的版本的 Crankshaft,而基准不会告诉我们性能回退的事实,因为从基准的角度来看这很好!现在你可以扩展基准,也许以上面我们相同的方式,并试图用基准覆盖一切,这是引擎实现者在一定程度上做的事情,但这种方法不能任意缩放。即使基准测试方便,易于用来沟通和竞争,以常识所见你还是需要留下空间,否则过度特定化将支配一切,你会有一个真正的、非常好的可接受的性能,以及巨大的性能悬崖线。

Kraken 测试还有许多其它的问题,不过现在让我们继续讨论过去五年中最有影响力的 JavaScript 基准测试—— Octane 测试。

深入接触 Octane

Octane 基准是 V8 基准的继承者,最初由谷歌于 2012 年中期发布,目前的版本 Octane 2.0 于 2013 年年底发布。这个版本包含 15 个独立测试,其中对于 SplayMandreel,我们用来测试吞吐量和延迟。这些测试范围从 微软 TypeScript 编译器 编译自身到 zlib 测试测量原生的 asm.js 性能,再到 RegExp 引擎的性能测试、光线追踪器、2D 物理引擎等。有关各个基准测试项的详细概述,请参阅说明书。所有这些测试项目都经过仔细的筛选,以反映 JavaScript 性能的方方面面,我们认为这在 2012 年非常重要,或许预计在不久的将来会变得更加重要。

在很大程度上 Octane 在实现其将 JavaScript 性能提高到更高水平的目标方面无比的成功,它在 2012 年和 2013 年引导了良性的竞争,Octane 创造了巨大的业绩和成就。但是现在将近 2017 年了,世界看起来与 2012 年真的迥然不同了。除了通常和经常被引用的批评,Octane 中的大多数项目基本上已经过时(例如,老版本的 TypeScriptzlib 通过老版本的 Emscripten 编译而成,Mandreel 甚至不再可用等等),某种更重要的方式影响了 Octane 的用途:

我们看到大型 web 框架赢得了 web 种族之争,尤其是像 EmberAngularJS 这样的重型框架,它们使用了 JavaScript 执行模式,不过根本没有被 Octane 所反映,并且经常受到(我们)Octane 具体优化的损害。我们还看到 JavaScript 在服务器和工具前端获胜,这意味着有大规模的 JavaScript 应用现在通常运行上数星期,如果不是运行上数年都不会被 Octane 捕获。正如开篇所述,我们有硬数据表明 Octane 的执行和内存配置文件与我们每天在 web 上看到的截然不同。

让我们来看看今天一些玩弄 Octane 基准的具体例子,其中优化不再反映在现实场景。请注意,即使这可能听起来有点负面回顾,它绝对不意味着这样!正如我已经说过好几遍,Octane 是 JavaScript 性能故事中的重要一章,它发挥了至关重要的作用。在过去由 Octane 驱动的 JavaScript 引擎中的所有优化都是善意地添加的,因为 Octane 是现实场景性能的好代理!每个年代都有它的基准,而对于每一个基准都有一段时间你必须要放手!

话虽如此,让我们在路上看这个节目,首先看看 Box2D 测试,它是基于 Box2DWeb (一个最初由 Erin Catto 编写的移植到 JavaScript 的流行的 2D 物理引擎)的。总的来说,很多浮点数学驱动了很多 JavaScript 引擎下很好的优化,但是,事实证明它包含一个可以肆意玩弄基准的漏洞(怪我,我发现了漏洞,并添加在这种情况下的漏洞)。在基准中有一个函数 D.prototype.UpdatePairs,看起来像这样:

D.prototype.UpdatePairs = function(b) {
    var e = this;
    var f = e.m_pairCount = 0,
        m;
    for (f = 0; f < e.m_moveBuffer.length; ++f) {
        m = e.m_moveBuffer[f];
        var r = e.m_tree.GetFatAABB(m);
        e.m_tree.Query(function(t) {
                if (t == m) return true;
                if (e.m_pairCount == e.m_pairBuffer.length) e.m_pairBuffer[e.m_pairCount] = new O;
                var x = e.m_pairBuffer[e.m_pairCount];
                x.proxyA = t < m ? t : m;
                x.proxyB = t >= m ? t : m;
                ++e.m_pairCount;
                return true
            },
            r)
    }
    for (f = e.m_moveBuffer.length = 0; f < e.m_pairCount;) {
        r = e.m_pairBuffer[f];
        var s = e.m_tree.GetUserData(r.proxyA),
            v = e.m_tree.GetUserData(r.proxyB);
        b(s, v);
        for (++f; f < e.m_pairCount;) {
            s = e.m_pairBuffer[f];
            if (s.proxyA != r.proxyA || s.proxyB != r.proxyB) break;
            ++f
        }
    }
};

一些分析显示,在第一个循环中传递给 e.m_tree.Query 的无辜的内部函数花费了大量的时间:

function(t) {
    if (t == m) return true;
    if (e.m_pairCount == e.m_pairBuffer.length) e.m_pairBuffer[e.m_pairCount] = new O;
    var x = e.m_pairBuffer[e.m_pairCount];
    x.proxyA = t < m ? t : m;
    x.proxyB = t >= m ? t : m;
    ++e.m_pairCount;
    return true
}

更准确地说,时间并不是开销在这个函数本身,而是由此触发的操作和内置库函数。结果,我们花费了基准调用的总体执行时间的 4-7% 在 Compare` 运行时函数上,它实现了抽象关系比较的一般情况。

Box2D 比较分析

几乎所有对运行时函数的调用都来自 CompareICStub,它用于内部函数中的两个关系比较:

x.proxyA = t < m ? t : m;
x.proxyB = t >= m ? t : m;

所以这两行无辜的代码要负起 99% 的时间开销的责任!这怎么来的?好吧,与 JavaScript 中的许多东西一样,抽象关系比较 的直观用法不一定是正确的。在这个函数中,tm 都是 L 的实例,它是这个应用的一个中心类,但不会覆盖 Symbol.toPrimitive“toString”“valueOf”Symbol.toStringTag 属性,它们与抽象关系比较相关。所以如果你写 t < m 会发生什么呢?

  1. 调用 ToPrimitive(t, hint Number)。
  2. 运行 OrdinaryToPrimitive(t, "number"),因为这里没有 Symbol.toPrimitive
  3. 执行 t.valueOf(),这会获得 t 自身的值,因为它调用了默认的 Object.prototype.valueOf
  4. 接着执行 t.toString(),这会生成 "[object Object]",因为调用了默认的 Object.prototype.toString,并且没有找到 LSymbol.toStringTag
  5. 调用 ToPrimitive(m, hint Number)。
  6. 运行 OrdinaryToPrimitive(m, "number"),因为这里没有 Symbol.toPrimitive
  7. 执行 m.valueOf(),这会获得 m 自身的值,因为它调用了默认的 Object.prototype.valueOf
  8. 接着执行 m.toString(),这会生成 "[object Object]",因为调用了默认的 Object.prototype.toString,并且没有找到 LSymbol.toStringTag
  9. 执行比较 "[object Object]" < "[object Object]",结果是 false

至于 t >= m 亦复如是,它总会输出 true。所以这里是一个漏洞——使用抽象关系比较这种方法没有意义。而利用它的方法是使编译器常数折叠,即给基准打补丁:

--- octane-box2d.js.ORIG        2016-12-16 07:28:58.442977631 +0100
+++ octane-box2d.js     2016-12-16 07:29:05.615028272 +0100
@@ -2021,8 +2021,8 @@
                     if (t == m) return true;
                     if (e.m_pairCount == e.m_pairBuffer.length) e.m_pairBuffer[e.m_pairCount] = new O;
                     var x = e.m_pairBuffer[e.m_pairCount];
-                    x.proxyA = t < m ? t : m;
-                    x.proxyB = t >= m ? t : m;
+                    x.proxyA = m;
+                    x.proxyB = t;
                     ++e.m_pairCount;
                     return true
                 },

因为这样做会跳过比较以达到 13% 的惊人的性能提升,并且所有的属性查找和内置函数的调用都会被它触发。

$ ~/Projects/v8/out/Release/d8 octane-box2d.js.ORIG
Score (Box2D): 48063
$ ~/Projects/v8/out/Release/d8 octane-box2d.js
Score (Box2D): 55359
$

那么我们是怎么做呢?事实证明,我们已经有一种用于跟踪比较对象的形状的机制,比较发生于 CompareIC,即所谓的已知接收器映射跟踪(其中的映射是 V8 的对象形状+原型),不过这是有限的抽象和严格相等比较。但是我可以很容易地扩展跟踪,并且收集反馈进行抽象的关系比较:

$ ~/Projects/v8/out/Release/d8 --trace-ic octane-box2d.js
[...SNIP...]
[CompareIC in ~+557 at octane-box2d.js:2024 ((UNINITIALIZED+UNINITIALIZED=UNINITIALIZED)->(RECEIVER+RECEIVER=KNOWN_RECEIVER))#LT @ 0x1d5a860493a1]
[CompareIC in ~+649 at octane-box2d.js:2025 ((UNINITIALIZED+UNINITIALIZED=UNINITIALIZED)->(RECEIVER+RECEIVER=KNOWN_RECEIVER))#GTE @ 0x1d5a860496e1]
[...SNIP...]
$

这里基准代码中使用的 CompareIC 告诉我们,对于我们正在查看的函数中的 LT(小于)和 GTE(大于或等于)比较,到目前为止这只能看到 RECEIVERs(接收器,V8 的 JavaScript 对象),并且所有这些接收器具有相同的映射 0x1d5a860493a1,其对应于 L 实例的映射。因此,在优化的代码中,只要我们知道比较的两侧映射的结果都为 0x1d5a860493a1,并且没人混淆 L 的原型链(即 Symbol.toPrimitive"valueOf""toString" 这些方法都是默认的,并且没人赋予过 Symbol.toStringTag 的访问权限),我们可以将这些操作分别常量折叠为 falsetrue。剩下的故事都是关于 Crankshaft 的黑魔法,有很多一部分都是由于初始化的时候忘记正确地检查 Symbol.toStringTag 属性:

Hydrogen 黑魔法

最后,性能在这个特定的基准上有了质的飞跃:

Box2D 加速

我要声明一下,当时我并不相信这个特定的行为总是指向源代码中的漏洞,所以我甚至期望外部代码经常会遇到这种情况,同时也因为我假设 JavaScript 开发人员不会总是关心这些种类的潜在错误。但是,我大错特错了,在此我马上悔改!我不得不承认,这个特殊的优化纯粹是一个基准测试的东西,并不会有助于任何真实代码(除非代码是为了从这个优化中获益而写,不过以后你可以在代码中直接写入 truefalse,而不用再总是使用常量关系比较)。你可能想知道我们为什么在打补丁后又马上回滚了一下。这是我们整个团队投入到 ES2015 实施的非常时期,这才是真正的恶魔之舞,我们需要在没有严格的回归测试的情况下将所有新特性(ES2015 就是个怪兽)纳入传统基准。

关于 Box2D 点到为止了,让我们看看 Mandreel 基准。Mandreel 是一个用来将 C/C++ 代码编译成 JavaScript 的编译器,它并没有用上新一代的 Emscripten 编译器所使用,并且已经被弃用(或多或少已经从互联网消失了)大约三年的 JavaScript 子集 asm.js。然而,Octane 仍然有一个通过 Mandreel 编译的子弹物理引擎MandreelLatency 测试十分有趣,它测试 Mandreel 基准与频繁的时间测量检测点。有一种说法是,由于 Mandreel 强制使用虚拟机编译器,此测试提供了由编译器引入的延迟的指示,并且测量检测点之间的长时间停顿降低了最终得分。这听起来似乎合情合理,确实有一定的意义。然而,像往常一样,供应商找到了在这个基准上作弊的方法。

Mozilla 1162272 漏洞

Mandreel 自带一个重型初始化函数 global_init,光是解析这个函数并为其生成基线代码就花费了不可思议的时间。因为引擎通常在脚本中多次解析各种函数,一个所谓的预解析步骤用来发现脚本内的函数。然后作为函数第一次被调用完整的解析步骤以生成基线代码(或者说字节码)。这在 V8 中被称为懒解析。V8 有一些启发式检测函数,当预解析浪费时间的时候可以立刻调用,不过对于 Mandreel 基准的 global_init 函数就不太清楚了,于是我们将经历这个大家伙“预解析+解析+编译”的长时间停顿。所以我们添加了一个额外的启发式函数以避免 global_init 函数的预解析。

MandreelLatency 基准

由此可见,在检测 global_init 和避免昂贵的预解析步骤我们几乎提升了 2 倍。我们不太确定这是否会对真实用例产生负面影响,不过保证你在预解析大函数的时候将会受益匪浅(因为它们不会立即执行)。

让我们来看看另一个稍有争议的基准测试:splay.js 测试,一个用于处理 伸展树 splay tree (二叉查找树的一种)和练习自动内存管理子系统(也被称为垃圾回收器)的数据操作基准。它自带一个延迟测试,这会引导 Splay 代码通过频繁的测量检测点,检测点之间的长时间停顿表明垃圾回收器的延迟很高。此测试测量延迟暂停的频率,将它们分类到桶中,并以较低的分数惩罚频繁的长暂停。这听起来很棒!没有 GC 停顿,没有垃圾。纸上谈兵到此为止。让我们看看这个基准,以下是整个伸展树业务的核心:

splay.js

这是伸展树结构的核心构造,尽管你可能想看完整的基准,不过这基本上是 SplayLatency 得分的重要来源。怎么回事?实际上,该基准测试是建立巨大的伸展树,尽可能保留所有节点,从而还原它原本的空间。使用像 V8 这样的代数垃圾回收器,如果程序违反了代数假设,会导致极端的时间停顿,从本质上看,将所有东西从新空间撤回到旧空间的开销是非常昂贵的。在旧配置中运行 V8 可以清楚地展示这个问题:

$ out/Release/d8 --trace-gc --noallocation_site_pretenuring octane-splay.js
[20872:0x7f26f24c70d0]       10 ms: Scavenge 2.7 (6.0) -> 2.7 (7.0) MB, 1.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       12 ms: Scavenge 2.7 (7.0) -> 2.7 (8.0) MB, 1.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       14 ms: Scavenge 3.7 (8.0) -> 3.6 (10.0) MB, 0.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       18 ms: Scavenge 4.8 (10.5) -> 4.7 (11.0) MB, 2.5 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       22 ms: Scavenge 5.7 (11.0) -> 5.6 (16.0) MB, 2.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       28 ms: Scavenge 8.7 (16.0) -> 8.6 (17.0) MB, 4.3 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       35 ms: Scavenge 9.6 (17.0) -> 9.6 (28.0) MB, 6.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       49 ms: Scavenge 16.6 (28.5) -> 16.4 (29.0) MB, 8.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       65 ms: Scavenge 17.5 (29.0) -> 17.5 (52.0) MB, 15.3 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]       93 ms: Scavenge 32.3 (52.5) -> 32.0 (53.5) MB, 17.6 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      126 ms: Scavenge 33.4 (53.5) -> 33.3 (68.0) MB, 31.5 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      151 ms: Scavenge 47.9 (68.0) -> 47.6 (69.5) MB, 15.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      183 ms: Scavenge 49.2 (69.5) -> 49.2 (84.0) MB, 30.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      210 ms: Scavenge 63.5 (84.0) -> 62.4 (85.0) MB, 14.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      241 ms: Scavenge 64.7 (85.0) -> 64.6 (99.0) MB, 28.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      268 ms: Scavenge 78.2 (99.0) -> 77.6 (101.0) MB, 16.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      298 ms: Scavenge 80.4 (101.0) -> 80.3 (114.5) MB, 28.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      324 ms: Scavenge 93.5 (114.5) -> 92.9 (117.0) MB, 16.4 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      354 ms: Scavenge 96.2 (117.0) -> 96.0 (130.0) MB, 27.6 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      383 ms: Scavenge 108.8 (130.0) -> 108.2 (133.0) MB, 16.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      413 ms: Scavenge 111.9 (133.0) -> 111.7 (145.5) MB, 27.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      440 ms: Scavenge 124.1 (145.5) -> 123.5 (149.0) MB, 17.4 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      473 ms: Scavenge 127.6 (149.0) -> 127.4 (161.0) MB, 29.5 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      502 ms: Scavenge 139.4 (161.0) -> 138.8 (165.0) MB, 18.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      534 ms: Scavenge 143.3 (165.0) -> 143.1 (176.5) MB, 28.5 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      561 ms: Scavenge 154.7 (176.5) -> 154.2 (181.0) MB, 19.0 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      594 ms: Scavenge 158.9 (181.0) -> 158.7 (192.0) MB, 29.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      622 ms: Scavenge 170.0 (192.5) -> 169.5 (197.0) MB, 19.5 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      655 ms: Scavenge 174.6 (197.0) -> 174.3 (208.0) MB, 28.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      683 ms: Scavenge 185.4 (208.0) -> 184.9 (212.5) MB, 19.4 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      715 ms: Scavenge 190.2 (213.0) -> 190.0 (223.5) MB, 27.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      743 ms: Scavenge 200.7 (223.5) -> 200.3 (228.5) MB, 19.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      774 ms: Scavenge 205.8 (228.5) -> 205.6 (239.0) MB, 27.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      802 ms: Scavenge 216.1 (239.0) -> 215.7 (244.5) MB, 19.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      833 ms: Scavenge 221.4 (244.5) -> 221.2 (254.5) MB, 26.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      861 ms: Scavenge 231.5 (255.0) -> 231.1 (260.5) MB, 19.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      892 ms: Scavenge 237.0 (260.5) -> 236.7 (270.5) MB, 26.3 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      920 ms: Scavenge 246.9 (270.5) -> 246.5 (276.0) MB, 20.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      951 ms: Scavenge 252.6 (276.0) -> 252.3 (286.0) MB, 25.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]      979 ms: Scavenge 262.3 (286.0) -> 261.9 (292.0) MB, 20.3 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1014 ms: Scavenge 268.2 (292.0) -> 267.9 (301.5) MB, 29.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1046 ms: Scavenge 277.7 (302.0) -> 277.3 (308.0) MB, 22.4 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1077 ms: Scavenge 283.8 (308.0) -> 283.5 (317.5) MB, 25.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1105 ms: Scavenge 293.1 (317.5) -> 292.7 (323.5) MB, 20.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1135 ms: Scavenge 299.3 (323.5) -> 299.0 (333.0) MB, 24.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1164 ms: Scavenge 308.6 (333.0) -> 308.1 (339.5) MB, 20.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1194 ms: Scavenge 314.9 (339.5) -> 314.6 (349.0) MB, 25.0 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1222 ms: Scavenge 324.0 (349.0) -> 323.6 (355.5) MB, 21.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1253 ms: Scavenge 330.4 (355.5) -> 330.1 (364.5) MB, 25.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1282 ms: Scavenge 339.4 (364.5) -> 339.0 (371.0) MB, 22.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1315 ms: Scavenge 346.0 (371.0) -> 345.6 (380.0) MB, 25.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1413 ms: Mark-sweep 349.9 (380.0) -> 54.2 (305.0) MB, 5.8 / 0.0 ms  (+ 87.5 ms in 73 steps since start of marking, biggest step 8.2 ms, walltime since start of marking 131 ms) finalize incremental marking via stack guard GC in old space requested
[20872:0x7f26f24c70d0]     1457 ms: Scavenge 65.8 (305.0) -> 65.1 (305.0) MB, 31.0 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1489 ms: Scavenge 69.9 (305.0) -> 69.7 (305.0) MB, 27.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1523 ms: Scavenge 80.9 (305.0) -> 80.4 (305.0) MB, 22.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1553 ms: Scavenge 85.5 (305.0) -> 85.3 (305.0) MB, 24.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1581 ms: Scavenge 96.3 (305.0) -> 95.7 (305.0) MB, 18.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1616 ms: Scavenge 101.1 (305.0) -> 100.9 (305.0) MB, 29.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1648 ms: Scavenge 111.6 (305.0) -> 111.1 (305.0) MB, 22.5 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1678 ms: Scavenge 116.7 (305.0) -> 116.5 (305.0) MB, 25.0 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1709 ms: Scavenge 127.0 (305.0) -> 126.5 (305.0) MB, 20.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1738 ms: Scavenge 132.3 (305.0) -> 132.1 (305.0) MB, 23.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1767 ms: Scavenge 142.4 (305.0) -> 141.9 (305.0) MB, 19.6 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1796 ms: Scavenge 147.9 (305.0) -> 147.7 (305.0) MB, 23.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1825 ms: Scavenge 157.8 (305.0) -> 157.3 (305.0) MB, 19.9 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1853 ms: Scavenge 163.5 (305.0) -> 163.2 (305.0) MB, 22.2 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1881 ms: Scavenge 173.2 (305.0) -> 172.7 (305.0) MB, 19.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1910 ms: Scavenge 179.1 (305.0) -> 178.8 (305.0) MB, 23.0 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1944 ms: Scavenge 188.6 (305.0) -> 188.1 (305.0) MB, 25.1 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     1979 ms: Scavenge 194.7 (305.0) -> 194.4 (305.0) MB, 28.4 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     2011 ms: Scavenge 204.0 (305.0) -> 203.6 (305.0) MB, 23.4 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     2041 ms: Scavenge 210.2 (305.0) -> 209.9 (305.0) MB, 23.8 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     2074 ms: Scavenge 219.4 (305.0) -> 219.0 (305.0) MB, 24.5 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     2105 ms: Scavenge 225.8 (305.0) -> 225.4 (305.0) MB, 24.7 / 0.0 ms  allocation failure
[20872:0x7f26f24c70d0]     2138 ms: Scavenge 234.8 (305.0) -> 234.4 (305.0) MB, 23.1 / 0.0 ms  allocation failure
[...SNIP...]
$

因此这里关键的发现是直接在旧空间中分配伸展树节点可基本避免在周围复制对象的所有开销,并且将次要 GC 周期的数量减少到最小(从而减少 GC 引起的停顿时间)。我们想出了一种称为 分配场所预占 allocation site pretenuring 的机制,当运行到基线代码时,将尝试动态收集分配场所的反馈,以决定在此分配的对象的确切部分是否存在,如果是,则优化代码以直接在旧空间分配对象——即预占对象。

$ out/Release/d8 --trace-gc octane-splay.js
[20885:0x7ff4d7c220a0]        8 ms: Scavenge 2.7 (6.0) -> 2.6 (7.0) MB, 1.2 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       10 ms: Scavenge 2.7 (7.0) -> 2.7 (8.0) MB, 1.6 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       11 ms: Scavenge 3.6 (8.0) -> 3.6 (10.0) MB, 0.9 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       17 ms: Scavenge 4.8 (10.5) -> 4.7 (11.0) MB, 2.9 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       20 ms: Scavenge 5.6 (11.0) -> 5.6 (16.0) MB, 2.8 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       26 ms: Scavenge 8.7 (16.0) -> 8.6 (17.0) MB, 4.5 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       34 ms: Scavenge 9.6 (17.0) -> 9.5 (28.0) MB, 6.8 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       48 ms: Scavenge 16.6 (28.5) -> 16.4 (29.0) MB, 8.6 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       64 ms: Scavenge 17.5 (29.0) -> 17.5 (52.0) MB, 15.2 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]       96 ms: Scavenge 32.3 (52.5) -> 32.0 (53.5) MB, 19.6 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]      153 ms: Scavenge 61.3 (81.5) -> 57.4 (93.5) MB, 27.9 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]      432 ms: Scavenge 339.3 (364.5) -> 326.6 (364.5) MB, 12.7 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]      666 ms: Scavenge 563.7 (592.5) -> 553.3 (595.5) MB, 20.5 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]      825 ms: Mark-sweep 603.9 (644.0) -> 96.0 (528.0) MB, 4.0 / 0.0 ms  (+ 92.5 ms in 51 steps since start of marking, biggest step 4.6 ms, walltime since start of marking 160 ms) finalize incremental marking via stack guard GC in old space requested
[20885:0x7ff4d7c220a0]     1068 ms: Scavenge 374.8 (528.0) -> 362.6 (528.0) MB, 19.1 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]     1304 ms: Mark-sweep 460.1 (528.0) -> 102.5 (444.5) MB, 10.3 / 0.0 ms  (+ 117.1 ms in 59 steps since start of marking, biggest step 7.3 ms, walltime since start of marking 200 ms) finalize incremental marking via stack guard GC in old space requested
[20885:0x7ff4d7c220a0]     1587 ms: Scavenge 374.2 (444.5) -> 361.6 (444.5) MB, 13.6 / 0.0 ms  allocation failure
[20885:0x7ff4d7c220a0]     1828 ms: Mark-sweep 485.2 (520.0) -> 101.5 (519.5) MB, 3.4 / 0.0 ms  (+ 102.8 ms in 58 steps since start of marking, biggest step 4.5 ms, walltime since start of marking 183 ms) finalize incremental marking via stack guard GC in old space requested
[20885:0x7ff4d7c220a0]     2028 ms: Scavenge 371.4 (519.5) -> 358.5 (519.5) MB, 12.1 / 0.0 ms  allocation failure
[...SNIP...]
$

事实上,这完全解决了 SplayLatency 基准的问题,并提高我们的得分至超过 250%!

SplayLatency 基准

正如 SIGPLAN 论文 中所提及的,我们有充分的理由相信,分配场所预占机制可能真的赢得了真实世界应用的欢心,并真正期待看到改进和扩展后的机制,那时将不仅仅是对象和数组字面量。但是不久后我们意识到分配场所预占机制对真实世界应用产生了相当严重的负面影响。我们实际上听到很多负面报道,包括 Ember.js 开发者和用户的唇枪舌战,虽然不仅是因为分配场所预占机制,不过它是事故的罪魁祸首。

分配场所预占机制的基本问题数之不尽,这在今天的应用中非常常见(主要是由于框架,同时还有其它原因),假设你的对象工厂最初是用于创建构成你的对象模型和视图的长周期对象的,它将你的工厂方法中的分配场所转换为永久状态,并且从工厂分配的所有内容都立即转到旧空间。现在初始设置完成后,你的应用开始工作,作为其中的一部分,从工厂分配临时对象会污染旧空间,最终导致开销昂贵的垃圾回收周期以及其它负面的副作用,例如过早触发增量标记。

我们开始重新考虑基准驱动的工作,并开始寻找现实场景驱动的替代方案,这导致了 Orinoco 的诞生,它的目标是逐步改进垃圾回收器;这个努力的一部分是一个称为“ 统一堆 unified heap ”的项目,如果页面中所有内容基本都存在,它将尝试避免复制对象。也就是说站在更高的层面看:如果新空间充满活动对象,只需将所有新空间页面标记为属于旧空间,然后从空白页面创建一个新空间。这可能不会在 SplayLatency 基准测试中得到相同的分数,但是这对于真实用例更友好,它可以自动适配具体的用例。我们还考虑 并发标记 concurrent marking ,将标记工作卸载到单独的线程,从而进一步减少增量标记对延迟和吞吐量的负面影响。

轻松一刻

喘口气。

好吧,我想这足以强调我的观点了。我可以继续指出更多的例子,其中 Octane 驱动的改进后来变成了一个坏主意,也许改天我会接着写下去。但是今天就到此为止了吧。

结论

我希望现在应该清楚为什么基准测试通常是一个好主意,但是只对某个特定的级别有用,一旦你跨越了 有用竞争 useful competition 的界限,你就会开始浪费你们工程师的时间,甚至开始损害到你的真实世界的性能!如果我们认真考虑 web 的性能,我们需要根据真实世界的性能来测评浏览器,而不是它们玩弄一个四年前的基准的能力。我们需要开始教育(技术)媒体,可能这没用,但至少请忽略他们。

2016 年 10 月浏览器基准之战: Chrome、Firefox 和 Edge 的决战

没人害怕竞争,但是玩弄可能已经坏掉的基准不像是在合理使用工程时间。我们可以尽更大的努力,并把 JavaScript 提高到更高的水平。让我们开展有意义的性能测试,以便为最终用户和开发者带来有意思的领域竞争。此外,让我们再对运行在 Node.js( V8 或 ChakraCore)中的服务器端和工具端代码做一些有意义的改进!

结束语:不要用传统的 JavaScript 基准来比较手机。这是真正最没用的事情,因为 JavaScript 的性能通常取决于软件,而不一定是硬件,并且 Chrome 每 6 周发布一个新版本,所以你在三月份的测试结果到了四月份就已经毫不相关了。如果为手机中的浏览器做个排名不可避免,那么至少请使用一个现代健全的浏览器基准来测试,至少这个基准要知道人们会用浏览器来干什么,比如 Speedometer 基准

感谢你花时间阅读!


作者简介:

我是 Benedikt Meurer,住在 Ottobrunn(德国巴伐利亚州慕尼黑东南部的一个市镇)的一名软件工程师。我于 2007 年在锡根大学获得应用计算机科学与电气工程的文凭,打那以后的 5 年里我在编译器和软件分析领域担任研究员(2007 至 2008 年间还研究过微系统设计)。2013 年我加入了谷歌的慕尼黑办公室,我的工作目标主要是 V8 JavaScript 引擎,目前是 JavaScript 执行性能优化团队的一名技术领导。


via: http://benediktmeurer.de/2016/12/16/the-truth-about-traditional-javascript-benchmarks

作者:Benedikt Meurer 译者:OneNewLife 校对:OneNewLife, wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

GitHub 和 Facebook 最近发起了一套工具集,它可以让你将你的可魔改 Atom 文本编辑器变身成为 IDE(集成开发环境),他们将这个项目叫做 Atom-IDE。

上周 Atom 1.21 Beta 发布之后,GitHub 引入了 语言服务器协议 Language Server Protocol 支持以集成其全新打造的 Atom-IDE 项目,它内置带有 5 个流行的语言服务器,包括 JavaScript、TypeScript、 PHP、Java、 C# 和 Flow,而更多的语言服务器正在赶来……

GitHub 的 Damien Guard 解释说:“该 IDE 的每个软件包都提供了基于底层的语言服务器的功能选择,并在打开它所支持的文件时激活。你至少需要安装两个包:Atom IDE 的用户界面和支持该语言的软件包。”

将 Atom 变成 Atom-IDE

如果你想要体验下 Atom 的 IDE 功能,在 Atom-IDE 项目的帮助下这很容易。你只需要在 Atom 的设置窗口中打开安装软件包对话框,并在其中搜索和安装 atom-ide-ui 软件包即可。

这将在你的 Atom 中呈现 IDE 界面,但是要成为一个完全可工作的 IDE ,你还需要安装你的语言服务器支持。目前,你可以从以下五种语言中选择:ide-typescript (TypeScript & JavaScript)、 ide-php (PHP)、 ide-java (Java)、 ide-csharp (C#)以及 ide-flowtype (Flow)。

当然,这些功能需要你安装使用 Atom 1.21 Beta 才能使用,它目前还是 Beta 版本,下个月才会发布正式版本。

你可能在各种应用中听说过 机器学习 machine learning (ML),比如垃圾邮件过滤、光学字符识别(OCR)和计算机视觉。

开启机器学习之旅是一个涉及多方面的漫长旅途。对于新手,有很多的书籍,有学术论文,有指导练习,有独立项目。在这些众多的选择里面,很容易迷失你最初想学习的目标。

所以在今天的文章中,我会列出 7 个步骤(和 50 多个资源)帮助你开启这个令人兴奋的计算机科学领域的大门,并逐渐成为一个机器学习高手。

请注意,这个资源列表并不详尽,只是为了让你入门。 除此之外,还有更多的资源。

1、 学习必要的背景知识

你可能还记得 DataCamp 网站上的学习数据科学这篇文章里面的信息图:数学和统计学是开始机器学习(ML)的关键。 基础可能看起来很容易,因为它只有三个主题。 但不要忘记这些实际上是三个广泛的话题。

在这里需要记住两件非常重要的事情:

  • 首先,你一定会需要一些进一步的指导,以了解开始机器学习需要覆盖哪些知识点。
  • 其次,这些是你进一步学习的基础。 不要害怕花时间,有了这些知识你才能构建一切。

第一点很简单:学习线性代数和统计学是个好主意。这两门知识是必须要理解的。但是在你学习的同时,也应该尝试学习诸如最优化和高等微积分等主题。当你越来越深入 ML 的时候,它们就能派上用场。

如果是从零开始的,这里有一些入门指南可供参考:

统计学是学习 ML 的关键之一

如果你更多喜欢阅读书籍,请参考以下内容:

然而,在大多数情况下,你已经对统计学和数学有了一个初步的了解。很有可能你已经浏览过上面列举的的那些资源。

在这种情况下,诚实地回顾和评价你的知识是一个好主意,是否有一些领域是需要复习的,或者现在掌握的比较好的?

如果你一切都准备好了,那么现在是时候使用 R 或者 Python 应用这些知识了。作为一个通用的指导方针,选择一门语言开始是个好主意。另外,你仍然可以将另一门语言加入到你的技能池里。

为什么这些编程知识是必需的?

嗯,你会看到上面列出的课程(或你在学校或大学学习的课程)将为你提供关于数学和统计学主题的更理论性的介绍(而不是应用性的)。 然而,ML 非常便于应用,你需要能够应用你所学到的所有主题。 所以最好再次复习一遍之前的材料,但是这次需要付诸应用。

如果你想掌握 R 和 Python 的基础,可以看以下课程:

当你打牢基础知识后,请查看 DataCamp 上的博客 Python 统计学:40+ 数据科学资源。 这篇文章提供了统计学方面的 40 多个资源,这些资源都是你开始数据科学(以及 ML)需要学习的。

还要确保你查看了关于向量和数组的 这篇 SciPy 教程文章,以及使用 Python 进行科学计算的研讨会

要使用 Python 和微积分进行实践,你可以了解下 SymPy 软件包

2、 不要害怕在 ML 的“理论”上浪费时间

很多人并不会花很多精力去浏览理论材料,因为理论是枯燥的、无聊的。但从长远来看,在理论知识上投入时间是至关重要的、非常值得的。 你将会更好地了解机器学习的新进展,也能和背景知识结合起来。 这将有助于你保持学习积极性。

此外,理论并不会多无聊。 正如你在介绍中所看到的,你可以借助非常多的资料深入学习。

书籍是吸收理论知识的最佳途径之一。 它们可以让你停下来想一会儿。 当然,看书是一件非常平静的事情,可能不符合你的学习风格。 不过,请尝试阅读下列书籍,看看它是否适合你:

  • 机器学习教程 Machine Learning textbook , Tom Mitchell 著,书可能比较旧,但是却很经典。这本书很好的解释介绍了机器学习中最重要的课题,步骤详尽,逐层深入。
  • 机器学习: 使数据有意义的算法艺术和科学 Machine Learning: The Art and Science of Algorithms that Make Sense of Data (你可以在这里看到这本书的幻灯片版本):这本书对初学者来说非常棒。 里面讨论了许多实践中的应用程序,其中有一些是在 Tom Mitchell 的书中缺少的。
  • 机器学习之向往 Machine Learning Yearning :这本书由 吴恩达 Andrew Ng 编写的,仍未完本,但对于那些正在学习 ML 的学生来说,这一定是很好的参考资料。
  • 算法与数据结构 Algorithms and Data Structures 由 Jurg Nievergelt 和 Klaus Hinrichs 著。
  • 也可以参阅 Matthew North 的 面向大众的数据挖掘 Data Mining for the Masses 。 你会发现这本书引导你完成一些最困难的主题。
  • 机器学习介绍 Introduction to Machine Learning 由 Alex Smola 和 S.V.N. Vishwanathan 著。

花些时间看书并研究其中涵盖的资料

视频和慕课对于喜欢边听边看来学习的人来说非常棒。 慕课和视频非常的多,多到可能你都很难找到适合你的。 下面列出了最知名的几个:

在这一点上,重要的是要将各种独立的技术融会贯通,形成整体的结构图。 首先了解关键的概念: 监督学习 supervised learning 无监督学习 unsupervised learning 的区别、分类和回归等。 手动(书面)练习可以派上用场,能帮你了解算法是如何工作的以及如何应用这些算法。 在大学课程里你经常会找到一些书面练习,可以看看波特兰州立大学的 ML 课程

3、 开始动手

通过看书和看视频了解理论和算法都非常好,但是需要超越这一阶段,就要开始做一些练习。你要学着去实现这些算法,应用学到的理论。

首先,有很多介绍 Python 和 R 方面的机器学习的基础知识。当然最好的方法就是使用交互式教程:

还请查看以下静态的(非互动的)教程,这些需要你在 IDE 中操作:

除了教程之外,还有一些课程。参加课程可以帮助你系统性地应用学到的概念。 经验丰富的导师很有帮助。 以下是 Python 和机器学习的一些互动课程:

  • 用 scikit-learn 做监督学习: 学习如何构建预测模型,调整参数,并预测在未知数据上执行的效果。你将使用 Scikit-Learn 操作真实世界的数据集。
  • 用 Python 做无监督学习: 展示给你如何从未标记的数据集进行聚类、转换、可视化和提取关键信息。 在课程结束时,还会构建一个推荐系统。
  • Python 深度学习: 你将获得如何使用 Keras 2.0 进行深度学习的实践知识,Keras 2.0 是前沿的 Python 深度学习库 Keras 的最新版本。
  • 在 Python 中应用机器学习: 将学习者引入到机器学习实践中,更多地关注技术和方法,而不是这些方法背后的统计学知识。

理论学习之后,花点时间来应用你所学到的知识。

对于那些正在学习 R 语言机器学习的人,还有这些互动课程:

  • 机器学习介绍 可以让你宏观了解机器学习学科最常见的技术和应用,还可以更多地了解不同机器学习模型的评估和训练。这门课程剩下的部分重点介绍三个最基本的机器学习任务: 分类、回归和聚类。
  • R 语言无监督学习 ,用 R 语言从 ML 角度提供聚类和降维的基本介绍。 可以让你尽快获得数据的关键信息。
  • 实操机器学习涵盖了构建和应用预测功能的基本组成部分,其重点是实际应用。

最后,还有很多书籍以偏向实践的方式介绍了 ML 主题。 如果你想借助书籍内容和 IDE 来学习,请查看这些书籍:

4、 练习

实践比使用 Python 进行练习和修改材料更重要。 这一步对我来说可能是最难的。 在做了一些练习后看看其他人是如何实现 ML 算法的。 然后,开始你自己的项目,阐述你对 ML 算法和理论的理解。

最直接的方法之一就是将练习的规模做得更大些。 要做一个更大的练习,就需要你做更多的数据清理和功能工程。

熟能生巧。

5、 项目

虽然做一些小的练习也不错,但是在最后,您需要做一个项目,可以在其中展示您对使用到的 ML 算法的理解。

最好的练习是实现你自己的 ML 算法。 您可以在以下页面中阅读更多关于为什么您应该做这样的练习,以及您可以从中学到什么内容:

接下来,您可以查看以下文章和仓库。 可以从中获得一些灵感,并且了解他们是如何实现 ML 算法的。

开始时项目可能会很难,但是可以极大增加你的理解。

6、 不要停止

对 ML 的学习永远不能停止,即使你在这个领域工作了十年,总是有新的东西要学习,许多人都将会证实这一点。

例如,ML 趋势,比如 深度学习 deep learning 现在就很受欢迎。你也可以专注于那些现在不怎么火,但是将来会火的话题上。如果你想了解更多,可以看看这个有趣的问题和答案

当你苦恼于掌握基础知识时,你最先想到的可能不是论文。 但是它们是你紧跟最新研究的一个途径。 论文并不适合刚刚开始学习的人,但是绝对适合高级人员。

其他技术也是需要考虑的。 但是当你刚开始学习时,不要担心这些。 例如,您可以专注于 Python 或 R 语言 (取决于你已经知道哪一个),并把它到你的技能池里。 你可以通过这篇文章来查找一些感兴趣的资源。

如果您还想转向大数据,您可以考虑研究 Spark。 这里有一些有趣的资源:

其他编程语言,比如 Java、JavaScript、C 和 C++ 在 ML 中越来越重要。 从长远来看,您可以考虑将其中一种语言添加到学习列表中。 你可以使用这些博客文章来指导你选择:

学无止境。

7、 利用一切可以利用的资源

机器学习是一个充满难度的话题,有时候可能会让你失去动力。 或者也许你觉得你需要点改变。 在这种情况下,请记住,有很多资源可以让你打消掉这种想法。 查看以下资源:

播客是可以让你继续你的 ML 旅程,紧跟这个领域最新的发展的伟大资源:

当然,还有更多的播客。

文档和软件包源代码是深入了解 ML 算法的实现的两种方法。 查看这些仓库:

  • Scikit-Learn:知名的 Python ML 软件包
  • Keras: Python 深度学习软件包
  • caret: 非常受欢迎的用于分类和回归训练 R 软件包

可视化是深入 ML 理论的最新也是最流行的方式之一。 它们对初学者来说非常棒,但对于更高级的学习者来说也是非常有趣的。 你肯定会被下面这些可视化资源所吸引,它们能让你更加了解 ML 的工作原理:

学习中的一些变化更加能激励你。

现在你可以开始了

现在一切都取决于你自己了。学习机器学习是一个持续的过程,所以开始的越早就会越好。 运用你手边的一切工具开始吧。 祝你好运,并确保让我们知道你的进步。

这篇文章是我基于 Quora 问题(小白该如何开始机器学习)给出的答案。


作者简介:

Karlijn Willems,数据科学记者


via: https://medium.freecodecamp.org/how-machines-learn-a-practical-guide-203aae23cafb

作者:Karlijn Willems 译者:Flowsnow 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

在开发和部署云原生应用程序时,运行容器化负载的 Kubernetes 平台起到了重大作用。

自然而然的,开发和部署云原生应用程序已经变得非常受欢迎。对于一个允许快速部署和连续交付的 bug 修复和新功能的流程来说,它有明显的优势,但是没有人会谈到鸡和鸡蛋问题:怎样才能达成这样的目的呢?从头开始构建基础设施和开发流程来开发和维护云原生应用程序是个不简单的、耗时的任务。

Kubernetes 是一个相对较新的运行容器化负载的平台,它解决了这些问题。它原本是 Google 内部的一个项目,Kubernetes 在 2015 年被捐赠给了云原生计算基金会,并吸引了来自世界各地开源社区的开发人员。 Kubernetes 的设计基于 Google 15 年的在生产和开发环境运维的经验。由于它是开源的,任何人都可以下载并使用它,并实现其带来的优势。

那么为什么 Kubernetes 会有这么大的惊喜呢?我认为它在像 OpenStack 这样的基础架构即服务(IaaS)和完整的平台即服务 (PaaS)的资源之间达到了最佳平衡,它的底层运行时实现完全由供应商控制。Kubernetes 提供了两个优势:对管理基础设施的抽象,以及深入裸机进行故障排除的工具和功能。

IaaS 与 PaaS

OpenStack 被大多数人归类为 IaaS 解决方案,其中物理资源池(如处理器、网络和存储)在不同用户之间分配和共享。它使用传统的基于硬件的虚拟化实现用户之间的隔离。

OpenStack 的 REST API 允许使用代码自动创建基础架构,但是这就是问题所在。IaaS 产品输出的也是基础设施。其创建后,支持和管理那些更多的基础设施的服务方式并不多。在一定程度上,OpenStack 生产的底层基础架构(如服务器和 IP 地址)成为管理工作的重中之重。一个众所周知的结果是虚拟机(VM)的无序蔓延,而同样的情况也出现于网络、加密密钥和存储卷方面。这样,开发人员建立和维护应用程序的时间就更少了。

像其它基于集群的解决方案一样,Kubernetes 以单个服务器级别的方式运行,以实现水平缩放。它可以轻松添加新的服务器,并立即在新硬件上安排负载。类似地,当服务器没有被有效利用或需要维护时,可以从集群中删除服务器。其它 Kubernetes 可以自动处理的其他任务是编排活动,如工作调度、健康监测和维护高可用性。

网络是另一个可能难以在 IaaS 环境中可靠编排的领域。微服务之间通过 IP 地址通信可能是很棘手的。Kubernetes 实现了 IP 地址管理、负载均衡、服务发现和 DNS 名称注册,以在集群内提供无痛、透明的网络环境。

专为部署而设计

一旦创建了运行应用程序的环境,部署就是一件小事了。可靠地部署一个应用程序是说起来容易做起来难的任务 —— 它并不是最简单的。Kubernetes 相对其他环境的巨大优势是,部署是一等公民。

使用一个单独的 Kubernetes 命令行界面(CLI)的命令,可以描述应用程序并将其安装在群集上。Kubernetes 从初始部署、推出新版本以及(当一个关键功能出现问题时)进行回滚,实现了应用程序的整个生命周期。运行中的部署也可以暂停和恢复。拥有现成的、内置的工具和支持应用程序部署,而不用自己构建部署系统,这是不容小觑的优点。Kubernetes 用户既不必重新发明应用程序部署的轮子,也不会发现这是一项艰巨的任务。

Kubernetes 还可以监控运行中的部署的状态。虽然你可以在 IaaS 环境中像编写部署过程一样编写这个功能,但这是一个非常困难的任务,而这样的情况还比比皆是。

专为 DevOps 而设计

随着你在开发和部署 Kubernetes 应用程序方面获得更多经验,你将沿着与 Google 和其他前行者相同的路径前行。你将发现有几种 Kubernetes 功能对于多服务应用程序的有效开发和故障排除是非常重要的。

首先,Kubernetes 能够通过日志或 SSH(安全 shell)轻松检查正在运行的服务的能力非常重要。通过一条命令行调用,管理员可以检查在 Kubernetes 下运行的服务的日志。这可能听起来像一个简单的任务,但在 IaaS 环境中,除非你已经做了一些工作,否则这并不容易。大型应用程序通常具有专门用于日志收集和分析的硬件和人员。在Kubernetes 中的日志可能不能替代完整功能的日志和指标解决方案,但它足以提供基本的故障排除。

第二,Kubernetes 提供内置的密钥管理。从头开发过自己的部署系统的团队知道的另一个问题是,将敏感数据(如密码和 API 令牌)安全地部署到虚拟机上很困难。通过将密钥管理变成一等公民,Kubernetes 可以避免你的团队发明自己的不安全的、错误的密钥分发系统或在部署脚本中硬编码凭据。

最后,Kubernetes 有一些用于自动进行缩放、负载均衡和重新启动应用程序的功能。同样,这些功能是开发人员在使用 IaaS 或裸机时要自己编写的。你的 Kubernetes 应用程序的缩放和运行状况检查在服务定义中进行声明,而 Kubernetes 会确保正确数量的实例健康运行。

总结

IaaS 和 PaaS 系统之间的差异是巨大的,包括 PaaS 可以节省大量的开发和调试时间。作为一种 PaaS,Kubernetes 实现了强大而有效的功能,可帮助你开发、部署和调试云原生应用程序。它的架构和设计代表了数十年的难得的经验,而你的团队能够免费获得该优势。

(题图:squarespace.com)


作者简介:

Tim Potter - Tim 是 Hewlett Packard Enterprise 的高级软件工程师。近二十年来,他一直致力于自由和开源软件的开发工作,其中包括 Samba、Wireshark、OpenPegasus 和 Docker 等多个项目。Tim 博客在 https://elegantinfrastructure.com/ ,关于 Docker、Kubernetes 和其他基础设施相关主题。


via: https://opensource.com/article/17/6/introducing-kubernetes

作者:Tim Potter 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

目录

  • break -- 在指定的行或函数处设置断点,缩写为 b
  • info breakpoints -- 打印未删除的所有断点,观察点和捕获点的列表,缩写为 i b
  • disable -- 禁用断点,缩写为 dis
  • enable -- 启用断点
  • clear -- 清除指定行或函数处的断点
  • delete -- 删除断点,缩写为 d
  • tbreak -- 设置临时断点,参数同 break,但在程序第一次停住后会被自动删除
  • watch -- 为表达式(或变量)设置观察点,当表达式(或变量)的值有变化时,暂停程序执行
  • step -- 单步跟踪,如果有函数调用,会进入该函数,缩写为 s
  • reverse-step -- 反向单步跟踪,如果有函数调用,会进入该函数
  • next -- 单步跟踪,如果有函数调用,不会进入该函数,缩写为 n
  • reverse-next -- 反向单步跟踪,如果有函数调用,不会进入该函数
  • return -- 使选定的栈帧返回到其调用者
  • finish -- 执行直到选择的栈帧返回,缩写为 fin
  • until -- 执行直到达到当前栈帧中当前行后的某一行(用于跳过循环、递归函数调用),缩写为 u
  • continue -- 恢复程序执行,缩写为 c
  • print -- 打印表达式 EXP 的值,缩写为 p
  • x -- 查看内存
  • display -- 每次程序停止时打印表达式 EXP 的值(自动显示)
  • info display -- 打印早先设置为自动显示的表达式列表
  • disable display -- 禁用自动显示
  • enable display -- 启用自动显示
  • undisplay -- 删除自动显示项
  • help -- 打印命令列表(带参数时查找命令的帮助),缩写为 h
  • attach -- 挂接到已在运行的进程来调试
  • run -- 启动被调试的程序,缩写为 r
  • backtrace -- 查看程序调用栈的信息,缩写为 bt
  • ptype -- 打印类型 TYPE 的定义

break

使用 break 命令(缩写 b)来设置断点。

用法:

  • break 当不带参数时,在所选栈帧中执行的下一条指令处设置断点。
  • break <function-name> 在函数体入口处打断点,在 C++ 中可以使用 class::functionfunction(type, ...) 格式来指定函数名。
  • break <line-number> 在当前源码文件指定行的开始处打断点。
  • break -N break +N 在当前源码行前面或后面的 N 行开始处打断点,N 为正整数。
  • break <filename:linenum> 在源码文件 filenamelinenum 行处打断点。
  • break <filename:function> 在源码文件 filenamefunction 函数入口处打断点。
  • break <address> 在程序指令的地址处打断点。
  • break ... if <cond> 设置条件断点,... 代表上述参数之一(或无参数),cond 为条件表达式,仅在 cond 值非零时暂停程序执行。

详见官方文档

info breakpoints

查看断点,观察点和捕获点的列表。

用法:

  • info breakpoints [list...]
  • info break [list...]
  • list... 用来指定若干个断点的编号(可省略),可以是 21-32 5 等。

disable

禁用一些断点。参数是用空格分隔的断点编号。要禁用所有断点,不加参数。

禁用的断点不会被忘记,但直到重新启用才有效。

用法:

  • disable [breakpoints] [list...]
  • breakpointsdisable 的子命令(可省略),list...info breakpoints 中的描述。

详见官方文档

enable

启用一些断点。给出断点编号(以空格分隔)作为参数。没有参数时,所有断点被启用。

用法:

  • enable [breakpoints] [list...] 启用指定的断点(或所有定义的断点)。
  • enable [breakpoints] once list... 临时启用指定的断点。GDB 在停止您的程序后立即禁用这些断点。
  • enable [breakpoints] delete list... 使指定的断点启用一次,然后删除。一旦您的程序停止,GDB 就会删除这些断点。等效于用 tbreak 设置的断点。

breakpointsdisable 中的描述。

详见官方文档

clear

在指定行或函数处清除断点。参数可以是行号,函数名称或 * 跟一个地址。

用法:

  • clear 当不带参数时,清除所选栈帧在执行的源码行中的所有断点。
  • clear <function>, clear <filename:function> 删除在命名函数的入口处设置的任何断点。
  • clear <linenum>, clear <filename:linenum> 删除在指定的文件指定的行号的代码中设置的任何断点。
  • clear <address> 清除指定程序指令的地址处的断点。

详见官方文档

delete

删除一些断点或自动显示表达式。参数是用空格分隔的断点编号。要删除所有断点,不加参数。

用法: delete [breakpoints] [list...]

详见官方文档

tbreak

设置临时断点。参数形式同 break 一样。

除了断点是临时的之外,其他同 break 一样,所以在命中时会被删除。

详见官方文档

watch

为表达式设置观察点。

用法: watch [-l|-location] <expr> 每当一个表达式的值改变时,观察点就会暂停程序执行。

如果给出了 -l 或者 -location,则它会对 expr 求值并观察它所指向的内存。例如,watch *(int *)0x12345678 将在指定的地址处观察一个 4 字节的区域(假设 int 占用 4 个字节)。

详见官方文档

step

单步执行程序,直到到达不同的源码行。

用法: step [N] 参数 N 表示执行 N 次(或由于另一个原因直到程序停止)。

警告:如果当控制在没有调试信息的情况下编译的函数中使用 step 命令,则执行将继续进行,直到控制到达具有调试信息的函数。 同样,它不会进入没有调试信息编译的函数。

要执行没有调试信息的函数,请使用 stepi 命令,详见后文。

详见官方文档

reverse-step

反向单步执行程序,直到到达另一个源码行的开头。

用法: reverse-step [N] 参数 N 表示执行 N 次(或由于另一个原因直到程序停止)。

详见官方文档

next

单步执行程序,执行完子程序调用。

用法: next [N]

step 不同,如果当前的源代码行调用子程序,则此命令不会进入子程序,而是将其视为单个源代码行,继续执行。

详见官方文档

reverse-next

反向步进程序,执行完子程序调用。

用法: reverse-next [N]

如果要执行的源代码行调用子程序,则此命令不会进入子程序,调用被视为一个指令。

参数 N 表示执行 N 次(或由于另一个原因直到程序停止)。

详见官方文档

return

您可以使用 return 命令取消函数调用的执行。如果你给出一个表达式参数,它的值被用作函数的返回值。

用法: return <expression>expression 的值作为函数的返回值并使函数直接返回。

详见官方文档

finish

执行直到选定的栈帧返回。

用法: finish 返回后,返回的值将被打印并放入到值历史记录中。

详见官方文档

until

执行直到程序到达当前栈帧中当前行之后(与 break 命令相同的参数)的源码行。此命令用于通过一个多次的循环,以避免单步执行。

用法:until <location>u <location> 继续运行程序,直到达到指定的位置,或者当前栈帧返回。

详见官方文档

continue

在信号或断点之后,继续运行被调试的程序。

用法: continue [N] 如果从断点开始,可以使用数字 N 作为参数,这意味着将该断点的忽略计数设置为 N - 1(以便断点在第 N 次到达之前不会中断)。如果启用了非停止模式(使用 show non-stop 查看),则仅继续当前线程,否则程序中的所有线程都将继续。

详见官方文档

print

求值并打印表达式 EXP 的值。可访问的变量是所选栈帧的词法环境,以及范围为全局或整个文件的所有变量。

用法:

  • print [expr]print /f [expr] expr 是一个(在源代码语言中的)表达式。

默认情况下,expr 的值以适合其数据类型的格式打印;您可以通过指定 /f 来选择不同的格式,其中 f 是一个指定格式的字母;详见输出格式

如果省略 expr,GDB 再次显示最后一个值。

要以每行一个成员带缩进的格式打印结构体变量请使用命令 set print pretty on,取消则使用命令 set print pretty off

可使用命令 show print 查看所有打印的设置。

详见官方文档

x

检查内存。

用法: x/nfu <addr>x <addr> nfu 都是可选参数,用于指定要显示的内存以及如何格式化。addr 是要开始显示内存的地址的表达式。

n 重复次数(默认值是 1),指定要显示多少个单位(由 u 指定)的内存值。

f 显示格式(初始默认值是 x),显示格式是 print('x','d','u','o','t','a','c','f','s') 使用的格式之一,再加 i(机器指令)。

u 单位大小,b 表示单字节,h 表示双字节,w 表示四字节,g 表示八字节。

例如:

x/3uh 0x54320 表示从地址 0x54320 开始以无符号十进制整数的格式,双字节为单位来显示 3 个内存值。

x/16xb 0x7f95b7d18870 表示从地址 0x7f95b7d18870 开始以十六进制整数的格式,单字节为单位显示 16 个内存值。

详见官方文档

display

每次程序暂停时,打印表达式 EXP 的值。

用法: display <expr>, display/fmt <expr>display/fmt <addr> fmt 用于指定显示格式。像 print 命令里的 /f 一样。

对于格式 is,或者包括单位大小或单位数量,将表达式 addr 添加为每次程序停止时要检查的内存地址。

详见官方文档

info display

打印自动显示的表达式列表,每个表达式都带有项目编号,但不显示其值。

包括被禁用的表达式和不能立即显示的表达式(当前不可用的自动变量)。

undisplay

取消某些表达式在程序暂停时的自动显示。参数是表达式的编号(使用 info display 查询编号)。不带参数表示取消所有自动显示表达式。

delete display 具有与此命令相同的效果。

disable display

禁用某些表达式在程序暂停时的自动显示。禁用的显示项目不会被自动打印,但不会被忘记。 它可能稍后再次被启用。

参数是表达式的编号(使用 info display 查询编号)。不带参数表示禁用所有自动显示表达式。

enable display

启用某些表达式在程序暂停时的自动显示。

参数是重新显示的表达式的编号(使用 info display 查询编号)。不带参数表示启用所有自动显示表达式。

help

打印命令列表。

您可以使用不带参数的 help(缩写为 h)来显示命令的类别名的简短列表。

使用 help <class> 您可以获取该类中的各个命令的列表。使用 help <command> 显示如何使用该命令。

详见官方文档

attach

挂接到 GDB 之外的进程或文件。该命令可以将进程 ID 或设备文件作为参数。

对于进程 ID,您必须具有向进程发送信号的权限,并且必须具有与调试器相同的有效的 uid。

用法: attach <process-id> GDB 在安排调试指定的进程之后做的第一件事是暂停该进程。

无论是通过 attach 命令挂接的进程还是通过 run 命令启动的进程,您都可以使用的 GDB 命令来检查和修改挂接的进程。

详见官方文档

run

启动被调试的程序。

可以直接指定参数,也可以用 set args 设置(启动所需的)参数。

例如: run arg1 arg2 ... 等效于

set args arg1 arg2 ...
run

还允许使用 ><>> 进行输入和输出重定向。

详见官方文档

backtrace

打印整体栈帧信息。

  • bt 打印整体栈帧信息,每个栈帧一行。
  • bt n 类似于上,但只打印最内层的 n 个栈帧。
  • bt -n 类似于上,但只打印最外层的 n 个栈帧。
  • bt full n 类似于 bt n,还打印局部变量的值。

whereinfo stack(缩写 info s) 是 backtrace 的别名。调用栈信息类似如下:

(gdb) where
#0  vconn_stream_run (vconn=0x99e5e38) at lib/vconn-stream.c:232
#1  0x080ed68a in vconn_run (vconn=0x99e5e38) at lib/vconn.c:276
#2  0x080dc6c8 in rconn_run (rc=0x99dbbe0) at lib/rconn.c:513
#3  0x08077b83 in ofconn_run (ofconn=0x99e8070, handle_openflow=0x805e274 <handle_openflow>) at ofproto/connmgr.c:1234
#4  0x08075f92 in connmgr_run (mgr=0x99dc878, handle_openflow=0x805e274 <handle_openflow>) at ofproto/connmgr.c:286
#5  0x08057d58 in ofproto_run (p=0x99d9ba0) at ofproto/ofproto.c:1159
#6  0x0804f96b in bridge_run () at vswitchd/bridge.c:2248
#7  0x08054168 in main (argc=4, argv=0xbf8333e4) at vswitchd/ovs-vswitchd.c:125

详见官方文档

ptype

打印类型 TYPE 的定义。

用法: ptype[/FLAGS] TYPE-NAME | EXPRESSION

参数可以是由 typedef 定义的类型名, 或者 struct STRUCT-TAG 或者 class CLASS-NAME 或者 union UNION-TAG 或者 enum ENUM-TAG

根据所选的栈帧的词法上下文来查找该名字。

类似的命令是 whatis,区别在于 whatis 不展开由 typedef 定义的数据类型,而 ptype 会展开,举例如下:

/* 类型声明与变量定义 */
typedef double real_t;
struct complex {
    real_t real;
    double imag;
};
typedef struct complex complex_t;

complex_t var;
real_t *real_pointer_var;

这两个命令给出了如下输出:

(gdb) whatis var
type = complex_t
(gdb) ptype var
type = struct complex {
    real_t real;
    double imag;
}
(gdb) whatis complex_t
type = struct complex
(gdb) whatis struct complex
type = struct complex
(gdb) ptype struct complex
type = struct complex {
    real_t real;
    double imag;
}
(gdb) whatis real_pointer_var
type = real_t *
(gdb) ptype real_pointer_var
type = double *

详见官方文档


参考资料


译者:robot527 校对:mudongliangwxy

让我们继续几周前在 CentOS 7.2 中开始的工作。 在本指南中,我们学习了如何初始化以及启动 Docker 1.12 中内置的原生的集群以及编排功能。但是我们只有 管理者 manager 节点还没有其它 工作者 worker 节点。今天我们会展开讲述这个。

我将向你展示如何将不对称节点添加到 Sawrm 中,比如一个与 CentOS 相邻的 Fedora 24,它们都将加入到集群中,还有相关很棒的负载均衡等等。当然这并不是轻而易举的,我们会遇到一些障碍,所以它应该是非常有趣的。

Teaser

先决条件

在将其它节点成功加入 Swarm 之前,我们需要做几件事情。理想情况下,所有节点都应该运行相同版本的 Docker,为了支持原生的编排功能,它的版本至少应该为 1.12。像 CentOS 一样,Fedora 内置的仓库没有最新的构建版本,所以你需要手动构建,或者使用 Docker 仓库手动添加和安装正确的版本,并修复一些依赖冲突。我已经向你展示了如何在 CentOS 中操作,经过是相同的。

此外,所有节点都需要能够相互通信。这就需要有正确的路由和防火墙规则,这样 管理者 manager 工作者 worker 节点才能互相通信。否则,你无法将节点加入 Swarm 中。最简单的解决方法是临时清除防火墙规则 (iptables -F),但这可能会损害你的安全。请确保你完全了解你正在做什么,并为你的节点和端口创建正确的规则。

Error response from daemon: Timeout was reached before node was joined. The attempt to join the swarm will continue in the background. Use the "docker info" command to see the current swarm status of your node.

守护进程的错误响应:节点加入之前已超时。尝试加入 Swarm 的请求将在后台继续进行。使用 “docker info” 命令查看节点的当前 Swarm 状态。

你需要在主机上提供相同的 Docker 镜像。在上一个教程中我们创建了一个 Apache 映像,你需要在你的 工作者 worker 节点上执行相同操作,或者分发已创建的镜像。如果你不这样做,你会遇到错误。如果你在设置 Docker 上需要帮助,请阅读我的介绍指南网络教程

7vwdxioopmmfp3amlm0ulimcu   \_ websky.11   my-apache2:latest
localhost.localdomain   Shutdown   Rejected 7 minutes ago
"No such image: my-apache2:lat&"

现在开始

现在我们有一台启动了 CentOS 机器,并成功地创建了容器。你可以使用主机端口连接到该服务,这一切都看起来很好。目前,你的 Swarm 只有 管理者 manager

Manager

加入 工作者 worker

要添加新的节点,你需要使用 join 命令。但是你首先必须提供令牌、IP 地址和端口,以便 工作者 woker 节点能正确地对 Swarm 管理器进行身份验证。接着(在 Fedora 上)执行:

[root@localhost ~]# docker swarm join-token worker
To add a worker to this swarm, run the following command:

docker swarm join \
--token SWMTKN-1-0xvojvlza90nrbihu6gfu3qm34ari7lwnza ... \
192.168.2.100:2377

如果你不修复防火墙和路由规则,你会得到超时错误。如果你已经加入了 Swarm,重复 join 命令会收到错误:

Error response from daemon: This node is already part of a swarm. Use "docker swarm leave" to leave this swarm and join another one.

如果有疑问,你可以离开 Swarm,然后重试:

[root@localhost ~]# docker swarm leave
Node left the swarm.

docker swarm join --token
SWMTKN-1-0xvojvlza90nrbihu6gfu3qnza4 ... 192.168.2.100:2377
This node joined a swarm as a worker.

工作者 worker 节点中,你可以使用 docker info 来检查状态:

Swarm: active
NodeID: 2i27v3ce9qs2aq33nofaon20k
Is Manager: false
Node Address: 192.168.2.103

Likewise, on the manager:

Swarm: active
NodeID: cneayene32jsb0t2inwfg5t5q
Is Manager: true
ClusterID: 8degfhtsi7xxucvi6dxvlx1n4
Managers: 1
Nodes: 3
Orchestration:
Task History Retention Limit: 5
Raft:
Snapshot Interval: 10000
Heartbeat Tick: 1
Election Tick: 3
Dispatcher:
Heartbeat Period: 5 seconds
CA Configuration:
Expiry Duration: 3 months
Node Address: 192.168.2.100

创建或缩放服务

现在,我们需要看下 Docker 是否以及如何在节点间分发容器。我的测试展示了一个在非常轻的负载下相当简单的平衡算法。试了一两次之后,即使在我尝试缩放并更新之后,Docker 也没有将运行的服务重新分配给新的 worker。同样,有一次,它在 工作者 worker 节点上创建了一个新的服务。也许这是最好的选择。

Scale service

Service ls

Services ls, more

New service

在新的 工作者 worker 节点上完整创建新的服务。

过了一段时间,两个容器之间的现有服务有一些重新分配,但这需要一些时间。新服务工作正常。这只是一个前期观察,所以我现在不能说更多。现在是开始探索和调整的新起点。

Service distributed

负载均衡过了一会工作了。

总结

Docker 是一只灵巧的小野兽,它仍在继续长大,变得更复杂、更强大,当然也更优雅。它被一个大企业吃掉只是一个时间问题。当它带来了原生的编排功能时,Swarm 模式运行得很好,但是它不只是几个容器而已,而是充分利用了其算法和可扩展性。

我的教程展示了如何将 Fedora 节点添加到由 CentOS 运行的群集中,并且两者能并行工作。关于负载平衡还有一些问题,但这是我将在以后的文章中探讨的。总而言之,我希望这是一个值得记住的一课。我们已经解决了在尝试设置 Swarm 时可能遇到的一些先决条件和常见问题,同时我们启动了一堆容器,我们甚至简要介绍了如何缩放和分发服务。要记住,这只是一个开始。

干杯。


作者简介:

我是 Igor Ljubuncic。现在大约 38 岁,已婚但还没有孩子。我现在在一个大胆创新的云科技公司做首席工程师。直到大约 2015 年初时,我还在一个全世界最大的 IT 公司之一中做系统架构工程师,和一个工程计算团队开发新的基于 Linux 的解决方案,优化内核以及攻克 Linux 的问题。在那之前,我是一个为高性能计算环境设计创新解决方案的团队的技术领导。还有一些其他花哨的头衔,包括系统专家、系统程序员等等。所有这些都曾是我的爱好,但从 2008 年开始成为了我的付费工作。还有什么比这更令人满意的呢?

从 2004 年到 2008 年间,我曾通过作为医学影像行业的物理学家来糊口。我的工作专长集中在解决问题和算法开发。为此,我广泛地使用了 Matlab,主要用于信号和图像处理。另外,我得到了几个主要的工程方法学的认证,包括 MEDIC 六西格玛绿带、试验设计以及统计工程学。

我也开始写书,包括奇幻类和 Linux 上的技术性工作。彼此交融。

要查看我开源项目、出版物和专利的完整列表,请滚动到下面。

有关我的奖项,提名和 IT 相关认证的完整列表,请稍等一下。


via: http://www.dedoimedo.com/computers/docker-swarm-adding-worker-nodes.html

作者:Igor Ljubuncic 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出