Archive for 2012年11月

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。