近期功课

2012/01/18

最近逛书店(Amazon.com,China-pub.com 还有西单图书大厦)收获不少。想买的和没想到要买的都有了:

  • 《罗马帝国衰亡史》,商务印书馆的经典版式。适合完全没有网络和电脑的时候看。不过有些功课还得通过 Wikipedia 补上。
  • 《The Design and Implementation of the FreeBSD Operating System》,想看看 kernel 的东西,不过没有当年 dig Linux kernel source 那股劲头了。浮光掠影的看看这个 OS X 的近亲吧。
  • 《DTrace: Dynamic Tracing in Oracle Solaris, Mac OS X, and FreeBSD》,对工作最有用的一本。有网络和地方摆电脑的时候认真看。
  • 重读经典的《人月神话》还差五章,没有照到这张里。

解剖 Mutex

2012/01/05

新年前读到 Varnish 开发者 Poul-Henning 的一篇 blog《 The Tools We Work With 》,谈到他对 POSIX thread mutex 做了简单封装来实现「assert if I’m holding this mutex locked」功能。(这里说明一下,Poul-Henning 和本文所指的 mutex 是构建 critical region 而且能够切换线程的 running-wait 状态的锁。在有的 framework 里这种机制有其它名称,如 Cocoa 的 NSLock。本文称进入和离开 critical region 的操作称为 lock/unlock mutex,处于 critical region 中的状态为 holding mutex。)

我对 Poul-Henning 的说法既感兴趣又怀疑,对数据进行多线程并发访问是非常容易出问题的。以前曾经有个著名的只在 rare-path 加锁的 double-checked lock 的错误例子。Varnish 的 assertion 对 mutex 本身相关的(而非它所保护的)数据进行多线程访问。这需要它的 mutex 封装里再提供某种同步机制。Varnish 有没有正确提供这种保护?或者是否会造成性能问题乃至于死锁?为了搞清这些疑问,我下载了一份 Varnish 代码。其中的 lock/unlock/assert 代码基本如下 [1]:

2011-1227-MutexWrapperFull

实际代码证实了我的担忧。Lck__Assert() 对 owner 和 held 的访问并无保护。修正这个错误要在 sturct ilck 里定义另一个 mutex(意味着整个系统的 mutex 数目加倍),或者使用一个新的全局 mutex(意味着整个系统的 mutex 竞争急剧增加)。如果仅仅是一个 debug assertion 的需要倒也不是大问题。可是仔细想想,实现 reentrant mutex 也需要类似的功能。因此肯定应该有更好的方法。最后我在 Stackoverflow.com 上得到了一个方案,修改 unlock 方法如下:

2011-1227-UnlockFixed

我给 Poul-Henning 发信说明这个问题,他同意了我的分析和补救 [2],同时指出了一个我没有意识到的问题。一开始我错以为 owner 是个整数 ID,给出的方案是在 Lck__Unlock() 中将其设置为某个非法值(比如 0 或者 -1)。Poul-Henning 指出 thread_t 是平台相关的不透明类型,没有非法值的定义,他提出用 memset() 清零来作为目前的方案,尽管仍然有下面的缺陷:

  • 全 0 的 thread_t 可能在某些平台上是合法的线程 ID。
  • 尽管在没有 hold mutex 的情况下 owner 几乎不可能等于当前线程 ID,但是由于没有保护,Lck__Assert() 中读取的 thread_t 有可能是一个被其它线程部分修改的受损数据 (corrupted data) 。在某些平台上也许 pthread_equal() 不能安全的处理这种 ID。
  • 不能完全排除在某些罕见情况下一个部分受损的 owner 变量的值恰好等于当前线程 ID。

给 mutex 提供额外的功能并不是简单的任务,给 POSIX thread mutex 这样的跨平台机制扩展功能就更难了。我觉定深入研究一下 mutex,以及在多大程度上能对它们进行跨平台的扩展。

纯软件实现

每个学习多线程编程的人一开始就要接触 critical region 的概念 —— 保证至多一个线程进入的代码区域。经典操作系统教材《 Operating System: Design and Implementation, 2nd Edition 》 [3] 中给出了一个叫做 Peterson 方法的纯软件 critical region 实现(为了描述简单,给出的例子只支持两个线程的情况)[4]:

2011-1227-SoftMutex

Peterson 方法解释了线程抢占问题,但它只是一个假定各线程严格顺序执行的教学例子,在今天的计算机上是不能正常工作的,因为编译器和 CPU 采取下列措施优化程序的性能:

  • 编译器生成的优化代码和源代码并非按顺序对应。
  • CPU 在运行过程中以提升性能为目的打乱指令的执行顺序。
  • CPU 在运行过程中通过流水线对同一指令序列的不同部分并行执行。
  • 每个 CPU 有自己的局部 cache,没有固定机制保证局部 cache 和主内存同步的顺序和时延。

当然编译器和 CPU 进行这些优化乱序的时候不能任意而为。为了在最大程度上延续这些技术出现之前的编程方式,这些优化机制保证在「单一线程内」的运行结果仍然和顺序执行等效。「顺序等效」确保在 Lck__Unlock() 中对 owner 清零可以让 assertion 正确工作,因为只有当 Lck__Assert() 和 lock 处于同一线程内,并且处于 critical region 内,owner 才等于当前线程 ID。

由于不改变编程方式,无需程序员介入,这种「等效保证」和 CPU 频率的提升一起被视为前多核时代的性能「免费午餐」。但是「顺序等效」不能扩展到多线程,如果没有显式求助硬件的保证:

  • 一个线程的执行在其它线程看来并非严格顺序执行。
  • 一个线程对内存的改动不能立即对其它线程可见。
  • 一个线程对内存的改动对其它线程的可见顺序不一定和改动顺序一致。

在上面的 Peterson 方法代码中,两个线程在第 12 行可能同时看到 while 的判定条件为 FALSE,因为变量 turninterested 可能分别在各 CPU 的局部 cache 中有未经同步的拷贝。在「唯一线程进入」的基础上,今天操作系统的 critical region 要提供更多功能,它要保证:

  • 最多只有一个线程能进入 critical region;
  • 编译器不进行跨越 critical region 边界的乱序优化;
  • CPU 不进行跨越 critical region 边界的乱序执行;
  • 所有 CPU 的局部 cache 在 critical region 的边界和主内存同步。

简单 Mutex

为了保证 CPU 和主内存之间的同步,硬件提供了一套 inter-processor communication 机制。这些机制是为了像 kernel 这样的高性能底层代码设计的,和 critical region 的基本概念有一定距离。比如 read/write barrier 只是保证了同步的时序,不保证时延。最接近 critical region 的机制是「总线锁」[5],它保证了上面列出的 critical region 四条要求的后三条。但总线锁的持续时间只是某些特定的单条指令 [6],不能构建真正的 critical region。操作系统在它的基础上实现了一套 lock/unlock mutex 的操作。Mutex 的数据结构是一个 CPU 总线锁支持原子化修改的整数类型和一个线程等待队列。Lock 操作如下(或者等效):

  1. 执行一个原子操作,对整数减一并返回结果。
  2. 如果结果为 -1,说明 critical region 内没有其它线程,继续执行进入 critical region。
  3. 如果结果小于 -1,将当前线程放入等待队列。

Unlock 操作如下:

  1. 执行一个原子操作,对整数加一并返回结果。
  2. 如果结果为 0,说明没有其它线程被阻塞。
  3. 如果结果小于 0,将等待队列中的一个线程执行放入 kernel scheduler 的 ready 队列(唤醒该线程)。

这种简单的 mutex 不是大多数应用程序使用的 rentrant mutex,和后者有如下区别,

  • 同一线程试图两次 lock 一个简单 mutex 会导致死锁。而 reentrant mutex 可以被同一线程 lock 多次。
  • 在同一线程中对 reentrant mutex 的线程必须成对出现。而简单 mutex 没有这个限制。
  • 由于第二点,处于一个 reentrant mutex 的 critical region 中的线程称为这个 mutex 的 owner。而简单 mutex 没有 owner。

简单 mutex 在 Linux 和 BSD 的 kernel 代码中叫做 semaphore。Reentrant mutex 在简单 mutex 的基础之上实现。

正是由于 reentrant mutex 的 lock 和 unlock 操作必须在同一线程中成对出现,Varnish 的 Lck__Assert() 才能(在加入我的修改之后)利用单线程的「顺序等效」保证 owner 的状态能正确反映 holding mutex 的状态。这里我们会发现 Varnish 的 assertion 有一个基本语义的问题,它使用了 owner 的概念,要求 lock/unlock 在同一线程成对出现,这表明它针对的是 reentrant mutex。但是在它的 lock/unlock 操作中没有处理 holding mutex 的嵌套层数。这是 Varnish 的另一个错误,它没有显现出来也许是因为在 Varnish 中 reentrant mutex 并不实际发生嵌套 lock。或者也可以说一个有 owner 的 mutex 只是要求 lock/unlock 处于同一线程,并不一定非要支持 reentrant,但我认为这样复杂化 mutex 的分类并无必要。最好还是把 owner 和 reentrant 等同起来概念比较明晰。好在明确这个问题之后不影响其它讨论。对嵌套层次的支持也可以很容易地加入到 Varnish 的 assertion 中。

Reentrant Mutex

到目前为止,我们有了一个利用「等效原理」基本可以工作的「assert if I’m holding this (reentrant) mutex locked」机制,也回顾了简单 mutex 的原理。理论上可以利用二者来实现 reentrant mutex。还有这么几个问题没有得到确定的回答:

  • 类型 thread_t 是一个复杂的结构还是一个简单类型(整数/指针)?
  • POSIX 库能否保证目前 Varnish 的 assertion 正常工作?
  • 如果 thread_t 是一个复杂类型的话,POSIX 库很可能需要一个 directory 来维护这个复杂类型和内核的 thread ID 之间的联系。那么该 directory 也需要某些同步机制来保护(比如 kernel semaphore)。那就意味着 pthread_self() 这样的方法也有相当可观的性能开销,在这种情况下利用「等效原理」避免 mutex 的性能提升是否有意义?
  • POSIX thread 库是否真的利用「顺序等效」实现 reentrant mutex?
  • 如果不是,POSIX thread 的实现方式和「等效原则」相比性能如何?

由于 POSIX 是跨平台标准,所以这些问题不可能得到完全解答。最后的答案只限于 OS X [7](很大程度上适用于所有 BSD 系统)。

在 OS X 上 thread_t 是一个指针。pthread_self() 从 GS 寄存器返回该指针。GS 是 kernel 维护 per-thread 数据的寄存器。Per-thread 数据在各自线程内遵循「顺序等效」,无需同步机制。这解决了前三个问题 —— Varnish 的 assertion 可以在 OS X 上正常工作, pthread_equal() 也不会有性能损耗。更进一步说,利用通用的 per-thread 数据机制可以更有效的利用「顺序等效」原理。比如可以维护当前线程 hold 的所有 mutex。POSIX 提供 per-thread 数据的通用接口,如 pthread_key_create()。 相比之下,目前 Varnish 利用「顺序等效」原理只是一种 ad-hoc 的方式。

接下来,POSIX thread 库利用 thread_t 的成员来实现 reentrant mutex 功能。在 OS X 的 pthread_mutex_unlock() 并没有类似 Lck__Assertion() 对 owner 清零的操作,只有类似将 held 设置为 FALSE 的操作。它并没有采用 ad-hoc 的「顺序等效」来实现「assert if I’m holding this mutex locked」,也没有采用 per-thread 数据。在 POSIX thread mutex 里,除了用来创建 critical region 的底层 semaphore 之外,还有一个专门保护 thread_t 成员的「锁」。这个锁不是 mutex 而是 spinlock。Spinlock 的语义类似简单 (non-reentrant) mutex (semaphore),不同之处在于线程竞争时后入线程不会被放入等待队列,而是进入空循环,所以在 critical region 很短的时候性能开销没有 mutex 那么大。

从 OS X 的实现来看,对性能开销的顾虑并没有我一开始担心的那么大。比较之下,我提出的修正 Varnish assertion 的方式太 ad-hoc,完全可以采用更可靠的 spinlock 或者 per-thread 数据。

脚注:

  1. 为了叙述清楚我删除和修改了一些无关代码。
  2. 我提议的修改 2011 年 12 月 28 日进入 Varnish 的 code base。
  3. 62 页图 2-9。这是我在大学买的第一本英文影印版书。Linus 编写 Linux kernel 之前通读了此书第一版。
  4. 由于采用空循环的 busy wait 而非线程的挂起,这个机制不是 mutex。
  5. 《Intel® 64 and IA-32 Architectures Developer’s Manual: Vol. 3A》,第 8.1.2.2 节:Software Controlled Bus Locking。
  6. 同上。
  7. Apple 的 open source 页面

Sandbox 初探

2011/12/16

Mac OS X Lion 的 Sandbox 是一项了不起的创新。当然,我不反对有人批评目前的 entitlement 可选项不够完备,还需要扩展。在假设今后可能加入新选项的前提下,现有的概念和实现已是巨大的进步。

操作系统局限于 discretionary access control 和 mandatory access control 两种安全模型已经太久了!后者概念复杂,除了涉密极高的部门,连电信银行等大型企业都几乎无人采用,完全没可能进入个人计算领域。前者又过于简单:资源(通常是文件)本身拥有一个访问限制列表 (access control list) [1];进程被赋予一个系统帐号身份,为下列情况之一:

  • 用户的登录帐号,如果是从普通的系统图形化工具如 OS X Finder 或者 Windows 的「start」菜单启动,这是最常见的情况;
  • Supervisor 用户,如果是用 sudo 或者 Windows 的 lauch as administrator 功能启动;
  • 其它帐号,如果是通过 login 等工具设定 shell 进程的帐号再从 shell 启动。

操作系统根据进程的帐号身份和资源的 ACL 决定满足还是拒绝进程对资源的访问请求。用户可能接触到两种程序和两种数据:

  • 非可信程序:其访问和处理数据的行为不明;
  • 非可信数据:可能通过 buffer overflow 等手段向程序注入恶意代码;所以,从系统安全的角度,非可信的数据和程序被等同视为恶意代码的潜在载体,下文统称「恶意代码」。
  • 可信程序:其访问和处理数据的行为经过验证(比如通过低权限帐号长时间运行而没有在系统的 audit logs 中发现其可疑行为被拒绝的纪录;或者由声誉良好的组织发布;或者是操作系统的自带工具)。系统安全的目的之一是避免可信程序被恶意代码篡改。如果可信程序遭到篡改,那么系统中就出现了名义上可信而实质上有恶意的代码,安全防线即被攻破。
  • 可信数据:用户自己创建的文件,或者通过其它方式确保不含有恶意成分的数据(比如通过低权限帐号运行的进程打开,而未在系统的 audit logs 中发现其可疑行为被拒绝的纪录)。系统安全的目的之一是避免可信数据被恶意代码篡改,以及避免敏感的可信数据恶意代码读取和泄漏。可信数据遭到篡改的后果和可信程序被篡改的危害相同,因为可信数据本身可以是控制其它程序行为的配置数据,也可以被修改为夹带恶意注入代码。

在可能接触到恶意代码的环境中,要通过 DAC 保护可信程序和用户的可信数据需要在使用中自觉遵守下列行为规范:

  • 每个用户要有多个系统帐号,对应不同的权限级别。
  • 可信数据的 ACL 只对足够高级别的系统帐号身份开放操作,对可信数据写操作的权限要求的帐号级别比读权限更高。
  • 不可信数据的 ACL 要先设置为对低级别帐号开放。
  • 用户访问不可信数据要以低权限级别的系统帐号启动应用程序。这样即使不可信数据对程序注入了恶意代码,也不会破坏或者泄漏可信数据。
  • 一定要用低权限级别的系统帐号启动不可信的程序。不可信程序只能暂时处理实验性质的数据(其 ACL 对低权限帐号开放,例如非敏感数据的实验拷贝)。
  • 当数据足够可信时,要修改它的 ACL 相应的提高允许访问的级别。
  • 当程序足够可信时才用足够高权限的帐号启动它,以便处理可信数据。

DAC 的问题

DAC 的概念虽然比 MAC 容易理解,但需要用户自觉遵守繁琐的行为规范 [2]。对于专门的服务器维护人员来说这不算什么。但是对个人用户来说,即便我这样的程序员也很难严格遵循所有规范(最多不用 supervisor 用户登录,避免系统文件被破坏,对用户可信数据的保护明显不够)。DAC 的主要问题在于静态性。资源的 ACL 是静态的。理论上说帐号在整个进程 session 中是静态的 [3] 。实际中更糟糕,由于缺乏工具(多为 sudo, login 之类的命令行工具而少有图形工具,多为提权 (previlege elevate) 而非限权操作),用户会在整个 login session 使用同一帐号启动所有程序(对,我也是这种人)。但是数据和程序的可信度是动态的,可信度的变化的对于不同的访问操作(读或写)也不相同。要求普通用户根据情况不断手工微调 ACL 和采用不同帐号的策略是不现实的。

人们采取过一些方法来弥补 DAC 的静态缺陷。一是权限隔离 (privilege separation) 。让程序中可能访问不可信数据的代码从主进程分离出去,运行在低权限的进程中 [4] 。这种方案的局限在于它只能弥补用户身份的静态缺陷而不能解决 ACL 的静态缺陷,不过它能满足一定适用范围的需求 —— 通常可信程序和数据的 ACL 会把读访问开放给权限较低的帐号,把写访问限制于权限较高的帐号,权限隔离可以防止可信数据和程序遭到篡改。但是对于可信数据泄漏等方面,仍然存在静态 ACL 固有的问题。

另一种弥补 DAC 静态缺陷的方法是动态 ACL 扩展:在操作系统本身为资源设定的静态 ACL 基础上,通过某种扩展为资源再强加一个更容易动态修改的 ACL。从基本概念来看,这是个改进,但是我一直对其很反感,因为从来没有一个真正可靠的动态 ACL 扩展实现:

  • 几乎所有动态 ACL 扩展都不是内核的有机组成部分,而是通过一些 daemon/service 加 kernel-hook 实现的半吊子监视器。除去各种具体的 bug 不一一列举,这种半吊子监视器甚至被暴露出普遍在多核系统上存在 race condition,会 grant 错误的,甚至是任意权限给恶意代码。
  • 由于不是操作系统的内建组成部分,难以保证所有应用程序都遵守动态 ACL。
  • 尽管比修改静态 ACL 方便,动态 ACL 的修改仍然需要过多的用户手工操作。
  • 要实现比较细致的安全保护,动态 ACL 需要动态的进程帐号来配合,而后者超出了一般动态 ACL 扩展的控制范围。

DAC 模型始终让用户和安全专家充满挫折感。它的困难在于缺乏有效的工具同时动态调整两个静态因素 —— 帐号和 ACL。通过半个多月的阅读文档,讨论,以及编程测试,我发现 OS X Lion sandbox 完全解决了上述问题。它是 kernel,系统服务和 UI framework 的完整组成部分,提供动态权限的模式脱离了基于资源的静态 ACL,基本不需要用户额外的手工干预。

基本 Sandbox —— Container 目录

首先回顾一下现有的 DAC 机制如何决定一次访问是否被允许。

如上图所示,DAC 机制下一次资源访问的具体步骤是:

  1. 应用程序向 kernel 提出访问资源的请求。我们日常讨论的时候经常把整个过程简化描述为「应用程序读文件」。但是从技术细节上来说,应用程序不可能绕过 kernel 直接访问资源。
  2. Kernel 检查资源的 ACL,确认当前程序的帐号是否被允许此次访问。
  3. 如果允许,kernel 将相应的信息(如文件内容,或者允许后续文件操作的文件引用)返回给应用程序。

Sandbox 下的一次访问如下图所示。操作系统 kernel 会为每个 sandbox-ed 应用定义一个 sandbox 范围。在文件访问方面,最严格的也是每个进程 session 初始状态的 sandbox 范围是分配给应用程序一个只供它使用的目录,称为 container 目录。Sandbox-ed 应用只能在这个目录里读取和创建文件 [5]。由于可访问的资源限制在 container 目录之内,这些资源的 ACL 没有实质的作用。

这和 iOS 的 sandbox 机制类似。但是对于桌面计算环境来说,大多数应用程序产生和读取文件的操作必需能在任意目录下进行。OS X Lion sandbox 的最大优势是提供安全而且友好的方式扩大 sandbox 范围。

必要的噪音

下图显示了一个 sandbox-ed 应用如何扩展自己的 sandbox 范围。在 OS X Lion 系统中有一个 Powerbox 服务进程。Sandbox-ed 应用程序向 Powerbox 提出请求扩展 sandbox 范围。Powerbox 执行步骤 2-6 ,通过 DAC 机制决定是否可以加入新文件到 sandbox 范围。如果得到允许,通知 kernel 扩展该应用的 sandbox 范围。

问题的关键是 Powerbox 基于什么原则来扩展 sandbox 范围。这是一个危险的敏感操作。通常安全系统的设计惯例要求这种操作释放出用户能感受到的「噪音」,即图中第二步的「necessary noise」。用户在接收到噪音之后判断程序的提权操作是否可疑。如果可疑就中止整个过程。

这种噪音的呈现形式既不能弱到让用户无所察觉地扩展 sandbox 范围,也不能强到过于干扰用户。Windows UAC 就是这方面一个著名的失败设计 —— 强迫用户过于频繁地回答「是」或「否」的问题让用户养成不假思索点击 OK 的习惯动作。(而 UAC 还只是一个简单的动态修改进程帐号的功能,并非 sandbox 这样完整的动态授权方案。)

OS X Lion sandbox 机制的聪明之处在于把这种提示做得不着痕迹又不可能被忽略。当应用程序请求 Powerbox 为其扩大 sandbox 范围时后者会弹出 Open/Save File Panel 让用户指定读取或者保存/创建的文件。这是把原本属于应用程序职责的两个操作 —— 提示用户打开和保存文件(注意是提示而不是打开/保存的操作本身,后者依然属于应用程序)—— 移交给 Powerbox 作为扩展 sandbox 范围的「噪音」形式。噪音从唐突的「是」或「否」问题形式,变成了用户早已熟悉而且一定会谨慎执行的操作。在 OS X 原有的 DAC 安全系统基础上,整个方案涉及多个模块改进和协作:kernel 提供系统调用 (system call) 允许服务进程  Powerbox 修改 sandbox-ed 应用的 sandbox 范围;Powerbox 提供 Open/Save File Panel,并且保证它们和应用本身 UI 的无缝集成(包括在正确的位置显示 panel,绘制 custom accessory sub-panel 以及在 Powerbox 和应用之间正确双向接力 accessory sub-panel 的动作);Cocoa 要保证 sandbox-ed 应用在 API 调用方面和非 sandbox 应用程序保持源代码一致。这是任何操作系统之外的第三方产品都无法做到的,也要求操作系统本身具有高度的整体性。

Sandbox 实测

由于 OS X Lion 发布时间不长,关于 sandbox 的文档和讨论并不多。我写了一个程序来验证上述概念。

验证程序的 UI 如上所示。在文本框中输入文件的路径。点击 Test 按钮,程序就会在 console 中打印出对这个文件是否有读操作的权限。UI 上还有两个 checkbox。原本打算在「Open Panel」选中的时候,让 Test 操作忽略文本框中的路径,(通过 Powerbox)开启 Open File Panel 让用户指定被测试文件。但在测试中发现了一个惊喜:Cocoa 的 NSTextField 支持从 Finder 拖放 (drag-and-drop) 文件到文本框。其中 drop 的过程已经被 Powerbox 接管,Powerbox 会把被拖放的文件加入目标程序的 sandbox 范围然后继续原来的 NSTextField 行为(显示文件路径)。整个过程等同于用 Open File Panel 打开文件,因此「Open Panel」的 checkbox 没有采用。

Test 按钮的行为由下面的代码实现:

首先只关心 24-30 行。如果文本框的内容是通过拖放从 Finder 中得到(也就是 Powerbox 已经扩展了 sandbox 范围),那么 console 的输出为「Data is available: YES」。如果是键盘输入,那么输出结果为「Data is available: NO」。

接下来关注刚才忽略的忽略 16-22 行与 32-25 行。这是为了验证一个在讨论中困惑了我和同事很久的问题:一个进程的 sandbox 范围在刚刚启动时总是只包括 container 目录,其后每次扩大范围都要释放「噪音」,这让某些操作非常不便。假设用户在上一次程序运行 session 中通过 Powerbox 打开过一个文件,那么在下一 session 中是否可以在某些情况下认为这个文件是无需用户确认即可被访问的?如果按照基本 sandbox 机制,就不可能再无「噪音」的实现目前大多数程序提供的 Open Recent 菜单。特别是 OS X Lion 提出了 automatic termination 和 resume 的概念,程序对用户呈现的运行状态和进程 session 不再一一对应,这就需要 sandbox 机制允许在同一个程序的先后两个进程 session 间无「噪音」地继承部分 sandbox 范围。

Sandbox 确实提供了这种机制。在 NSWindow 中提供了 setStorable:setRestorationClass: 等方法。不过这些属于特别为 document-based 多窗口应用设计的高级接口。在验证程序里检验了更基本的方式。对于一个已在当前 session 的 sandbox 范围内的文件,调用 NSDocumentControllernoteNewRecentDocumentURL: 方法会把它加入到 recent document 列表中。这个列表由 Powerbox 为每个 sandbox-ed 应用程序维护。在该应用下次运行时,只要调用 recentDocumentURLs 方法,Powerbox 就会把列表中的文件无「噪音」地加入 sandbox 范围。用验证程序检验这个行为的步骤是:

  1. 点中「Add to Recent」checkbox,
  2. 从 Finder 拖放某文件到文本框,
  3. 按 Test 按钮,
  4. 退出并且重新启动验证程序,
  5. 然后手工输入刚才的文件路径,
  6. 按 Test 按钮,程序会输出「Data is available: YES」。

Cocoa 实现的界面会自动反映调用这些方法的效果。noteNewRecentDocumentURL: 会向 Open Recent 菜单增加相应的条目。recentDocumentURLs 会在用户打开 Open Recent 菜单的时候被自动调用(因此在只实现 Open Recent 菜单的时候无需程序员显式调用该方法)。删掉 Open Recent 菜单不会影响这些方法对 sandbox 范围所起的作用。这个方法可以用于非 document-based 的简单应用。目前看来这是跨 session 继承 sandbox 范围的机制中 Cocoa 和 Powerbox 最直接打交道的部分,其它方法或多或少基于此实现。

结论

OS X Lion sandbox 是个人计算安全模型的一个大发展。它是对 DAC 模式的一个易于理解的扩展。通过 kernel,Powerbox 服务和 Cocoa 框架的无缝集成,给开发者引入的负担和给用户带来的干扰比权限隔离 [6] 、动态 ACL、UAC 等方案要小得多。最后,利用 recent document 列表跨进程 session 继承 sandbox 范围的机制进一步避免了向用户发出不必要的噪音。尽管对 recent document 列表中的文件保护有所削弱,但是和提供的功能相比风险代价是合理的。需要普通用户注意的是,System Preferences 中 Appearance 下的 Number of recent items 也成为了一个关系到安全风险的选项。

脚注:

  1. 在 OS X 这样既提供 BSD Unix 实现又加入了任意 ACL 的系统中,狭义的 ACL 特指文件访问权限属性 (user, group, others) 以外的扩展部分。此处的 ACL 为广义定义,包括 Unix 的文件访问属性。
  2. DAC 本身概念的简单性正是以行为规范的复杂为代价的。
  3. 在一个进程 session 中改变帐号是可能的,但是需要发出「噪音」(见下文解释)。
  4. 有一种「相反的」权限隔离的方案是主进程运行于较低权限,分离的进程运行于较高权限(当然需要释放「噪音」提权)。这种方案是用于那些需要偶尔修改系统文件的程序,降低系统文件受攻击的风险。和本文讨论的保护用户可信数据的场景不同。
  5. 当然,除此之外,必不可少的读取和调用系统库文件肯定是允许的。
  6. 但在其它适当的场合采用权限隔离仍然是被鼓励的,只是说权限隔离不再是弥补静态 ACL 缺陷的必要方法。

Retreat Corner

2011/12/03

办公桌的 retreat corner。

Objective-C 到底给了程序员什么

2011/11/27

不少程序员和我一样,对 Objective-C 经历了从反感到喜爱的转变。反感的是方括号语法和没有虚拟机 (virtual machine) 的动态语言实现。转变因为两个原因。一是 Objective-C 相对简单的语言构造。需要面向对象编程又惧怕 OOC 的程序员们终于摆脱了 C++ 的绑架,没有了跨函数的异常处理,没有了声东击西的操作符重载 (operator overloading) ,获得了统一的内存管理从而摆脱了 C++ 的 value copy 和 block-scope 语义(什么时候 copy constructor 会调用?什么时候 destructor 会调用?)。第二,尽管基于虚拟机的动态语言更强大,但完全脱离 C 还是不现实的,构建实际的软件需要 hybrid 编程,而没有虚拟机的 Objective-C 是一种「单一」语言,还是单一语言好用。

首先谈谈第一点,Objective-C 比 C++ 简单是否仅仅限于砍掉了一堆 C++ 语言的 feature 而已?那么又增加了方括号语法是不是在「砍掉 feature」大基调下一个不和谐的杂音?

范式分割

C++ 被其设计者 Bjarne Stroustrup 定义为「多范式 (multi-paradigm) 的通用编程语言」[1]。Stroustup 的基本出发点看似没有什么问题,解决不同的问题,尤其是不同层次的问题,需要不同范式的编程语言。但是,能不能把不同范式揉合到一种语言当中呢?至少 C++ 的具体方式很糟糕。我认为正确的方式是让程序员在(面向不同问题,或者分析不同代码的)不同时候用不同范式思考,提供在范式之间切换的手段。尽量让程序员在每个时刻只需用单一范式思考。同时混用多个范式是巨大的智力负担。C++ 正好反其道行之:对所有范式都选取相似的语法符号,更糟糕的是为了加大相似度特地提供了操作符重载。Stroustrup 在自己的书里多次提到他的出发点是让所有范式的所有元素都成为 first-class citizen。这是个似是而非的肤浅观点。平等的成为 first-class citizen 并不的意味着被相似的符号表示。C++ 不仅鼓励没有节制的混用范式,即使某段代码实际上只采用单一范式,维护它的程序员也无法放心的排除其它范式。结果是对任何看似简单的代码都必须同时考虑 C++ 的所有范式。

应该说这个问题并非 C++ 独有。高级动态语言同样可能在没有明显代码风格差异的情况下改变范式。但我在之前的 blog 里讨论过 [2],丰富表现力的子语言解决复杂单一的问题的可以采用这种方式,却并非适合团队协作的通用编程语言应该采用。

Objective-C 和 C++ 相比无论语言元素是增是减,始终为了一个目的,向程序员清晰的表达目前所处的范式。首先它的目标更简单,没有难以融合的 generic 范式,集中精力实现 OO 范式。它提供了方括号语法明确表示范式的切换。排除了操作符重载,避免混淆范式。没有跨函数的异常处理,避免破坏 C 的过程范式的重要部分,也是起系统胶合剂作用的 ABI 重要部分 —— call stack 规范。所以,当程序员看到一段 Objective-C 代码时,他可以根据方括号的出现与否轻松的决定是否让头脑卸下思考 OO 动态行为的负担,集中注意力到 C 的静态部分;还是让头脑卸下思考内存管理等底层问题的负担,用 OO 的高级抽象思考问题。

Objective-C 的设计者 Tom Love 解释过方括号语法确实是为了让 C 部分和 OO 扩展部分成为有鲜明区别的「两个」语言 [3]。虽然 hybrid 编程会带来的互操作和调试的负担,但是程序员都喜欢用不同范式解决不同层次的问题来减轻头脑的负担。适合问题的范式和范式之间明确的界限是 hybrid 编程最大的优势。

工具融合

如果 Objective-C 是两种语言的 hybrid 编程。那么一开始提到,和其它高级动态语言与 C 语言的 hybrid 编程相比,Objective-C 是一种「单一」语言又该怎么理解?这取决于工具支持。Objective-C 在语言设计方面保持两个独立的语言定义,又用工具把实际使用中可以融合的部分尽量融合到一起。

首先,hybrid 编程依靠不同的语法来区分范式。大多数 hybrid 编程把针对不同语法的编译器也区分开来。比如,Lua 的编译器是一个 library,和 C 编译器没有任何联系。参数通过 API 传递。Objective-C 的做法是提供可以同时处理两种语言的单一编译器。虽然揉合了两种语言的处理,但是可以设想,除去前端 parser 的语法树 (AST) 产生可能复杂一些,这个编译器的后端处理不会比单独写两个编译器更复杂。和 C++ 融合多种范式的编译器复杂度不可相提并论。对程序员来说,Objective-C 的源文件作为一个整体是极大的便利。

不论范式如何不同(摧残头脑的 functional 编程除外),程序的运行都是线性的,所以每个线程中包括所有范式语言的单一 call-stack 是存在的。尽管逻辑上可行,许多 hybrid 编程环境里并不提供能展示单一 call-stack 的调试工具。如果需要,程序员只能自己用头脑分析和想像。Objective-C 极大的改进了这点。除统一的编译器之外,让 Objective-C 成为「单一」语言的另一个要素是能集成分析 C 和 OO 扩展部分运行状态的调试器。

从工具集合来看,Objective-C 比大多数开源语言都丰富。无论开源还是商业领域,直接支持 hybrid 编程的单一编译器和展示 hybrid 运行时的单一调试器都是 Objective-C 独特的优势。

共生系统

Objective-C 体现了独特但合理的设计编程环境的思路:使用同一工具的并不非得是一个语言,多个语言也不见得非要使用多个分立的工具。集成不同语言的可以是方便的工具而不是脆弱的 API。对两种定义清晰的语言提供高度集成的同一工具是 Objective-C 比很多晚出现十几年的语言更显先进的原因。太多语言设计者抱着这样的错误成见:我的语言必须是一个整体;我的语言可以令程序员从开始 coding 一刻起就避免和解决很多其它语言需要用工具解决的问题,所以不需要太多工具;我的语言这么好所以会有无数的人为它写工具,我只要专心设计语言最小核心的编译解释工具。我曾经写过 Lua 最大限度避免了集成污染 [4],因为 Lua 利用了 C 编译器对 ANSI C 的良好支持。遗憾的是,在这个利用工具的良好开端之后,和其它语言一样,Lua 的开发团队始终是限于语言本身的改进而没有任何附带的增强工具。

编程环境是语言和工具的共生体,但是人们经常忽视这个事实,或者只是关心共生体的一小部分,徒劳的希望别人来改进其它部分。Mac OS X 的编程环境,得益于 Apple 这家强于 end-to-end control [5] 的公司选择了独特的 Objective-C。

脚注:

  1. Design and Evolution of C++》。
  2. 高级动态语言和软件业》,《高级动态语言和软件业 —— 交流与内省》。
  3. 《Masterminds of Programming》。
  4. 集成污染
  5. Objective-C 说明了 end-to-end control 并不等同封闭。它的实现 Clang 和 LLVM/LLDB,乃至之前的 GCC 都是 open source 实现。Control 只是推动了它们的整体发展和最终集成。Apple 选择了工具支持最好的 Objective-C,同时在业界对其缺乏兴趣的环境下一力发展 Objective-C 的语言和工具是有勇气的。

数据迁移

2011/11/20

这周末开始把过去六年存到几十张 DVD 上的数据挪到一块移动硬盘上。六年前为了共享数据和备份买了第一台外置可写光驱(当时的 Dell 笔记本还是 DVD ROM)。那时 DVD 看起来是个完美的备份方案。

六年过去,很多事情改变了。有了宽带,有了数码相机,而且平时都用一张照片 20 MB 的 RAW 格式。MP3 从一首歌一两兆变成了七八兆,还要内嵌专辑封面。另一方面,DVD 作为存储介质快死了。单张容量还那么大,盘片越积越多(为了节省放置空间不得不从单盘单盒装变成了多盘一盒)。下一代 MacBook Pro 估计就没有光驱了。从 Snow Lepoard 的 64-bit kernel 开始,早先在 Windows 上刻录的 DVD 有一大半不能被 OS X 系统识别,而且我想 Apple 也不打算修复这个问题了。

更重要的是想法的改变。当年开始备份这些数据只是出于任何东西都不愿轻易扔掉的习惯。把它们按照时间顺序贴上标签,想着以后还可以怀念一下旧日时光。至于说几年以后从这上百 G 数据,甚至十几年之后从几 T 数据中找出所需资料,我是不抱太大希望的。所以即使读取光盘不是个非常顺畅的操作,当时也没当个问题多加考虑。而现在 OS X 的 Spotlight(还有 Windows 上的仿制品,导致 Microsoft 最终放弃 SQL 和文件系统结合的 WinFS 设想)已经超过了被 Google 杀掉的 Desktop Search,达到了可以从本地海量数据里挖掘有用信息的水平。也许我没法理清存下的每一条数据,但是仍然可以像利用 Internet 一样有效的利用它们。像《实时放逐》的高科技时间旅行者,或者《深渊上的火》的「纵横二号」飞船,每个人的数据都是一生受用的资源,只要先进的 indexing 技术引入个人电脑。

一个人是否能改变世界

2011/11/16

1975 年 6 月 29 日,一个人在键盘上敲了几个键,他面前的「显示器」显示了对应的几个字母。在他之前,没有电脑能做到这点。在他之后,所有人都以为电脑从来就是如此。尽管是 Steve Jobs 为这台电脑推向大众做了关键的一步,但是站在这个历史转折一刻的,是 Wozniak 一个人。

如果不是时时重温这段历史,很容易以为 Unix kernel 是在类似 vi 的编辑器上写就的。很少有人能一力创造如此改变世界的转折。就连 Ken Thompson 创造的杰作也没能扫除世界上 90% 计算机上的 VMS 遗留物。

阅读

2011/10/30

《 Steve Jobs 》,啤酒,电子辞典和 Wikipedia 。

OpenGL 随想(五):从 Fixed-Function 到 3.2

2011/10/23

国庆之后一直没写 blog。一来其它事情很多;二来闲下来就想阅读。长假还算没有荒废,以 OS X Lion 支持 OpenGL 3.2 为动力开始重启 OpenGL 的研究。假期前两天在基于 OpenGL 2.1 的产品中加上了用 GLSL 1.1 写的 bicubic interpolation 的 fragment shader,给学习 OpenGL 2.1 画上一个句号。接下来用其余的假期调通了第一个 3.2 core profile 程序。这个假期可以说是从主要基于 fixed-function pipeline 的旧 OpenGL 向完全可编程 pipeline 的新 OpenGL 前进的一步。

从 OpenGL 2.1 到 OpenGL 3.0 是一道分水岭。在其后 OpenGL 开始致力于抛弃之前版本中只适合过时硬件结构的 API,让开发高质量高性能的 driver 变得更容易,从而带动整个 OpenGL 平台的改善。到 OpenGL 3.1 之后,虽然不少实现仍然保留旧功能(nVidia 的 Windows driver 号称永不去除任何功能),但是在同一 OpenGL context 中混用二者越来越困难。OS X Lion 允许创建两种 context:OpenGL 2.1 和 OpenGL 3.2 core profile。前者只能通过 EXT 使用 3.x 有限的部分功能,后者可以使用 3.2 的所有新功能(以及通过 EXT 使用更高版本的部分功能),但无法使用 2.1 的旧功能。

被抛弃的最大部分是立即模式 (immediate mode),也就是初学者熟悉的 glBegin/glEnd/glColor/glTexCoord/glVertex 等等函数,以及和矩阵相关的功能。再者是去掉了原来固化在 driver 或者硬件中的坐标变换、光照等等 fixed-function pipeline 功能,转而必须由 shading language (GLSL) 编写的 shader 完成 (OpenGL 2.1 里可以用 shader 来替代 (override) fixed-function pipeline,但并非必须,在没有 shader 的时候 fixed-function pipeline 依然工作) 。我认为去掉立即模式的原因是 GPU 能直接访问的独立显存数量和 CPU-GPU 间的带宽不断增加,以致立即模式一次搬运少量 vertex 信息节省显存和带宽的优点逐渐失去意义,反而突显了性能方面和 driver 复杂度方面的劣势。用 shader 取代 fixed-function pipeline 是对应用程序开发者暴露更多的硬件灵活性。而且,在立即模式下,shader 只能通过预定义的 built-in variable 访问 CPU-side client 提供的 vertex 数据,去掉立即模式让 CPU 上的 client 代码和 GPU 上 shader 能直接传送任意命名的数据,二者的衔接更加直观。

OpenGL 是显卡的 C 格式机器码。有意思的是,当硬件处于初级阶段的时候,这种「机器码」还呈现一定程度的描述语言 (declaritive language) 风格。而硬件发展到高级阶段之后,反而彻底失去了高级风格。说到这儿,要回头谈谈 3D 开发的两种模式:场景 (scene) 模式和管线 (pipeline) 模式。场景模式中应用负责描述 3D 场景,由 API 负责把场景的描述翻译成屏幕显示。对开发者来说场景模式固然更容易理解,但是在《OpenGL 随想(四):计算机如何解决问题》里解释过,现有的计算能力很难完全满足场景模式。光照、阴影、平滑曲面、镜面反射等等都很难离开程序员的干预纯粹通过场景描述来自动完成。为每种场景效果都设计一个可配置的属性也让 API 过于繁琐。所以场景模式大都存在于 AutoCAD、3DS Max 这样的交互式建模工具中。而编程 API 往往采用更底层的管线模式,即在程序员理解硬件功能的基础上为硬件的计算提供必要的数据、参数和代码 (shader) 。

在 OpenGL 2.0 之前,由于硬件和 driver 只能提供固定功能的 pipeline,所以调用这些功能的方式向最常用的场景描述信息靠拢。用立即模式编写的代码,除去一些 OpenGL 状态机的状态变换,很像场景描述。所以初学 OpenGL 容易误认为这是一个简单的场景模式 API。只有接触到反射、阴影等等复杂用例之后才会转换思维。

从 OpenGL 3.0 之后,整条图形处理 pipeline 都由程序员掌握(除了少数功能,比如从 geometry shader 到 fragment shader 的光栅化)。在《OpenGL 随想(四):计算机如何解决问题》里提到过,像曲面平滑这类效果是先计算关键点在投影平面上(也就是 viewport)的颜色,然后对 viewport 中其它像素的颜色进行二维插值 (interpolation) 来完成的。在 3.2 之后,程序员可以做更灵活的选择,比如可以对每一点的法向量 (normal) 和光源向量进行插值后分别计算各点颜色,而非基于关键点的颜色插值。程序员编写的代码时会更多地从控制 pipeline 而非「描述场景」的角度思考。OpenGL 3.2 成为主流之后,初学 OpenGL 的曲线可能会更陡 (我的第一个 3.2 程序调试了四天时间。因为当整条 pipeline 的功能完全暴露在程序员面前的时候,驾驭起来并不容易。即使是一个小小的 hello world 也可能出很多错误),不过以后接触复杂用例的时候不用再经历思维模式的转换。

越灵活的功能必然呈现越底层的界面。而越高级的抽象只能暴露越僵化的功能。三年前 Ars Technica 访谈 3D FPS 引擎 Unreal 的设计师 Tim Sweeney,后者预测不光 fixed-function pipeline 会被可编程 pipeline 取代,甚至 pipeline 概念本身也会消失,3D 开发从基于 API 的方式专为使用通用编程语言。

三年后的今天,3D 开发仍旧以 OpenGL 4.2/DirectX 11 这样的 API 为最前沿,而 OS X 和 Windows 7 这样的主流环境处于 OpenGL 3.2 的水平。也许这就如同 CPU 上的编程语言一样。我不是要讨论汇编语言,这里不讨论编程语言对硬件能力的驾驭,而是说对问题描述的表现力。人们曾经用 COBOL 尝试模仿自然语言编程,就像最初的看似场景模式的 fixed-function pipeline 和立即模式,最终证明那实际上只是针对某些模式问题的肤浅方案。于是又有人像 Tim Sweeney 展望 3D 开发那样想像最灵活最有力的表达方式,得到几乎没有语法的 Lisp 语言。最终我们在一定程度上接受了动态类型、lexical closure 这样的概念,但还是在 C、Objective-C、Python、Lua 这样的中间点安顿下来。同样,在很长一段时间内 3D 开发还是会保持 API/Shading Language 的可编程 pipeline 模式。

乔布斯安息

2011/10/06

No one wants to die. Even people who want to go to heaven don’t want to die to get there. And yet death is the destination we all share. No one has ever escaped it. And that is as it should be, because Death is very likely the single best invention of Life. It is Life’s change agent. It clears out the old to make way for the new. Right now the new is you, but someday not too long from now, you will gradually become the old and be cleared away. Sorry to be so dramatic, but it is quite true.

Your time is limited, so don’t waste it living someone else’s life. Don’t be trapped by dogma — which is living with the results of other people’s thinking. Don’t let the noise of others’ opinions drown out your own inner voice. And most important, have the courage to follow your heart and intuition. They somehow already know what you truly want to become. Everything else is secondary.