Archive for the ‘Mac OS X’ Category

Core Animation 初探

2012/08/17

尽管在产品中要实现不少动画效果,却还没用过 OS X 的 Core Animation。一方面是因为经常要考虑用一套 code base 兼顾 OS X 和 Windows 两个操作系统。另一方面是习惯了基于 MVC 的手工实现方法 —— 在 model 中加入描述过渡帧的变量,用 timer 定期修改变量并通知 view,view 根据变量绘制过渡帧。不过偶尔模糊地见闻一些对 Core Animation 的褒扬 —— 充分的利用 GPU,释放主线程的压力,iOS 的 graphics 完全转向 Core Animation 等等。所以最近终于抽空看了些 Core Animation 的资料 [1]。

真正阅读资料之前对简介 Core Animation 的优点一直有些疑问。我无法设想与手工 MVC 动画相比 Core Animation 如何进一步压榨 GPU 的效能。在手工 MVC 动画中,虽然控制中间过程的变量需要 CPU 计算,但是这种变量会被设计为最简形式。比如,实现旋转一个矩形的动画时 timer 修改的不会是矩形的四个顶点,而是在 model 中存储旋转角度的变量。在 view 绘制中间帧时,才会根据这个角度计算四个顶点。这时的计算并非主要由 CPU 承担,而是把旋转角度变为旋转矩阵,交给 GPU 担负各个顶点的计算。

Core Animation 文档再次印证了我的一个感觉。当你感觉一个东西表面上被宣称的优点不成立,那么这种感觉很可能是对的。如果这个东西仍然很流行,那么在宣传中肯定遗漏了这个东西的真正优点 [2],并且伴随着一些隐晦的妥协 [3]。Core Animation 的妥协在于,它并不能完全替代手工 MVC 方式的灵活性。手工 MVC 动画中 model 里描述过渡状态的变量和 view 根据这些变量计算显示过渡帧的过程可以自由定义,在 MVC 这一套原则下可以实现任何动画。Core Animation 仅仅实现了有限的预定义动画,如 layer 的旋转,透明度变化,颜色,位移。尽管 Core Animiation 预定义的动画属性相当多,而且有数种组合方式达到无限的组合结果,但是仍然不能覆盖理论上的任何动画。所以它的动画效果是「罐装 (canned)」的。

但「罐装」效果对灵活性的牺牲带来了在表面宣传中未提及的好处 —— transactional。即使是初学者也能很快的实现一个简单的手工 MVC 动画,但是一旦加入复杂的 transaction 要求,即使老手也很为难:如何处理在一个动画播放的过程中用户 cancel 或者 undo 一个操作,或者更加复杂,在动画过程中用户执行引发另一个动画的操作。这要求动画在任意阶段的回退,或者从一个动画效果无缝切换到另一个效果,并保证 view 最终总能正确呈现 model 的稳定状态。通过把动画效果限制在有限的「罐装」组合,Core Animation 在程序员无需干预的情况下自动实现 transaction [4]。

更重要的是,虽然理论上听起来「罐装」效果对灵活性的牺牲很可怕,实际上 99% 的应用只需要「罐装」效果,因此 Core Animation 作出的牺牲其实是几乎无代价的。当然,不排除由于 Core Animation 的流行,一些原本应该更有创意的动画的地方被用平庸的「罐装」效果来应付。但我认为这是很罕见的情形。而且,动画效果已经从早期的 eye candy  变成提供 UI 行为的 clueness 的普遍要素(比如,当前的 view 变化是因为图像旋转还是图像发生了变化 —— 前者显示为图像旋转而后者则是新旧图片的平移切换)。从少数 fancy 操作扩展到几乎所有 view 的变化。动画的一致性 (consistency) 和 least suprise 变得比创意更为重要。

如果抛开 Core Animation 出现的真实历史 (historical accuracy) 来设想一下一个类似 Core Animation 的库是如何在 Apple 这样的公司出现的。它应该不会是自上而下的需求,不会是为了 OS X 的市场而设计的 feature。相反,像 Apple 这样的公司会对内部产品的界面动画有一套详尽的关注细节的指导原则,最初必然是由程序员用手工 MVC 实现,只需要极少创意和极复杂的 transaction 处理。于是一段时间之后程序员们终于把动画效果「罐装」起来,此后原来的指导原则也几乎不再有提及的必要,因为几行代码就能自然地实现,反而是没有动画效果的操作显得不够自然了。这必然是从真实的痛苦中诞生的技术。

脚注:

  1. 看的是 OS X 版本,所以对 iOS 平台的描述仍然不尽明了。
  2. 比如很多场合人们宣称 GPU 的优势在于大规模并行。这当然会引来一些思维敏捷的初学者的疑问 —— 为什么 Intel 们不把 CPU 做得一样并行化。而 GPU 的威力真正来源是它把要处理的数据结构局限在异常简单而且可以完美对齐的方式,并且配以高速的内存总线。最直接的证据就是无论 OpenGL 还是 OpenCL,在可预见的未来都不会支持指针。
  3. 如上 GPU 对数据结构的妥协。
  4. 底层实现上,Core Animation 通过三套 layer tree 实现自己的 MVC 结构来实现 transaction。这同样是由于把问题局限于「罐装」效果才可能做到。Core Animation 自己的 mini-MVC 让 app 的 MVC 架构脱离了动画 transactional 的负担。

开发 Batch Crop

2012/07/16

从 6 月 8 日到今天有一个多月没更新 blog,以前从未拖过这么久。因为这段时间在开发我的第一个准备在 Mac App Store 上发布的应用 —— Batch Crop。前几天终于把 1.0 版提交给 Apple iTunes connect 等待审查(就我的习惯来说,这个应用应该叫 0.6 版,但是 Apple 不允许 beta 版进入 App Store)。目前页面中的截图是正在开发的新版本。

以前作为 one man team 开发软件的经历不是很多。这次经历感受匪浅,想写的东西也不少。无奈不断改进 Batch Crop 的工作完全占用了 day-job 和家庭之外的所有时间。这也让我开始非常敬佩那些既开发自己的产品又撰写 blog 的牛人们。

Text Field 与 Field Editor

2012/06/08

Cocoa 提供了两种文本编辑控件 [1]:NSTextViewNSTextField。从表面上看,前者比后者功能丰富,前者一般用作复杂的文字编辑,后者一般接受简单的数据输入。二者处理 Enter 和 Tab 键的行为不同。NSTextView 的方式和通常的编辑器相同:给编辑内容添加换行或者 tab 字符。
NSTextField 的方式则类似于其它非文本编辑的 Cocoa 控件:Enter 键触发 target action(缺省为终止编辑),Tab 键令焦点移到相邻的下一控件。

有瑕疵的世界观

如果根据表面现象粗浅地猜测,有这么几种可能:

  • 二者是实现完全不同的类,运行时没有协作;
  • NSTextView 是 NSTextField 的子类;
  • NSTextField 是 NSTextView 的封装,对外隐藏后者的高级功能。

实际上这三个猜测都是错误的。查看文档可以排除第二种。另外两种的真伪则要花些功夫来辩明。当然,很多应用界面仅需要 NSTextView 提供的缺省 rich-text 编辑功能以及把 NSTextField 作为简短数据输入方式,所以我们大可以采用第一种假设来开发 90% 的应用。但若需要精细调整文本编辑行为,采用有瑕疵的猜想像是用牛顿力学和以太的概念指导宇宙航行。

以太概念的抛弃

要了解两个类的关系,它们的命名可以作为切入点——其中的「field」是什么意思?在数据库记录、表格或文件格式中一段相对独立的数据经常被称为「field」,所以自然的猜想是 NSTextField 作为简单的数据输入方式其名称中的「field」源于此意。但是 field 还有「现场」、「场所」的意思。

其实在 Cocoa 中提供文本编辑功能的类只有 NSTextView
NSTextField 不是 NSTextView 的封装,它的作用是为实际承担编辑工作的 NSTextView 提供操作「场所」。其名称中「field」的意义不是表格或文件格式意义上的 field。当一个 NSTextField 控件不拥有焦点的时候,它只显示自己存储的文本值 [2],并不和 NSTextView 有任何关系。当它获得焦点时,其所在的窗口会把一个 NSTextView 控件置于其上,并将原来的 NSTextField 对象设置为该 NSTextView 对象的 delegate,真正获取焦点并且成为 first responder 的控件是 NSTextView 对象。在同一窗口中,置于所有 NSTextField 之上的是同一个 NSTextView 对象实例。因为只有一个控件能获得焦点,所以共享单一的 NSTextView 实例没有问题。这个唯一的实例称为「field editor」,即放置在 text field 上的 editor。

Field editor 由窗口负责创建和管理。开发者如果希望实现自己的 field editor,可以重写 (overried 或者 implement) 下面的函数之一:

  • NSWindowfieldEditor:forObject:
  • 窗口的 delegate 的 windowWillReturnFieldEditor:toObject:

在说明 field editor 机制如何导致对 Enter/Tab 的不同处理行为之前,先简单说明一下 Cocoa 对键盘事件的总体处理机制。下图截自《Cocoa Event Handling Guide》,Figure 1-5。

最后一步「Insert as character in view」对于 NSTextView 来说相当于接收到 keyDown: 消息。Enter/Tab 作为 key action 被路径中更早的模块截取 [3],即图中的「Send action message to first responder」。所以 Enter/Tab 事件不会向 field editor 发送 keyDown: 消息,而是分别发送 insertNewLine:insertTab: 消息。

现在回到 NSTextViewNSTextField 对 Enter/Tab 的不同处理。严格的说是非 field editor 的 NSTextView 对象和作为 field editor 的 NSTextView 对象的不同行为。 NSTextView 的 isFieldEditor 属性表示当前对象是否为 field editor。一切行为差异的秘密就在于 insertNewLine: 和 insertTab: 会根据 isFieldEditor 的返回值来决定控件的行为。

问题的解决

有了正确的世界观,就可以自由地对文本编辑行为作出调整。比如,如何让控件在接收到 Enter/Tab 事件的时候始终插入相应的字符而非终止编辑或者切换焦点?可以有以下方案:

  • 始终用 NSTextView,并且保证 isFieldEditor 属性返回 NO
  • 重写窗口 delegate 的
    windowWillReturnFieldEditor:toObject: ,返回 custom field editor。此方案需要创建两个新类:窗口 delegate 和 NSTextView 的子类。后者的 insertNewLine: 和 insertTab: 需要被改写。
  • 让处理 key action 的模块发送 insertNewLineIgnoringFieldEditor: 和
    insertTabIgnoringFieldEditor: 消息给 field editor,保证始终插入换行或 tab 字符。

下面详细说一下如何实现最后一个方案。处理 key action 的模块首先检查拥有焦点的 NSTextField 是否有 delegate。如果有的话会向其发送 control:textView:doCommandBySelector: 消息。重写此函数可以改变发送到 field editor 的消息 [4] [5]。

- (BOOL)control:(NSControl*)control textView:(NSTextView*)fieldEditor
                         doCommandBySelector:(SEL)commandSelector
{
    if (commandSelector == @selector(insertNewline:)) {
        [fieldEditor insertNewlineIgnoringFieldEditor:self];
        return YES;
    } else if (commandSelector == @selector(insertTab:)) {
        [fieldEditor insertTabIgnoringFieldEditor:self];
        return YES;
    }
    return NO;
}

脚注:

  1. 本文混用「控件」和「类」来表示 NSView 的子类。在强调该类的用户界面交互行为的时候偏向于使用「控件」。
  2. 实际上 Cocoa 中的静态 label 也是由 NSTextField 实现,只不过这时它没有获取焦点的能力,不作为 NSTextView 的「field」。
  3.  这个「更早的模块」是 Cocoa 的 key-binding manager。可以参见《Cocoa Event Handling Guide》的 Key Bindings 章节等。
  4. 用 debugger 在其中设置断点查看 call-stack 可以发现更多信息,比如关于 key-binding manager 的信息。
  5. 更多细节可以阅读《Editing Programming Guide》的 Working With Field Editor,以及《Technical Q&A 1454》。

空间化界面

2012/05/14

在论坛社区里有个问题每过段时间就会被提一次:是否有可能出现一种图形化编程方式能完成「传统」方式的所有任务,甚至取代后者的主流地位。知乎的 Rio 在回答中提出像 Lisp 这样语法超级简洁的语言也许可以通过图形化语法树的形式完成图形化编程。从更广的角度来说,自 Steve Jobs 访问 Xerox PARC 之后(甚至更早),软件设计者们一直在挑战一个难题:软件界面应该图形化到何种程度。Apple 在传统 Macintosh 中做到了图形化的极端,甚至去掉了键盘的方向键;在后期的系统以及 OS X 中则向相反方向回退:AppleScript 鼓励普通用户编写脚本,命令行的 Terminal 重新发挥作用。合适的边界在哪里,而编程又在边界的哪边?

问题维度

小学时学习实数用到数轴,中学时学习一元函数用到二维笛卡儿坐标的图形化。足够细心的人能用绘图法能够解决很多选择题。如果配合坐标纸,绘图甚至可以独立解答一部分平面解析几何的题目。更进一步,空间想象力和绘图功力不错的人可以用正交投影绘图来研究二元函数和立体几何。但投影绘图方式只能作为提供思路的辅助工具,无法起到一元函数和平面解析几何的绘图法那种独立解题的作用。作为研究问题的工具,图形化所能胜任的问题维度受限于工具介质的维度。而且这里的图形化严格说来应该称之为「空间化」。由于高维度的问题无法空间化,研究者必须直接操作逻辑符号。

计算机的显示器是二维的,即使它能用各种投影法来显示 3D 内容,指点设备 (pointing device,比如鼠标) 操作界面的方式仍是二维的。所以软件的界面受限于二维。当然,实际要更复杂一些。借助窗口,tab,分割线,甚至于更复杂的 dockable  palette 等等,软件界面是具有层次关系的一组二维空间。但是具有层次关系的二维空间组仍然不是三维空间。

适合图形化界面的问题,是在经过层次划分之后每个层次可以被自然地二维空间化的问题。比如文件管理,文字处理和排版。在 《The Mythical Man-Finger》 中,作者循序渐进的说明了简单的管理音乐文件的问题如何一步步复杂化到远远超越二维空间化的程度,最终必须由命令行来实现。比如「把 iTunes 中随机/最后播放/最常播放的 n 首歌曲同步到其它 app/device」。把这个问题拆借成单独的小问题,很容易分别图形化:

  • 按照随机/最后播放/最常播放排序 —— 排序的空间化;
  • 找到任意数量的歌曲 —— 数量的空间化;
  • 同步到 iPhone —— 位移的空间化。

由于每个问题单独空间化的意义不同,它们的界面往往是分开的。组合到一起成为复合需求之后则难以图形化。这也是为什么一些图形界面应用总体设计的不错,一到了 Import/Export 界面却变成很凌乱的对话框,反而不如像 AppleScript 这样的脚本化功能来的自然。

空间化作为呈现问题的一种方式,是将问题本身空间化,而非空间化问题的另一种表现方式。不能因为一个文字排版软件优美地编排了一个阐述四维空间问题的论文,就说这个软件成功的空间化了这个四维空间问题。同样, 把所有的 Import/Export 选项用下拉列表和 TextEdit 控件并列出来,固然也算可用的界面,但并不优于命令行方式。Rio 关于图形化 Lisp 语法树的设想,以及很多软件通过图形化流程图来尝试图形化编程的方式,都属于这种不成功的空间化。数轴不是把实数的十进制表示空间化,而是把实数本身空间化。二维坐标系不是把一维函数的公式空间化,而是把函数本身空间化。

目前用计算机处理多维度问题的方式主要要是定义一个多维的非空间化表示方式,然后再尽可能的用图形化界面来呈现这种方式。此时的图形化是为了呈现表现方式的符号而不是问题本身。今天我们有支持多 tab 和彩色字符的 Terminal;有支持语法高亮,括号匹配和 block 折叠的代码编辑器,它们是符号化图形界面而不是空间化界面。

空间化与符号化

回到最初的问题。既然早已存在高度图形化的代码编辑器,为什么「图形化编程」的问题会一再被提出?因为提问者和回答者都在潜意识里把「图形化」等同于「空间化」,直觉上对符号化的图形界面并不满意。但是缺乏「空间化/符号化」的分类思维又无法认识到空间化的局限。

为什么「空间化」如此深入人心?我只能猜想,在几百万年的进化中,人类的空间化记忆和认知对生存至关重要,这是人类最熟练的技能。

即使不考虑键盘和指点设备的输入带宽差异(幻想有某种神奇的指点设备能用键盘输入字符的速度操作空间化元素),把逻辑符号生硬的空间化也未必能提高效率。比如说,语法树这个逻辑概念中,每个节点的空间坐标其实是没有意义的。当然,在具体的教学例子和某位程序员的思考过程中,把某个语法树空间化是有用的。但是这更多的是特定例子(对初学者)的呈现和短时间自省的工具,而非一般的交流和存储方式。试想如果把成千上万的语法树空间化表示,光是调整节点的空间布局就需要无谓地耗费多少精力。在长期的知识表示和交流中,语法树的空间信息是正熵而非负熵。

逻辑符号标志着人类中的一部分超越了可能是自然进化赋予的空间化记忆和认知能力,数学(特别是超越三维的问题)和编程语言体现了直接在高维度思考的能力。每个人掌握这种能力的程度不一,有些程序员可以通过默想写出程序,而另一些则需要绘制大量用于自省的草图,。但是无论如何,空间化工具已经不足以解决高维度问题的过程中充当主要角色。人类和工具在相互影响,一方面工具尤其是软件在不断适应人类几百万年的空间化认知习惯,另一方面,能够驾驭高维逻辑工具的人在整个人类中的比重也应该越来越多。

模态和对话框

2012/04/19

最近这两年,我对软件 UI 设计经常反复强调的一个意见是尽量避免使用模态对话框 (modal dialog box) 。这个曾经广泛应用的 UI 元素逐渐式微。不严肃的说,模态对话框的多寡似乎成了区分「企业开发」的垃圾 UI 和消费者/专业级软件 UI 的一个新指标。

有些设计源自妥协,一旦妥协的前提不复存在,建立于其上的设计也随之衰败。比如说,一度公认的观念是应用程序必须「正常」退出,强行中止程序或者 crash 可能导致严重的数据丢失。但是近年来 iOS app 和 OS X 的 sudden termination 概念改变了旧观念。因为硬件性能的提高,原来把重要数据只保留在内存中直到用户明确表示存盘或者退出的做法,逐渐被更安全的方式替代 —— 重要数据一旦被创建或者修改立即写入磁盘,称为 crash-only 方式。「正常」退出成为不必要的 performance hack。

模态对话框也源自类似的妥协,我称之为「系统范化 hack 」。将本该由软件设计者承担的责任过多地由系统和 UI framework 代劳,根源在于对非功能性需求的轻视。OS X 和 iOS 将用户体验提升为最重要的竞争指标,「系统范化 hack 」也失去了立足的基础。

模态操作、消息循环和对话框

基于 model-view-controller 模式的 app 的用户体验由一系列修改 model 数据的「模态 (modal) 」过程组成。在一个模态过程中,某些 UI 元素接受用户输入并修改 model,其它 UI 元素接受用户输入的能力被暂时禁止或限制,以此避免过多不确定顺序的修改操作破坏 model 的数据一致性 (integratiy) 。「模态」的名称就是表示 UI 时刻处在不同的状态 (mode) 中,每个状态中只有特定的元素可以接受用户输入。「模态」过程由下面的概念构成:

  • 过程的开始和结束,即本次 mode 的时间范围;
  • 过程的边界,即在本次 mode 的时间范围内可以接受用户输入的 UI 元素和禁止接受用户输入的 UI 元素的界限;
  • 过程的结果:根据用户的输入修改 model (submit),或者在不修改 model 的情况下中止 mode (cancel)。

最基本的模态过程实现是把完成该过程的所有代码都放在单次 event-handler 调用中,在过程结束之前,消息循环无法获得控制权,以此禁止所有 UI 元素接受用户输入。这种方式有两个局限:

  • 执行的操作耗时不可过长,否则会导致界面假死;
  • 对用户输入简单地完全禁止,而非有选择的限制。在过程开始之后无法追加修改输入。适合诸如「键盘输入一个字符」或者「按下一个按钮」这类无追加输入的操作。而像文件存盘过程中涉及文件系统的 naviagation,起名等操作,就不能采用阻塞消息循环的方式。

根据「模态」过程的一般定义可以显而易见的分析模态对话框的行为。开始和结束对应对话框的打开和关闭,边界即对话框的边界,submit 和 cancel 由对话框的不同关闭操作完成。

从实现角度看,模态对话框是 UI framework 对「第二消息循环」的粗糙封装。内层循环可以阻塞主消息循环,而且消息循环具备过滤 event 的能力,不但可禁止 UI 元素接受用户输入,还可以有选则的允许用户追加修改输入 [1],所以「第二消息循环」很适合实现模态过程的边界和时间范围,自然而然的成为模态过程的主要实现方式。但是消息循环本身是一个比较难理解的概念,MFC 和 Java Swing 甚至企图完全隐藏它 [2]。所以,早期的 app 开发者和 UI framework 串通好采用模态对话框这个粗劣封装。本该由开发者决定的丰富多彩的「模态」过程被系统僵化同质的罐装行为替代。

模态对话框这个 one-size-fit-all 的封装面临一个两难问题:模态过程和整体界面的融合度高不高?如果融合度很高,那么弹出对话框的行为就会撕裂用户体验。如果融合度不高,那么在模态过程中,对话框下的主窗口就是干扰用户注意力的视觉污染。所以 UI 设计应该尽可能抛弃模态对话框,根据模态过程和整体 UI 的融合度来决定时间范围和边界行为。

鼠标拖放

鼠标拖放 (drag and drop) 第一代 Macintosh 和 Windows 3.x 时代就进入了用户体验。但是相对复杂的编程接口让程序员总是希望逃避它,所以一直以来只有 Finder、Windows Explorer 以及 Text Field 之类的系统控件支持有限的缺省拖放行为。很多开发者没有意识到鼠标拖放是一种能被高度定制的模态过程。这是非常可惜的。鼠标拖放巧妙的利用了「冲压机原理」[3] 来限制边界。通过把模态过程的开始和结束分别定义为鼠标键的按下和抬起来占据鼠标设备,避免边界之外的 UI 元素接受用户输入 [4]。

鼠标拖放过程的边界由三种 UI 元素构成:拖动的元素,开始拖动的源,drop 的目的元素(有多个可能的目的元素,最终在其中之一上进行 drop 而结束过程)。如果对 model 的某种修改能够反映为 UI 元素的空间位置变化,就可以采用鼠标拖放。比如常见的图片编辑器中对图片的平移、旋转、缩放。常见的文件移动操作因为 spatial file manager 的概念把文件操作对应为空间操作,也采用了鼠标拖放。

鼠标拖放由「第二消息循环」来实现 [5]。由于 MFC 和 Swing 等 framework 试图隐藏消息循环概念,它们支持鼠标拖放的 API 相当蹩脚,文档晦涩不明,是阻碍拖放的流行的一个因素。在 Cocoa 和 Qt 这类 framework 中鼠标拖放的编程模型回归到基于消息循环的概念 [6]。由鼠标键的按下开始拖放过程。在合法目的元素上,鼠标键的抬起引发 submit,在非法目的上,引发 cancel 操作。键盘的 ESC 键也可以进行 cancel 操作。鼠标拖放可以配合键盘修饰键(比如文件拖放中 Shift 的按下与否表示拷贝还是移动),磁力粘合,拖动手势,不同的目的元素接受 drop 的行为等追加输入完成复杂的操作。还可以配合 UI 元素上不同的 handler 完成旋转、缩放等操作。和整体 UI 的融合度很高的模态过程几乎都可以采用鼠标拖放来完成。设计良好的图形化 UI 的重要特征是把 model 的表示空间化 [7]。

进度条

使用进度条,是避免 UI 过度复杂的前提下,在耗时较长的计算过程中禁止用户的一切输入,同时避免 UI 呈现假死。用户可以中止该过程,以放弃部分完成的计算结果为代价换取立即执行其它操作的机会。在老式 UI 中这类过程通常由带有一个进度条 (progress bar) 和一个 cancel 按钮的模态对话框实现。

这种模态过程的 submit 不由用户控制,用户或者等待过程结束自动 submit,或者主动 cancel。如果换一个思路,此类模态过程无需禁止用户输入 —— 对 UI 元素操作视为 cancel 即可。因为没有复杂的边界定义(也就无需 event 过滤),这种模态过程可以采用「第二消息循环」以外的方式实现(比如 timer)。可以直接在主窗口上显示进度条。过程完成后隐藏进度条,在操作过程中如果用户进行其它操就 cancel 过程。

进度条对话框还有一个略带病态的变种 —— 提示对话框 (alert box) ,仅仅向用户提供一条信息。这种操作应该由主窗口底部的状态条来代替。

整体状态切换

如果模态过程和整体 UI 的融合度非常低,应该考虑让主窗口整体进入模态。Photoshop 的 Filter plug-in 是采用模态对话框实现的。但是近年来随着新出现的 Filter UI 越来越复杂,新的 Filter 如 CS6 版本的 Lighting Effect 和 Field Blur 均采用了主窗口整体模态的方式,在 Filter 工作时不再弹出模态对话框,而是将整个 Photoshop 主窗口切换为 Filter Workspace。

在这种模态过程中,边界之外的 UI 元素被隐藏,甚至完全销毁,所以不存在 event 过滤问题。实现的难度在于模态过程之前的 UI 状态保存和之后的状态恢复。需要应用程序开发者实现的细节比较繁琐,UI framework 提供的编程接口则相对简单。

附着型系统控件

上面谈的例子宗旨是让 app 开发者多做一些,让 UI framework 的责任少一些,接口灵活一些。但是在快速开发中,app 开发者还是期望有一些简单的方案。现代的 UI framwork 也提供了一些新的罐装机制 —— 虽然不如手工打造的模态过程精美,也比老旧的模态对话框好得多。

除了提过的融合度难题,老式模态对话框还有两大弱点:第一,它阻止用户访问同一 app 的所有其它窗口;第二,弹出模态对话框的窗口 (owner) 和对话框本身没有任何视觉联系。从逻辑上来说,每个模态对话框都有自己的 owner,而且在多窗口 app 中,每个窗口代表一个独立的 document,实在没有必要让一个模态对话框阻断用户对所有窗口操作。新的罐装模态过程机制虽然不能解决融合度问题,但是针对这两个次要弱点做了不小的改进。

OS X Cocoa 提供了 pull-down sheet,解决与 owner 缺乏视觉联系和禁止所有窗口操作的问题。在多窗口的 document-based app 中,pull-down sheet 可以在不增加代码复杂度的情况下完全替代模态对话框。Lion 又提供了 Pop Over  控件,将模态对话框的 owner 关系进一步突显到控件级别。

脚注:

  1. 这也是「对话 (dialog) 」一词的由来。
  2. MFC、Swing 等 UI framework 试图隐藏消息循环的做法给开发者的学习制造了不小的障碍。我在 MFC 和 Swing 中滚打多年,对这个概念的理解也一直比较模糊。多年以后,在 Cocoa 的文档中看到开篇明示消息循环的概念,才有醍醐灌顶的感觉(见注释 5)。
  3. 为了防止意外伤害工人的手,冲压机有两个冲压按钮,在远离冲压孔的两个方向,只有两个钮同时按下冲压机才会执行冲压操作。所以只有两只手的生物绝对没有机会在冲压操作中把手放到冲压孔内。
  4. 至少避免了指点设备 (pointing device) 的输入。键盘成为拖放操作中追加后续修改操作的手段。
  5. 这点可以参考 Cocoa 的 Event Handling Guide,Handling Mouse Dragging Operations
  6. 同注释 3。特别是其中的 The Mouse Tracking-Loop Approach。注意,鼠标拖放 API 的设计模型基于第二消息循环,并不意味着 API 本身必须完全暴露第二消息循环。
  7. 从另一方面说,本质上高于三维的 model 已经不适合图形化 UI。比如编程语言。参考知乎问题

新增功课

2012/02/10

在 Amazon.com 上买的《Mac OS X Internals: A System Approach》经过十天到货了。虽然有电子版,但还是买个纸质的收藏一下。

技术与文化

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 质量本身而非效果甚微的底层方案才是正确的。

为什么 Mac OS X 先进 (续) ?

2012/01/19

几个月前写的《为什么 Mac OS X 先进?》有人认同也有人反感。想给它写个续篇缘于最近发生的两件事情。一件是为了买《 The Design and Implementation of the FreeBSD Operating System 》在 amazon.com 上闲逛(后来在 china-pub.com 上买到了影印版),惊喜地看到《 DTrace: Dynamic Tracing on Oracle Solaris, Mac OS X, and FreeBSD 》已经出版了。五年前在杭州西湖边的酒店首次听 Sun Microsystems 工程师介绍 DTrace,还只是 Solaris 上和我不太相关的东西。半年前得知 DTrace 已经被移植到 OS X 上,在 amazon.com 上看到这本书处于未出版的预定状态。今天终于能翻着这本书在 OS X 上把玩 DTrace,心情有些许激动。

另一件事是同事向我推荐 Blackhat 的 Dionysus Blazakis 的《 The Apple Sandbox 》。在《 Sandbox 初探 》中我提到过,Sandbox 是在 DAC 和 MAC 两种安全模型都不适合个人计算的情况下另辟的第三条路。读了 Blazakis 的 paper 发现 Apple 没我描绘的那么特立独行 [注]。OS X 自 Leopard 起就已经集成了通用 MAC 架构 TrustedBSD,只是一直未有具体策略施加其上(TrustedBSD 只是执行 MAC 策略的机制而不是策略本身)。Lion 中新出现的 Sandbox 是在该架构上编写的 policy module。如《 Sandbox 初探 》所述,Apple 的创新在于和 UI framework 集成的动态安全策略。在执行安全策略的底层架构上,Apple 并不是自己发明车轮,而是直接采用了 BSD 社区的方案。另一方面,如 Blazakis 所说,Sandbox 也为 TrustedBSD 增加了高价值的功能,没有这类 closed 系统的无缝封装,无法想像 TrustedBSD 能在普通大众的计算机上发挥作用。

这两件事情让我回忆起前段时间看到的关于「开放模式」的讨论。Mac OS X 常被称为 closed 系统,相对而言有人甚至称 Windows 为 open 系统。Closed 略带贬义,因为有人盲目崇信 open 必胜。Steve Jobs 和 Bill Gates 都更愿意用含蓄一些的词,前者称 Mac 为 integrated 系统,后者称 Windows 为 compatible 系统。我认为做比较还是该用 open/closed 这样的直白词汇,但是要在各个层次分别描述:

  • Closed open-core 系统:Mac OS X
  • Open closed-core 系统:Windows
  • Open 系统:Linux,BSD

这些年 Mac 似乎远比 Windows 成功。Linux 和 BSD 这类 open 系统的成功领域是嵌入式设备、移动系统、服务器和超级计算等定制硬件系统,从软硬件的整体角度来说也构成 closed open-core 系统。看来 close open-core 是这阵子的黄金模式。传记《 Steve Jobs 》反复表达 closed 系统在提供优秀用户体验方面的 open 系统不可比拟的优势。但是人们经常忽视 Apple 产品在 open-core 方面的优势。

上面两个实例充分体现了 open-core 模式的优势。Jobs 公布 OS X 的第一个预览版时强调它是 Unix 并非吸引观众的权宜之计,目前为止七个版本的发展中,OS X 和Unix-like 系统社区一直保持密切的交流(还没有提这些年 Apple 向 Unix-like 反哺的代码,比如 Grand Central Dispatch)。这几年 Mac 上很多创新都来自公共领域经过考验的杰作。Closed-core 的 Windows 尽管能用于更广范围的廉价硬件(以牺牲用户体验为代价),但是集成这些创新明显力不从心。一是和 Unix-like 不兼容的内核必然导致移植成本增加,二是长久隔离于 Unix 圈之外的开发团队对公共领域的创新缺乏敏感,以至于只有 Apple 等其它厂商早有所行动之后才能缓慢跟进。

关于底层和上层的 open/close 模式问题,曾经在《 Open Source 的界限 》中从 open source 项目角度讨论过。Open-core 无疑是总结这个模式的好名字。Open source 的界限,就是 open 的界限,也是 open 的生命力所在。

注:虽然《 Sandbox 初探 》在 Sandbox 和 MAC 之间的关系上不太准确,但是这不妨碍它是 Blazakis 的 paper 的有益补充。后者虽然有无可比拟的 reverse engineering 实证作为基础,却没有涵盖 Sandbox 最重要部分 —— 和 UI 集成的通过 Powerbox 动态扩展 sandbox 范围的机制。关于这部分机制我认为《 Sandbox 初探 》的解释是正确的。

解剖 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 缺陷的必要方法。