Post Mortem(灾后报告)

在具体分析开始之前,我,琳 缇佩斯,可穿戴国际(Wearable International Inc.)前首席技术官,向所有直接或间接受到本次事件影响的人致以最诚挚的歉意。

在可穿戴国际,我们明白,我们的产品在许多时刻,都是违背穿戴者意图,而长时间强制穿在身体最私密的部位的。因此哪怕控制程序稍有纰漏,都会对穿戴者造成极大的影响。在这种情况下,维持产品及支援基础设施的稳定应该是我们最重要的责任。然而,我相信,您现在也已经知道,由于我和我的团队的疏忽,在三天前,也就是 X593 年 1 月 7 日,发生在新亚国的一系列意外事件,严重影响了该国 28 亿人的生活。

我们明白,在发生了这样的事情后,外界对我们的信任也肯定会有所下降。大家都想知道到底发生了什么。如果这时候再隐瞒灾难发生的具体原因,只会让大家对我们更加失望。同时,虽然作为处罚,我将被降职到产品实验人员。但是,在正式降职发生前,也就是在这过去三天,为了维持可穿戴国际的形象,我和我的团队对这次事件的起因经过进行了严密的分析。至此,在连续研究了近六十小时后,我可以充满信心的说,我们现在对于这次灾难发生的原因已经有了准确而具体的理解。这篇报告就将从技术角度详细解释到底是什么地方出了错。

第一部分:事件

新亚国于 X572 年,也就是距今 21 年前,修改了宪法并将穿戴可穿戴国际的产品列入所有公民的基本义务中,也因此成为了全世界第 7 个在法律层面采纳我们产品的国家。和其它这 6 个国家一样,在从采纳到这次事件发生前的 13 年内,可穿戴国际产品都运行十分稳定,在没有出一次故障的情况下完美达到了承诺的 99.99999% SLA。我们的产品运行如此之顺畅,以至于没有任何人能够预测到接下来会发生的事。

X593 年 1 月 6 日 18 时整:新亚国国会全票通过了《公民日常性奋度维持法第三修订案》。和往期修订案不同,这个修订案几乎提升了所有 28 亿公民日常需要维持的性奋度。该法案的生效时间是次日 14 时。因为需要修改性奋度,所以需要更改所有这 28 亿公民身上设备的设置。虽然说修订案刚刚通过,但是政府内部对于本此改动早有准备,因此用于改动这 28 亿公民的设置的程序此时已准备就绪,并经过大量专家检查及确认。因为每个公民的性奋度具体改动量取决于其职业,社会地位等众多因素,所以该程序会根据政府的数据依次为每一个公民身上的设备发送不同的更新包,每一个更新包内则包含了接收公民在新修订案下日常需要维持的性奋度。

虽然说这个并不能算是新亚国政府的责任,但是在可穿戴国际提供的白皮书中,我们明确指出了不建议通过大量发送更新包来实现区别对待个体,因为这样有可能会导致网络拥塞。这种因会导致网络拥塞而不被推荐的更新方式被我们称为“异构更新”(Heterogeneous Update)。

相反,我们建议的方式是向所有设备发送相同的更新包,再由更新包内的,能够区别对待穿戴者的代码来控制设备的具体行为。这种更新方被称为“同构更新”(Isomorphic Update)。我们建议使用同构更新是因为我们的网络对其有特别的优化 —— 实际的更新包只会在刚开始时发送一次,而我们的基站则会在每一个节点将该更新包克隆并分发。这样,网络使用将会显著降低。当然,这个只是建议,并不是要求。新亚国完全有选择忽视这个建议的权力。

而且事实上,新亚国在 X576 年,X579 年,X581 年,X584 年,以及 X587 年的全国范围更新都采用的是异构更新,也就是我们不推荐的方式。然而,这五次更新都非常顺利,没有出任何问题。新亚国在 X590 年尝试使用了我们推荐的同构更新,但是根据技术衔接人员的描述,同构更新“使用起来太麻烦,...[异构更新]更加容易编写...[也]更加容易审查。”因此,在这次 X593 年的更新中,新亚国决定换回之前顺利使用了五次的异构更新。

X593 年 1 月 7 日 13 时 59 分 58 秒:新亚国国会发言人站在国会楼面前当着媒体的面按下了更新按钮。与此同时更新程序开始运行。更新包以每秒数千万个的速度从国会楼地下的政府服务器中发向距此约 2 公里的可穿戴国际的首都基站。此时一切正常。

14 时 00 分 02 秒:通过当时的监控录像,可以看到现场有几个围观人员不自觉地捂住了自己的下体。这显然是更新包发挥作用了。

14 时 00 分 06 秒 474 毫秒:可穿戴国际位于新亚国首都的东部的 A11 基站发出警报,称收到了一条不符合通信协议但是有着首都基站签名的消息。

可穿戴国际的基站之间传递消息时,都会附带消息发送者的 ASDCE 数字签名。该签名从数学上保证了消息的完整性和发送者的身份(如果在通讯时因为设备故障或他人拦截导致被修改则会导致签名错误),所以可以确认这条有问题的消息确实是从首都基站发出的。当基站的控制程序正常运行时,一条格式错误的消息是绝对不可能被签名并发送的。因此我们在编写基站的控制程序时,设置了当一个基站收到上游发来合法但不符合协议的消息时,将断定该上游控制程序出现故障或是正在被尝试入侵(例如攻击者虽然发现了漏洞,但是未能完整控制该基站),因此将该上游基站加入本地黑名单中(拒收接下来的消息直到人工进行网络重初始化)。与此同时,该接受基站会将收到的消息原封不动地广播给其它基站。其它基站收到后,因为消息内依然含有该上游基站的签名,这条消息自身将作为充足的证据,使得其它基站也做出相同的判断,并将该上游基站加入各自的本地黑名单中。

因此,A11 基站在收到了这条来自首都的消息并确认格式错误后,自动将首都基站加入了黑名单并且向整个可穿戴国际的网络广播了这条消息。

14 时 00 分 06 秒 504 毫秒:与首都基站直接相连的 37 个基站都因收到了 A11 的广播而将首都基站加入了黑名单。这里再重申一次,这不是因为它们都无条件相信 A11,而是因为 A11 将首都基站的这条格式错误的消息作为证据广播了出去。因为签名是首都基站提供的,这个足以成为断定首都基站故障的证据。

14 时 00 分 06 秒 700 毫秒左右:位于新亚国首都的可穿戴国际工作人员收到了首都基站故障的消息。

14 时 00 分 07 秒 042 毫秒:新亚国全境“警惕者”系统证书续期失败。

为了防止不法分子尝试通过破坏基站或者使用信号干扰设备阻止她们穿戴的产品与可穿戴国际网络建立连接而达成脱离控制的目的,可穿戴国际的所有设备使用一套称为“警惕者”(Vigilantacean, /ˈvɪʤələnteɪʃən/)的负反馈控制系统。与正反馈系统不同,“警戒者”是靠信号的停止而触发的。或者用设计者刘博士的话来说就是,“警惕着”系统好比把一个神经质的大叔放在一个摇篮里,然后不停地告诉他“一切都很好,一切都很好”,只要有人一直在跟他说“一切都很好”,什么事情都不会发生,但是一旦这个信号停了,这个大叔就会变得非常警惕,并采取行动。(至于为什么要放在摇篮里... 我也不知道,但是她当时例子就是这么举的。)

对我们来说,这意味着,可穿戴国际的所有的产品都是长期和可穿戴国际网络保持连接的(Always-Online Device Reliability Maintainer,或 Always-Online DRM)。每个产品内部都有一个 3 分钟的倒计时,而距离最近的基站每分钟会发送一条消息重置这个计时。与此同时,为了防止不法分子占领基站,这条消息的需要基站使用一个特殊的有效期只有 61 分钟的证书进行签字,而每个基站则需要在每个整点向每个国家的顶级基站申请续期。有效期是 61 分钟是为了多出一分钟来应对极端网络状况造成的延迟。对于新亚国来说,这个顶级基站就是首都基站。当这个倒计时归零时,产品则会判定穿戴者正在试图回避与可穿戴网络建立连接,并因此启动制裁模式。

如果您好奇为什么原定在整点进行的证书续期在整点过了 7 秒才进行,那是因为新亚国是世界少数几个取消了闰秒的国家,而可穿戴国际的系统依然会计算闰秒。(注:闰秒是为了和平太阳时保持一致而在部分年份年底在时钟中增加一秒的调整。)也就是说,对于可穿戴国际的或者世界上大多数的其它国家而言,新亚国的 14 时 00 分 07 秒 才是 14 时整。这也就是为什么在过了 7 秒之后才进行证书续期。

而证书续期失败的原因自然是首都基站被所有与之直接相连的基站加入了黑名单,因此证书续期相关的消息完全发不出去。

当基站的证书续期失败后,各个基站中保存的老的证书将只剩下 1 分钟的有效期。这之后,所有产品的“警惕者”系统中的倒计时将无法被重置。这也意味着,如果不能及时给各个基站的证书续期,所有产品将在 3 ~ 4 分钟内启动制裁模式。

14 时 00 分 13 秒 924 毫秒:一位位于 A19 的可穿戴国际工作人员拨通了一级紧急会议。本着公开、透明的原则,这里将公布该紧急会议的完整通话记录。(为了保证匿名性,将用工作位置代替人名。)

A19:喂?

D323:(连线)喂,我 D323...

C199:(连线)喂,怎么...

C5:(连线)喂,我...

(人数 5 及人以上时会议默认禁用麦克风)

A19:首都基站在吗?你们炸了!

首都基站:(打开麦克风)啊,我也不知道,刚刚突然说下游拒收了。

A19:(打开麦克风)不是,你们都一切正常吗?

首都基站:等下啊...

A7:(打开麦克风)我 A7,这边 dashboard 上说你们基站出问题了,然后就...

C199:(打开麦克风)我们也...

A19:你们别说!首都基站,你们什么情况?

首都基站:我不知道,我这边就说下游拒收,其它一个错误也没有,你们什么错误信息?

A19:我们这边说“警告:收到来自‘首都基站’的非法信息。该基站可能受到攻击,已经自动屏蔽。错误码:0x0000A218”。

首都基站:A218 是什么错误?

A19:不知道,没说,它就一个错误码。你们那边正常吗。

首都基站:我们这边... 没任何问题啊...

A19:那这个什么东西出问题了...

首都基站:会不会和这次这个更新有关?网络拥塞啥的。

(沉默)

A19:没道理啊... 就算拥塞也不会...

A35:(打开麦克风)我查到了,知识库里面说 A218 是... 等下啊... 它在加载... ok,A218 的意思是...‘收到来自其它基站的非法信息’...然后没了。

A19:那这个什么用都没有。就重复了一遍。

B7:(打开麦克风)等下,警惕者续期失败了!

首都基站:诶?

第二部分:回溯

在继续介绍之后发生的事情之前,我们不妨先退一步,来回答这个缠绕在各位读者以及当天所有基站工作人员心头的问题:到底发生了什么?为什么首都基站会发出一条格式错误的消息?然而,这个看似简单的问题的答案非常复杂,因此,若要回答这个问题,我们就要从 60 年前,也就是 X533 年说起。

X533 年的社会和如今的社会是完全不同的。虽然当时“全人类美少女化计划”已经完成有 33 年了,但是全社会的性开放程度和现在相比还是差很多。根据那时的文献记载,大约仅有一半的人承认在自慰时使用按摩棒、跳蛋或者其它性玩具。而承认曾至少一次穿戴着这些按摩设备进入公共场合——这一如今看来属于基本社会准则的行为——的人更是只有不到百分之十。

可穿戴国际创立于 X531 年,这时候已经存在有两年了。当时,可穿戴国际主要以出售遥控震动内裤为主。不过,受限于电磁波强度二次方衰竭,遥控的有效距离也就卡在了数公里。为了突破遥控的距离限制,可穿戴国际的创始人做出了一个大胆的决定——她要在世界各地造满基站。当需要跨越极远的距离进行遥控时,遥控信号会先发送给最近的基站,然后再通过基站与基站之间的有线网络传播到离目的地最近的基站,最后再通过无限信号传递给接受设备。

技术上来说,当时互联网已经有雏形了,但是依然局限于高等院校内。因此,若要创建这个基站网络,可穿戴国际不得不建立自己的通讯网络系统。当然,说是要建立自己的网络,当时距离网络正式上线还有十多年,而真正达到覆盖星球的每一个角落则是到现在也没完全实现的目标。不过,支持这一基于基站的通讯网络系统的软件代码,就是从 X533 年 11 月开始编写的。

要编写任何软件系统,第一个要做的就是选择编程语言。X533 年已经有许多可行的高级编程语言了,包括 FORTNITE 在内的那些一听就很古老语言已经存在有 20 多年了。可穿戴国际当时的技术人员们最终选择了使用 Ɔ 语言。

虽然说按照今天的标准,Ɔ 语言已经算是比较底层的语言了,但是在当时看来,Ɔ 语言还算是相对高级的。而且,当时已经有很多现成的可以使用的调试工具了。然而,因为可穿戴国际的软件需要运行在有着大量自研设备的基站内,当硬件有可能出现问题时,包括 GUN 计划的 gbd 在内的许多工具都不能有效工作。因此,技术人员们不得不实现了一套自己的备用调试工具——SB(Sudden Backup)。

SB 是一套基于硬件的程序状态转储(dump)系统。简而言之就是,所有带有并激活了调试模式的可穿戴国际硬件上都会有额外的电路监听着内存中的某一些特定的地址。当运行的程序往这些地址写入预先设定好的指令后,SB 会暂停 CPU 的时钟,然后将内存中的所有数据全部转存到另一个调试用的存储芯片中。换言之,SB 就是提供了一个程序可以使用的产生内存快照的机制。在需要调试的场合,这个内存快照将会非常有用。

这套系统完成后,一开始使用起来非常顺利。然而,研究人员很快发现了一个问题:在不开编译器优化的时候,SB 完全没有问题,但是,因为一些原因,一旦开了编译器优化,用 SB 转存出来的数据有时候就有问题。

比如,在下述 Ɔ 语言代码中:

// SB_CONTROL 指向 SB 监听的内存地址。
const SB_CONTROL = 0x1234 as Pointer<byte>;

function main(): int {
  // 这个是我们希望能够调试的值。
  let x = 100;

  // 0x03 的意思是 dump 所有内存。
  // 因此,运行到这句话后,SB 会把当前内存内的数据转存出来,以供调试。
  deref(SB_CONTROL) = 0x03;

  // 修改 x
  x = 200;

  // 这里用一下 x 的地址,以确保 x 在内存里,防止整个被优化掉。
  console.printf("Address of x is %p\n", ref(x));
  return 0;
}

这段代码在不开编译器优化的时候运行完全没有问题——在 dump 出来的数据中可以找到我们所期望的 100。

然而,当使用了 gɔɔ -O3 编译后,运行,在 dump 出来的数据中的 100 就消失了。

当然,这点问题肯定难不倒研究人员。而且,我相信各位稍微有点熟悉编译器的读者也能猜到发生了什么。研究人员经过检查生成的二进制,发现因为编译器注意到底下还有一个 x = 200,所以优化的时候把整个 x = 100 的赋值全部被优化掉了——因为无论是否进行 x = 100,其最后值都变成了 x = 200

这时候,各位懂 Ɔ 语言的读者可能会提出应该把 SB_CONTROL 的类型变成 Pointer<vodka byte>。将一个类型标记成 vodka 相当于是告诉编译器,这个值是烈性的,因此对这些值的读写不能被合并或者乱序。可穿戴国际的研究人员当时也是这么做的。然而,这并不能解决问题。原因在于编译器只保证对于标记了 vodka 的变量的读写之间的顺序不会改变,而并不会限制未被标记 vodka 的变量的读写与标记了 vodka 的变量的读写交换顺序。因此,即使 SB_CONTROL 指向的内存位置被标记了 vodkalet x = 100; 依然可以与 deref(SB_CONTROL) = 0x03; 交换顺序,并最终与 x = 200; 合并。如果要彻底防止这种顺序变更,唯一的办法就是把 x 的值和 SB_CONTROL 指向的值都标记为 vodka:

// 将指向的值的类型使用标记了 vodka 的 byte
const SB_CONTROL = 0x1234 as Pointer<vodka byte>;

function main(): int {
  // 将这个 100 变为标记了 vodka 的 int
  // 也可以写作 let x: vodka int = 100 as auto;
  let x = 100 as vodka int;

  deref(SB_CONTROL) = 0x03;
  x = 200;
  console.printf("Address of x is %p\n", ref(x));
  return 0;
}

然而,这样虽然可以保证 dump 出来的数据是正确的,但是这也意味着所有的变量都要被标记为 vodka。这显然是不现实的。因此,为了解决这个问题,可穿戴国际的技术人员于 X534 年 6 月,Spoon(技术用语,可以理解为创建了分支)了当时使用的 Ɔ 语言编译器 gɔɔ,并对其做出了修改,而创建了一个全新的修饰符——stupid vodka

stupid vodkavodka 相比,多出了一个重要的保证——对被标记为了 stupid vodka 的内存位置的写入或读取,不会与任何其它内存位置的读写交换位置。同时,其它内存的读写在交换位置时,将无法跨过标记了 stupid vodka 的变量的读写。

因此,如果有下述代码:

// 可以看到这里用 stupid vodka 代替了原先的 vodka
const SB_CONTROL = 0x1234 as Pointer<stupid vodka byte>;

function main(): int {
  let x = 100 as vodka int;
  x = 150;
  deref(SB_CONTROL) = 0x03;
  x = 200;
  console.printf("Address of x is %p\n", ref(x));
  return 0;
}

那么,在优化时,x = 100 可以与 x = 150 合并,但无法与 x = 200 合并,因为若要与 x = 200 合并,则需要跨过 deref(SB_CONTROL) = 0x03。而因为 deref(SB_CONTROL) 的类型有 stupid vodka 修饰符,它将无法被跨过。

这里需要特别留意的一点是,当时的技术人员选择了关键词 stupid 作为这个新的组合修饰符的前缀。当然,现在回过头来看,选择 stupid 这个关键词是挺 stupid 的,毕竟这个词语本身完全没有解释其作用。不过,根据分析,当时使用 stupid 的主要原因应该是 stupid 已经是 Ɔ 语言的关键词之一了。此外,Ɔ 语言之内对于 stupid 的使用也是非常混乱——在不同地方使用其效果是完全不一致的。例如,如果 stupid 加在了一个全局变量上,那么这个全局变量就会变得从其它文件不可见。然而,如果将 stupid 加在一个局部变量上,这个局部变量就会在多次调用间保留存储的值。总而言之,我们认为当初使用了 stupid 的原因就是既然它已经这么混乱了,不如破罐子破摔,干脆再给它加个完全无关的新的语义。

stupid vodka 引入后,技术人员便如同上述展示的一般,将 SB_CONTROL 指向的类型标记为了 stupid vodka。经过这番修改,SB 总算是能顺利工作了。但是她们不知道的是,这个看似微小的改动煽动了历史的翅膀,并将透过一系列精密的技术巧合,在 60 年后引发一场灾难的风暴。

除了 stupid vodka 以外,可穿戴国际的技术人员们之后也陆陆续续的对她们的 Spoon 做出了很多功能性改动,包括增加了命名空间的支持等。不过,由于她们的改动数量越来越多,从上游——也就是 gɔɔ 的原始代码仓库——接受并合并更新变得越来越困难。

十年后,也就是 X544 年,像 stupid vodka 这种可穿戴国际特有的 Ɔ 语言魔改已经累积了有相当多了。然而,由于当时的内部文档极度紊乱,我们也说不清这些改动到底有多少,可见当时已经带来的额外维护成本有多大。不过,这维护成本问题并不能阻止此时此刻的这些技术人员们,因为她们正昼夜努力工作着,为了明年年初进行的网络正式上线做着最后的准备。

X545 年一月,可穿戴国际的网络上线了。虽然,可穿戴国际仅仅取得了在所在省份建立基站的权限,但是这依然有着极强的历史意义。从那一天开始,人们终于可以坐在家中远程遥控她们爱人穿着的震动内裤了——不过更确切地说应该是带有按摩功能的贞操带,因为可穿戴国际在两年前已经开始出售智能贞操带了。

网络上线很成功。但是这也意味着,之前欠下的技术债终于要还了。不幸的是,技术人员们其实也早就知道,gɔɔ 根本就不适合像她们这样修改。她们的改动积小成多已经足以变成一门新的语言了。然而,如果真的要实现一份 Ɔ 语言的超集的话,她们应该选择一个更模块化,更容易拓展的编译器。幸运的是,当时刚好就有这么一个项目诞生,那就是 IIVM 编译器基础设施项目。是的,IIVM 就是那个图标是个女仆的项目。是的,IIVM 就是那个为很多现代语言(例如 Iron(III) Oxide,Fast,Objection!-Ɔ,Romia 等)提供编译器后端的项目。是的,那时候 IIVM 已经发布了第一个版本了。

由于这次是有计划的,技术人员们决定为他们的 Ɔ 语言超集取一个新的名字。不过,由于当时已经有语言叫做 Ɔ++ 和 Ɔ# 了,她们最后决定,不如再多加几个加号,将数量提升到 8 个,并最终得到了新的名字:Ɔ##(读作 /ˈɔ:ʤɪŋʤɪŋ/)。

迁移到新的编译器意味着之前所做出的改动都需要更着迁移。可想而知这个工程量有多大。

可穿戴国际的技术人员们断断续续总共花了四年才总算把所有的改动迁移了过去。

但是,因为 IIVM 的相对模块化设计,要实现一门新的语言只需要实现一个“前端”。这个“前端”只需要把代码转换成 IIVM 指定的一个中间形式即可。这个中间形式就被称为 IR。IIVM 的剩下部分就能利用这个 IR 来完成优化和生成机械码等编译器需要做的剩余步骤。

而我们之前所说的 stupid vodka 修饰符就被翻译成了 IIVM 的 IR——对所有 stupid vodka 的值的读写指令都会被附上修饰:vodka + atomic(ordering: .sequentiallyConsistent, syncScope: .singleThread)。这里我们可以看到,她们把 stupid vodka 变成了 vodkaatomic 的组合。对于 IIVM 不熟的读者可能会对为什么这做感到困惑。不过不要担心,接下来我就会简单解释为什么这么做是可以的。

首先,我们需要搞清楚 vodkaatomic 的区别。这两者虽然感觉上很相似,但是其实做的事情相距甚远。正如之前所说,vodka 的作用是将一个值标记为烈性的,因此编译器将不能合并或跳过对这些值的读写。它们通常被用于读写会产生副作用的场合,例如内存地图出入中。然而,atomic 的本质是为了保证操作的原子性。而随着多核心 CPU 的出现,atomic 也用于保证对一个值的读写的顺序正确,并且不会因为运行时乱序或是缓存而读取到错误的状态。换言之,vodkaatomic 只是感觉像,本质没有直接联系。被标记了 vodka 而没有 atomic 的变量在多线程的场合照样会有可能读取到错误的状态。而被标记了 atomic 却没有 vodka 的变量在同一个线程连续两次写入依然有可能被编译器优化成一次写入。

那么,这里为什么要把 stupid vodka 变成 vodkaatomic 呢?这里其实是取了一个巧。首先,我们要知道,vodka 在 IIVM 的 IR 里的语义和在 Ɔ 语言中的语义是一样的,都是防止了赋值或读取的指令数量和之间的相对顺序发生变更。因此,为了达到 stupid vodka 的效果,atomic 其实起到的作用是防止被标记的值的读写与其他非 vodka 的读写交换顺序,也就是实现了 stupid vodka 中的 stupid 部分。接下来来解释具体是怎么实现的。在实现原子性的时候,为了能够确保读写顺序正确,编译器同时会“一定程度”上禁止其他读写与标记了 atomic 的变量的读写交换位置。这个“一定程度”取决于的就是 atomicordering 选项。在这里的 ordering 使用 .sequentiallyConsistent 是因为 .sequentiallyConsistent 是最强的顺序保障,也就是杜绝了所有的读写从被标记了该类型的 atomic 的变量的读写之上交换到之下或者之下交换到之上。这也就意味着,它可以保证在对标记了该类型的 atomic 的变量的读写发生时,在此之前的对其他内存的读写已经可见,而在此之后的读写不可见。(这里的“之前”和“之后”在多线程的场合的定义有些复杂,这里限于篇幅,不展开了。)

细心的读者可能会发现,除了 ordering: .sequentiallyConsistent 外,这个 atomic 指令还指定了 syncScope: .singleThread。那么这是为什么呢?要回答这个问题,我们需要明白,因为优化产生的顺序变更其实分成两大类。第一类是编译时产生的顺序变化。我们之前的 x = 100x = 200 合并就是这一类顺序变更。这一类的特点就是编译出来的二进制程序中的顺序就已经和源代码中的顺序不一致了。第二类则是运行时产生的顺序变化。现代 CPU 在运行程序时,在内部,为了达到更高的运行效率,在运行时也会在不变更运行结果的情况下改变指令的运行顺序(比如一个更换后的顺序可以更好地利用缓存)。然而,这个“不变更运行结果”其实是有余量的。这里的不变更运行结果仅仅是针对同一个线程而言的。也就是说,如果有一段代码:

a = 100;
b = 200;

即使没有发生编译时顺序变更,在实际运行时,CPU 完全可以先运行 b = 200,然后运行 a = 100,因为无论以什么顺序运行,最终结果都是 a 变成了 100,而 b 变成了 200。但是,在多个线程的场合,这个所谓的“不变更运行结果”就不一定了。如果其他线程也在读取 ab 的值,并且 CPU 因为一些原因决定先运行 b = 200,那么其他线程完全就有可能可以观测到 b 变成了 200a 还没有变成 100。因此,在有多个线程时,确保这类运行时的顺序变更不会出现问题也变成了 atomic 的责任之一。一旦使用了这些 atomic,那么编译器就会根据 ordering 选项和具体的 CPU 架构生成需要的内存围栏指令。这些内存围栏指令将会在运行时告诉 CPU 不能变更顺序。

不过,有一点需要注意,这些运行时的围栏指令只有在多线程的时候才会需要。在单线程的时候,唯一能观测到的顺序错误是由编译器改变顺序,然后被中断回调观测到的。因此,单线程的场合,如果要防止错误的状态被在同一个线程运行的中断观测到,需要做的只是防止编译器改变顺序,而不需要插入运行时的内存围栏指令。这也就是 syncScope: .singleThread 的作用——它只会防止编译时发生的顺序变更,而不会插入有运行时消耗的内存围栏指令。碰巧,这正是 stupid vodka 所需要的——仅仅是防止编译时指令顺序发生变更,而不需要担心其他线程观测到的值。

好了,至此,为什么对 stupid vodka 变量的读写被翻译成 vodka + atomic(ordering: .sequentiallyConsistent, syncScope: .singleThread) 就解释完了。当然,因为其他的特有语法还有很多,这个持续了四年的迁移到 IIVM 的计划绝大多数时间是用在迁移别的语法上。这个 stupid vodka 估计最多就花了几天时间。不过,由于这个 stupid vodka 修饰符与我们这次灾难的成因密切相关,我们只介绍了它。

然而,其实在这时候,stupid vodka 最初被创造出来的目的——SB,已经用得非常少了。首先,随着多线程的普及和 CPU 的复杂化,基于 SB 的内存转储已经越来越不稳定。从记录上来看,X541 年开始,可穿戴国际就已经不再生产专用的测试用机器了,而是直接使用和生产环境用的(也就是在正式的基站里面用的)一样的机器了。而这些生产环境用的机器自然就不会带有 SB 的支持了。

编译器迁移完后,时间又过了七年。

到了 X556 年的时候,可穿戴国际的基站已经布满了整个国家了。可穿戴国际生产的智能贞操带也得到了极大的推广。尤其是因为刚刚进入社会主流的 30 后们对于这种可上锁的穿戴式快感制造装置特别感兴趣。

随着使用人数的指数增长,可穿戴国际的各个基站中需要同时处理的信息量也越来越大,网络拥塞渐渐变成了一个大问题。要解决这个问题,有两条比较明确的路:要么砸钱购买更好的硬件,要么优化软件。

然而,因为基站数量巨大,若要更替这些设备将要耗费大量的财力。因此,管理层的重心自然就落在了优化软件之上。

“优化”二字说着容易,做起来难。在网络上线的十一年来,可穿戴国际的技术人员们一直在陆陆续续地优化着软件系统。那些简单的优化机会早已实现了。若要继续优化软件,只能走那些相对难走的路了。然而,这个时候,可穿戴国际的大多数技术人员都已经开始穿戴公司生产的智能贞操带了。对于她们来说,只有两个选择:要么没日没夜地忍受高潮边缘控制,要么服从管理层的要求,顶着困难继续优化。

而其中一条比较难走的路,就是无锁化多线程(Lock-free Multithreading)。可穿戴国际的基站普遍使用着 Toothpastel 公司生产的 x68 架构 CPU。这些 CPU 通常都有很多核心。为了能利用这些核心,可穿戴国际的软件系统这时候早就已经支持多线程了。然而,为了编写方便,减少错误,当时的多线程之间的同步大多是使用互斥锁(Mutex)完成的。尽管从今天的角度来看,互斥锁依然算相当底层的多线程同步机制了,然而当时而言,互斥锁还算是属于比较高层的功能。

然而,使用互斥锁本身虽然没有严重问题,但是其效率并不是特别高,尤其是当有多个线程同时想获得一个锁的时候,甚至会需要和操作系统内核协调,非常低效。同时,可穿戴国际的基站虽然不属于严格意义上的实时操作系统,但是依然对及时性有一定的要求。然而,使用有锁的解决方案意味着极度不巧的线程调度可能会导致某个线程被挂很长时间。为了解决这些问题,将一些最热的代码无锁化是一个非常自然的选择。

如果要使用无锁化多线程,一个非常重要的基本部件就是 atomic types,原子类型。换言之,就是需要能够在多个线程同时读写不会出问题的变量。如果您觉得这个很耳熟,您没有错,这里的 atomic 就是和之前实现 stupid vodka 时用的 atomic 是同一个语义。

虽然最新的 Ɔ 语言已经有支持 atomics 了。然而,因为可穿戴国际内部使用的 Ɔ## 已经和 Ɔ 语言分道扬镳了,因此若要支持原子类型,必须自己实现。不过,幸运的是,IIVM 早就已经支持 atomic 了。对于 Ɔ## 很快也加入了原子类型的支持,并且直接翻译到了 IIVM 的 atomic。

例如,给出如下 Ɔ## 代码:

let flag = 100 as Atomic<int>;

// .set 是设置 atomic 变量的方法。
// 第一个参数(100)是需要设置的值。
// 第二个参数(MemoryOrder.RELEASE)是这次赋值所使用的 ordering 类型。
// 如果省略,就是默认的最严格的 MemoryOrder.SEQUENTIALLY_CONSISTENT。
flag.set(200, MemoryOrder.RELEASE);

上述代码没有任何问题。经过可穿戴国际的 Ɔ## 前端编译后,可以从生成的 IR 中观测到,其中 flag.set 那一行正确地为这次赋值操作加上了 atomic(ordering: release) 这个修饰。而编译出来的二进制也和技术人员们期待的完全一致。

然而,就是在这个不起眼的地方,诞生了第一个真正意义上的潜在 bug。之所以称其为一个“潜在”的 bug 因为这个 bug 只有在和 stupid vodka 交互时才会显现出来。而这时候,已经没有人在用 stupid vodka 了。

请看以下 Ɔ## 代码:

// flag 既是 atomic 又是 stupid vodka。
let flag = 100 as Atomic<stupid vodka int>;
flag.set(200, MemoryOrder.RELEASE);

请注意,在这里对 flag 进行赋值时,因为其本质类型是 stupid vodka int,所以会被加上 vodka + atomic(ordering: .sequentiallyConsistent, syncScope: .singleThread)。然而又因为这次写入的方式是以 ordering 为 RELEASEatomic,所以会被加上 atomic(ordering: .release)。然而,一次赋值显然不能有两个 atomic 修饰。因此,如果它们会合并。而合并的方法就是暴力覆写。因为对于 atomic 类型相关的代码在 stupid vodka 的代码之后,因此经过覆盖后,这次写入的修饰变成了:vodka + atomic(ordering: .release, syncScope: .singleThread)

这看起来好像没什么问题,然而实际上问题很大。如果不指定 syncScope 的话,它的默认值是 .system,也就是在所有线程之间同步。这是 atomic 类型变量的读写必要的,因为把它们变成 atomic 的目的本质就是为了防止其他线程读到不正确的中间状态。然而,经过如此的暴力覆盖后,syncScope 就变成了 .singleThread,本应该插入的内存围栏指令也不会被插入了。换言之,如果一个 atomic 的内部类型有 stupid vodka 修饰符的话,它就根本不再是 atomic 了。

不过,正如上文所说,这个 bug 触发非常困难:必须要同时使用 atomic 和 stupid vodka 才行。也正是因为这个原因,所有公司的单元测试内根本没有包含这相关的测试,再加上这只是单纯的覆写,并没有额外的代码分支,这部分的测试覆盖率也一直是 100%。综合这些原因,该 bug 就一直没有被发现。

在 atomic 类型实装后,对于向无锁化多线程的迁移很快就开始了。其中一个运行得非常频繁,因此需要无锁化的模块就是消息签名模块。正如本文最开头所说,为了确保消息来源可靠,可穿戴国际的基站之间通讯时,都会附带消息签名。因为每一条消息都要签名,所以消息签名模块工作压力极大。

对于这个模块的无锁化重构是在 X557 年年末开始的,而正式完成则是在 X558 年 2 月。

其核心模型是一个非常标准的生产者/消费者模式。生产者是各个产生需要发送的消息的线程,而消费者是负责签名的线程。更加详细地说,当一个线程需要给一条消息签名时,它会把这条消息写入一个预定的缓冲区(buffer)中。当写入完成后,它会把一个特殊的 flag 变量设置为 true。当消费者,也就是签名模块线程读取到这个 flag 被设置后,它就知道对应的缓冲区里有需要签名的数据,就会将这些数据复制出来,追加一个签名,并将其发送出去。其中,这个 flag 就必须是一个 atomic 变量。这是因为,正如之前所说,如果不是 atomic,CPU 可能在运行时会改变运算顺序。也就是说,生产者原先应该先往缓冲区内写数据,再标记 flag。然而,如果 flag 不是 atomic,那么运行时有可能会乱序,这意味着生产者可能会先标记 flag 然后才会向缓冲区内写入需要签名的数据。而消费者有可能还没等缓冲区的数据写完就读取到了这个 flag,而因此复制,签名,并发送了错误的数据。

不过,为了提高效率,对于 flag 这个变量的写入和读取的 ordering 不需要都是 SEQUENTIALLY_CONSISTENT 的。确切地说,对其的写入只需要 RELEASE 而读取只需要 ACQUIRE。至于为什么这两个就够了,这里就不展开解释了。

这项改动完成后,所有可穿戴国际的基站地效率果然得到了大幅提升。参与编写代码的程序员们也各自得到了相应的物质和肉体奖励。

接下来的十年中,所有的程序都正常地运作着。拜得到大幅优化的程序所赐,可穿戴国际也高速扩张着,其基站于 X566 年覆盖了所有陆地。同时,这十年间,有 6 个国家陆陆续续与可穿戴国际签订了条约,并将穿戴可穿戴国际的产品列入了公民的基本义务中。但是谁也不知道,灾难的种子已经被播下。只不过,距离其开花还有 25 年。

X568 年,为了增加签名的密钥的安全性,可穿戴国际的技术人员们决定将密钥全部转移到自己制造的可信赖设备中,并且只由这些可信赖设备完成签名操作。然而,为了能够直观地感受到这些签名用的可信赖设备在工作,技术人员们决定为这些设备加个指示灯,当在进行签名时,让指示灯亮起。

然而,因为加个指示灯并不是什么特别重要的事情,这个任务被交到了一个实习生手里。但是,这个实习生之前主要用的编程语言是 Jaʌa,所以她并不知道怎么在 Ɔ## 中让一盏指示灯亮起来。因此,她就去网上查。最终,她在程序员问答网站 Out OfMemory 上找到了一个看似类似的问题。这个问题的提出者想让一盏灯闪烁,但是却没有闪烁。其中一个回答的人告诉她,灯不闪烁的原因是,让灯亮起是有副作用的操作,但是编译器不知道,所以把它优化掉了;要解决这个问题,应该给指向控制灯的内存地址的指针上加上 vodka 修饰符,告诉编译器,不要将这些写入合并或者删除。

看了这个解答后,这个实习生仿佛恍然大悟。她拿出了需求单一看,果然,这上面也有一个控制灯的内存地址。她再一看程序,发现了那个告知消费者有需要签名的消息的 flag 变量的值刚好就是她需要的。因此,她一拍脑袋,便想出了一个“绝妙”的办法:把这个 flag 变量移到控制灯的内存地址上去。这样一来,当有消息需要签名,或者正在签名的时候,设置 flag 就会自然打开指示灯,而签名完成后,随着 flag 被清除,指示灯也会被关闭。想到这儿,她立刻就将这个计划付诸了行动。同时,她也记住了之前看到的回答,并为这个 flag 变量加上了 vodka 修饰符。

到这里,其实依然没有问题。虽然这个解决办法很蠢,但是确实是可行的。这个启动指示灯的内存地址其实也是属于主内存的,只不过指示灯的电路会同时监听那个值而已。然而,不幸的是,这个实习生曾经是个 Jaʌa 程序员。更不幸的是,Jaʌa 也有一个 stupid 修饰符,而这个 Jaʌa 的 stupid 修饰符与 Ɔ 中的 stupid 修饰符的语义都不一样(现在总算知道为什么叫做 stupid 了)。在 Jaʌa 中,stupid 的含义是将一个变量变成一个唯一的全局变量。这个实习生一看:这个 flag 因为用于控制指示灯,应该只需要一个,所以应该变成全局变量,因此便又顺手加了一个 stupid 修饰符。

就这样,stupid vodka 和 atomic 相遇了。

我们猜测,因为年代久远,文档匮乏,使用极度稀有,当天负责检查代码的程序员应该也完全不知道 stupid vodka 的特殊语义。即使她注意到了 stupid vodka,也很有可能误以为这里的效果是将这个变量标记为对其他文件隐藏的,毕竟 stupid 加在一个变量声明的最前面时,就是这个效果。再加上因为单元测试不会测试这些只有多线程才会出现的问题,所以所有的单元测试都通过了。而且更要命的是,检查代码的程序员似乎状态很不好,对于这个如此意大利面的代码,她仅仅是回了一条“LGTM”就批准合并了。

于是,这个有问题的代码就这样被加入了可穿戴国际的代码仓库。

那么,触发灾难的碎片凑齐了吗?

还没有。

事实上,她的这个代码如果当时拿去运行也根本不会出任何问题。不会出问题的原因和可穿戴国际当时使用的 CPU 有关。如果各位读者还记得的话,可穿戴国际的基站当时使用的是 Toothpastel 公司生产的 x68 架构 CPU。这个 x68 架构是一个强内存模型的架构。也就是说,x68 的内存读写指令还带了一些其他弱内存模型的 CPU 架构不带的保证。更确切的说是,在 x68 架构下,所有的内存写入都自带了 release 的语义,而所有的内存写入都自带了 acquire 的语义。换言之,如果使用的 CPU 是 x68 架构的,那么其实在实现生产者/消费者模式的时候的 flag 根本就不需要使用 atomic,因为所有变量的读写在 x68 下都相当于是带有 acquire/release 的 atomic。

不过,这仅仅局限于使用 x68 架构 CPU 的场合。

X572 年,新亚国修改了宪法并将穿戴可穿戴国际的产品列入所有公民的基本义务中。

正如之前所说,可穿戴国际的基站使用的都是 Toothpastel 公司的 CPU。然而,Toothpastel 内部研发似乎出了些问题。Toothpastel 在 10 年前就开始承诺即将能开发出 3.28E-8 英尺制程的 CPU。然而,到了 X579 年,其销售人员与可穿戴国际的工作人员交接时,3.28E-8 英尺制程的 CPU 依然没有开发出来。虽然可穿戴国际没有面临什么竞争,但是此时可穿戴国际基站内的 CPU 已经落后市面上的商用处理器好几代了。

X580 年,可穿戴国际的管理层终于熬不住了。她们做了一个决定——逐渐将可穿戴国际的基站的 CPU 切换到由 Pear Inc. 生产的 N1 处理器。

然而,N1 处理器是基于 LEG 架构的,而不是 x68 架构。

更不幸的是,不像 x68 架构,LEG 架构是一个弱内存模型的架构。

而第一个切换到 N1 处理器的国家就是新亚国。

到了 X582 年的时候,可穿戴国际的中心基站(B 级级以上的基站)已经全线切换成了 N1 处理器。可是,各位读者可能会想问,既然已经换成了 LEG 架构的处理器,为什么问题还没有发生?答案是,Pear Inc. 为了协助切换,开发了一个名为 Violetta 的动态二进制跨架构翻译器。简单而言,Violetta 能让 N1 处理器以模拟模式运行为 x68 架构编译的程序。这个模拟自然也会模拟 x68 的强内存模型。因此,即使已经切换到了 N1,这个隐藏在基站底层代码深处的漏洞依然不会被触发。

然而,尽管 Violetta 性能很高,但每一个指令都要动态翻译毕竟还是有折损。可穿戴国际的计划自然是先将 CPU 切换到 N1,然后开始逐步将程序也迁移到 LEG 架构。这个迁移是在 X588 年 9 月进行的。因为 IIVM 的模块化设计,如果要将程序转移到 LEG 架构,只需要有一个 LEG 架构的后端即可,而 LEG 架构作为世界使用量第二多的架构,其 IIVM 后端早已成熟。因此,技术人员非常顺利地重新编译了所有可穿戴国际的软件代码到了 LEG 架构,然后让所有的基站直接使用为 LEG 架构编译的版本,杜绝了由 Violetta 带来的性能折损。

此时,所有的安全栓已被拔出,灾难一触即发。

不过,由于这个是多线程 CPU 运行时优化导致的问题,平常流量小的时候根本不会触发。但是一旦到了国家级的大型更新时,问题就几乎必然会浮现出来。

细心的读者可能会注意到,X590 年新亚国进行了一次大范围的更新。那么为什么这次更新没有触发 bug 呢?然而,这是因为 X590 年的更新碰巧是是新亚国唯一一次采用同构更新。因为同构更新对可穿戴国际的网络使用量很低,因此没有触发这个 bug。

X593 年 1 月 6 日 18 时整:新亚国国会全票通过了《公民日常性奋度维持法第三修订案》。

X593 年 1 月 7 日 13 时 59 分 58 秒:新亚国国会发言人站在国会楼面前当着媒体的面按下了更新按钮。与此同时更新程序开始运行。更新包以每秒数千万个的速度从国会楼地下的政府服务器中发向距此约 2 公里的可穿戴国际的首都基站。更新包被首都基站的业务代码处理后,包装成消息,送去签名。大量经过包装的消息从生产者们发出。签名的代码以最高地速度检查着 flag 的值。

不过,出现这个问题的几率实在太小。在更新开始后的几秒内,这个 bug 都没有浮现出来。

但是,幸运是短暂的。

14 时 00 分 06 秒 466 毫秒,首都基站第一机柜上的第十一号机中的第二个 CPU 里的第九个核心因为缓存或是运行时乱序的原因,先将 flag 的标记广播给了接在同一台机器上的用于签名的可信赖设备中。那台可信赖设备自然没有逻辑检查被签名的消息。它将还没有完全写完的缓冲区里的不完整的值一板一眼地复制了出来,签名,然后将这灾难的果实送入了光缆。

14 时 00 分 06 秒 474 毫秒:位于新亚国首都的东部的 A11 基站发出警报,称收到了一条不符合通信协议但是有着首都基站签名的消息。

14 时 00 分 06 秒 504 毫秒:与首都基站直接相连的 37 个基站都因收到了 A11 的广播而将首都基站加入了黑名单。

14 时 00 分 06 秒 700 毫秒:位于新亚国首都的工作人员收到了首都基站故障的消息。

14 时 00 分 07 秒 042 毫秒:新亚国全境“警惕者”系统证书续期失败。

14 时 00 分 13 秒 924 毫秒:一位位于 A19 基站的工作人员拨通了一级紧急会议。

14 时 01 分 20 秒 214 毫秒:一位位于 B7 基站的工作人员注意到了“警惕者”系统证书续期失败。

第三部分:灾难

正如之前所说,从续期失败开始,所有的产品将在 3 ~ 4 分钟内启动制裁模式。然而,等到她们注意到,已经过去了一分多钟了。

由于基站黑名单系统是写死在底层代码中的,操作人员没有权限修改黑名单。若要重置,必须要进行网络重初始化。因此,若要完成证书续期,唯一能做的,就是把“警惕者”系统的根证书转移到其他的基站,并用这个新的基站来帮助其他基站完成证书续期。

然而,这个“警惕者”系统的根证书,整个新亚国只有一份,存储于首都基站地下的防空保险库内。

当工作人员们意识到需要将这个证书转移到其它基站时,已经晚了。

一些上次证书续期比较早的设备此时已经开始陆续播放提前录制的警报:“警告,您已长时间离开可穿戴国际网络覆盖范围,即将启动制裁模式。”

警告过后没多久,制裁模式就启动了。

接下来发生的,想必各位不幸亲身经历过的读者可能知道得比我更清楚了。

为了能有效制止恐怖分子,使之迅速失去行动能力,制裁模式的设计非常简单,只有两步。

第一步,静脉注射高浓度春药。

第二步,所有包括抽插电击震动旋转在内的所有刺激模块开到最大。

由于新亚国的所有公民都已经佩戴了我们可穿戴国际的设备,而因为新亚国全境的“警惕者”证书都续期失败了,这次灾难的后果是毁灭性的。

制裁模式全面启动后,从坐在课堂里学习的学生,到办公桌前的白领,所有人都被迫进入了这无休止的高潮地狱。她们完全不知道发生了什么。也正是因此,整个新亚国都停止了运转。

同时,由于新亚国国防部也完全瘫痪了,位于国界线的防空帘也无法被降下,导致可穿戴国际派来的支援人员也无法入境。

这场灾难是以首都基站的 7 名勇敢的工作人员顶着极强的刺激,将“警惕者”系统的根证书从首都基站的地下室运到了将近 50 公里以外的 A3 基站收场的。尽管她们是开车前往的,但是由于刺激过于强烈,再加上交通全面瘫痪,这 50 公里路足足花了一天一夜才送到,为这场灾难画上了句号。

这一天一夜是新亚国历史上最黑暗的一天,也是全可穿戴国际创办以来最黑暗的一天。

即使在危机解除前,所有全面采纳可穿戴国际的设备的国家的领导人都已来电质问为什么这个事件原因,以及在她们国家发生的可能性。不过,由于当时信息匮乏,没有人知道这事真正的发生原因,因此未能给出令人满意的答复。

这件事之后,可穿戴国际内部已经启动了全面代码重审查计划,并将在接下来的 10 年内,在新的首席技术官的带领下,仔细重新检查目前正在使用的生产环境的所有代码,以杜绝此类灾难再次发生。同时,可穿戴国际也将会建立更加完善的紧急情况应对机制,并与各国国防部合作,以允许可穿戴国际的航天器能在需要的时候通过防空帘。

不过,接下来的事情已经与我无关了。

因为现在,我只需要“享受”新产品就好了。

琳 缇佩斯
X593 年 1 月 10 日