Archive for the ‘软件开发’ Category

空间化界面

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。比如编程语言。参考知乎问题

Beta 发布 Adaptive Wide Angle

2012/03/29

3 月 21 日 Photoshop CS6 beta 开始公开下载。随之和用户见面的包括 Adobe 中国 Photoshop 团队开发的首个 Photoshop 主要功能 [1] —— Adaptive Wide Angle。这是我们工作一年半的成果。从用户的反应来看结果和前景都令人兴奋。

Adaptive Wide Angle 不仅是 Photoshop CS6 的主要功能,在图像处理方面也是新的突破。Photoshop 一般用来弥补照片的瑕疵或者制作各种超现实效果。Adaptive Wide Angle 面对的是一个较少为人了解的领域 —— 摄影与人主观视觉的差异。

除了人眼的双视立体效果外,普通人对摄影和主观视觉的差异了解不多。Adaptive Wide Angle 处理的问题正是双视立体之外的主观视觉(可以简单视为闭上一只眼之后感受到的视觉效果)和摄影的差异。所以 Adaptive Wide Angle 面临双重挑战:除了提供强大 UI,还需要向用户传达新领域的概念。另一方面,我从一开始就认为传达新概念这个任务并不全属于 UI 设计本身,因为新概念很快会成为被广为接受的普通概念,而 UI 演进要求一定的稳定性。所以,在总结工作心得的同时,这篇 blog 也是对这个新概念的简单介绍。

人的单眼视觉可以达到 100 度左右的视角(和视线中心轴成 50 度夹角的锥形)。这 100 度的视角投射到视网膜上,经过视神经和大脑的处理,最终具备下面近乎完美的特性:

  1. 所有直线仍然是「直线」;
  2. 局部不变形。物体的大小变化只和物体离开人眼的距离有关,在以人为球心的球面上移动物体,观察到的大小不会发生变化。

摄影技术无法完全还原单眼视觉。3D 全息技术固然可以,但在无需双视立体效果的情况下并无必要采用这种复杂昂贵的技术。完全还原单眼视觉的最简技术是把场景投影在以人为球心的球面上(称为视面球,view-sphere)。这种方法的关键在于直线并不真正投影为直线,但是也不像 fisheye 之类的镜头投影为平面上的曲线,而是投影为球面上的「侧地线」[2]。所以既可以保证主观感受的直线不弯曲,也能保证局部不变形。这正是大幅电影采用弧面银幕(部分球面或者部分柱面)的原因。但是这种做法代价仍然很大,以人为球心并且半径不能太小的球面不可能便于携带,也难于在建筑物平面上安置。

画家们在 16 世纪发明了灵活变通技术:对于包含明显直线的大尺度物体,比如背景建筑物,采用保证直线的透视投影 (perspective projection,等价于 pin-hole projection);对于本身不包含明显直线的小尺度物体,采用它们在视面球上的形式(由于球面的小部分可视为近似于平面)。比如这幅油画 [3]。这种技术称为「自由透视」或者「移动视角」[4],是即能避免球面投影又接近主观视觉效果的最佳折衷。但是没有经过透视画法和艺术创意训练的普通人显然和这种技术无缘。美国的图形学家找到了一种可以由计算机实现的数学方法。这篇论文的作者中的 Aseem Agarwala 和 Robert Caroll 和中国团队合作完成了 Photoshop CS6 Adaptive Wide Angle。中国团队从两位图形学家手中得到了质量很高的偏微分方程的线性方程逼近算法代码。

我们的第一个目标是让 Adaptive Wide Angle 和整个 Photoshop 平台风格高度一致。Photoshop 在过去的几十年中不断加入的新功能以及与 Filter 的交互方式,Adaptive Wide Angle 都给予了支持,主要包括:

  • Smart Object(同时也是对 action 的支持);
  • LAB,CMYK 等色彩模式;
  • 每通道 16 位色;
  • 透明通道。

Adaptive Wide Angle 还克隆了一些较新的 Filter 辅助功能,比如 Liquify 按住/放开 X 可以将 Preview 面板的放大系数增加四倍/恢复原状,同时不打断任何正在进行的 on-canvas 元素编辑。

第二个目标是在渲染能力上的扩展。Adaptive Wide Angle 既使用显卡加速渲染,又避免渲染的图片大小受到显存和内存大小的限制。

第三个目标也是中国团队投入精力最多的环节是界面的可用性。Aaptive Wide Angle 是目前 Photoshop 中界面最复杂的 Filter。我们尽量让它的界面简洁直观。Adaptive Wide Angle 的界面上先后存在过 30 多个不同控件,在不减少功能和灵活性的前提下减少到现在的十几个。更多的任务通过在 Preview 面板上编辑 on-canvas 元素来完成。

Adaptive Wide Angle 面临的最大的可用性挑战是如何让用户准确的输入照片的焦距 (focal length) 和投影类型 (比如 perspective, fisheye) 。在这方面我们体会到了 Photoshop 平台的强大威力。Adaptive Wide Angle 充分利用了 Photoshop 的资源:

  • 对于 Photoshop 支持的镜头/机身组合,Adaptive Wide Angle 自动根据 Lens Profile 数据计算焦距(同时弥补镜头的光学畸变);
  • 对于 meta-data 中同时提供 focal length 和 crop factor 的照片直接采用这些数据;
  • 通过增强 Auto-Align Layer 功能,把 Photomerge 生成的全景图的虚拟焦距存入 PSD 文档(或者其它任何支持 Adobe XMP 标准的文件);
  • 对于没有任何 meta-data 的 fisheye 镜头,中国团队发明了在 Preview 面板上一次成功的估测方法。

经过这些努力,大多数用户根本不必了解焦距和投影类型这两个概念。克服这个可用性障碍是中国团队的努力和 Photoshop 平台的巨大资源积累的合力。

最后,我们如愿以偿地看到很多用户通过 UI 本身和简单介绍能理解这个工具所实现的新概念。甚至于有些用户评价,由于这个工具直观的 UI 和结果的可预测性,一些理论上能够由 non-adaptive 工具 [5] 完成的任务,也可以通过 Adaptive Wide Angle 来完成。

脚注:

  1. Photoshop 中国团队之前开发过 Adobe Lens Profile Creator 和 Adobe Lens Profile Downloader。都是配合 Photoshop 使用的独立工具应用。
  2. Great circle。球面上连接两点最短的弧。可以看作两点和球心所在的平面与球面的部分交线。
  3. 注意此画两侧和中央的人物大小相同,而桌子的透视灭点又说明观察者距离场景并不远(不是长焦效果),这在相同视角的普通照片上是不可能的。
  4. 这个功能中的 adaptive 就是表示根据图像的内容自适应的进行自由透视投影。
  5. Non-adaptive 表示所进行的图像变形是有解析形式的统一数学变换,和 adaptive 算法进行的自由透视方法相对(后者是由用户输入和图像性质共同决定的偏微分方程逼近结果,没有解析形式)。Lens Correction 和 Perspective Transform 是常用的 non-adaptive 工具。

软件质量之路

2012/03/20

上个月有段时间修正 bug 忙得要死,最后缓口气的时候统计了一下,发现这段时间修正了近 20 个 bug。这个数字大大出乎我的意料。

这些修正 bug 的工作并非是目前我开发的产品,而是一个两年前维护过的现在已经交给其它 team 维护的系统。即便两年前,对此系统的诸多细节也不甚了解。如今已两年没有接触它的 code base,只是对其大体构架还有印象。最近维护该产品的人手紧张,调我临时支援一下,主要处理一些罕见的 random crash。这些 random crash 很难在测试环境下复现。产品发布给数千的 pre-release 用户后才会在不断的日常使用中偶尔产生。遭遇 crash 的用户可以通过自动工具把 crash report 提交给服务器,report 中的信息不是很多,最主要的是所有 thread 在 crash 时刻的 call-stack,后端系统会根据导致 crash 的 thread call-stack 自动进行分类。这就是这些 bug 的背景:一个不熟悉的产品,没有复现 bug 的情景,只有少量线索。因为还要保证目前负责的产品的进度,每天只能花一半工作时间研究这些 crash report,如此历经三周。修正近 20 个 bug 的成绩令人颇感意外。

这件事情给我的启示是多方面的。Eric Steven Raymond 在《The Cathedral and The Bazaar》中针对 open source 模式总结出「Linus Law」,后来又在《The Art of UNIX Programming》中详细阐述:

Given enough eyeballs, all bugs are shallow.

有人质疑「Linus Law」(比如这篇),开放 source code 是否等同于有更多的人发现并修正 bug。因为不管一个 open source 软件多么流行,真正积极参与开发,能深入了解 code 的总是少部分人。这段时间修正 bug 的经历却证明修正软件稳定性方面的 bug 无需深入了解特定产品的 source code,特别需要的是与产品无关的通用软件开发知识,比如 C/C++ 的语言和 runtime,操作系统,二进制接口,静态和动态链接,汇编,多线程多核等等。影响和推动某个 open source 软件产品的通常是小团队,但能够参与提高产品稳定性质量的可以是很大的开发者群体。因为所需技能并非与某个甚至某类产品相关,一个高水平的程序员甚至可以在业余时间帮助提高多个 open source 产品的质量,即使有些产品的类型超出他熟悉的范围。

第二方面的启示是关于在软件中寻找问题。这些 crash 的代码乍看起来是非常「无辜」的。甚至于我每次都会惊呼一定是 C runtime 的 malloc() 有 bug 或者宇宙射线导致的内存错误。而且没有任何动态分析(比如使用 debugger,输出 log)的手段。不过只要定下神,告诉自己确信这个地方一定会导致 crash,经过几个小时的静态分析和冥思苦想之后几乎总是能找出一些问题。根据这些模模糊糊的推测完成的修复没法即时验证,只能先 submit 再说,称为 blind-fix。同样出乎意料同时也颇有成就感的是 crash 统计系统中相当多的 crash 数量在相关的 blind-fix 之后陡然下降为零。

在软件系统日益复杂的时候,程序员却失去了一些 postmotern debugging 的信息(例如,large sparse 进程空间让 full core dump 在用户生产系统上无法接受),但是这些真实的 blind-fix 实例说明:第一,call-stack 是最重要的调试信息;第二,修正问题最重要的一步是能 100% 的确信一定存在问题。这两点的重要程度之高远远超乎我之前的预料。关于第一点,我曾经很多次提到过,对待破坏 call-stack 可见性(比如,由于增加动态语言的虚拟机而导致两层 call-stack)和完整性(比如 message-posting,provider-consumer,Reactor 模式)的技术要非常慎重。

第三方面是关于工具。一个简单的自动提交 crash report 的工具和一个 crash 分类数据库,不算什么高深的技术。但是,如果没有这套系统,发现和研究 random crash 与验证 blind-fix 就是不可能的。在软件工业里,再不起眼的小工具都是加速整个行业进程的一部分。一些老程序员有个共同的经历:开始编程是因为能直接操控一台复杂的机器快感;经过一段时间之后,发现这机器比预想的要难驾驭,产生很强的挫败感;经过一段迷茫之后又发现,自己每一点努力的成果,并不是限于直接操控机器,而是增加整个群体的驾驭系统的能力,同时自己的能力也在这个体系上加速提高;如此重新找到编程的热情。

十几年前,「软件危机 (software crisis) 」还算个值得一提的名词,而且《人月神话》里「no silver bullet」的预言至今也没有被打破。不过,虽然没有数量级的生产力提高,软件开发还是在工具与协作模式上持续地稳步提升生产力,甚至还有些许的加速度。我们已经安然度过了「软件危机」。知乎上有一个问题 —— 是否能用十年前的技术造出今天的软件,我说的是,这十年软件业并没有空过,和十年前相比,我们毕竟拥有了很多无法或缺的东西。

用户是最大的漏洞

2012/02/15

从安全方面来说,用户是系统中最薄弱的环节。不过「聪明的」用户总是会找出各种理由把责任推卸给「技术」。甚至研究者有时也会落入这种陷阱。

不久前在知乎上有一个关于 MD5 的讨论。不得不说 MD5 是一种非常弱的 hash 算法。考虑到使用 SHA-1 等更强算法的额外负担完全可以忽略,我建议无条件的避免使用 MD5。但是从另一个方面来说,到底多少安全问题真正的责任在于 hash 算法的脆弱性?我在知乎上的总结是这样的(下面有些描述是符号化的,但在知乎上用的是非符号化语言。如果二者造成理解上的差异我为知乎上的不准确抱歉):

给定一个 hash 算法 f(x),对 f(x) 的「破解」有下面几种方法(注意「破解」不能等同于攻击。破解是非蛮力攻击。所以以下方式均有「比穷举算法效率更高」这个限制):

  1. 使用比穷举算法效率更高的方法得到 x1 和 x2,满足 f(x1) = f(x2);(可称为随机盲目碰撞)
  2. 给定 x1,使用比穷举算法效率更高的方法得到 x2,满足 f(x1) = f(x2);
  3. 给定 x1,使用比穷举算法效率更高的方法得到满足约束 A 的 x2,满足 f(x1) = f(x2)。约束 A 越严格,达成实质攻击的可能越大。比如,若 A 是描述自然的英语或者汉语,则任意的签名欺诈可行矣。

目前在理论界只有破解方式 1 得到了一些针对 MD5 级别的弱 hash 算法的突破。方式 2 和 3 在理论上的可能性也还未证实,甚至于对最弱的 hash 算法即使用蛮力也无法得到方式 3 中真正可以施行欺诈的 x2。我认为方式 1 很难达成实质的攻击。但是有研究者不同意这个观点本地存档)。这个链接给出了一个方法,通过方式 1 的随机盲目碰撞也能达成实质的数字签名欺诈。果真如此吗?

错!

(以下描述需要理解刚才给出的链接内容)

Caesar 的错误不在于使用了弱 hash 算法,而是他轻易相信了 Alice 给他的 PostScript 格式的文件。他没有审查这个文件的实质代码(尽管 PostScript 文件的源代码实际上是可读的),而是轻信了一个 PostScript viewer 一时 render 出来的外观就匆匆地数字签名了。如果把 PostScript 文件变成可执行文件,把 PostScript viewer 变成操作系统,Caesar 变成某家水果公司。那么以上关于 Caesar 和 Alice 的情景无异于:一个开发者给水果公司一个 app,水果公司运行了一下发现没问题,然后水果公司就用公司证书给这个 app 签名并将签名后的 app 发回给开发者了。那么水果公司因此背上任何黑锅都是咎由自取。事实是:

  • 水果公司不会用自己的证书,而是用自己的证书认证开发者证书,然后要求开发者用自己的开发者证书签名;
  • 水果公司要保留追溯开发者的机制,在后期发现 app 违规时可以追加惩罚。

所以,数字签名技术提供的信任是有约束的。对简单的可审查文件(如纯文本)才能任意签署。对复杂的不可审查文件(如可执行文件,复杂结构化文档,特别是带有动态内容的文档 —— 其实质等同于可执行文件,有些甚至达到了 Tuning-complete 级别)必须坚持「谁产生谁签署」的原则,而且必须有后续的法律追溯机制。更近一步说,对复杂的不可审查文件的轻信是任何技术都无法挽救的安全溃败。

其实上文提到的这个研究者在最后一页中提到了采用其它文件格式作为解决方法。但是他明显认为和采用更强的 hash 算法相比这是一个次要因素,而且他提出的居然是 Word 文档,一种内在格式和外观差异与 PostScript 不相上下的格式,而非「所见即所存」的简单文本格式。我曾经参与过几次安全培训,那些很有经验的讲师一般也会在几天的培训里不经意举出一两个他们认为的技术上的安全漏洞,而实为用户「盲目轻信」导致的问题。从信噪比的角度看,这些疏忽对那些安全专家的知识价值可说瑕不掩瑜,但也说明在人类心理和行为模式方面的安全才是最脆弱的。

技术与文化

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

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

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/09/24

最近在读《 Code Complete, 2nd Edition 》。谈论比喻 (metaphor) 的重要性时,这本书把建筑作为软件开发的主要比喻之一,并且认为这个比喻很贴切很有用。我认为这本书有很多正确的结论,但不包括对这个比喻的看法以及随之而来的某些直接推论。

对于一个建筑,很容易区分哪些是基础,哪些是附属(或者分辨各个部分更接近基础还是更接近附属的程度)。墙面的装潢很容易修改,改变墙体的构造就很困难。对摩天大楼来说,改变顶层的尖塔很容易,而改变首层的承重结构就几乎不可能。基础和附属在修改成本方面的巨大区别令人直观的意识到规划的重要性。构建一个各部分都「摸得着、掰得下来」的狗窝和构建一个很多东西「封在里面、压在底下」的摩天大厦绝对需要完全不同的规划方式、设计手段、和管理模式。对于后者,构建之前的确需要仔细分析,针对基础部分的决定一旦作出很难更改。

但是把这个比喻延伸到软件会引入众多似是而非的问题。可以从修改的难度来界定软件的基础和附属吗?可以从暴露的程度来区分软件的核心和外围吗?让我们尝试几种判断的方法。

首先,可以把软件系统中靠近硬件的部分,或者被最多的模块依赖的部分称为基础吗?可是,有无数的成功实例说明应用软件可以从一个操作系统移植到另一个操作系统,或者一个操作系统从一种内核迁移到另一种内核。在整个软件栈 (software stack) 中这种釜底抽薪的替换工作并不鲜见。在软件栈稍靠上的地方,也不乏保留上层模块而替换相对底层模块的例子。就在最近的 Adobe Creative Suite 5 for Mac 大部分产品从 CS4 的基于 Carbon 转换到基于 Cocoa。

可以把靠近用户的部分称为基础吗?大多数完全不了解编程的人可能都会反对。软件的界面、皮肤、look and feel 都是可以不断变化的。可以把与具体算法无关的机制称为基础吗?不久前我的 blog 里关于微内核和 XNU 的文章里可以看到内核的演化中,各个模块的运行级别可以在内核态和用户态之间切换,通信机制也可以发生变化。可以把与具体机制无关的算法称为基础吗?Linux kernel 的调度算法在不改变进程切换机制的情况下从线性时间复杂度变成常数时间复杂度。Mac OS X 的 built-in VNC 实现 (Share Screen.app) 在不修改基本机制的情况下加入了大大优于其它实现的屏幕数据压缩算法。符合标准的接口和协议是不能轻易修改的基础吗,比如 x86 指令集或者明文协议?你总是可以用某种中间翻译层来替换它们,比如 VMWare、Linux 的 Wine、Windows 下的 Cygwin、以及 Mac 下的 Rosetta 或者 ssh tunnel 这样的 gateway 方案。

所以,软件和建筑根本的不同是前者不可能存在被「封在里面、压在下面」的「基础」。《 The Art of UNIX Programming 》里告诫程序员,让软件系统长久保持生命力的唯一手段,是把它分解成功能独立而且可以替换的模块。换句话说,软件系统中出现了如同建筑中那种不能轻易修改的基础才是失败的设计。软件需要一定程度的规划,却并非如同建筑所比喻的那样。相反,这种规划是为了让软件具有完全不同于建筑的灵活性。软件同样需要事前考虑避免重复投入,但是在软件系统中,并没有建筑中那种一损俱损的中心基础模块,修复不同模块的设计缺陷的花费并无数量级的区别。适度的事前规划软件开发是必要的,但是我见过很多的新手惧怕的风险其实来自「建筑的基础」这个蹩脚的比喻,事后往往证明,很多在他们看来很严重的必须在 coding 之前分析透彻的潜在问题,都能非常容易在 coding 进行到中后期的时候被解决。而且在这个时候,他们往往已经从 coding 和初步的 testing 中吸取了很多事前分析无法获得的知识和分析手段。而他们认为的一些「关键」组件,即使不幸存在设计缺陷,也并不比外围组件的缺陷修复起来更难。