之前的章节:
之前讲过了,几乎无论什么时候性能都是个永恒的追求,内核也不例外。所以在九十年代初期,开发者有机会从零设计一款操作系统内核的时候,很多方面都要为了性能作出让步。
其中受影响最大的便是 IPC 的设计,它不仅仅是受性能影响这么简单,更重要的是 IPC 的设计代表了设计者对于软件交互逻辑的理解,也决定了未来这个操作系统上,所有的应用程序要如何编写,可谓是决定性的要素。
用今天的眼光重新审视 linux/NT/XNU 当年的设计,我个人所能总结的经验教训就是:不可能有一种完美的、能够适应所有时代需求的方案,所以取舍就变得很重要,不能既要又要。
在回答如何设计 IPC 这个问题之前,更重要的问题是为什么要有 IPC ,这里特指内核控制中,不同于传统 Unix 提供的 socket/pipe 实现。而要回答这个问题,就要考虑九十年代初这个时间节点,图形化的应用程序是怎么写的。
今天看起来非常普遍的图形应用开发范式:比如 UI 和逻辑线程分离,再比如异步 RPC 调用,在图形操作系统刚出现的时候还不存在。甚至几乎没有多少真正意义上的“多线程”应用程序,因为不仅当时的 CPU 没有多核心设计,而且同时期的 Unix 还只有 fork ,而更轻量化的线程( thread )直到 1995 年才定稿。
对于大多数应用开发者来说,只需要考虑系统或者底层提供了什么样的 API 可以使用即可。而对于基础库或者操作系统的开发者来说,这个思考过程是反过来的:
先想象未来的图形 App 是什么样子,要如何编写;
之后发现现在的 C 语言和硬件支持不了这种编程范式;
确定需要在内核里增加一套支撑机制;
将内核机制封装为用户态可用的图形库供开发者使用。
于是尽管目前的章节要讨论的是 IPC 设计,实际的切入点却是图形界面应用程序。(理论上在操作系统进入多任务时代的时候,非图形界面应用程序之间也有 IPC 需求,但图形界面应用程序更具代表性,需求也更加一般化)
在正式讨论开始之前,先补充一些背景知识。
现代意义上的图形界面最早是 1973 年由 Xerox Alto 计算机实现的,Alan Kay 等人设计了 GUI 软件界面以及相应的操作工具:鼠标。此时工程师们意识到,传统的线性应用程序遇到一个难题,即程序并不知道用户下一秒是要操作鼠标还是键盘,或者是其他操作。
于是 Dan Ingalls (也就是 Smalltalk 的设计者)提出了一种新的应用程序结构,主程序启动不再退出而是进入一个循环,循环中程序会轮询或等待中断调用。这个模式也称为 RunLoop 一直沿用到今天。
得益于那个时代面向对象理论的快速发展,开发者很快意识到,鼠标键盘输入和 IPC 消息等等都可以抽象成事件,操作系统只需要将事件发送给不同的应用(即多消息队列)就可以支持多任务,包括 MVC 这样的概念也就是那个时期就已经成熟了。后来 Smalltalk-80 将其抽象化成了今天熟知的 EventLoop 模式。
不过真正意义的“多任务”操作系统是很久之后的事情了。还记得之前提到的 2003 年 Linux 2.6 版本实现的所谓“抢占式”机制吗?所谓抢占式( Preemptive )就是与协作式( Cooperative )相对应的,协作式简单说就是应用程序自己才能决定退出,而抢占式指的是内核调度器可以主动打断并切换当前运行的进程。
这里我们能看出,内核的抢占式支持是基础,而操作系统的多消息队列同样重要。1995 年发布的 Windows 95 版本首先支持了图形界面抢占(仅限 32 位应用),每个 UI 线程都有一个独立的消息队列。而 Classic Mac OS 就一直只有协作式图形界面,直到 2001 年的 Mac OS X 10.0 使用了 XNU 内核之后才实现抢占式图形界面支持。
严格来说“抢占式内核”指的是当执行内核 syscall 的时候能否被打断,比如说执行某个慢 io 操作时,如果希望同时播放音频,在非抢占式内核上就要等之前的 syscall 调用完成,在抢占式内核上当之前的 syscall 时间片到期后,播放音频的指令就可以被执行。也就是说,即便是非抢占式内核,也可以实现抢占式的图形界面逻辑,只是一般来说图形界面要求低延迟,在抢占式内核上这样做才有意义。
对于 Linux 来说,由于它从第一天起就没有专属的图形界面,很长一段时间中 X 就是事实上的图形界面标准。所以是否能支持抢占式图形界面,完全取决于 X 自己的实现。由于 X Server 只是一个运行在用户空间的应用程序,而内部的消息队列又是基于 socket 实现的,所以天然就获得了图形界面的抢占式特性。
技术层面它是两个原因的共同结果,一方面是底层 IPC 走的是 socket ,在内核侧是有缓冲的,另一方面 X 设计为 C/S 架构,单个 Client 阻塞绝大多数时间不会造成 Server 的阻塞。这里就不展开讲了。
之所以 Windows 95 没有实现 16 位应用的抢占式图形系统支持,是因为早期基于协作式多任务的应用程序代码,在抢占式环境中不是线程安全的。Classic Mac OS 也有类似的问题,所以后来 Mac OS X 10.0 之后就放弃支持完全重做了。
在协作式时代,没有操作系统层面的调度器,那应用程序可以随便写,反正在应用程序主动交出控制权之前,内存和图形库也是全局独占的,不需要考虑线程安全的事情。
到了抢占式时代,操作系统的设计者要考虑的问题就变成了:如何解决线程安全的问题?
回到之前提到的设计者思考路径:
很明显 Run/Event Loop 的模式是不会变的;
最好还能保持协作式时代的写法,而且让应用侧去控制显存锁不合理也不现实;
内核侧应该主动去控制显存锁,这样某个时刻就只有一个应用在访问显存,但这样就会导致大量用户态和内核态之间的上下文切换;
内核为了隔离和安全,并不想将内部 IPC 机制完全暴露,所以要通过某种协议提供用户态的高级封装,供应用程序来调用。这个 IPC 机制可以不局限于图形界面绘制,也可以一般化为应用程序之间的交互方式,但是性能要好。
绕了这么一大圈,终于回到了 IPC 的话题上。不过这个逻辑是我本人的推理,并没有哪个知名人士以访谈或者回忆录等形式记述这段历史发展历程。
关于图形系统的部分再稍微补充一点,其他留到之后的章节再讨论。
现代图形系统的核心逻辑是合成器模式,每个应用程序在自己私有内存空间中进行绘图,由操作系统提供的合成器按需合成后交给显卡现实。在 2000 年之前是不具备这个条件的,因为当时的电脑内存太小了,整个系统只能保留一个公共的显存,无法让每个应用都有自己的绘制空间。所以当时的图形系统核心是失效重绘的模式,即内核维护显示输出的失效状态,然后调用对应的应用程序对失效部分进行重绘。
以今天的眼光来看,IPC 机制本质上就是一套协议,这套协议在内核语境下,应该具备以下特性:
载荷无关( Payload Agnostic ),即 IPC 的信息传递对于内核来说是透明的,解析是由 IPC 通信的参与者完成。
异步交互( Async Interaction ),描述的是 IPC 调用的时空边界。可以通过底层异步来模拟同步调用,但需要明确它的执行代价(和 RPC 做区分)。
能力导向( Capability Oriented ),主要说的是 IPC 调用的安全边界,声明式的权限控制是目前实现容器化安全的底层机制。
这里描述的是通用的设计通用通信协议的一般原则。这是全世界的开发者们用了几十年时间,在各个领域进行了不同的尝试,如今总结出来了经验教训。注意这里描述的是一般设计原则,并非实现技术。从技术层面上说,同一种目标可以有很多不同的实现手段。
实践中可以在实现层面,为了达到特定目的而做一些不完全符合设计原则的调整,但一定要清楚它的代价。还是之前那句话,取舍是一种智慧,不能既要又要。这样说可能不是很好理解,我这里就专门列举一下,那些曾经的设计失误,以及由此产生的后果。
“不要误会,我不是要针对谁,我是说在座的各位……”
D-Bus 诞生于 2002 年,这里 D 的意思是 Desktop 桌面。这个协议基本上是 GNOME 桌面的人开发的,是的,还是 Red Hat 的人。这个协议设计之初的目的是替代 KDE 的 DCOP 协议,以方便移除 Qt/X11 等依赖。(实际上目前 Freedesktop.org(Fd.o) 旗下的 systemd/Wayland/NetworkManager/PulseAudio/PipeWire 也都是红帽的人在主力维护)
如果你没有基于 D-Bus 写过代码,可能不太好理解 D-Bus 的工作原理。简单说它是一个消息总线,任何程序可以注册任意对象,也可以在任意时间用任意方式去访问总线上的任意对象。你看我用了这么多“任意”,应该能猜到这是一个鼓励“动态化”的协议。(技术上是通过发送消息的方式实现的,而不是调用函数,这里为了方便描述简化了)
所以它就选择了 XML 作为交换格式( 2002 年的时候还没有 JSON 什么事)。按照协议设想,应用程序或者说服务方要主动声明自己具有哪些能力,方便其他应用使用,这个机制叫做 Introspection 自省。如果调用特定的自省接口,就可以通过 XML 获得所有接口以及对应的能力。(准确说 XML 只用于接口自省,实际上传输的数据 WireFormat 是二进制的)
听起来很美好是吗?实际上无论是 D-Bus/DCOP 都是 NeXTSTEP/Smalltalk 思想的延伸。D-Bus 设想中的自己,应该是和 macOS 上“服务”一样的效果,应用程序可以枚举出当前系统所有能够提供功能的服务端,然后调用对应的功能。然而现实是 Linux 生态极其碎片化,同时工具链支持也非常弱,就导致了完全不一样的效果。
桌面开发大部分时间都是用 C/C++ 的,实现一个 XML 解析是非常痛苦的事情。2002 前后可没有 GitHub 这样的服务,C 生态中造轮子是常态。GNOME 为了解决这个问题,创造了 GObject/GVariant 类型系统,并提供了配套的代码生成工具。桌面应用开发者的工作可以不再解析 XML 而是用工具来生成。
但 XML 编写本来就麻烦,修改一次接口就要重新生成代码再编译,开发者们就开始找捷径,于是 a{sv} (Array of String-Variant) 登场了。这玩意就是个字典,键是字符串,值是任意类型。传递一个 a{sv} 之后,整个世界清净了,再也不用每次改接口都重走一遍构建了。
这样一来 XML 存在的意义也彻底没了,自此之后,什么 D-Bus 规范、Fd.o 白皮书都滚蛋吧,没人在意,也没人去写了,一切以约定为准。
我就想问问,“一切以约定为准”是不是听起来很耳熟,数组传数据结构是不是很爽,大家有没有在工作中干过类似的事情?只能说,大家都是草台班子,谁也别笑话谁。
当然 D-Bus 协议还有其他问题,主要是安全性方面的。最早的 D-Bus daemon 实现也全是坑,这个等以后专门再讲。相对来说,安全性是受限于时代性的,而且解决起来也不是那么困难。还有一点要注意,D-Bus 虽然是目前 Linux 桌面的实际 IPC 标准,但它却不在 Linux 内核中。
高情商的说法是,今天 Linux 桌面还能用,而且看起来跑得不错,D-Bus 的再实现起了关键性作用。换个说法,今天的碎片化程度,D-Bus 要先把锅背好。
我这里一定要提一个草台班子的事情。为什么我一直强调说,类似 LKML 这样的讨论比代码更有学习价值,就是因为对话和文字记录中可以看出来开发者是怎么想的,他为什么要做某种设计,这样的经验无比珍贵。因为经验这个事属于知道就是知道,而不知道就是不知道,不知道的情况下一定会重复踩别人踩过的坑。
目前的 MCP 协议,精神上和 D-Bus 没有任何不同,协议规范层面,就是用 JSON 代替了 XML 。而且得益于现代工具链,开发者偷懒的机会变少了。但是机制上,如果开发者都在 payload 中塞一个类似 a{sv} 的字典,一样会完蛋。
所以说不是用上现代技术就能避免设计上的误区了,编程这件事在哲学层面大致是相通的,人类世界的复杂程度并没有因为新技术而变得更高,反倒是设计理念这种理论会一直保持下去。
这一章节内容比较长所以分开了,后面还会接着锤其他的设计。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.