Archive for the ‘开源’ Category

Lua 的垃圾回收

2013/10/27

这篇 blog 是最近研究 Lua 垃圾回收 (Gabage Collector) 的笔记整理。研究 Lua 虚拟机源代码的完整笔记已经放到 GitHub 上。以后会不断更新。

GC 类型

很多关于 Lua 虚拟机源代码的文章往往危言耸听地把 GC 称作最难理解的部分,建议放到最后研究。我习惯用「深度优先」方式理解问题,很难说服自己完全不研究一个模块的内部,除非其接口文档非常正式,而且与系统其它部分相对隔绝。Lua GC 的接口虽然比较清晰,但也没有正式文档,并且不是单步 stop-the-word GC [1],其状态和虚拟机其它部分有很多关联。

研究 Lua GC 的第一个收获是 GC 方式的细致分类。首先 Lua GC 属于 root-tracing 这个大类。「Tracing」指通过对象之间的引用关系检查对象的 reachability,以是否 reachable 作为回收对象的标准。「Root」指 reachability 的源头,一般指全局变量 [2] 和当前 thread 的 stack。Root-tracing GC 一般采用 mark-and-sweep 策略。在 trace 阶段给所有 reachable 对象打一个 mark,然后进入 sweep 阶段,将没有 mark 的对象回收。最简单的实现是把 trace 和 sweep 两个阶段整个作为原子化过程,执行中不允许虚拟机执行 OP_CODE [3],这就是 stop-the-world GC。更为复杂的策略是把内存中的对象按照生命周期长度分成「代 (generation)」,每次仅对一代对象进行 mark-and-sweep 操作。而且对每代进行操作的频度不同,叫做 generational GC。如果设计合理,这种策略可以及时回收临时变量 [4] 又避免了对生命周期很长的对象进行过多不必要的 trace 和 mark。

为了实现的简洁,Lua 直到 5.1 都没有 generational GC。5.2 版本实现了这个策略,但是缺省处于关闭状态,而且设计者一再声明是一个实验性的功能,将来可能移除。目前 Lua 采取的策略是把 trace 和 sweep 两个阶段分成很多小片段,在执行各个片段之间允许虚拟机执行 OP_CODE。其代价是 GC 无法在一个周期中识别出所有 unreachable 对象,导致部分 unreachable 对象只有到下次「GC 周期」才能被回收。这种把 trace/sweep 分成多个片段的方式称为 incremental GC。

周期和步骤

既然提到了「GC 周期」,就先把一个周期的完整步骤 [5] 列出来:

  • Pause-设置 GC 的基本初始状态,特别是 root-tracing 中的 root;
  • Propagate-主要实现 mark-and-sweep 中的 trace 阶段;
  • Atomic-实现从 trace 阶段转到 sweep 阶段中不能被打断的原子化部分;
  • Sweep-string-实现 sweep 阶段中为 string 优化的部分;
  • Sweep-userdata-实现 sweep 阶段中对 userdata 的处理;
  • Sweep-Sweep 阶段的主要实现。

这些步骤的入口和跳转的逻辑在 singlestep() 函数中实现。

再说说 collectable value 的「颜色」概念。Lua 中需要被 GC 回收的 value 被称为 collectable value [6] ,其共有属性由 struct GCheader 实现,其中 field marked 表示一个 value 的「颜色」。Field marked 的 bit 0 和 bit 1 表示颜色是否为 white,bit 3 表示颜色是否为 black。但是一个 value 的颜色并不仅仅由 marked 决定。为了不引起混淆,我们把仅仅由 field marked 决定的颜色称为「marked 颜色」,把所有因素共同决定的颜色称为 value 的「颜色状态」。Lua 虚拟机的全局标志 global_State::currentwhite 用来解释 marked 的 bit 0 和 bit 1 哪个表示 current-white,另一个 bit 表示 other-white。

新创建 value 初始被置为「current-white 状态」。

Lua GC 的 trace 阶段对应于「propagate 步骤」,每次执行时搜索到的 reachable value 的 marked 颜色被设为「black」。这些 value 中有一部分 —— 比如 table 和 function —— 可以再引用其它 value  ( table 通过 key-value,function 通过 upvalue ) 。一个可以引用其它 value 的 reachable value 的 marked 颜色刚刚变为 black 之后,它自己会被放到一个称为 gray-list 的链表中。这种 marked 颜色为 black 且处于 gray-list 中的 value 被视为「gray 状态」。

在一次 propagate 「片段」中,Lua GC 会把片段开始前就已经存在于 gray-list 中的全部或者一部分 gray value 取出 (使它们成为真正的「black 状态」),把它们直接引用的 value 置为 black 状态 (如果是简单的不会引用其它 value 的类型,比如 string) 或者 gray 状态。一旦 value 处于 black 状态 ( marked 颜色为 black 并且不在 gray-list 中) ,在当前 GC 周期中就不再被检查。具体代码中这些工作由两个函数实现:

  • propagatemark()-将 gray-list 头部的 value 取出变成 black 状态,把它引用的其它 value 变成 gray 状态或 black 状态。这个函数代表 root-tracing GC 中的 trace;
  • markvalue()markobject()-接受一个表示 value 的参数,把这个 value 变成 gray 或者 black 状态。它实现的概念是 mark-and-sweep 中的 mark。

这两个函数相互配合实现 trace 过程 [7]:propagatemark() 直接调用 markvalue() / markobject() 来扩散 value 的 black 状态,markvalue() / markobject() 向 gray-list 中添加 value 来影响后续的 propagatemark() 调用。函数 propagatemark() 每次进行 trace 起点是 gray-list。上文提到过 root-tracing GC 的 root 是全局变量和各个 thread stack。关联这种「root」到 gray-list 的任务由 pause 步骤完成。Pause 步骤的实现主要在函数 restartcollection() 中,代码如下。

其中的 markobject(g, g->mainthread) 将 main-thread 放入 gray-list (即代码中的 g->gray)。在 Lua 中,全局变量存储在 main-thread 的 upvalue table _ENV 中。把 main-thread 放入 gray-list 就完成了对 root 的准备工作 —— 把全局变量,thread stack 与 gray-list 关联起来。至此我们简单讨论了 value 的颜色状态以及 pause/propagate 两个步骤。接下来看其它 GC 步骤。

函数 propagatemark() 不断从 gray-list 中取出 value,期间 markvalue() / markobject() 也会添加一些 value,不过最终 gray-list 会被取空,这时 Lua GC 进入 atomic 步骤。Atomic 步骤主要由两个函数实现:atomic() 和 entersweep() 。上图是 atomic() 函数的代码。在这个函数中 GC 把虚拟机中的所有 thread 以及一些已经处于 black 状态的 value 重新置于 gray 状态 (见 atomic() 函数中对 retraversegrays() 函数的调用及其前后的代码) ,随后进行数次不会被打断的 non-incremental trace (调用 propagateall() )。这几次临近 sweep 阶段前最后的 non-incremental trace —— 也可以叫做 atomic trace 确保所有 current-white value 都是 unreachable value。

即使到了这一步,Lua GC 也并未完全把 value 的 reachability 和颜色状态严格对应起来。Atomic 步骤确保的只是从 current-white 到 unreachable 的单方向对应。反方向并不成立,即 unreachable value 并不一定都处于 current-white 状态,因为有些 black value 在被 mark 之后才变成 unreachable 状态。这不影响 GC 的正确性,只是这些 unreachable value 要等到下个 GC 周期才能得到回收。这就是 incremental GC 的取舍:每次中断 OP_CODE 运行的时间都不长,但是一个「GC 周期」不能确保所有垃圾完全回收干净。

Atomic 步骤中的 trace 过程完成之后,atomic() 函数会修改 g->currentwhite (上图倒数第三行) 。 这就是上文说过的用来解释 collectable value 的 marked field 中 bit 0 和 bit 1 意义的 flag。这次修改令所有 current-white value 立即转到 other-white 状态。至此,为 sweep 阶段所做的准备已经基本完成。Sweep 阶段的主要任务是回收 other-white 状态的 value,又称为 dead value。

这里还有一个例外:atomic() 函数中调用的 separateobefnz() 函数把所有被 __gc mark [8] 过的 dead value 放到 g->tobefnz 链表中。Sweep 阶段不回收这些 value,而是留待下次 GC 周期一开始调用它们的 __gc meta-method,之后把它们作为普通的没有 __gc mark 的 value 常规处理。这是因为 dead value 的 __gc meta-method 有可能把它自己重新赋给 其它变量,使其恢复 reachable 状态,这种现象叫做「resurrection」。Lua GC 不能用 mark-and-sweep 检测 resurrection 现象,否则 mark-and-sweep 算法会变成无限递归过程。Lua GC 采用的方法虽然 ad-hoc 但是也算取舍得当:一是定义了「__gc mark」这个概念,缩小了 __gc meta-method 起作用的范围 [9]。二是在当前 GC 周期中不回收 __gc marked value。在下一个 GC 周期中这些 value 失去「__gc marked」资格 (除非它们再次在代码中被显式的 __gc mark) 后再做普通处理。本文一开始说过,很多讲 Lua 虚拟机的资料建议尽量推迟对 GC 的研究,也是因为 Lua GC 在简单的 mark-and-sweep 算法上添加了很多特殊情况的处理。

如果在 atomic 步骤执行的 non-incremental trace 耗时太长,就会影响 Lua 虚拟机执行 OP_CODE 的性能。因为之前的 propagate 步骤已让大部分 reachable value 处于 black 状态。此时需要 trace 的只是各个 thread 被置为 black 状态之后新创建的 value。这时有一个疑问,因为 thread 被置为 black 的时间点在一次 GC 周期中并不是非常靠后 [10],那么 atomic trace 处理的 value 是否会非常多?有两个因素保证这个担心是不必要的:

  • 在 atomic 阶段真正要被处理的 value 实际上少于「thread 被置为 black 状态之后新创建的 value」。因为在 thread 被置为 black 状态之后创建的 value 中有一部分还被 gray-list 中的其它 value 引用,这样的 value 在 propagate 阶段就会被 trace。
  • 那些在 thread 被置为 black 之后新创建的,而且没有被其它 gray value 引用的 value,大多仅被 stack 上的 local 变量引用。到了 atomic 阶段,这些 local 变量中很多已经离开了自己声明的 block,相应的 value 处于 unreachable 状态 [11]。所以 atomic trace 不会处理它们。到了后面的 sweep 阶段,因为它们处于 other-white 状态,会被回收。

接下来,atomic 步骤的 entersweep() 函数会把所有 collectable value 放入几个 sweep-list 链表。然后 sweep 阶段的几个步骤会遍历这些链表,回收其中的 dead value,把其中的 black value 变为 current-white 状态留待下个 GC 周期处理。因为 value 颜色状态的复杂逻辑已经在 trace 阶段处理完毕,sweep 阶段的逻辑比较简单,只需要注意一次回收的时间不能打断 OP_CODE 的运行太久。

内存分配

上面讨论的主要是内存的 trace 和 mark,简单的提了一下 sweep。还有一个方面没有涉及,就是在 GC 的每次运行片段之间 Lua OP_CODE 的执行所创建的新 value 如何影响 GC 的状态。上文只简单提了「新创建的 value 被标记为 current-white」。实际上,current-white value 总要有其它 value (称为「referrer」) 去引用它 (否则就让它一直 current-white 下去,直到 atomic 步骤变成 other-white 然后被 sweep 回收就好了) ,还可能随时增加新的 referrer。如果这些 referrer 是 current-white 或者 gray 状态,处理起来比较简单:只要等到 GC 去 trace 这些 referrer 就好。可对于 black 状态的 referrer,如果不做额外处理,GC 就不会再次 trace 它们,那么它们后来引用的 current-white value 就不能反应正确的 reachability 状态。

所以处于 black 状态的 referrer 和其它的 current-white value 建立新的引用关系时,涉及两种处理:

  • 如果该 referrer 引用其它 value 的关系比较复杂,Lua 虚拟机会调用 luaC_barrierback(),把这个 referrer 加回到 gray-list 中。
  • 如果该 referrer 引用其它 value 的关系比较简单,Lua 虚拟机会调用 luaC_barrier(),把新被引用的 current-white value 置为 gray 或者 black 状态。

这两个方法 ——  luaC_barrierback() 和 luaC_barrier() 是 Lua 虚拟机的其它部分和 GC 进行显式交互的主要接口。

此时还遗留了一个问题:local 变量和 thread。理论上来说,一个新创建 value 如果只赋给 local 变量,那么它是被当前 thread 通过 Lua stack 引用,也应调用上面的两个 barrier 函数之一进行处理。但前文说过 local 变量的生命周期不长,不值得每次给它们赋值时都把当前 thread 重新放回 gray-list 或者把赋值的 current-white value 立即置为 gray 状态。Lua GC 对这个问题的处理是给 thread 一个特殊的颜色状态:当 thread 从 gray-list 中被取出的时候,它被放到一个 gray-again-list 链表中,该链表在 atomic 步骤的 retraversegrays() 被放回 gray-list 由 atomic trace 处理。

由此可以看出 Lua GC 对临时变量的处理节省了 CPU,但是大大增加了它们在内存中存活的时间。这也是 Lua 在有大量临时变量的应用中占用内存比较多的原因。同时,过多的临时变量没有及时没识别出来,最后势必也会加重 sweep 阶段的 CPU 负担。所以 Lua 目前也在考虑如何利用和改进刚刚加入的 generational GC。

脚注:

  1. 后文说明。
  2. 从 Lua 5.2 开始,严格意义上 Lua 不再有全局变量。全局变量是 top-level chunk 的 upvalue _ENV 的属性。这个细节在后面讨论「pause 步骤」的时候会提到。
  3. Lua 的术语,相当于 Java 的 byte-code。
  4. 严格地说,这里的「临时变量」是指「只被临时变量引用的对象」。下文有多处描述类似。
  5. 本文把 mark-and-sweep GC 的两个大步骤 —— trace 和 sweep 称为「阶段」。把 Lua 的 incremental GC 实现的六个状态称为「步骤」。其中 Lua GC 的最后一个步骤也叫做 sweep,不要和「sweep 阶段」混淆。每个步骤并不是原子化执行,而是分成「片段」多次完成。
  6. 前文泛指 GC 讨论时用了「对象」这个名词。因为 Lua 不是单纯的面向对象语言,所以后文采用《Programming in Lua》中的术语:在 Lua 中,赋给变量的东西叫做「value」。Lua 的 value 有九种类型:nil, boolean, number, string, table, function, userdata, thread。其中 string, table, function, userdata, 和 thead 是 collectable value。
  7. Mark-and-sweep GC 中的 trace 阶段是 mark and propagate mark。其它类型的 root-tracing GC 的 trace 操作不一定如此,比如 copy-and-sweep GC 的 trace 阶段执行的是拷贝对象。
  8. __gc mark」是指给一个 value 设置 meta-table 时,后者就包含 __gc meta-method。给一个 value 已有的 meta-table 添加 __gc 方法并不是「__gc mark」,所加的方法也不会被 Lua GC 调用。
  9. 其它 meta-method 只要在相应的 meta-event 出现时存在,就会被调用。而 __gc 必须在设定 meta-table 时就存在才会被 GC 调用。
  10. 考虑到上文所说,只要 Lua Registry 中的 value 和 meta-table 被 trace 完毕,Lua GC 就会去 trace main-thread。这时只要 main-thread 的 upvalue 和 stack 上的 value 被置为 gray 状态,main-thread 就会完全变 black。
  11. 唯一引用它们的地方 —— stack,已经 shrink 回不再包含它们的状态。

Programming in Lua(六)-Continuation

2013/07/14

在之前的 blog 中 () 讨论了 Lua C APIs 的 continuation 概念。可以说 Lua continuation 的 VM 实现和 APIs 设计是「inevitable and perfect design」,一个支持 coroutine 的 embeded/extendable 语言就得这么设计。但是前几篇 blog 还没有完全解释 continuation 如何在整个 coroutine 机制中起作用。

图 6-1 是 Lua VM 运行时的 stack 的示例。Lua VM 是 stackless 设计,图中 Lua stack 和 C runtime stack (CRT stack) 并立,两者 stack frame 的对应关系已被标出。橙色部分表示  CRT stack 上一些 Lua VM 维护自身状态的函数,无法明确对应于 Lua stack 上的具体 frame。 此外还要注意几点:

  • Lua stack 是 dual-stack,stack frames 和 stack entries 分别存储在 struct lua_State 中的 CallInfo 链表和 TValue* 数组中。图上只画出了 Lua stack frames,而且没有画成链表形式,没有画出 Lua stack entries。但不妨碍讨论问题。
  • 为了简化图示,Lua VM 的一些次要函数没有在 CRT stack 上被画出,虽然在实际代码执行中它们会出现在 CRT stack 上。
  • 图中,「CallInfo (Lua)」表示 Lua 函数的 stack frame,「CallInfo (C)」表示 Lua 调用的 C 函数的 stack frame。不管有多少「CallInfo (Lua)」,只要它们在 Lua stack 上的位置是连续的,中间不存在「CallInfo (C)」,对应到 CRT stack 上都是一级 luaV_execute()。这就是 stackless 的意义。
  • C 函数本身的执行不论有多少层函数调用,Lua stack 上都只有一个「CallInfo (C)」。当然 C 函数可以通过 lua_*call* [1] 来间接地调用另一 C 函数,这时 Lua stack 上会出现相邻的多个「CallInfo (C)」。但本文不涉及这种情形。

接下来看看两个 stack 在运行中如何增长和恢复:

  • luaV_execute() 取到一条 OP_CALL 指令的时候,会调用 luaD_precall(),该函数调用 next_ci() 增长 Lua stack。如果被调用的是 Lua 函数,CRT stack 保持不变。
  • luaV_execute() 取到一条 OP_RETURN 指令的时候,会调用 luaD_postcall(),该函数会恢复调用前的 Lua stack。注意只有 Lua 函数才有 OP_RETURN 指令,C 函数只是简单返回并且恢复 CRT stack。
  • 如果 luaD_precall() 发现被调用的是 C 函数,它会调用该函数,并在其返回之后调用 luaD_postcall() 来恢复调用前的 Lua stack 状态。
  • 在 C 函数中通过 lua_*call* API 调用 Lua 函数时,lua_*call* 会间接地调用 luaD_precall(),该函数会调用 next_ci() 来增长 Lua stack。并且会调用 luaV_execute() 运行被调用的 Lua 函数。此时 CRT stack 上会出现多个 luaV_execute() frame。只有在两种情况下 CRT stack 上出现多级 luaV_execute() frame,这是一种情况,另一种情况是 coroutine,下面说明。除此之外,CRT stack 不会出现多个 luaV_execute() frame。
  • 如果一个 Lua 函数是被 C 函数直接调用的,它返回的时候执行它的 luaV_execute() 也会返回 [2]。这时作为 caller 的 C 函数会继续运行。

接下来考虑加入 coroutine 之后的情形。首先考虑只有 Lua 函数的情况 [3]。图 6-2 中,最初只有一个属于 main thread 的 Lua stack (在图的左下方)。在 main thread 中创建一个 coroutine,并且对其调用 coroutine.resume()coroutine.resume() 调用 luaV_execute(),这时 CRT stack 上有两层 luaV_execute(),分别对应 main thread 和 coroutine。由于最顶层 luaV_execute() 的参数是 coroutine 的 struct lua_State,「当前的」Lua stack 从 main thread 切换为 coroutine。注意,Lua 并没有显式的数据结构表示「当前的」thread,处于 CRT stack 顶端的 luaV_execute() 所对应的就是当前的 thread 和 Lua stack [4]。

在 coroutine 中发生 yield 时 Lua VM 会调用 longjmp() 回到原来 lua_resume() 执行的地方,导致 CRT stack 恢复为 resume 时的状态,从而恢复执行 main thread 对应的 luaV_execute()。如果不出现 C 函数调用 Lua 函数的情况,而且 yield 始终发生在 Lua 函数中,那么 coroutine 的切换就是简单的调用  luaV_execute() 和 longjmp()

现在考虑在 coroutine 中调用 C 函数。图 6-3 显示了在 coroutine 中 Lua 函数调用了一个 C 函数,后者又调用了 Lua 函数的情况。

如果此时调用 coroutine.yield()longjmp() 会恢复 CRT stack (灰色阴影部分被销毁),C 函数的 callstack 将丢失。如果 main thread 再次 resume coroutine 之后,stack 如图 6-4。

这时的问题在于如何处理 coroutine 中的「CallInfo (C)」。两个原因决定了这个 C 函数的状态无法恢复。第一,原来的 CRT stack frames 已经在 yield 过程中丢失;第二,此时的 luaV_execute() 是由 resume 调用的,而不是原来的 lua_*call*(),所以顶层 luaV_execute() 无法返回到 C 函数中正确的代码位置。

这时 continuation 开始发挥作用。上文提到过,两种情况会导致在 CRT stack 上出现多级 luaV_execute():一是从 C 函数中调用 Lua 函数, luaD_precall() 调用 luaV_execute();二是 resume。两者的不同之处在于,前者调用的 luaV_execute() 返回之后, luaD_precall() 在进行一些简单处理之后也会返回;而后者调用的 luaV_execute() 返回之后会调用 unroll() 函数进入一个循环 [5]。在循环中,会检查当前 Lua stack 顶端是否为「CallInfo (C)」,如果是的话,会调用这个 CallInfou.c.k field。这个 field 就是 lua_callk()/lua_pcallk() 接受的 continuation 参数 [6][7]。

图 6-5 表示 continuation 起作用的情形。和图 6-4 相比,coroutine Lua stack 顶端两个「CallInfo (Lua)」消失了,其对应的 Lua 函数已经返回。由于接下来的 Lua stack 顶端是「CallInfo (C)」,所以最顶层的 luaV_execute() 返回。然后 resume() 调用 unroll(),后者再调用 stack 顶端的 CallInfou.c.k。此时 coroutine 在 CRT stack 上暂时没有对应的 luaV_execute(),等到 continuation 函数执行结束返回后,unroll() 中的循环会再调用 luaV_execute() 运行 coroutine 中剩下的 Lua 代码。如图 6-6 所示。unroll() 中的循环会不断检查 Lua stack 中的 frames [8],相应的执行 continuation 函数或者调用 luaV_execute() 运行 Lua 函数。所以在 coroutine 中不论有多少层 C 到 Lua 函数的调用,只要每次都提供正确的 continuation,就可以保证正确的 coroutine 切换。

由此可见,continuation 是专门为 coroutine 设计的概念。运行在 main thread 中的代码从 C 函数中调用 Lua 函数不用提供 continuation。

脚注:

  1. 本文用 lua_*call*() 来表示 lua_call(), lua_pcall(), lua_callk(), lua_pcallk() 一族 APIs。
  2. 一个 .lua 文件被调入 VM 之后被视为一个 Lua 函数。
  3. Lua 程序在运行时会经常调用 C 函数,不过这些 C 函数往往会很快返回。所以通常在 yield 时 Lua stack 上不会有「CallInfo (C)」存在。
  4. 对于一个 Lua VM 来说,始终只有一个 CRT stack。
  5. 这个不同仅仅针对经过至少一次 yield 之后被重新 resume 的 coroutine。lua_Statestatus field 标示了一个 coroutine 是第一次被 resume 还是被 yield 之后重新 resume。
  6. 实际上,在 yield 时 Lua VM 会检查被破坏的 CRT stack 部分对应的所有「CallInfo (C)」是否拥有 continuation 函数,如果没有就会抛出 error。
  7. 本文讨论的 yield 发生在 Lua 函数中 (stack 上有 C 函数,但是不处于顶端)。如果 yield 发生在 C 函数中,情况类似,只不过使用的是 lua_yieldk() 而不是 lua_callk()/lua_pcallk()
  8. 当然 unroll() 不会到检查每一个 frame。luaV_execute() 的一次执行就会「吃掉」Lua stack 顶端所有连续的「CallInfo (Lua)」frame。

Lua vs. Python

2013/05/13

在《 Programming in Lua 》系列里谈了 Lua 的 stackless 实现。说到 stackless 设计,难免和 Python 的 stackful 实现比较一下。

以前总有一个疑惑。为什么 Python 既要采用 native thread,又要用 great-lock 将其变成实质上的协作式 thread。像 Lua 这样的 coroutine 不好么?现在知道了,非不为,不能也。既要尽量保证虚拟机的可移植性,又采用了非常依赖 CRT stack 的 stackful 设计,语言本身没有 synchronous primitive,不能应付真正的 preemptive 多线程。这种情况下,多线程加 big-lock 是唯一的折衷了。由此也知道了 Python 的 generator 为什么只允许在第一层函数中 yield,因为 stackful 设计不允许保存 call stack (说老实话,只允许在第一层函数中 yield 的 coroutine 不过是两个函数调来调去,在 C 里实现起来也不难)。Python 3.3 开始支持更宽松的 yields,不过实现的方式和 Lua 的 yields-in-C 差不多,作为基于虚拟机的语言是比较原始的手段。

拿 Lua 和 Python 做比较令人恍惚感觉正在比较 Objective-C 和 C++。Lua/Python 和 Objective-C/C++ 都是在共同基础上发展出来:后者扩展 C 语言;前者用 C 语言实现基于 byte-code 的虚拟机。它们都有理想的「标杆」:Objective-C/C++ 的标杆是 Smalltalk/Simula 等面向对象语言先驱;Lua/Python 是 Lisp 这样的高级动态语言先驱。努力的方向都是降低「标杆」过大的性能开销和简化「标杆」过于复杂 (或者过于精简) 的概念。Python 和 C++ 相对较早的尝试,都采用了比较低级的机制:C++ 用函数指针模拟成员函数;Python 依赖 CRT stack 直接实现 byte-code stack。这些「第一次」都没能「do things right」。后来的第二次尝试才作出了更妥当的取舍。

在《 The Art of UNIX Programming 》里指出了系统设计的「第二系统综合症 (second-system effect)」。乔布斯也提到过「第二个产品」的问题。在一个成功的系统上衍生的第二个系统有时会因为没有理解第一个系统成功的真正原因而失败。但是,如果还有机会的话,由此衍生的「第三系统」往往会做得更好。对于上面所说的语言发展来说,它们的基础 (C 语言) 和「标杆」是「第一系统」,第一次改进的尝试毁誉参半,而后来的「第三系统」更加出色。

Programming in Lua(五)- Coroutine, Lua Stack

2013/05/09

在《 Programming in Lua(三)- Yields in C 》里讨论了 Lua 虚拟机对 yields-in-C 及其 stack 的处理。当时还未读 Lua 虚拟机的实际代码,只根据语言的行为来推测,有些术语也不符合通常用法。最近从 Lua stack 的实现入手,发现了一些以前没想过的问题:为什么 resumes-in-C 从来不是问题?为什么有 lua_yieldk() 而没有对应的 lua_resumek()

首先从术语的标准化说起。《 Programming in Lua(三)- Yields in C 》里有多处这样的描述:

  • 「stack 上 ⋯⋯ 的执行层次」;
  • 「virtual stack 上的 Lua 部分的 stack」;
  • 「Lua stack 段」。

其中「执行层次」、「部分」、「段」这样的字眼应该替换为「stack frame」这个更常用的术语。线程运行时,stack 呈现两层意义。一是后入先出的简单线性结构;二是把此线性结构划分成与函数调用层次一一对应的若干段,这样的一段就被称为一个 stack frame。大多数语言的 runtime 或虚拟机中,stack frame 并无单独的数据结构表示。在 64-bit x86 的 C runtime (CRT) 中,每个 stack frame 的首项是上一层 stack frame 的最低地址 (base),称为 stored frame pointer (SFP),最顶层 stack frame base 存储在 %ebp 寄存器中 。即每次生成新的 stack frame 时,首先将 %ebp 寄存器入栈形成 SFP,然后把当前的 %esp 赋给 %ebp。通过这种方式让需要解析 stack frame 的程序 (比如 debugger) 得到所需信息。(SFP 并非一定存在,臭名昭著的 omit-frame-pointer 编译器优化会去掉 SFP,这时 debugger 只能借助额外存储的 symbols 来解析 stack frame。)

就需求本身来说,Lua stack 要解决的问题比 C 复杂的多,甚至比同为动态语言的 Python 更复杂。基于虚拟机的语言的 call stack 有两种可能的设计:一是借用虚拟机本身的 CRT stack。Byte-code 的函数调用指令对应虚拟机本身 native 代码的函数调用,虚拟机的 CRT stack 随 byte-code 函数调用的层次增加而增长。二是由虚拟机维护额外的 call stack 数据结构。Byte-code 的函数调用指令和其它指令一样,在虚拟机的同一个循环中完成,虚拟机的 CRT stack 不体现 byte-code 函数的调用层次。后者通常被称为 stackless 方案,前者暂且对应称为 stackful 方案。

Lua 是 embedded/extension 语言,byte code 的运行总会夹杂 C 函数。这些 C 函数的 call stack 在逻辑上是 byte-code 运行状态的一部分,实际上则间杂在 Lua 虚拟机的 CRT stack 中 (在涉及 Lua 的情况下讨论 CRT stack 时,要始终说明是虚拟机的 CRT stack 还是 C 函数的 call stack)。从这个角度来说,embedded/extension 语言更倾向于选择 stackful 设计。但 stackful 设计的固有缺陷在于 stack 结构是平台相关的,很难用跨平台的方式实现诸多功能,比如协作式多任务 (cooperative multi-threading),跟踪垃圾回收 (tracing-GC),lexical closure。尽管不是全部原因,Python 缺少诸多高级特性与其 stackful 实现有很大关系。

为了遵守 ANSI C 的跨平台性和更好的实现高级动态功能,Lua 采用了 stackless 实现。这给处理 C 代码的 call stack 带来了一些挑战。Lua 的 stack 存储在 struct lua_Statestack field 中,是一个 TValue* 的数组。其内容包括:

  • 函数指针。Proto* (Lua 函数) 或者 lua_CFunction (C 函数)。注意函数指针不是函数的返回地址。
  • 函数的参数和返回值。包括 Lua 和 C 函数之间传递的参数和返回值。
  • Lua 函数的局部变量。

在这个 stack 上缺少一些属于 call stack 的东西:

  • C 代码本身的 call stack。
  • 函数的返回地址。
  • Stack frame 信息,类似 SFP。

这是因为 Lua 采用了双 stack 结构。对应的 stack frame 信息存储在一个 struct CallInfo 链表中,每个节点对应一个 stack frame,它对 TValue* 数组 stack 的描述如下:

  • Field func 表示 stack frame 在 TValue* 数组上的起始位置 (之所以用 func 作为 field 名称是因为在 TValue* 数组上这个位置永远是函数指针),field top 表示结束位置。
  • Field union u 存储和函数类型相关的信息。Lua 函数信息存储在 u.l 中,C 函数在 u.c 中。
  • u.l.savedpc 表示函数的返回地址。这个值仅当 Lua 函数作为 caller 的情况有效。C 函数作为 caller 时,返回地址在 CRT stack 中。
  • 当 C 函数中发生 yield 时,CRT stack 被破坏,该 coroutine 下次被 resume 的执行地址由 u.c.k 来承担。详见《 Programming in Lua(三)- Yields in C 》。

这里值得多说一句,为什么在 C 函数中执行 yield 会破坏 CRT stack?上文说过,Lua 的设计主要是 stackless 方式,其具体实现是通过 luaV_execute() 中的循环执行 byte code,通过额外数据结构 (其实是双数据结构) 而非 CRT stack 来维护 call stack。但在 resume coroutine 时,luaV_execute() 间接地递归调用自己并在 callee 的循环中执行 resumed coroutine。也就是说由 CRT stack 来维护 coroutine 上下文切换。Yields 的机制是 longjmp 回到 luaV_execute() 函数递归调用自身的下一条指令 (虚拟机的 native 指令而非 byte-code 指令),同时把 CRT stack 恢复到 resume 前的状态。所以 yields-in-C 会破坏 C 函数的 call stack。

尽管 coroutine 涉及了对 CRT stack 的操作,但是和 error 一样,仅限于 ANSI C 支持的 longjmp,不会破坏 Lua 虚拟机的跨平台性。问题是,为什么 Lua 要在总体的 stackless 设计中制造这个 stackful 例外?首先退一步说,即使采用 stackless 方式实现 coroutine 切换,仅仅能避免在 yields-in-byte-code 中使用 longjmp,仍然无法避免在 yields-in-C 中使用 longjmp。这是因为,虽然不再有必要 longjmp 回到最近一次 resume 之处,但是仍然需要从 yield 之处回到最近的 Lua 虚拟机代码。不仅如此,stackless 方式还要给 resumes-in-C 引入类似的 longjmp (因为不再利用 CRT stack,所以 resumes-in-C 也必须立即回到 Lua 虚拟机代码),破坏调用 resume 的 C 函数的 call stack,给 resumes-in-C 加上同现在的 yields-in-C 一样的局限性。而现在的 stackful 方法则完全没有这方面的问题。这正是无需 lua_resumek() 的原因。Stackful coroutine 是一个非常巧妙的设计。

Programming in Lua(四)- Nil 和 List

2012/12/22

粗浅地看,Lua 的 nil 很容易被等同于「无」。如下面这段代码:

function r_nil()
    return nil
end

function r()
    return
end

a = r_nil()
b = r()

print(a .. ", " .. b)  -->  nil, nil

尽管函数 r_nil()r() 的返回语句分别带有和不带有 nil,接受它们返回值的变量 ab 的值都是 nil。另一个例子是 nil 对 table 的作用。

tab_v = { attr1 = 1, attr2 = 2 }
for k,v in pairs(tab_v) do
    print(k .. ", " .. v)
end  -->  attr1, 1
     -->  attr2, 2

tab_v.attr1 = nil
for k,v in pairs(tab_v) do
    print(k .. ", " .. v)
end  -->  attr2, 2

将 table 的一个 field 赋值为 nil 不仅仅改变其值,而是让这个 field 本身消失了 (这个例子中是 field attr1)。

分析 nil 的实际含义可以从 Lua 的另一个比较特殊的概念 —— list 入手。List 的特殊性在于它不是 first-class 类型。「First-class」是动态语言中常被提及的概念。编程语言有越多的构成元素符合 first-class 标准,其概念模型就越一致、越简单。Lua 的基本数据类型 (包括 nil) 和函数都符合 first-class。满足 first-class 标准通常有四个要求:

  • 可以被赋值给变量;
  • 可以作为参数;
  • 可以作为返回值;
  • 可以作为数据结构的构成部分。( 注意 nil 并不完全符合这个要求,但是可以通过某个 field 的缺失来表示 nil。)

在《Programming in Lua, 2ed》的第 5.1 节提到 list 只能用于四种情形:

These lists appear in four constructions in Lua: multiple assignments, arguments to function calls, table constructors, and return statements.

List 有两种具体的表现形式,一种是用逗号分割的一组表达式,表示一个具体长度的 list;另一种是三个点构成的省略号 (...),表示其长度和内容不定。第二种表示方式不能用在 multiple assignments 的等号左方,也不能创建新 list,只能从函数的形式参数列表中获得。由此可以看出,list 不符合 first-class 标准:

  • 它的部分内容可以赋给几个变量,但本身不能作为整体赋给变量;
  • 它是参数列表的全部或一部分,但不是任何参数 (注意两者的区别);
  • 它不能作为数据结构的构成部分。注意,「...」不能作为 closure 的 upvalue。用 first-class function 存储 list 的方式行不通。

作为非 first-class 类型,list 无法被生命周期较长的数据结构存储。短期的完整传递 list 的内容只能利用函数调用/返回的方式:

function f_outer(...) -- important: f_out() must accept
                      -- "...".
end

f_outer(f_inner())
-- pass the list returned by f_inner() to
-- f_outer() as the latter's argument list

f_outer(1, 2, f_inner())
-- pass f_outer() a new list, which is "1, 2" appended
-- by f_inner()'s returned list

function f_caller(...)
   f_callee(...) -- pass argument list of f_caller()
                 -- to f_callee()

   return f()    -- pass list returned by f() to one
                 -- level up
end

另外还有一些反例:

function f_caller(...)
   a, b = ...   -- not pass a list, "a, b" is
                -- a different list of two elements
                -- obtained by adjusting the "...",
                -- and this list is very short-live,
                -- existing in this line only

   tab = {...}  -- not pass a list, tab won't
                -- have fields for nils in the "..."

   local function test(a, b)
   end
   test(...)    -- not pass a list, test()'s
                -- argument list accepts only the first
                -- two items of "..."

   for i in ... do  -- the "for" uses only the first
                    -- three elements in "..."
                    -- (two accepted by for internally,
                    -- and one received by i)
   end
end

List 会成为 Lua 中为数不多的非 first-class 类型是因为它实际代表了 stack 上的一段数据。一般只有动态分配的数据能作为 first-class 类型,操纵 stack 上的数据则只能通过函数调用的参数和返回值等有限的方式进行 (这也是因为 stack 在一定程度上代表了程序的 continuation)。不过在其它语言中,stack 的内容并没有被抽象为类似 list 这样可以被操作 (尽管不能像 first-class 类型那样自由地操作) 的概念。因为 Lua 提供了多返回值,鼓励可变参数以及参数/返回值和 table 的互相转化,特别是它著名的 C 接口就以 stack 为中心来设计,所以它有了独特的 list 概念来操作 stack。

如果在 Lua 中一定要将 list 和 first-class 混用怎么办呢?比如说,一个函数返回的 list 通常还是要存储在变量中,或者应用在某个表达式中。这是上面的反例代码中已经提及的机制 —— adjustment。Adjustment 并不是真的传递一个 list 的内容,而是用一个 list 的内容构建另一个新的 list。当新 list 的长度小于原 list,多余的值被丢弃,当新 list 长度大于原 list,就用 nil 补齐。

Lua 的 nil 担当了三种角色:

  • 一般的数据类型,通常标志某种特殊情况 (应用或算法本身的特殊情况,而非语言的特殊情况)。
  • Table field 的删除器。
  • List adjustment 的补全值。

Lua 的 nil 不代表「无」,反而恰恰起到了「有」的作用。在应用 adjustment 的情况下,我们往往用新 list 末尾的 nil 来判断原 list 的「无」。这个做法有一个缺陷:无法辩别原 list 末尾本来就确实含有的 nil。如果需要区别对待 list 结束和 list 本身含有 nil 这两种情况,既可以自行编写 C 代码来检测 stack,也可以使用 Lua 现成的 API select()。回到第一个例子,稍加修改就可以区别两种情况:

function r_nil()
    return nil
end

function r()
    return
end

a = select("#", r_nil())
b = select("#", r())

print(a .. ", " .. b)  -->  1, 0

下面是精确区别 list 结束的一个实际例子 —— 关于 stack 的递归终止条件。若希望一个函数对它的 argument list 中的每个参数执行 op() 操作:

function map_list(op, ...)
    if select("#", ...) == 0 then
        return
    else
        local a = ...
        return op(a), map_list(op,
                               sub_list(...))
    end
end

函数 sub_list() 返回的 list 是其接受的 argument list 去掉第一个元素。这个函数的实现如下 (如果用 C 语言来实现会更简单)。如果 op() 允许接受 nil 并且在此情况下返回有意义的值,或者 map_list() 接受的 list 在中间含有 nil,那么 map_list() 的递归终止条件就必须基于 select() 而不可以基于对 nil 的判断。

function sub_list(...)
    local list_start
    function list_start(start, ...)
        if start > select("#", ...) then
            return
        else
            return select(start, ...),
                   list_start(start + 1, ...)
        end
    end
    return list_start(2, ...)
end

List 是 Lua 中最不符合 first-class 的数据类型。但由于其不能作为变量但可以被函数的返回值构建的特性,List 反而可能是 Lua 中最纯粹的 functional programming  元素。放弃 table 而完全用 list 来编写 Lua 程序也许是把 Lua 转化为一种 FP 语言最简单的手段。

Programming in Lua(三)- Yields in C

2012/11/21

Handling Yields in C 是 Lua 5.2 的重大改进之一,最早从 blog《Lua 5.2 如何实现 C 调用中的 Continuation》了解到。这些资料围绕新 API lua_yieldklua_callk,和 lua_pcallk 来介绍这个新特性,自然有很多关于新增加的 continuation 参数的讨论。其实以 continuation 参数作为切入点介绍 yields-in-C 容易混淆问题的实质。首先回顾一下《Programming in Lua, 2ed》(中文版) 中的一段话 (第 30.1 章):

The only way for a C function to yield is when returning, so that it actually does not suspend itself, but its caller — which should be a Lua function.

这段话针对 Lua 5.1 而写,当时尚无 continuation 参数。严格地说这会误导读者。根据描述本身,可以理解为 Lua 无法在 C 代码中 yield (包括被 C 代码再次调用的第二层 Lua 代码以及之后的 stack 上更深的执行层次) 是因为无法纪录这种情况下 resume 所需的信息 —— C 代码的 stack 和 program counter。这种解释的推论是,在 C 代码即将返回 Lua 前,由于 C stack 已经恢复为调用前的状态 (可以称为「空 stack」),program counter 也处于即将进入 Lua 代码的状态,所以可以调用 lua_yield。原理上这个结论可以推广到 lua_call/lua_pcall。如果程序在 Lua 和 C  代码之间调用切换多次,整个 virtual stack 上的 Lua 部分的 stack 会被 C 代码分割成若干段。不过只要这三个 API 总是在 C 代码即将返回 Lua 前被调用,那么这些 C stack 都是空 stack,Lua VM 只需知道 C 代码在 Lua stack 段间的位置,不需要实际纪录 C stack/program counter 本身的内容。「在多于一层 C/Lua 切换的情形下 yield」应该正常工作。

问题是 Lua 5.1 不支持「在多于一层 C/Lua 切换的情形下 yield」!

根据上面的分析,这个限制并非 Lua 语言或 C API 本身的设计所固有,它是一个纯粹的 VM 实现问题。也就是说,即便 Lua 在 5.1 之后不引入 continuation 参数,保留「lua_yield (以及 lua_call/lua_pcall) 只能在即将返回到 Lua 之前调用」这个限制,也还是可以支持从 C 或者从第二层及以上的 Lua 代码中 yield。

Lua 5.2 实现了「在多于一层 C/Lua 切换的情形下 yield」,这是一个 VM 内部改进,仅仅为此并无必要引入 continuation 参数。 Continuation 参数解决的是另一个问题 ——「Lua 无法跟踪程序在 C 代码中的 stack 和 program counter」,但仍保留诸多限制:首先,它无法解决纪录 C stack 的问题,所以,仍然不允许在 C stack 上有多于一层 C 函数时调用新 API;其次,它也无法纪录 program counter,编写 C 代码时必须手工把可能发生 yield 之后的 C 代码 factor 到一个单独的 C 函数中,通过函数分割这种变通方式部分的模拟 yield 时的 program counter。由于没有真正的管理 C stack,充当 continuation 参数的 C 函数在运行中不能依赖 caller 的 C stack (实际上这个问题不大,因为它只能接受一个 lua_State 结构)。最后,仿照某些评测给 Lua 5.2 的新特性做一个「优雅 / 有待改进 / 丑陋」的总结:

优雅

实现了「在多于一层 C/Lua 切换的情形下 yield」。对于「Lua 无法跟踪程序在 C 代码中的 stack 和 program counter」这个问题的剪裁得当,既扩大了支持的应用场景,放松了对 C 代码的限制。同时避免了编程接口过分复杂化,和使用底层 C runtime 机制破坏 VM 的跨平台性。

有待改进

文档没有分别说明两个问题,混淆了 VM 内部实现的改进和 API 改变的原因。

丑陋

新 API 在 continuation 参数为 NULL 时沿袭旧 API 的限制 —— 禁止在多于一层 C/Lua 切换的情形下 yield。这是不必要的,也是混淆两个独立问题的误解最大的来源。现在,对于那些已经在「即将返回 Lua 之前」被调用的 lua_yieldk/lua_callk/lua_pcalk,也必须传入一个 no-op 的 continuation 函数。不过,Lua 5.2 的发布已经有段时日,估计这个 API 上的小问题也不会再未来更改了。

Programming in Lua(二)- 异常与错误码

2012/11/15

我不喜欢编程语言用「异常处理 (exception handling) 」的方式处理错误。从以往经历看,先有 C++ 创造了最差的异常实现 —— 没有 GC 帮助内存管理,扰乱 C 的二进制接口 (Application Binary Interface, ABI)。为了绕过这个拖累,维护 C++ 代码往往要花费双重开销来完成没有异常的语言可以免费获得的东西:code review 必须保证代码的「异常安全 (exception-safty)」[1],同时不写会抛出异常的新代码。

Java 提供了 GC,解决了安全实现异常处理最大的的先决条件。不过凡事皆 checked-exception 的方式令人毫无好感 [2]。Objective-C/Cocoa 中没有跨越 block 的异常机制,基本上采取传统的返回错误码方式,让我舒了一口气。但是接下来,Lua 通过 longjmp 实现跨函数的类似异常处理。一方面,让我怀疑 Lua 以简洁著称的设计是否在这点上采取了错误方式;另一方面,longjmp 并未实际引起麻烦,让我好奇异常处理是否也有某些价值。

异常处理和传统的返回错误码 (error code) 两种处理错误的方式是一场持久的争论。就在最近,围绕 Go 语言完全抛弃异常处理的语言特性,《Why I’m not leaving Python for Go》的作者表了极大失望。

Russ Cox 针对上文为 Go 语言进行了辩护。其中提到了 Raymond Chen 两篇旧日的 blog:

Raymond Chen 用严密的逻辑和实例说明了编写正确异常处理的代码 [3] 非常非常困难。特别要注意 (但不限于) 以下两点:

  • 正确管理资源,特别是资源的回收;
  • 关键数据的修改尽可能滞后。在中间可能抛出异常的步骤中,随时保证数据处于一致 (integral) 的合法状态。

关注第一点也许会令人假定,如果程序不涉及内存以外的资源,并有成熟的内存管理机制,就足以保证写出正确的异常处理代码。毕竟把异常处理放到 feature list 中的语言无不首先重视提供 GC 机制。由于需要根据异常的 stack unwinding 情形考虑内存回收,这些语言一般采用 root-tracing GC 而非 ref-counting [4]。但是,将资源管理局限于内存并不足以对第二条豁免,比如复杂的图结构 (graph structure),或者更常见的情形:对象需要向多个相互关联的全局表注册自身的引用。而且话说回来,「纯」内存数据操作除了内存用尽 (out of memory) 之外又有什么值得担忧的错误需要处理呢?归根结底异常处理是一个主要面向 I/O 问题的机制。

在「纯」内存无 I/O 的环境下,能体现异常处理价值的领域并不多,仅存的适用领域之一是语言虚拟机。这正是 Lua 采用 longjmp 类似异常处理的原因,主要用于类型检查和数组越界等语言虚拟机问题。而且这时处理的错误往往不是最终产品代码 (production code) 期待的行为,并不真正用来弥补错误,只是起一些辅助作用,比如揭示 bug 和收集诊断信息,防止应用完全退出,在多文档应用中让用户有机会保存其它信息,或者让应用以 reset 之后的状态接受其它请求。类似于 Go 中的 panic 机制和 Java 中的 runtime-exception (unchecked excpetion)。

GC 虽然是实现安全的异常处理机制的先决条件之一,但只是朝向最终解决问题的很小一步。因为真正能体现异常处理价值的地方是 I/O 密集程序。有哪些 I/O 机制目前可以做到「关键数据的修改尽可能滞后。在中间可能抛出异常的步骤中,随时保证数据处于一致的合法状态」呢?作为 naive 的尝试,C++ 提出了 RAII。但是很遗憾,异常安全的需求明显超出了 RAII 的能力。除了关系型数据库事务处理 (RDBMS transaction) 的二步式提交 (two-phase commit),我不知道还有什么 I/O 机制满足这个要求。也就是说,在日常需要的软件工具中包括图形化窗口化 UI,网络,打印等等常见 I/O 操作中,只有纯粹的数据库 CRUD 系统这个特殊领域适于异常处理机制。正因为如此,非数据库的 I/O API 的错误处理都应该采取返回错误码形式。特别是,以异常处理文件访问错误的 API 都是失败的设计 [5]。Java 正是被鼓吹适合数据库 CRUD 领域,所以其异常处理机制获得了一些正面评价。但是当其野心不限于此时,将仅限于数据库领域用的还不错的异常处理机制匆忙的推广到其它问题就招致了恶名。

某些系统通过异常处理或者类似异常处理的机制来解决某些问题,而且解决得还不错。这是它们的设计者针对一些能体现异常处理价值的特定领域选择的方案。这些成功案例并不能简单地推广。保守地说,要采用异常处理,必须保证所有资源置于二步式提交的事务管理之下;或者限于虚拟机内部对类型检查等非 I/O 操作的「粗粒度」错误处理。「粗粒度」表示一旦发生错误,系统采取的应对策略是放弃整个粒度较大的操作,异常处理仅仅保证程序不退出,收集 bug 诊断信息,或者保留机会处理其它请求,而不是去弥补刚发生的错误。特别是对于 Lua,这个问题还有一层含义。Lua 允许用 C 编写扩展。这种情况下要把基于 longjmp 的异常处理部分限于开始的参数类型检查,置于触及关键数据和 I/O 操作之前,一旦 C 代码涉及了实质的数据和 I/O 操作,错误处理方式就必须变为返回错误码机制。Lua 支持多返回值特性正是为返回错误码方式的应用提供便利。显然,Lua 的可扩展性也是其基于 longjmp 的机制彰显天下的原因,对于 Java 来说,虚拟机内部的具体实现和使用它的程序员是毫不相关的。

脚注:

  1. C++ 中所谓的「异常安全」也不过就是尽量使用 on-stack 对象 (以及基于 on-stack 对象的「智能」指针) 和 RAII (下文还有涉及) 而已。
  2. 错误处理有经常被人混淆的两个方面。一是如何保证程序员不忽略可能的错误;二是在程序员意识到可能的错误时,如何编写正确的处理代码。本文只讨论第二个方面。因为,如何「不忽略可能的错误」属于程序员掌控应用逻辑的问题,已经超出了编程语言的能力。Java 的 checked-exception 试图用语言解决这个问题,但是即便是 checked-exception,也允许程序员相当容易的把异常遗留给上层 caller。其结果是,越多的错误集中在一处处理,而且远离错误发生的地点,这段异常处理代码的正确性就越难保证 (或者这段代码除了 crash/quit 无法做任何其它有意义的工作)。也许,这正是没有任何其它语言借鉴 checked-exception 机制的原因。
  3. 注意这里的「异常处理的代码」指程序员用具备异常处理机制的语言编写处理实际错误的代码,不要和异常机制本身的实现混淆。
  4. Objective-C/Cocoa 舍弃异常处理的可能原因之一。另一方面,如果在 stack unwinding 时进行特定的处理,也可以用 ref-counting GC 配合异常。比如 C++ 调用 destructor 以及由此衍生的「智能」指针,还有 Python 的机制。但是我不喜欢这种将 unwinding 复杂化的机制。
  5. 导致每行一个 try-catch block。

Programming in Lua(一)

2012/10/21

最近没有腾出精力写 blog,不过在软件方面投入的时间并不少。决定从头到尾读完《 Programming in Lua, 2ed 》的计划目前进展平稳,没有像前几次那样半途而废。也许是开始时下决心做的投资起了些作用 —— 买了这本书的多格式电子版并将 mobi 格式放到 kindle 上。最初手中有这本书的一个来路不明的 PDF 版,再下决心买另一种格式,而且价格并不便宜,不管怎么说也感觉有些浪费。不过总归这次的进展比之前的几次成功。

Kindle 的笔记功能其实很烂,特别是既无键盘又不支持 touch 的 kindle 4。但是关键在于笔记和进度都能在一个地方集中管理,减轻了负担,至少从心理上来说是个自以为减轻了管理负担的积极暗示。当然,最初不能善用 PDF viewer 的笔记功能是我的不足,不过作为一个主要用 kindle 阅读的人,多用一个系统来管理信息看来是很大的精神负担。

另一个问题是,写了十多年软件之后,很难静下心从头开始一页页读一门新语言的基础介绍材料。总想找点捷径。这样的捷径并非绝对不存在。学 C 时找到的那些基础介绍书籍都很失败,读了几页《 C Programming Luangue 》,发现前言说了不少 Unix 就没心情看下去(毕竟是 93 年,连 Windows 3.1 还没见过)。后来找到《 C 语言常见错误 》(当年没有授权的译本,今天国内出版已经不是这个名字,不过很值得怀念)。本来是有经验的 C 程序员交流使用的资料,不过拿来给有点 Basic 知识的高中生似乎也能用来从头学习 C。或者,学 Lisp 语言可以用《 Structure and Interpretation of Computer Programming 》这样夹叙夹议的书籍。所以有点小聪明的人希望了解不熟悉的技术时,总是期望能有这种即非专为初学者,又不需要太多预先基础的东西。Lua 的经典教材显然只有这本。不过《 Programming in Lua, 2ed 》这本书其实有很高信息密度和文字质量,不止是一本介绍基础语言的书。

阅读开始前碰巧读到小说《万里任禅游》里的一句话:

When you want to hurry something, that means you no longer care about it and want to get on to other things.

所以这次决定把学习 Lua 这件事搞得舒缓一些。在新的设备,新的心态,以及儿子的成长中看这本书,倒是找回一些当年本科通信专业精疲力尽的期末复习中看那些「不相干」的计算机书籍的乐趣。

技术与文化

2012/02/07

我写了两篇《为什么 Mac OS X 先进》(  ) 。主要讨论什么样的文化经历了什么样的历史如何沉淀到技术中去。不过,文化有时候就保持为文化,不是所有的文化都有机会或者有必要沉淀为某种具体的技术。

前不久有一阵关于 Android 和 iOS 用户体验差异的讨论,始于《Why is Adnroid laggy, while iOS, Windows Phone 7, QNX, and WebOS are fluid》。这篇文章的作者认为原因在于两个操作系统调度线程 (thread scheduling) 的方式不同,在 iOS 上 UI 由一个有 realtime 优先级的专用线程处理,而在 Android 上只由普通线程处理。但是马上有人指出其实 iOS 和 Android 线程调度方式的差异并无文中猜测的那么巨大。

文章的作者承认自己的结论属于「猜测」。他说明自己并无特权接触iOS 底层(如果真有「特权」接触,也就不太可能被允许讨论),只是根据观察两个操作系统的外部行为大胆推测。他的观察是准确的,iOS 上大多数 app 界面渲染的优先级高于其它任务的优先级的程度,要比 Android 上类似的差异大。不过我推测这种最终处理效果的优先级差异并非源自操作系统的线程调度机制。

第一,虽然大多数 app 需要对用户的操作作出及时反应,但并不要求这种反应很快地提供最终计算结果。所以很多计算并不要求在后台和 UI 渲染同步进行(或者说,在单核系统上,和 UI 渲染以 preemptive 的方式分时进行),相反,只是要求 UI 渲染能比较快地打断计算即可。这种「打断」一般是利用 timer 等协作式多任务机制来完成。因为相对操作系统本身的 preemptive 多任务,协作式多任务对数据结构一致性的处理更为简单。此类多任务并不受操作系统的调度,而是 timer 本身的实现和 timer 的调度库(比如 Cocoa,Cocoa Touch)负责的。其中又以前者的影响为大。

第二,现代操作系统的线程调度一般基于动态反馈优先级 (dynamic feedback priority) 。在这种策略中,如果一个线程总是用尽分配给自己的时间 (time slice) 而必须由操作系统强制收回 CPU,它的优先级就会降低。而一个线程如果总是由于等待鼠标、键盘、或者磁盘主动让出 CPU,它的优先级就会提升。这种策略只考虑收回 CPU 的方式,而不考虑线程本身运行和休眠 (sleep) 的历史情况。在此基础上,有些操作系统,如 FreeBSD 和 Linux,会统计线程运行和休眠状况的历史,并且根据二者的关系推测该线程是否为 interactive 类型,即上文的 UI 线程。不过这种基于统计的推测做不到 100% 准确。Interactive 线程从休眠状态恢复到可运行状态时,会放入运行队列直接等待 CPU 调度。而 non-interactive 线程从休眠状态恢复到可运行状态时,会放到比运行队列低一级的「next 队列」。CPU 调度线程时不会考虑 next 队列,只有当运行队列清空之后(其中线程都已经调度过,并且其间没有 interactive 线程被重新唤醒),next 队列和运行队列的角色才会对调。值得注意的是 OS X 的 XNU kernel 中的 Mach scheduler 并没有采用这种「运行/next」队列的概念,也没有 interactive/non-interactive 类型,它只为每个 CPU core 创建和维护唯一的基于动态反馈优先级的运行队列。我猜测同样基于 Mach 的 iOS 和基于 Linux kernel 的 Android,其 scheduler 也与各自的源头基本相同。所以,现代操作系统中为提高用户体验的最重大的努力之一并没有应用到 OS X / iOS 中,而结果是似乎后者的用户体验并未受到什么伤害。

在 time-sharing 系统中,虽然 scheduler 是一个关键角色,但是因为它没有上层应用的 knowledge,scheduler 根据统计 (statistic) 和启发式 (heuristic) 算法所作的优化并不如上层应用根据自身逻辑进行的优化效果更明显。OS X 和 iOS 上 app 顺畅的操作感来自于 app 开发者本身对高质量界面文化的认同,而不是操作系统提供的「免费午餐」。OS X 没有采纳「运行/next」队列是不完美的,我希望这点最终能改变,不过目前看来其它操作系统和 OS X / iOS 在质量文化方面的差距差距抵消了后者技术上的不完美,亦或是,把有限的资源用在提高 app 质量本身而非效果甚微的底层方案才是正确的。

来自代码

2011/01/18

舍弃旧代码是程序员经常面对的一种诱惑。不必说维护旧系统的人经常要面对,就是开发新功能新产品的人也时常如此 —— 因为你经常被建议要借鉴一个老产品的 code base ,而你本能地想拒绝而重新打造一个干净的系统。幸运的是,很早以前我已经开始相信舍弃这些东西是不对的,不应该重写任何不必重写的东西。当几次看到那些险些被舍弃的旧东西节省了大量工作之后,我更是坚信对那种看起来又老又旧的东西,应该留出一个星期的时间来仔细研究它是否有用。这一个星期可能会节省一个月的工作。

这个原则后来读到的很多文章里得到印证。但是我记得开始编程的时候完全不是这么想的。最近一个星期我仔细回顾了一下这个转变过程。

2005 年开始看 Linux kernel 的代码。我当时有一种稍稍自卑的感觉,感慨 Linux kernel 的代码为什么能把一切情况都考虑周全。比如一个 40 行左右的函数,让我来写最多只能写出十几行,而差的那 30 行处理的是很重要又不容易考虑到的情况。

当时看代码是用《 Understand the Linux Kernel, 3rd Edition 》做主线。书上讲的是 2.6.x 版本( x 是一个远小于 11 的数字)。而我参照的源代码是从网上下载的最新稳定版。从 2.6.1x 一直到 2.6.2x 更新了很多次。其间越来越多的看到书上的代码和最新代码的差异。跟随这些差异我开始阅读不同版本的 release notes ,以及针对特定修改的 mail list 讨论和 patch 注释。最后我意识到,书上的版本和最新版本之间的差异,往往就类似于上面提到的我能写出的十几行和实际的 40 多行的差异。为什么能把一切情况都考虑到?答案就是持之不懈地 Fix Bug 。

回想起来,尽管不够清晰,但这应该就是『绝不重写无必要重写的代码』这个想法产生的过程。没有人能把事情一次做对。旧的代码有很多错误,但是抛弃它们只是让已经犯过的错误有机会被重犯一次,加上新的错误。

另一方面,那些最后形成的代码,如果不比对《 Understanding the Linux Kernel, 3rd Edition 》上的旧版代码,似乎像是一次写就的。这是我最初的自卑的来源,也是很多初学者对编写代码的错觉。人们其实并不像最后结果体现的那样能一次就把事情做对,但是我们对错误的修正也没有必要直接体现错误本身。在循序渐进的过程中要把结果造就成一次就做对的面貌。如果你真的想回忆每个与之战斗过的错误,version control 才是怀念历史的地方。

已经有三年多没有碰过 Linux kernel 代码了。回顾起来,阅读它的代码让很多正确的想法第一次被植入到我头脑中。对于程序员来说,更多正确的有用的东西还是来自代码。