开发者的厌恶 —— Objective-C/Cocoa

2010/11/23

前几天看 Buzz 有感匆就了一篇超短的《开发者的厌恶》,涉及了一些很有趣的问题:一个平台,应该如何在开发者和用户之间达到平衡。作为程序员,我不会不知道一个受开发者喜爱的平台才能具有生命力,《 The Art of UNIX Programming 》里大书特书的 programming barrier 、 hobbyist programming 和 fun to hack 等概念是我经常引用的。但另一方面,以纯用户身份转向 Mac 进而成为其开发者的经历 [1],以及 Mac 成功的事实,也说明它们之间没有绝对的优先级,必须在两个相反方向上达到平衡。

达到平衡需要顾及的问题很多。先谈谈 Objective-C 和 Cocoa 这个方面。我对 Objective-C 和 Cocoa 的看法是:远非完美,但是还没有更好的替代。

如果谈及 Objective-C/Cocoa 的不可替代性,就必须谈及 C++ 。业界普及 C++ 已经有几十年,有了 MFC 、QT 等等 framework 。如果说我们认为这些东西是够用的,那么大谈 Objective-C/Cocoa 如何不可替代就是无稽之谈。在这个 blog 里我专门用一个类别来讨论 C++ 的问题。简而言之,我认为业界必须逐渐脱离 C++ 的绑架(如果有人说 GoF 绑架了什么,那么程度不及 C++ 百分之一)。如果你还认为 C++ 足以堪用,那么可以略过这篇 blog。

第二,如果谈及 C++ 的缺点,就必须找替代物。否则一个远非完美但不可替代的 C++ 和一个远非完美但不可替代的某某语言没有比较的必要。对系统开发来说替代物显而易见,Linux kernel 和 git 的成功都说明我们需要的代码组织能力即使是原始的 C 也可以提供。问题在于用户界面的开发,即便轻视面向的人也大都认为界面开发必须使用面向对象。由于其复杂性,面向对象和设计模式是编程语言在这个领域的自然而且必要的延展。所以,必须找到脱离 C++ 的面向对象方案。

这个方案必须具备统一的内存管理。当我说内存管理,我的底线是『线程安全的引用计数』。其实 OS X 的 Objective-C 2.0 已经提供了(可选的)基于引用跟踪的垃圾回收(tracing GC) 。但是我仍然要强调引用计数是一个稳健而明智的方案。关于这一点我在《 C++ 与垃圾回收》中讨论过,针对实际工程中的内存问题,泄漏其实是个无伤大雅的问题,而且 tracing GC 解决内存泄漏也远非完美,内存管理更多的是要解决生命周期,尤其是生命周期过早结束的问题,这点来说引用技术很成功;C++ boost 的 shared_ptr 堪称完美,可惜作为库而非语言组成其强制力大打折扣;Cocoa 的引用计数方案相对来说具备了语言级别的强制力,虽然不是基于栈和作用域的自动方案,但是借助方法的命名规范和 Xcode 的代码静态分析,基本上能达到相同的效果。

当我说最需要统一的内存管理解决的问题是生命周期问题,我指的是普遍的在基于消息循环和派发的环境中的生命周期问题而非狭义的对象生命周期。所以,面向对象需要内存管理方案,而要对整个 code base 和整个开发团队施行统一的内存管理方案,也需要一种面向对象语言,它们在界面开发中是互相依赖的。

一个疑问是这个方案是否必须基于非本地码(也就是 byte code 或 P-code 等非 native code)的虚拟机。我倾向于否定。首先是 OS X 出现的时机,在 2005 年以前,我不认为开发者有信心在当时的硬件条件上使用虚拟机方案。当然,今天是 2010 年。不过,我一向认为好的产品是 do more with more,而不是 do acceptable with less。好的程序员会把 native code 带来的性能变成对产品有利的东西。在那些提供所谓简化模型的虚拟机平台上提供和 native code 具有同样竞争力的界面功能经常需要降低代码的可读性。不可否认,在更原始的平台上开发一个功能需要自己编写更多的底层函数。但是有两个补偿,第一是你有很多 open source 库,不用等待或者自己把它们移植到 Java 或者 ActionScript 上;第二,如果你的代码要维护很多年,你可以写更直观的代码(由于 native code 带来的性能收益),而且多维护几个函数并不会影响代码的整体和局部可读性。另一个原因是,虚拟机方案还能带来什么?一个错误的程序在虚拟机上抛出一个无法恢复的 except,同样错误的程序在 native code app 中造成崩溃并且生成详细的 callstack,甚至生成详细的 core dump。很大的差别吗?

说到差别,我们对所谓低级语言和高级语言的在软件开发中对生产力影响的差别很大程度上夸大了事实。对自动 tracing GC 和虚拟机语言的鼓吹,其实是一贯忽视代码静态检查和 crash callstack report 一类工具的结果。

如果抛开对虚拟机的痴迷,支持 Java 语言对 Mac 平台来说就少了许多吸引力。接下来,Java 在各个层面都有不能容忍的问题。首先,Oracle 对 Google 的诉讼再一次告诉我们,Apple 不可能使用一个由另一家公司完全控制的语言。其次,让语言和 OS 分属不同公司控制也是开发者完成工作的噩梦,同属虚拟机方案的 WPF 用户体验尚不算寒酸。而 Java 无论在哪个不受 Sun 控制的平台上的用户体验都有目共睹(其实我用加那个定语吗)。

接下来,Objective-C 的语法。如果你真的看过我上面的描述,如果 Objective-C 在上面这些方面做得比其它所有语言都稍稍好一些,那么它的语法就算比今天还糟糕又如何?Not a big deal !难道,Java 里用匿名类安装回调函数的语法非常优美?难道 C++ template 和 operator overload 的词句十分押韵?别,连美国人都开始说 long time no see 了。

Apple 应该在 2000 年的时候创建另一种语法完美的语言吗?(或者说 NeXT 在更早些时候?)我不知道。也许如果那发生了,今天我们就会皆大欢喜。可惜的是没有,就像今天的汉字不都是形声字能让我们认半边。我不想讨论 Apple(或者 NeXT )在当时花精力构建一种新语言是在构建完美未来还是在自身难保、资源捉襟见肘的形势下自寻死路,只想说从构建更好的软件出发,如果今天完全剔除 Objective-C/Cocoa 所有引起开发者厌恶的东西是得不偿失的无的放矢。

注释:

  1. 我从 07 年开始在 Mac 上写 code 。不过完全是工作所需。这里的作为用户转向 Mac 和成为其开发者指的是我在自由时间的选择。

开发者的厌恶

2010/11/22

Mike Lee 上周讨论 Mac OS X 为什么容易遭到 geek 冷淡的时候说,Apple 经常以它自己对用户体验的理解为依据禁止开发者做特定的事情(或者让用特定的方式以外的方式变得异常困难),但是他又说一个不能对任何人说『不』的系统(指的是 Android)也并不能讨好大多数人。

最近用 Cocoa 开发一个小程序,能体会到作为开发者很难不被这个平台的很多细节搞得精疲力尽乃至产生厌恶。但是,喜欢上 OS X 的时候我不是一个开发者(当然我还要写代码来工作赚钱,但是当时已经不再把开发作为兴趣),但是喜欢上 OS X 之后却情不自禁地产生为它开发程序的兴趣。能持续发展的平台应该把开发者作为用户吸引过来。那些声称能让开发者『集中精力在主要问题上』的平台也许忘记了开发者关心的主要问题并非用户所想(即使是那些声称重视用户需求的开发者)。

程序猿

2010/11/20

最近一直没有写 blog。时间都被其它事情占去了。第一件大事是在等待三年之后终于开始摆弄这个星球上最好的 code base 之一。第二件事是又开始写一个 hobbyist 应用,再次让我感受到能体会软件开发脉动的唯一方法就是多写 code。

了解背景

2010/10/24

同事向我介绍张银奎的译作《观止》( Showstoper )很久了。可我一直都提不起兴趣来读这段克隆 VMS 的历史。因为非常赞同《 The Art of UNIX Programming 》的观点,所以 NT 内核这个工程从来在我眼里属于二流(和 Linux kernel 之类相比)。我要承认 David Cutler 从精力、智力、意志力、对软件业的价值等等方面比我强不止两三倍甚至十几倍。但我认为,一个人如果要让别人花时间读自己的故事,那么比别人强百倍也并非过分的要求。同样,我只是说强百倍并非过分,而不是说那些比平常人优秀一些的人的故事就不值得看。

最近突然想在闲暇的时候看看软件史的八卦,所以借来读了读。开始阅读之前我这样想,NT 比 Unix 在底层的清晰和稳固方面是差了一些,但是它仍然是一个成功的产品,其弱点可能更多的应该归咎于历史机缘,让我来看看它的诞生有什么激动人心的地方。

读过三分之一之后,我又退回到最初的想法,David Culter 仍旧比我阅读前所期望的要差。感觉从『 Cutler 没能借鉴 Unix ,但是他仍然做了一个伟大的产品』变成了『 Cutler 把 VMS 带到 Microsoft,让 Microsoft 勉强不至于在 Unix 之前没有还手之力』或者说『让 Microsoft 在 Unix 商业阵营的疏忽面前不至于放过机会』。这主要因为两点:

第一是 Culter 只负责 NT 内核。而大多数人都应该同意 NT 能取得商业成功的亮点绝非内核。《观止》在序言里就说道『 NT 4 改写了网络工程师的模型:不需要再记住一长串文字命令,系统是可以用鼠标管理的』。

你可以为 Cutler 辩护,内核是 NT 图形的基础。在当年的 386 硬件上完成如此基础,才是他的功绩。而这正是我疑惑的第二点,NT 内核的构建是在 90 年代初,花费了一个百人团队 5 年时间,而与此几乎同期的 Linux kernel 的雏形由个人完成。正如《 Art of UNIX Programming 》所言,NT 的成功来自商业 Unix 社区的分裂和他们对 PC 的忽视,而不是技术上的难度。Culter 成就了历史,但是并不是在逆境中通过过人(仍然强调,『过人』的『人』不是我这样的人)的能力把资源极致优化的结果,相反,我的感觉是他拥有远远超过必要的资源,而只是勉强把事情做成。如果当年其它阵营向 PC 服务器领域正面进攻,NT 的技术水平是无法招架的。这是 Microsoft 和 Gates 的成功,而 Culter 的成功更多的在于找到了 Microsoft 这个有财力和意愿把他的能力倍增的组织,却并非释放了自身的潜能。

所以,没有什么对原有看法的颠覆。对 VMS 的克隆仍然更像一段技术债务而非激动人心的革命。

不过,看到 Cutler 和 NT 团队的人以写代码为本能,我开始逼着自己在晚饭后从懒洋洋的只想看看业界八卦的情绪中挣脱出来,写写大段的代码。再次发现,只要不停的写下去,软件的乐趣其实很简单。这是这段阅读给我的回报。

我在网上,虽然没有上网

2010/10/08

父母在郊外的新家刚刚重新装修好,还没有安装 ADSL 宽带。国庆要陪他们住几天,所以赶紧办了一个联通的 WCDMA 号码,以便用 3G tether 上网。同事看到我新办的号码,知道原由之后对我说『你就不能利用国庆假期休息休息戒戒网瘾?』

仔细想想,我是有轻度网瘾吧。可是还没有那么严重。这个假期我想好好读读朋友给我推荐的 alternative history 小说(怎么翻译呢?叫历史虚构小说如何)。偏巧这本书属于国内不大可能出版的那种 [1]。好在 Amazon 支持跨洋送货,等待了十几天之后,终于在国庆长假前送到我手上。

以我的英语水平用阅读中文小说一半的速度阅读没有问题,但词典是不能离手的。而且,今天网络词典和本地词库相比,前者中的中等水平产品对俚语和短语的解释也要比后者中的高级别专业产品好。所以我必须联网。

网络词典的需求仅仅是一部分原因,而且将就的话还勉强可以用 iPhone 来替代 [2]。这本小说讲述在柏林发生的故事,引用了大量二战前后的历史人物、历史事件(比如 Night and Fog)、各大洲地名,以及德语的专有名词(比如 Obergruppenführer)。即使是美国读者若非对欧洲和二战很感兴趣读起来也会颇为吃力。我必须要时刻访问 Google 和 Wikipedia 这样的资料。对于查阅资料的任务来说,iPhone 的屏幕大小或者 GPRS/EDGE 的网速太吃力了。

所以,我必须待在网上,即使没有上网。互联网不是消磨时光的好去处,但是这项伟大的技术让人能够而且敢于在没有背景知识的情况下去欣赏不熟悉的作品。时刻联网也许会削弱我们的智力,但是需要重建的是对其它事物的兴趣而非拒绝网络。

注释:

  1. 也许压根 alternative history 这类小说都没有在国内出版的可能。
  2. iPhone 可以用 GPRS 上网,但是不能用 GPRS tether 上网。Nokia 的手机倒是可以,不过那是另外一个话题。

多核与锁

2010/10/04

业界有两次事件刺激了关于多线程在桌面开发中的应用和相关讨论:Windows 95 将抢占式多线程(preemptive threading)引入主流桌面平台;AMD 和 Intel 把多核处理器引入桌面。前者让桌面的主流开发者开始关心锁(lock、mutex [1])的使用。后者是硬件设计者无计可施的体现 —— 无法进一步提高时钟频率,也无法进一步提高硬件自动对代码的执行进行并行化处理的程度,关于这方面的评论 Herb Sutter 的《不再有免费的午餐》最为精彩,他用了『撞上物理定律铁壁』(reach hard physical limits)这样的描述。

写过《不再有免费的午餐》之后 Herb Sutter 又对锁的应用写了一系列文章(123[2]。在他看来这是对《不再有免费的午餐》一文的自然延续。可能很多人也同意这点。但我认为这后三篇文章和《不再有免费的午餐》讨论的是两个不同问题,也就是说,锁和多核 —— 虽然它们有必然的协作需求 —— 但是主要是为了应对不同的问题而出现的。把它们分别要解决的两个问题在同一个主题下讨论是并行软件开发领域很多讨论混乱的根源。

计算的本质

什么是计算的本质,关于这个问题计算机科学家会告诉你关于图灵机、寻径问题、判定性问题等等概念。而我只想找一个让普通开发者看的舒服的说法。基本上,计算是把一种形式的数据变换成为另一种形式。

硬件制造者试图通过两种方式提高执行『算法』的效率 [3] :提高 CPU 的时钟频率;试图让硬件自动找到算法中没有相互依赖的部分并且最大可能的并行执行它们。前者遇到了『物理定律铁壁』。后者主要通过乱序执行(out-of-order)和超标量流水线(super-scalar pipeline)实现,也已经接近优化的极限。

并行执行

所以,硬件制造者决定把第二种优化方式的控制权直接丢给软件开发者,而不再试图用硬件自动寻找可以并发的部分。这就是『免费午餐』的终结。

这种『并发执行』的关键是如何把数据分割成在算法处理中互不依赖的,而在处理之后又能重新汇合成完整数据的片段。这个过程中除了在最后的数据合并阶段需要少量的同步操作,关键在于数据分割的方法。锁在这个模式中并没有非常重要的作用。

其实把『并发执行』的控制权 —— 也就是数据分割的责任 —— 丢给软件开发者并非始于多核,从 MMX 时代开始流行的 SIMD 指令就是一个例子。

并发访问

锁的重要性,正如上文所述,其实和业界第二次刺激多线程开发的原因 —— 多核并没有绝对的联系。多核是硬件制造者将 out-of-order 和超标量的等效作用向上转移到软件设计的决定。其关键在于数据的无依赖分割。

而第一次刺激多线程开发的因由,Windows 95 将 preemptive threading 引入主流桌面开发,才是锁占据主导地位的事件。这次事件远在多 CPU 引入桌面系统之前。这时多线程的应用并非为了提高算法的执行效率,而是为了改善用户界面的响应速度。

如果从『数据 A』到『数据 B』的处理时间非常久,让用户能够稍感满意的做法是不时的显示处理的中间状态。这需要把算法分割成一系列步骤,把每个步骤产生的中间结果交给负责显示的代码处理。这种方式中,负责数据处理的代码和负责(中间)结果显示的代码处于不同线程。锁对于两个线程的协作起了至关重要的作用,因为整个算法只有某几个特定点才能产生对用户有意义的显示(保证数据 integrity)。这个方式和上文的『并行方式』有两个区别。

第一,这个方式的关键是算法分割而不是数据分割。『并行执行』注重数据分割,分割的要求是对算法处理不会产生相互依赖。『并发访问』注重算法分割,分割的要求是数据的呈现对用户有意义。

第二,『并发访问』并不关心访问的确定性。如果显示代码显示『数据格式 A1』的速度比较慢,它完全可以忽略『格式 A2』,只要最后能正确显示『数据 B』。如果有多个显示代码线程要同时显示中间结果和最终结果,它们显示了哪些中间结果,显示中间结果的前后顺序,是完全没有关系的,只要保证每个线程中前期步骤的结果不会在后续步骤的结果之后显示,并且最终都显示最终结果就行。

执行 vs. 访问

所以,多核的目的是『并行执行』,而锁的作用在于『并发访问』。有时实际问题需要混合两种方式的方案。例如,如果数据分割不能做到完全的消除依赖,那么各个算法线程之间就会进行少量的并发访问。但是,把两种方式完全混合的方式并不多,实际的系统总是非常偏向某个方式。

有一点点讽刺的是多核和锁似乎都没有真正成为解决原本期待用它们来解决的问题的利器。适合『并行执行』的任务往往符合两种情况:一是分割后的数据相互关联程度很低,完全没有必要通过共享地址空间来协作,这时其实通过简单的多进程处理就能提高 through-put 。常见的并行编译即属于此类。另一种情况是在处理每个数据片段的时候较少用到分支,这正是向量处理和 SIMD 的强项。所以『并行执行』的任务实际更多的被 Cuda 或者 OpenCL 之类的通用 GPU 计算担当。对多核的利用应该从对锁的注意,迁移到对数据分割的理论研究。

需要『并发访问』的问题,其实在单线程和协作式多任务处理中就通过消息循环定期发送 timer 消息得到了解决。被分割后的算法放到一系列 timer handler 中和负责显示中间结果的代码在同一个线程里交替运行。使用 timer handler 过多的一个并不严重的缺点是代码的静态表现不能直观的体现其静态流程,出现问题的时候 call-stack 也不够明晰,对调试和查找 bug 增加了一定的困难。把『并发访问』的问题由 timer 模式迁移到多线程加锁的模式,虽然提高了算法线程的代码流畅程度,也由于同步操作的设置极大的增加了整体复杂度。所以大量的开发实际还是停留在协作式的方式,比如 Cocoa 开发中的动画和拖放就提倡使用 timer 来完成。在 ActionScript 这类没有线程模型的运行环境 timer 也是唯一的途径。

多核和锁都和多线程都紧密的联系,但是这种联系更多的是表面层次的。本质上它们倾向于解决各自的问题。而在分析这些多核和锁『各自的问题』时一旦很好的消除了另一种方式的干扰因素,问题本身又可以通过其它方式得到解决。

注释:

  1. 线程同步操作有很多种。Mutex 只是多线程同步化操作的一种(一般在 UNIX 中 mutex 指那些利用内核等待队列让暂时不能取得执行临界区(critical region)的线程放弃 CPU 的操作)。此外还有 bus lock,spin lock,memory barrier 等等操作。本文在不必涉及具体区别的地方统一称为锁。
  2. Sutter 的后两篇名为 lock-free 。但是正如注释 [1] 所说,这里还是需要用到 bus lock 或者 memory barrier 一类的同步操作。
  3. 第三种方式是通过高速片内缓存(on-die cache)。这是利用的算法执行对数据和代码局部化(locality)特性。因为和算法本身的逻辑执行效率没有关系,所以不做讨论。

思维能力和 UI 设计

2010/09/19

最近读到两篇很有启发性的文章:《好软件如何让我们变蠢》和《在石头与界面之间》。主要说人们的思维能力正在被那些以『易用性』为最高设计原则的软件所伤害,并给出心理学实验的证据。这印证了我一直以来的一些看法。

第一是学习和解决问题的方式。现在我们非常依赖 Google 等搜索引擎解决问题,甚至指导学习。在搜索引擎上无休止的猎取『正合好』的方案很可能会损害学习能力。回顾我写过的《大部头与网络短篇》,也倾向于通过 Google 拼凑片段信息,值得警惕。但是《大部头与网络短篇》基本上还是把有深度的 blog 和 Wikipedia 列为学习阅读的主体,仅限于让 Google 担当组织阅读的角色。简单一句话,对 Google 的搜索结果要重视的是那些有深度的长文章,尽量花些时间系统阅读,不要急于绕过看似和最终问题关系不大的外围概念。

第二是关于 UI 设计。我在工作中经常强调对初学者适用的 UI 未必适合专业用户,就此得到的认同不多。几次讨论中我以此为理由建议 UI 的设计不要过多倾向于初学者,基本上得到的反应是付之一笑。也许大家是这么想的吧:反正专业用户无论如何都要用我们的软件,不如吸引点儿初学者。这倒是和 marketing 喜欢给产品的 feature list 添砖加瓦一脉相承。感觉今天的 UI 设计风气过分夸大 discoverability 的重要性,反而忽视了各个功能之间的内在组织联系。如果你对别人说,这两个功能的逻辑关系不是并列的,不能放在一个对话框上,别人会笑你 geeky,纯 engineer。仅仅因为你是从『逻辑』角度而非『用户的』角度考虑 UI 设计。如果你建议一个功能的输入和输出应该是线性对应的,这样可以对全范围的输入用统一的算法处理,但是这种对应关系会和初学者的直观产生些微冲突,这时『易用性』往往又会占了上风。最终这个功能的处理算法十有八九为了迎合『直观』而对输入的不同范围分段区别对待,或者加入若干特例。

我喜欢《在石头与界面之间》提出的两个术语,internalise 和 externalise 。过分迎合初学者的 UI 把界面 externalise 成割裂的部分,损害了软件功能之间的正交性。其实用户长期使用的软件应该通过严密的逻辑关系让用户得到 internalised knowledge 。不论是频繁使用,还是偶尔接触的软件都应该如此。其实正因为后者的使用间隔长,才更需要用户建立比较深刻的理解。缺乏 internalization 的软件最终的结果是限制了高级用户深入使用的潜力,妨碍用户 internalise 知识的形成从而增加用户记忆的负担,更重要的也许是最终会改变人类的思维能力。不好说人类是否会成为一个拥有新思维模式的物种,但我期待的是高速人机交互下塑造的新思维,而不是被 externalise 割裂的思维形式。

再抽象一点

2010/09/03

软件开发是控制复杂度的艺术,是『抽象』[1] 的艺术。软件开发者要熟悉和操作各种抽象。无法想像离开进程、套接字(socket)、内存地址空间(address space)、互斥量等等这些抽象如何构建有用的软件系统。但是,也有大量的垃圾抽象,它们声称能带来的好处无法抵消造成的麻烦 [2] ,仅仅当情况和设计者提供的例子最接近甚至完全相同的时候才能隐藏复杂度,而在其它情况下 —— 比如新的 use case ,和第三方库集成,满足性能需求,提高用户体验 [3] ,或者在学习阶段等等 —— 都必须一再让其开发者对本来应该隐藏底层的细节不得不进行深入了解。

优秀的抽象往往也是稳定的。尽管底层系统沧海桑田,优秀的抽象概念不会变化,还会默默地借助底层的发展为开发者提供免费的午餐。而垃圾抽象们每每梢有风吹草动,就要推出一个划时代的新框架,呼吁号召大家抛弃遗产(legacy)。为什么不同抽象的差异这么明显?

两种方向

Donald Knuth 在《Coder at Works》中解释他设计软件的方法:一方面,有一个总的目标,从总目标开始把问题细化(break down),分别解决每一个问题,在解决的过程中再做进一步的细化,这是自顶向下的方式(top-down);另一方面,对手头的原始接口 [4] 从直觉和经验出发进行初步加工,简化原始接口的一般复杂性,较少直接考虑最终目标,这是自底向上(bottom-up)。除非是非常简单的问题,几乎不可能通过纯粹的 top-down 或者纯粹的 bottom-up 达到目标。当 top-down 方式被过于频繁的 break down 不断打断,我们就会转入 bottom-up 模式。在 bottom-up 过程中,如果底层接口中显而易见的可简化部分被处理完,进一步的简化方向不能确定,我们就会转入 top-down 模式。Eric Steven Raymond 在那本著名的《The Art of UNIX Programming》里也阐述过类似的理论。

大型通用开发领域的『抽象』基本属于 bottom-up 方式 —— 不直接关注系统的最终目标,集中精力隐藏中间环节的复杂度。而且,在通用软件领域的竞争和协作的大环境中抽象意味着执行 bottom-up 减少接口复杂度和执行 top-down 完成最终目标的是不同的人群。这意味着 bottom-up 的执行者必须预测 top-down 的执行者的意图,通过预测后者的行为,降低接口的整体复杂度。而且,应该承认,为了降低接口复杂度,就必须牺牲底层接口的灵活性。不牺牲任何灵活性的抽象是不存在的(除非这个抽象没有降低接口复杂度 —— 失败的抽象,或者底层接口的某些灵活性本来就是画蛇添足)。所以,只有成功的预测才能保证牺牲的值得。

直接交流

预测他人行为最好的方式是直接问他要干什么。『抽象』的构建者与很难大范围的与高层开发者直接交流,即是由于交流成本,也是必须避免得到的需求过于分散。如果缩小直接交流的对象范围,最后构建出来的『抽象』往往会成为一个高层项目的附庸,或者专门为特定的需求特殊的小群体(niche)采用。先不谈为 niche 设计的抽象,作为高层项目附庸的抽象重用度不高,基本可以算 top-down 的行为通过重构(refactor)的延伸,但是这种努力绝不算失败,Linux kernel 中经过重构形成的 CPU 抽象接口( MMU 抽象、SMP 内存同步操作等等 )是个成功的例子。尤其是考虑到通过其它预测方式创造出来的所谓通用抽象被高层成功接纳的几率,我觉得更应该提倡这种不那么有野心的抽象方式。

相反,有些抽象的设计者有意避免通过直接交流决定抽象的主要概念和功能,从而期望让其抽象保持最大的通用性。比如进程这类抽象适用于整个计算机行业,今天已经很少有计算行为脱离进程这个抽象来完成了(除去一些嵌入式设备)。也几乎不会有网络应用脱离 socket 来开发。但是有更多的避免直接交流而设计出的抽象并不成功。比如,很多公司已经或者正在试图把所有操作系统的 GUI 开发接口统一到单一的抽象(比如 Java Swing/SWT,Adobe AIR,Qt 在 KDE 之外的努力等等)。这些努力拒绝向某个或者某种应用倾斜,而把自己定义为适合绝大多数应用 UI 开发的抽象。就我认识的业界现状,只能说这样的努力都很失败 [5] 。下面谈谈通过直接交流之外的方式设计抽象的成败原因。

控制行为

预测一个人更好的方式是规定他要干什么。对于抽象的构建者来说,最好的方式是完全不让高层用户接触底层接口。但是,这样要面对双重压力,第一种压力是开发者无法忍受灵活性的牺牲,要求直接接触底层接口。因为灵活性的牺牲往往意味着不能满足用户的特定需求,特别是那些底层接口可以实现但是由于被高层抽象牺牲灵活性而变成不可能的需求。第二种压力是底层的实现者总是要极力推广自己接口,没有人设计一个接口从一开始就是为了让另一个人来封装的(如果这样最初的设计者就是承认自己的接口设计的很烂),在高层抽象的实现者鼓吹复杂度的降低时,底层实现者也会极力展示那些用高层抽象无法达到的效果。

两种情况可以降低上面的两种压力。第一是底层的提供者为了实现简单,提供的接口复杂度非常高,几乎不考虑解决实际问题的难度,大多数开发者因为过高的复杂度主动避免直接接触底层接口。第二是底层接口的承载体由于成本或者其它原因不能被大多数开发者接触到。高级语言、进程、套接字等等成功地被广泛接受的抽象,在其早期都满足其中一种或者两种情况。但是,这两个缓解压力的情况并不是经常满足的。提供高复杂度接口的行为几乎被限制在了硬件设计行业,PC 等开放平台占据主导地位也让第二种情况变得越来越罕见 [6] 。

并且,在竞争条件下,只要有相当一部分人采用了某个级别的抽象,他们在最终产品的性能和细节上的竞争力往往就会超过采用更高级别抽象的最终产品(假设更高级别抽象做出了性能和灵活性的牺牲)。这样,即使一个更高级别的抽象最初吸引了很多人甚至是相对多数的开发者采用,只要低级别的抽象有一定用户比例,他们的产品的高竞争力也会最终迫使大多数开发者回归。这意味着在竞争条件下,只要有一个开发者对一个抽象施加了第一种压力,他的行为就会迫使其它开发者把这种压力增大几倍。一个抽象成为主流的关键在于早期就要抢占绝大多数低层接口的直接用户,保证不再流失用户。这里的绝大多数不是 51% ,而可能是 85%,95% 甚至更高。而这种快速抢占用户群的情形只有满足上面提到的缓解压力的情况满足的时候才能发生。一个不是非常难用的接口,是不会轻易流失用户的。高级语言在出现之后就很快就让绝大多数开发者 [7] 放弃了直接使用低层的汇编。而之后的 C 语言等比较低级的语言不管有多少缺点,都不会像汇编那样迅速丧失几乎所有的用户,尽管一旦有更高级的新语言出现(特别是背后有大财团的那种),C 就会失去部分开发者,但是随后它们在竞争中总是能和更高级的语言在争夺开发者上进行拉锯。一个抽象概念的提出是应该一击必杀,打持久战就意味着在大型通用开发领域输给了底层接口。

另外,在协作条件下,主流采用的抽象级别也会影响后来者采用的抽象级别。如果你需要的很多第三方支持只提供底层的抽象(比如基于引用计数的直接内存模型),就很难在自己的系统里采用高级抽象(比如纯粹的不带直接内存访问的对象模型)。使用某一种抽象方式的模块越多,就会有更多的模块采用同类的抽象。基本上,90 年代之后,商业软件的开发抽象层次停留在 native 进程(而非 managed 虚拟机)、直接内存模型(有时附带引用计数)、C 语言这一层面上很长时间。类似竞争条件下的那种压力倍增的效应在协作田间下也存在,虽然程度稍低。

以网络通信来看,CORBA 等基于 remote-procedure-call 概念的技术曾经希望以更直观的类似函数调用的方式取代基于 socket 链接的 application-protocol 方式。因为大多数系统仅仅提供对 Socket 完全兼容的实现,最终今天主流的网络开发完全退回到 application-protocol 方式。这是协作和竞争条件共同施加压力的结果。

以编程语言来看,一般只在 C 语言之下的级别才符合缓解压力的两种情况。由于汇编语言等比 C 更低层的抽象过于和机器的实现方式接近,大多数开发者对它们不会有全面的认识,所以少数的编译器和操作系统实现者完全垄断了这种接口的使用。这些少数的接口垄断者可以完全控制上层使用低层的方式,从而可以相对来说比较自由的牺牲某些灵活性而不会受到过多的来自上层开发者的压力。同时,因为都处在被编译器和操作系统将低层接口隔离的境地,上层开发者也不会因为竞争压力而采用更低层的接口。更高级的语言一般建立在 C 之上,有些在操作系统之上还提供虚拟机实现。对于它们来说不幸的是,不管它们声称 C 和操作系统的接口多么难用,这些接口还是还是能被一个本科生基本掌握(相反,汇编和硬件接口对于工作过几年的人来说仍然有一定困难)。所以,不管它们提供了多少便利性,在商业软件开发的竞争和协作中总是会受到直接使用 C 和操作系统的产品的排挤。不管有多少人推广更高级的语言,只要在大型通用开发中有一小部分,比如说 5% 喜欢 C 并且真的能够做出好的产品,他们产生的压力就足以诋毁对更高级语言的推广。更高级语言今天还是只能为专业定制化、企业开发和原型开发广泛采用,因为在这些领域竞争压力和协作的需要比商业级开发小得多。

猜测行为

当你并不甘心依附于某个高层项目,又找不到合适的底层复杂接口,也没有财力去忽悠或者控制别人应该用什么样的平台,剩下的就只有猜测用户对抽象的需求了。遗憾的是你几乎肯定会进入这样的境地:因为你封装的接口并不极端复杂,所以能理解并且直接使用它的人很多,尽管这些人也都希望简化这个接口,但是他们各自希望的简化方式大相径庭,你永远没法讨好所有人、大多数人、甚至没法讨好足够多的人。更糟糕的是,那些一开始喜欢你的抽象的人发现,他们的对手直接用底层接口往往能更精细的实现一个功能,于是开始对你提一些不符合你的抽象设计原则的要求,结果是或者你的抽象开始越来越凌乱,或者在在你拒绝他们的要求之后这些人回到底层接口。

结论

今天,并不是每个从事计算机开发的程序员都了解关于 MMU,寄存器,缓存级别,以太网这样的知识,但是他们中相当一部分都了解进程、套接字、消息循环、事件驱动这样的概念。这样的一部分人存在于商业软件开发的行业中,即使他们不占相对多数,通过竞争和协作的互动,也足以让比他们熟悉的抽象层次更高的抽象失去主流市场。

尽管抽象是控制复杂度的利器,但是大型通用开发领域对于新抽象的接纳是极为抵触的。今天当我们有冲动提出一个新抽象的时候,除非作为长期谨慎的研究行为,应该慎重思考是否应该放弃,或者应该根据现有的抽象概念实现新的可重用组件。除非是原型、临时使用的专用程序、或者为特定行业开发的非通用应用,应该拒绝那些没有占据绝对主导地位的抽象方式。

注释:

  1. 本文只关注面向开发者的中间层次抽象。抽象体现为 API,库,底层服务等形式,同一种抽象可以有不同的具体体现。比如,POSIX 的文件操作和 OS X 的 File Manager 是两种 API,但是它们同样基于无结构文件的创建、读、写、权限的文件抽象。比如,UNIX 和 Windows 对文件的权限设置不同,但是它们同样采用基于 owner,group,everyone 这样的访问权限列表抽象。IMP4 和 POP3 都是采用 application-protocol 这样的抽象概念。
  2. 同下文,带来的好处和麻烦都是指在大型通用开发的领域。一种技术在开发大型通用应用和开发小型原型中的优势和劣势会有很大区别。一般来说,前者在解决主要问题之外,还要比后者更多的考虑边缘情况(edge case)和长期使用维护成本。
  3. 性能需求和用户体验在小型原型、企业或者特殊行业开发、临时小型工具中的需求最低。这方面需求的降低往往会连带降低对新 use case 和第三方库集成的需求。
  4. 『原始』指底层机制提供的接口的高复杂度和低组织结构化。
  5. 这些跨平台 UI 框架在内部工具开发和原型开发上取得了一些成功,这里的失败是指在大型通用软件市场方面的失败。
  6. 有趣的是移动设备的开发似乎又提供了这种可能。
  7. 时间上和今天的技术相比并不快,但是考虑当时的还在 PC 成为大众化产品之前,在计算资源贫乏的情况下当时接纳高级语言的速度是很快的。

接受调试器

2010/08/18

⋯⋯ 太多时候,用 printf 或者 log 就能发现问题。一些人于是发誓只用这种方法,而且如人所愿,只要时间充裕,力气下足,其实不用 debugger 也能干活。

—— Kernel Programming Guide

十年前刚毕业的时候,在公司里被分配了第一个像样的任务:用 Java 写一个 GUI editor 。准备开发环境的时候遇到一个问题: 对当时的我来说,和 Visual C++ 之类的工具相比,JDK 的 debugger 太难配置了。另一方面,System.out.println 倒是唾手可得的『利器』。最终我完全用后者调试完成了那个 5000 多行的 GUI editor 。之后的七年里我几乎再没有碰过任何 debugger 。对它的全部认识就是设置断点,运行程序 hit 断点之后单步(step in/over)。每次搭建开发环境的时候似乎都会因为因为这样那样的原因而放弃使用 debugger ,printfprintln 乃至 log 文件是不二的应对法宝。

直到三年前重新回到 Visual Studio 和 Xcode 的 IDE 环境之后,抱着反正不用费力气配置直接可以用的态度重新拾起 debugger 。下面是大致按照时间顺序的发现:

第一条:call stack

最初七年里无数次从 Java exception 的 stack trace 里获得解决问题的线索,也在研究 Linux 代码的过程中意识到能随时了解 call stack 的好处,可是我从把 call stack 和 debugger 联系上。重拾 debugger 之后的第一个新认识是它的作用不光是单步,更重要的是可以揭示 call stack 。如果没有 debugger 和 call stack ,阅读已有的 code base 的难度会提高一个数量级。

第二条:死锁

多线程问题和性能问题被我认为是 debugger 一无用处而非 log 不可解决的难题。直到两年前对着一个debug 状态下僵死的程序做了一个 pause 操作,debugger 显示的 call stack 稳稳当当的停在了死锁的位置。

第三条:UI 操作

UI 问题被认为是最容易应用 debugger 的,因为一般来说触发断点最直接,而且线程也不多。但是遇到 debugger 会干扰程序本身的 message queue 的时候就束手无策了。为了尽量避免手工加入 logging 代码,也找了各种土办法减少 debugger 对 message queue 的干扰。

比如,如果在窗口绘制代码中设置断点,就会让 debugger 在绘制窗口的过程中夺取焦点,continue 之后又会把焦点返回原来的被调试程序的窗口,在 Windows XP 上就会又触发窗口绘制。而 OS X 和 Windows Vista 的 buffered window 能让窗口绘制的代码不会像 Windows XP 里的窗口那样每次在 debugger 把焦点交还的时候都触发窗口绘制。尽量利用不同系统的消息处理差异来找到最合适解决问题的环境。

但是这些方法都要靠系统实现的差别和被调试程序逻辑的机缘巧合,并非每次都能成功使用。无法找到一定之规,有的情况也根本没有办法。比如,有些控件会处理失去焦点的事件,用如果在处理失去焦点的代码里加入断点,调试起来就很难分别哪些事件是正常情况下会发出的,哪些是受 debugger 的干扰发出的。

困惑了两年多之后终于发现处理 UI 调试的方法其实简单得令人发指。只要能找到纯命令行的 debugger(比如 gdb,Flash 的 fdb),用 ssh 或者 telnet 从另一台机器登录到被调试程序所在的机器,用命令行 debugger 调试就行。这时,任何断点的中断都是通过后台协议传递给 ssh/telnet client 上的终端而不会干扰被调试机器的 message queue 。回顾过去的十年,很惊讶被这个问题困扰了那么长时间的不光是我,还有我询问过的几乎所有同事,几乎每个人处理这种问题都是依靠 log(不得不说有些人,比如志岩还是提出了 remote debugging 的思路,但是很可惜只是一个灵感而不是整套解决方案)。

第四条:条件断点

如果一个断点的第一次、第二次、⋯⋯ 第 n 次被 hit 的时候你都并不想单步或者检查状态怎么办?当然可以多点几次 debugger 的 continue 按钮,让真正期待的第 n+1 次 hit 到来。但是如果第一次的 hit 会干扰第二次呢?(我听见有人说可以用 ssh+命令行 debugger 调试。不过大多数情况没有必要祭出 remote debugging 。)又或者 n 是 10000 怎么办?为了对付这种情况用了不少土方法,比如临时给被调试的程序代码增加一个分支特地用来设置断点,或者在程序弹出 modal dialog 的时候设置断点。

其实 gdb 一类的 debugger 一直都有条件断点的功能。可以让一个断点并不是每次 hit 的时候,而是满足一些额外条件的时候才中断,比如第 n 次被 hit ,或者一个变量大于某个值,或者一个 C 函数的返回值符合条件的时候 ⋯⋯

第五条:监视变量

你是否曾经大声咒骂过:这个变量到底什么时候被改动的?然后不得不一步步的 step over 查看,发现值发生变化之后,停止调试,重启程序,重复刚才的 use case ,step over 到刚才的地方,step into ,然后再 step over ⋯⋯

其实 gdb 和很多 debugger 都能自动监视变量的变化,并且在变化符合某种条件的时候自动中断。条件的配置和条件断点一样灵活。而且 x86 CPU 为了支持这种功能特地设置了几个 debugging resigter (32 位下四个,64 位下更多),所以监视变量几乎不会影响调试速度。不用这个功能都对不起 x86 CPU 上那些专门用来 debug 的硅。

第六条:Log

最终,还是会有些问题确实需要在不间断的执行中监视某些状态。也就是 log 。可是 log 必须得是在代码里加入 printf 吗?gdb 的 commands 命令让断点在 hit 的时候可以执行一段命令,任何合法的 gdb 命令,比如打印某个变量之后 continue 。

结论

… In many cases, you can find the problem through careful use of printf or IOLog statements. Some people swear by this method, and indeed, given sufficient time and effort, any bug can be found and fixed without using a debugger.

—— Kernel Programming Guide

接受 debugger 是一个抛弃原有成见的漫长过程。一开始我们总认为自己的土方法是最有效的(因为当我们抱着进入尖端领域的心情开始软件开发的生涯时,周围的前辈总会教你几个土方法)。然后我们知道真的有先进的工具。我还远远未了解所有 debugger 的所有功能。但是配置一个 debugger 是将我在所有开发平台上的第一件事。

地址空间划分(一)

2010/08/09

研究过 32 位 Linux 内核的人都知道这个内核著名的 3G/1G 划分:低 3G 作为用户态空间,高 1G 作为内核态空间。内核态和用户态共享 4G 的 32 位地址空间。

这个划分成了 32 位内核的基本设计方式。Windows 内核也采用类似的划分(缺省为 2G/2G 划分,可以通过启动参数改为 3G/1G)。所以,可能很多人像我一样,很自然的把这种划分当成了内核的必要设计:内核态和用户态必须共处在同一个地址空间里;似乎如此内核才能管理用户态的内存。见到 Mac OS X 内核 XNU 的设计颠覆了我的观点。XNU 的内核态独占 4G 内核空间,而非与用户态共享。(如下左图显示。)

如果不是因为一个采用如此设计的内核就摆在面前(而且已经在上面工作了三年),我几乎不会想到内核的地址空间还能如此设计。看到这个设计的同时也豁然开朗:一开始认为『内核态和用户态必须共处在同一个地址空间』才能让内核『管理用户态的内存』的想法实在是短路。内核并不能了解用户态如何使用内存,更重要的是,内核并不能信任用户态内存的内容。所以,它根本不能通过和用户态『共处在同一个地址空间』这种方式来管理后者的内存空间。因为『共处在同一个地址空间』提供的唯一功能只是可以直接通过线性地址访问(也就是俗称的裸指针),而内核绝对不能碰用户态的裸指针。内核管理用户态内存的方式只能是通过设置页表和页目录来保证用户态的内存访问的合法性和一些特殊映射。

再者,Linux 和 Windows 下与内核共享地址空间的用户态只能是当前运行的进程。如果是通过系统调用进入内核,还多多少少可以说这时的当前进程和内核的关系比其它进程密切一些。如果是通过中断(时钟或者I/O)进入的内核,这时哪个进程是当前进程对内核来说完全是巧合。更何况无论如何内核也不可能只管理当前进程的内存空间。所以从这个角度说共享地址空间对内核管理用户态内存也没有帮助。

退回到 Linux 和 Windows 的设计,为什么还要让内核与用户态共享地址空间呢。其实是因为尽管每次进入内核态都可能发生进程切换,但是大多数情况下并非一定发生这样的切换。因此,共享地址空间可以避免进出内核态的时候进行地址空间(cr3)的切换。在 x86 构架下,切换地址空间要导致所有 TLB 失效,CPU 必须访问主存更新 TLB。所以,共享地址空间是一个性能 hack,仅此而已。这个性能 hack 如此历史悠久,以至于有些人如我一般完全无法想像还能有其它方式。

不过,XNU 的内核地址空间也并非和用户态空间绝对分离。在每个用户态地址空间的最高几兆也被保留为内核空间(如上右图所示)。这是因为从用户态切换到内核态(通过软中断或者比较新的 x86 syscall 指令)时,并不能同时切换地址空间,所以必须保留这段短短的共享空间来完成地址空间的切换。其实,我们可以把这个设计想像成一个处于 micro-kernel 和 monolithic kernel 之间的中间设计。和 micro-kernel 类似,内核也享有独立空间,但是和 monolithic kernel 类似,整个内核都是处在特权级,没有从微内核到内核服务的二次切换。