« April 2019 | Main | June 2019 »

May 18, 2019

为什么说执行 996 工作制的脑力劳动者非蠢即坏

抱歉我用了这么个吸引眼球的标题。但我其实是想分析一下 996 工作制度到底存在怎样的问题。注意,我说的是身体力行执行 996 工作制的人,而非要求员工进行 996 工作的老板,这是两类人,今天我想骂的是前一类。

如果让我给执行 996 工作制下个定义,我想不能把全身心投入到工作和事业上的工作方式等同。它并不指工作时长;而是指刻意的制度性的把工作安排在非正常工作时间段。对待工作,不是以是否完成计划内的工作为衡量标准;而是本末倒置的先预设工作时长,然后想办法填满这些工作时间。

对于我最熟悉的游戏软件行业,它的工作本应该是脑力劳动为主。尤其对于程序员来说,主要的工作应该是在你的脑子里通过思考完成的,如果你的工作效率受限于每天不停的敲打键盘、移动鼠标、那么就变成了一项体力劳动。不久的将来,猴子和 AI 都能替你把事情干了。体力劳动或许可以通过制度性的延长工作时间来加快进程,可硬生生的脑力劳动变成体力劳动,只能用蠢来解释了。

如果工作的重点是通过大脑思考完成的,那么就不在乎你在什么时间,坐在哪里进行这些思考。甚至入睡前的那段时间都能想很多,限制每天在办公室坐上 10 多小时没有任何意义。那为什么说起来简单,996 反而成了国内程序员工作的普遍状态呢?我以最大的善意揣测人性,若他们不是心眼坏的话,那只能是大多数从业程序员能力欠缺了。

从游戏行业看,投资做游戏的人最期盼的是什么?并不是压榨你用更短的时间把游戏做出来,而是你能给个明确的计划时间表,在这个时间内保证质量的完工。这个时间可以比较长,但只要计划是明确的,就能估算出未来的收支情况。成功的游戏利润率非常之高,如果游戏能成功,用暴力手段压缩制作时间而减少的成本简直无足轻重。所以,游戏产品上线前,一改再改,无限延长开发时间反而是常态。

健康的项目,计划的落实是第一位的。那么最影响计划安排的是什么?不是工作时长,而是开发中的不确定因素:

顺利的情况下,一个人一天产生 300 到 500 行代码游刃有余。一个游戏程序最终的代码量,抛去一些机械产生的东西,由人的智力产生出来的不会超过 10 万行。这样算也就是 10 个人月不到的事。以现在动则十几数十个程序的开发团队,开发几年才能让游戏上线来看,绝大部分的工作都被废弃掉,或是意外产生的额外量。比如 Bug ,为一个 Bug 花掉 2,3 天时间,这种经历我想大部分程序都经历过;更常见的是改需求,不断地废弃掉已经完成的工作。项目完成后复盘的话,肯定会发现,如果一开始走了正确的路,能压缩掉一个数量级的项目开发时间。996 制度能增加多少工作时长?2 倍我觉得是极限了,再增加工作强度,影响到程序员的日常判断力的话,一定会大幅度的增加出错的概率。

所以,如果存在一个工作计划,那么就不可能事前规划出超长工作时间。因为你必须为不确定因素留出时间。因为经验原因未能按计划做完事情,说明能力不足,有改进空间。但既然是意外,就有顺利的时候,无论多糟糕的程序员,都有不出 bug 的日子,制度性延长工作时间对于落实工作计划毫无意义。

除非,程序员干的就不是智力工作。当我们把工作的智力因素无限降低的话,996 工作制度的确可以保障任务以更加稳定的时间周期完成。就好比你用计算器从 1 加到 100 ,只要你肯把 1 2 3 ... 100 依次按下去,总能得到结果,当你按了几十次按钮后,就能大致估计出全部按完 要多长时间;而寻找等差数列的公式、验证其正确性,然后用更快的方法得到结果,反而是不可控的。996 有效的前提就是:一定要找到最笨最机械的方法去执行,通常必须由蠢人来干,如果你不够蠢,就要把自己变得更笨一点;整个团队的智力在工作状态中拉到一条线上,就可以有条不紊的推进了。

我刚刚提到了团队,团队合作正是另一项重大的影响因素。

996 工作制度下,通常就是无休止的讨论、会议、头脑风暴等等。团队的工作相互依赖,你的工作需要我的工作先完成,我的工作需要交给他审核。团队规模越大,分工越细,越需要更多的人长时间待在一起。否则,做了一半的事情找不到上游或下游就无法开展下去。可是,受过基本训练的合格程序员都应该明白一个道理:越是复杂的项目,就越是需要不同部分(工作)模块的解耦。为了模块划分干净,可以不惜增加模块间的沟通成本。工作也是这样,随时可以找到人讨论问题并不是一件好事,它好比两个模块间事无巨细的反复调用对方的 api 。就算你全部实现都 inline ,开到 -O3 的优化,都未必高效。设计问题无法用精巧的实现弥补;工作规划的失误,无法用超长的工作时间抵消。我们应该容忍工作因为沟通延迟或失误造成的工作浪费,但不应该容忍无穷尽的讨论和会议浪费掉的时间。聪明人规划好计划后,会珍惜每次有限的沟通机会。

不轻易打搅别人应该是职业人应该具备的基本素养。这点至少我觉得自己是做到了的。记得 10 多年前,我有项目半夜出了 bug ,因为是非工作时间,即使 bug 不出在我编写的模块中,甚至我连代码都没看过。可我还是忍住不联系相关的同事,花了一个通宵阅读可能有问题的代码,并在天亮前修复;另一个项目在上线发布前一晚,一块关键数据除了问题,但是维护数据的人已经下班,修改数据的专有工具甚至没有人会使用(编写工具的人也下班了),我的选择不是联系负责的人,而是根据数据格式连夜重新编写工具,在用新写的工具修改错误的数据。举这样的例子,一是想说明,全心投入到工作中,和 996 不是一码事;二是想说,有能力有意愿加班做事是一回事,要求别人是另一回事。并没有那么多耽误不起必须在今晚完成的工作,如果你判断事情严重到必须当晚解决,那么就要具备独自解决问题的能力。如果你没有这个能力,第二天天也不会塌下来,好好做好下一段的计划,不要总是陷入这种僵局。

减少工作沟通并不是说不和别人沟通,闭门造车。反而应该在工作外的时间,尽可能地和不同的人交流,了解不同领域的人做了什么,采用了什么方法。这样才能开拓视野。工作中的许多困境往往都是因为陷入了思维定势,需要一个外部灵感帮你跳出来。和影响工作进程的各种偶然因素一样,捷径出现在眼前也是出乎意料的。你无法期待一个灵感能给你节省多少时间,但若你关上了门,每天干到睡觉,起来就奔赴办公室,也就关上了这扇门。


谈完了蠢,就想说说坏了。这个坏或许不是本心,但实质性的伤害了别人。

当人不能通过自身的技能完成任务,又无法提高自身达到要求。或许能做到的就是给自己降级,用更无智力含量,对技能无所要求的方法来做事了。这样对自己是最佳的选择,因为本质上 996 这种制度性工作才是最容易办到的事,无需思考,只用不停的敲打键盘即可,还能获得极大的自我满足。同时,这样可以把身边的同事,同行业的其他人也一起拖入泥沼。我做不过你,但我可以肝死你,恐怕是这一类人的心声了。

聪明的工作方式,需要团队一起都用聪明的方法来做事,每个人都勤于思考,找到高效的途径。通过独立思考,减少自己的错误产出(尤其是会影响到他人工作的产出)。但是,只要有一两个人做了坏事,就很难维持。比如,原本可以由一个人写一个 meta 模块解决一堆类似的问题,可一旦有一个人偷懒写下一坨狗屎只解决其中一个,并要求后面的人依照这个模板复制修改 10 份,那么工作就轻易的拓展到了 10 人份。最终的结果就是劣币驱逐良币,团队所有人都要趋向于用笨方式更稳健的做事。一旦脑力活动从工作中消退,就演化成 996 制度性长时间体力劳动。

不思进取的工作方式还会导致对自己的结果不做进一步思考,设计好的功能扔给别人实现,期待做好了再看效果;写好的代码不仔细自我检查,期待有测试人员把关,对坏味道无动于衷,等出了 bug 再改,反正干什么活不是干呢?debug 也不影响拿工资就是了。

如果一整个团队就这么下去,坏影响会蔓延到同业。因为无论这种工作方式效率多低,但是产出稳定啊。滥产品能做出来也是产品,也能占据市场,至于对市场是否有消极影响就无所谓了。暂时做不出好产品的团队,一旦忍受不了,最便捷的方式就是把自己降低到同一个水准上。正所谓把敌人拉到和自己一个水平,用自己最熟悉的手段击倒对手。这或许才是整个行业都充斥着 996 工作制的真相。

May 16, 2019

通过对缓存测速提取信息的旁路攻击

最近爆出对 Intel 系 CPU 的新的安全漏洞。MDS 攻击 可绕过安全屏障,取得同一个核心上运行的其它进程(或其它虚拟机)处理的数据。安全界的建议是暂时关闭超线程,避免泄露信息。

我对这个安全漏洞颇感兴趣,花了一些时间研究,弄明白了来龙去脉。

其实,新的 MDS 攻击的思路继承至更早发现的 Meltdown 攻击方式。除了原始论文,我找到了 这篇 blog 把其原理介绍的非常清晰。

如果不介意我可能有理解偏差,那么可以看看下面我对其原理做的一点中文复述:

现代 CPU 为了运行的更快,会把我们编写的复杂指令集指令内部转换为更细粒度的微指令运行。这些微指令无非就是做一些寄存器运算、存取数据、根据条件运算结果修改地址寄存器(达到指令运行流程分支的效果)等。为了让这些工作可以工作的更快,CPU 内还提供了不同的处理单元处理不同的业务,这样就可以并行工作。

表面上看,我们的程序是依次循序执行的。但实际上,许多微指令都是并行工作。为了提供性能,并行的运算单元间很难保证先后次序。这就是所谓的乱序执行。但最终的结果在面对用户层面还是用算法保证的。

有些指令天生就次序无关,比如对 A 寄存器加一和把 B 寄存器放到内存中,这两件事情就是无关的。谁先谁后都无所谓;有些事情却是相关的,比如从内存读一个字到 A 寄存器,然后再以 A 寄存器内的字为地址,去访问对应的内存,取出数据放到 B 寄存器,这就是相关的。你不读出 A 就不知道怎样访问 B 。

再比如,根据前面的一个条件运算结果,修正指令执行寄存器的值,也就是条件分支,再没有得到条件运算结果的时候,就无法知道后面要执行什么指令。

现代 CPU 为了适应多任务环境,以及操作系统和应用层的隔离。我们的程序运行时访问的内存地址,并不是真实地物理内存地址,而是虚拟地址。访问一个虚拟地址时未必有权限访问。所以理论上,不同内存访问的行为也是有次序依赖的。


CPU 为了加速这些有次序依赖的行为的方法就是,不管后面真的要做什么,我都去按最可能发生的事情先处理,如果事后发现做错了,就废弃已经做好的事情,把逻辑纠正回正确的轨道上。

对于内存访问的依赖,CPU 根据当下缓存中的数据提前预测马上要访问的内存,尽早通知缓存处理模块加载缓存;对于分支程序,提前处理最可能走的分支,如果预测错了,就退回重来;对于内存权限校验,等校验失败再退回已经做的事情……

在很长时间,我们都认为这些都是安全的。因为这是 CPU 内部的事情,应用层无法直接访问 CPU 的内部状态。CPU 即使加载了不需要的数据到缓存中,恶意程序也无法读取它。直到 Meltdown 攻击方式出现。


Meltdown 攻击允许用户程序在没有权限的情况下,获取操作系统保护起来的数据。它是怎样做到的呢?看这样一小段示意代码:

; rcx = a protected kernel memory address
; rbx = address of a large array in user space
mov al, byte [rcx]         ; read from forbidden kernel address
shl rax, 0xc               ; multiply the result from the read operation with 4096
mov rbx, qword [rbx + rax] ; touch the user space array at the offset that we just calculated

rcx 指向的是一个内核保护起来的地址。当我们运行到 mov al, byte[rcx] 时,是无法获得真正的值的,因为在用户权限下 cpu 拒绝读这处内存。

但是,访问失败这件事是稍后才处理的。现代 CPU 中,其实后面两行代码是被真的(被乱序)运行过了,只是最终的用户可访问的寄存器中,看不到结果而已。CPU 会废弃做过的事,但 CPU 内部缓存的状态却不一样了。

这里,把原本企图读取的值(一个 0-FF 的数字),乘上 4096 ,也就是一个缓存页的大小。然后去读一个 rbx (用户空间地址)的相对地址。

由于前面 al 并没有真的读出来,所以 rbx 也没有真的读出合理的数据。但是由于后两行指令其实被 cpu 处理过,所以对应的内存页其实被加载到缓存中了。

我们虽然无法直接查看 cpu 缓存的状态,但是这里有一个重要的区别:访问不在缓存中的内存地址比访问已经缓存的内存地址要慢的多。

所以,接下来我们只需要尝试访问 rbx 后的 256 个地址,统计分别的访问时间,那个最快访问到的地址对应的 al 值,就是我们原本没有权限访问的内存值。


从 Meltdown 攻击衍生的 Spectre 攻击更复杂一些。但可以用一个进程去偷取另一个进程的数据。这篇关于 Spectre 攻击的论文 写的浅显易懂,后面还附有概念验证的 C 代码,很值得一读。

对于那段 C 代码,其核心就是这么两行:

if (x < array1_size)
  y = array2[array1[x] * 4096];

这段代码对 y 取值的结果有几处不确定性,一是关于 x 的条件判断,二是 array1[x] 的值,三十 array2[...]的值。这三者是相互依赖的,从逻辑上必须循序执行。

但是 CPU 在处理时,会预测最有可能的情况,用最大速度运行。如果最终发现已运行过程中的某个环节是事实不符,再作废已经获得的数据。但是,整个过程中 CPU 的 cache 状态却保留了下来。

我们可以通过对需要攻击的程序的源代码或二进制进行还原,找到某处我们想关心的包含有敏感数据的地址位置,通过这个攻击手段猜测出那个进程中该地址处的数据。原理是这样的:

  1. 攻击代码先预先跑这段代码几次(5次足够),让第一个条件一直成立,这样就欺骗了 cpu 以后再跑这段代码,需要执行判断成立的代码。

  2. 让 array1[[x] 指向的虚拟地址等于想攻击的另一个进程的那个包含有敏感信息(比如密码)的地址,假设最近访问过那个虚拟地址,那么数据就在 cpu cache 中。

  3. 故意让条件判断失败,再跑一次上述函数。由于 CPU 会认为条件很可能成立,则会预算跑后面的这一段。这时,如果该虚拟地址在另一个进程中最近访问过,那么 cpu 认为数据就在 cache 中(虽然本进程该虚拟地址对应的物理内存并不是同一个位置),cpu 会取出 cache 中的值,也就是攻击对象中的敏感数据;并且去加载 array2[...] 的相关内存到 cache 。等到 cpu 意识到自己做了错误的操作:cache 对应的虚拟地址映射的内存并非当前正确的映射,它会放弃后续的操作,但对 array2[...] cache 的影响已经造成了(因为乱序执行的缘故)。

  4. 最后和 Meltdown 攻击一样,通过对 cache 访问测速,猜测出曾经存在于 cache 中的另一个进程的数据。


Meltdown 和 Spectre 攻击非常类似,它们利用了现代 CPU 流水线工作的机理,在各种 CPU 上(Intel AMD 甚至 ARM 的手机芯片上都能进行。它们都是在同一系统下,让只有有限用户权限的代码通过旁敲侧击获得它没有权限获取的内存数据(内核中的或其它进程中的)。通过给操作系统内核打一些补丁,可以在承认一定性能损失的情况下,尽可能地防范这类攻击。

这类攻击是针对内存 cache 的,对于新设计的 CPU ,也从硬件上阻止了漏洞。但是,新发现的 MDS 攻击 虽然原理类似,但它并非攻击内存 cache ,而是 CPU 的内部 Buffers 。开启超线程的 CPU ,同一个核心会共享一些内部 Buffers ,所以就让运行在同一个核心的两个不同超线程下的进程/虚拟机,有了绕过系统屏障,读取对方敏感信息的漏洞。

目前暂时还没有软件方法规避,所以在有安全风险的环境下,只能建议关闭超线程来避免攻击。

May 11, 2019

Windows 下 UTF-16 的坑

最近帮多个活跃的开源项目改了同一个 bug : 完善 Windows 下的 Unicode 支持。

问题源于 Windows 和其它平台不同,它有悠久的历史包袱。和现代大多数操作系统对 Unicode 支持的共识不同,它的 API 不是基于 UTF-8 而是基于 UTF-16 的,且还遗留了一套过去对 ANSI 支持的兼容 API 。

最近我接触的几个活跃开源项目,都是西方人主导开发的,他们维护者似乎对 UTF-16 均有所误解,或是懒得做全面的支持。我认为罪魁祸首就是微软自己把 ANSI 编码称为 MBCS 多字符编码字符集,和 WideChar 宽字符集对应起来,暗示 WideChar 是单字编码。而事实上 UTF-16 方案应该成为 MWCS 才合理。

Unicode 虽然常用的只有 BMP ,也就是 U+0000 到 U+FFFF 部分的字符集。但是随着 Emoji 的普及,SMP 也变得非常常见了。其实 Unicode 目前的标准有 17 的 Plane ,需要 21bit 才能表示完全。而很多人直观的觉得,UTF-16 每一个字 ( 2 字节 )表示一个码点。这是不对的,像 Emoji ,一些罕见汉字(SIP)需要用两个字来表示。这个叫做 surrogate pair ,对于 Unicode ,U+D800 到 U+DFFF 这个区段是不存在的,专门用于实现 UTF-16 中的 surrogate pair 。

U+D800 到 U+DBFF 留出了 1024 个位置,也就是 10 bit ;U+DC00 到 U+DFFF 也是 10bit ,这 20bit 刚好能表示 BMP 外的 16 个 Plane ( 4 + 16 bits )。

现代软件通常都会统一使用 UTF-8 作为内码,为了兼容 Windows ,则在最后调用 API 的时候再和 UTF-16 做转换。虽然 UTF-8 编码并不复杂,但这里最好不要自己实现这个转换,因为容易犯错(漏掉 surrogate pair 的处理)。建议尽量使用 MultiByteToWideChar 和 WideCharToMultiByte 。btw, 因为犯错误的人/项目实在太多,甚至有人提出了一个叫做 WTF-8 的 Unicode 编码方案,可以正确编码错误的 UTF-16 串。

在 Windows 下比较坑的是,WM_CHAR 这个消息,在 Unicode 模式下,也是传递 UTF-16 编码的,如果敲出的字符不在 BMP ,那么会用两个连续的消息分两次传递。大多数开源软件都没能正确处理这个问题。我最近给 imgui 提了一个 PR ,想解决它的这个问题。我同样在 lua iup 和 bgfx 中发现了类似问题。

btw, 很多人没有细读 msdn ,被 WM_UNICHAR 这个消息名误导,认为只要处理这个消息,就可以直接拿到 Unicode 的 codepoint ,因为 msdn 上写的是,这个消息会按 UTF-32 来发送消息。但实际上,Windows 的 IME ,也就是会直接产生超出 BMP 的输入的主要途径,它并不会向窗口发送 WM_UNICHAR 消息。这个消息时专门用于 ANSI 窗口向 Unicode 窗口发送 Unicode 字符消息用的,我认为设计这个消息的动机是,如果你连续向一个窗口发送两个 WM_CHAR ,很难保证原子性,很可能中间被插入另一个 WM_CHAR 。所以必须有这么一个消息,可以原子的发送一个 Unicode 字符。至于 Windows 系统内部通过 DefWindowProc 产生出来的 WM_CHAR 消息,很可能是直接对线程的消息队列写,则能保证超过 BMP 的 Unicode 字符可以被分割为两个消息,中间不被打断。换句话说,WM_UNICHAR 只是为了解决程序自己内部传递字符用的,你不处理它,系统的 DefWindowProc 也会帮你转换为 WM_CHAR ;而只处理它是无法解决 surrogate pair 问题的。

似乎目前唯一的方法是,当你在 WM_CHAR 中发现了 surrogate (U+D800 到 U+DBFF) 时,把它记在和窗口相关的数据结构中,等下一个 surrogate (U+DC00 到 U+DFFF)到来后,再和之前记录的值拼接起来,算出 code point ,再转发出去。


对于历史悠久的开源项目修改,需要注意,很多项目仍旧遗留了 ANSI 版本的 Windows API 封装,而并非完全是 Unicode 版本的。混用 A 版本和 W 版本(或是 T 版本)的 API 要非常小心,尤其是设计基于 DLL 的插件处理。Windows 官方的说法是,建议严格使用 T 版本的宏,不要直接调用 A 版或 W 版 API 。但大多数现在的开源项目,为了减少不确定因素,都回避了这类 API 的宏替换。

虽然 Windows 内核中一律使用 Unicode ,A 版本的 API 只是做一个转换。但不要认为只有涉及字符串的 API 才分 A 和 W 版本,设计消息参数的也会区分。例如 PeekMessage DispatchMessage DefWindowProc 都有两个版本(但 TranslateMessage 却没有)。一个窗口的消息输入,其实主要就是最终把支付输入翻译成 WM_CHAR 的过程,到底是翻译成 ANSI 还是 UTF-16 ,只取决于 Windows 的 Class ,也就是你是用 W 版的 RegisterWindowClass 还是 A 版的。如果一个窗口的 Class 是 Unicode 的,那么你必须使用 DefWindowProcW 函数过滤消息。你的 WindowProc 不可以直接调用,而需要使用 CallWindowProcW 调用,它负责对 MSG 做正确的转换。

记住,Windows 的消息循环是基于线程,而不是基于窗口的。一个线程只能有一个消息循环分发点。如果你不在意 BMP 之外的 Unicode 支持的话,其实使用 PeekMessage/GetMessage 的 A 版或 W 版都无所谓,Windows 最终都会做正确转换的。

但是,ANSI 字符集仅仅是 Unicode 的一个子集,如果你的线程中混用了 ANSI 类窗口和 Unicode 类窗口,那么你用 PeekMessageA 获取消息,当 DispatchMessageA 去触发 Unicode 窗口的 WinProc 时,有些字符就会丢失。所以,你必须用 PeekMessageW 和 DispatchMessageW 才能保证信息不会丢失。

不要觉得不可能出现这种混合的情况,我用 google 就找到了这么一个古老的案例 。它解答了我起初的一个疑惑:为什么微软在设计 API 的时候,不干脆去掉 ANSI 版的 PeekMessage/GetMessage/DispatchMessage ,反正 Unicode 是过去所有字符集的超集了。这是因为,有不少历史遗留软件,因为 MFC 的缘故,还会绕过 WindowProc 直接去访问 GetMessage 获取的 MSG 中的消息。当然,我还是认为这是 Windows API 的设计失误。


最后再补充一些我在帮 imgui 完善 Unicode 支持,做测试时了解到的小知识。

一般中文字体文件都不会包含 BMP 以外的字符,而扩展的罕见汉字,SIP 那些你在常规的 ttf 文件中是找不到的。但是你在 Windows 下用字体 API 却取的到。这是因为,在 Windows 下,如果你的字体使用了 SIP 中的字但找不到,Windows 会尝试注册表 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\LanguagePack\SurrogateFallback 中对应的替代字体文件。比如宋体,在我的系统上,最终会从 C:\Windows\fonts\simsunb.ttf 中查找那些罕见字。