
之前的章节:
之前讲过了,几乎无论什么时候性能都是个永恒的追求,内核也不例外。所以在九十年代初期,开发者有机会从零设计一款操作系统内核的时候,很多方面都要为了性能作出让步。
其中受影响最大的便是 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} 的字典,一样会完蛋。
所以说不是用上现代技术就能避免设计上的误区了,编程这件事在哲学层面大致是相通的,人类世界的复杂程度并没有因为新技术而变得更高,反倒是设计理念这种理论会一直保持下去。
这一章节内容比较长所以分开了,后面还会接着锤其他的设计。
1 iamzuoxinyu 1 天前 > 但 XML 编写本来就麻烦,修改一次接口就要重新生成代码再编译,开发者们就开始找捷径,于是 a{sv} (Array of String-Variant) 登场了。这玩意就是个字典,键是字符串,值是任意类型。传递一个 a{sv} 之后,整个世界清净了,再也不用每次改接口都重走一遍构建了。 Unix 世界的开发者倾向于使用 Text 作为一切信息传递的 payload ,导致了 D-Bus 的混乱和低能。Windows 这边的 COM 就是个鲜明的对比。 |
2 june4 23 小时 31 分钟前 @iamzuoxinyu COM 就是微软系一贯的臃肿过度设计的体现 |
3 kuanat OP @iamzuoxinyu #1 XML 的问题在于对人来说可读性和书写都是比较不友好的,JSON 可读性还过得去但一般也不适合手写,后面 YAML 也有类似的问题,后面 TOML 做得稍微平衡一些。不过这些都不重要,文章里也说了 D-Bus 协议还是二进制的,XML 是专用于自省接口的。 Linux 生态中通用的 payload 格式就是 stream ,无论 socket/pipe 都是二进制流,纯文本反倒是少见的。 D-Bus 混乱的确与用了 XML 之后,开发者不买账然后自己瞎传有关。 但是 D-Bus 的低性能 XML 只占了非常小的原因。核心在于 D-Bus 本身是在内核之外的,相对内核 IPC 至少多两次上下文切换。实现方面,daemon 实际是个星型总线,需要额外处理广播订阅以及安全机制,早期为了线程安全锁粒度也比较粗。今天正常用到的版本已经和二十年前完全不一样了,性能完全够用,只是类似 Wayland/PipeWire 都因为别的原因选择了不用。 Windows COM 有自己的问题,下一个帖子就到了。 |
4 kuanat OP @june4 #2 我个人认为 COM 本身问题不大,但是它是建立在一个不合理的基座之上的。本身这种规范还是比较有意义的,可以理解成如果 D-Bus 有类似 IDL 契约的化,有可能不会出现今天各种绕开 XML 定义的情况。 真正过度设计的是 OLE ,但对 Windows 来说属于“不可抗力”了,这个之后会展开讲。 |
5 sjdhome 21 小时 30 分钟前 LLM 对各种不同格式的数据,哪怕是非结构化的,适应性还是比较强的。a{sv} 字典在短期内应该不会对 LLM 造成困扰。 目前我自己观察下来,实践上比较大的分歧是应该让 LLM 自己调用 MCP 的 Tool 获取数据,还是一股脑全喂给它。 |
6 Saniter 21 小时 22 分钟前 |
7 kuanat OP @sjdhome #5 是这样的。我前两天看见说豆包训练输入法,虽然没有专门训练,但还是基本学会了双拼,不得已还要专门写代码让单字全拼的权重提高。(大意是这样) 所以就算是无文档的数据结构,让 LLM 来猜也问题不大,只要数据够多。 我想表达的重点不是 MCP 这样设计是错的,而是说作为设计者很难想象最终用户会怎么用。作为协议来说需要重策略轻机制,作为标准来说用机制保证它被按照设计意图来使用,不给出错的机会是很重要的。 |
8 kuanat OP @Saniter #6 我个人的观点是 D-Bus 的安全性问题更多是“时代局限性”,本身还是比较好解决的。 这篇文章上 HN 的时候我就看过,作者 vaxry 是 hyprland 的作者,也是有名的喷子了(非贬义)。 早期计算机系统( Unix )所谓的多用户,是建立在 UID/GID 逻辑上的,那个时候是真的一个人一个 UID 。当个人 PC 逐渐成为主流之后,UID 这个概念就不够用了,现在的 UID 已经不再对应某个人,而是用来区分不同的进程或者权限实体。 D-Bus 在设计的时候,是基于 ACL 方式将 UID 作为权限隔离的依据,这个逻辑在设计之初没问题,只是跟不上时代了。 文章中讲了 IPC 设计的第三条原则,不再使用 ACL 而是采取 capability 声明的方式来对权限做细分和隔离,这已经是个标准化共识。对于当前的 D-Bus 来说,比较大的问题是如何过渡到新的方案上。另外 Linux 内核也不是对内核 IPC 完全不接受,也许未来会有一个基于 io_uring+eBPF 的标准化 IPC 机制也说不定。在纯用户态做也不是不可以,只是形成共识会比较困难,最终可能还是红帽的人主导一个方案,然后慢慢等生态迁移。 |
9 HTravel 8 小时 54 分钟前 图形系统唯一成熟到能支持各种消费场景和工业场景的,就是 win3.2 一直成长到的 win XP 。分析这段历史有巨大价值。即使直到今天,XP GUI 界面在当年 CPU 上的跟手速度,依然能把在最新 CPU 上跑的基于 web UI 的跟手速度吊打。 至于 Linux 的图形系统,一直就是垃圾的代表,连 macOS 这种垃圾都比不上(虽然我是苹果全家桶用户,但苹果是从 iPhone 才成熟的,macOS 虽然改过,但本质变化不大,今天的 Finder 能和 XP 文件管理器比功能、比易用性吗?)。在有 AI 的今天,还去炒冷饭分析 Linux 图形这类失败案例,完全是浪费所有人的时间。 至于有人在回复中说 COM 是微软臃肿过度设计的体现,估计又是不会写汇编的菜鸟。否则就会发现二进制接口协议,必然走到 COM 这条路上来。当然,微软后来基于 COM 技术做了很多垃圾设计,但这不是 COM 技术方案垃圾,是微软故意的。 因为 COM 技术原理很简单,就是问它有没有一个接口,如果有,它就返回这个接口的内存地址。这个接口中的各函数的内存地址的指针,就在从该接口内存地址那开始,依次排列着。所以你想调用哪个函数,就是加上这个偏移量直接取函数地址,然后跳转过去即可。COM 原理就这么简单。相比汇编进步的一点,就是明确规定了一个接口中各函数地址的排列顺序,一经编译发布,就不可更改,新版本新增的函数接口,必须排在后面。但这个规定函数地址顺序,难道不应该、不必要吗? COM 就通过这一点点的设计约束,就撑出了 COM 技术体系这一个参天大树。 也就是说,要在二进制层面设计一套面向对象( COM 接口就是一个个对象)的函数接口方案,设计方案还要确保不引入任何运行期的性能损失,那 COM 就是唯一最佳方案,没有之一。 |
10 pollux 7 小时 20 分钟前 > 由操作系统提供的合成器按需合成后交给显卡现实 |
12 kuanat OP @HTravel #9 COM 本身是个好技术,有问题的是建立在其之上的 OLE 之类的技术。COM 技术的核心就是契约化的 ABI ,但契约化 ABI 不一定只有 COM 这一种实现方式。 我写这篇文章的一个目的就是以史(经验)为鉴,桌面图形系统才不过二三十年,谁知道下一个十年会是什么样子。如你所说到 XP 的系统还称得上成熟,能支持消费和工业场景,那为什么后面的系统就不成熟了,是微软不想吗,会不会是因为之前的设计决策把自己的路封死了? Windows 在除开 PC 的工业或者消费场景中,存量市场很大,但新增市场几乎 100% 被 Android 和 Linux 瓜分了。 现在这个系列还没有讲到 Windows 的部分,我一直在强调一个观点,这个世界上就没有十全十美的东西,既要性能又要好用,还要兼容性同时想容易维护,那么代价是什么。还是那句话,停留在好于不好的争论上没有什么意义,了解和学习背后的设计理念更具有价值。 |
13 MoGeJiEr 5 小时 48 分钟前 写的很不错,就是太短了看着不爽阿,后面的能不能一波放出来了 |
14 HTravel 5 小时 22 分钟前 @kuanat 我 100%支持以史为鉴。 但以史为鉴是为了支持我们下一步的进步,所以应该主要总结这么多年这么多聪明人经过了这么多实践,最终被证明是做对了的那些事。即使放在图形技术来说,也是 win32 GUI 做成功了,web UI 在丰富特效方面做成功了。web UI 虽然容易不跟手,但反而在最强调手速的游戏场景,里面的背包、聊天、任务场景,反而是普遍采用了 web UI 技术。这些才是值得深思、值得深挖的。但问题是这类深挖的文章太少了,都是浅浅的回忆了一遍历史,然后什么也没留下,天下文章一大抄,最终也就是多了篇冗余信息的文章(因为文章中所有的信息都能从网上其他文章搜到)。 至于 Linux 的图形技术,谁不知道那就是一坨屎?真想分析,还不如分析安卓图形技术,这是真正成功了的。 失败的东西那么多,再深挖,能挖出啥?那么多聪明人都折戟沉沙了,你真有这技术实力为其分析失败原因、然后给出可行的成功方案?所以在我看来,只有深挖各种成功的 GUI 方案,才可能进一步给出下一步 GUI 技术演化方向。即使最终给不出下一步 GUI 演化方向,深挖了成功的 GUI 方案后,也便于自己写出更好更跟手的 GUI 交互界面,这就是其现实价值所在。 XP 后面不再成功,是因为市场方向变了啊。服务端转云,消费端转手机,即使企业内部的办公软件,也都尽可能转 web ,那操作系统 GUI 还做个啥?就剩个复杂的生产力软件吧。但生产力软件的 GUI ,为了追求极致特效、极致性能、甚至现在还要考虑跨操作系统,所以基本上都自绘了,更是没有操作系统 GUI 组件什么事。macOS 不也没再成功嘛,连苹果自己的 office 软件都改成了免费送。 我认同你说的“了解和学习背后的设计理念更具有价值”,所以你要把成功 GUI 背后的设计理念讲出来啊。至于 Linux GUI 这种垃圾,你讲的再透彻,即使透彻到我能为其写一个复杂生产力软件 UI ,又有什么价值? |