超越 POSIX:一个时代的终结?
原文标题:《Transcending POSIX: The End of an Era?》 URL:https://www.usenix.org/publications/loginonline/transcending-posix-end-era 作者: 佩卡·恩伯格、阿什温·拉奥、乔恩·克劳克罗夫特、萨苏·塔科玛
指导: 里克·法罗
翻译:原力注入
背景知识(笔者补充)
POSIX(Portable Operating System Interface for Unix,可移植操作系统接口)是一个由 IEEE 定义的标准接口,目的是使不同操作系统上的应用程序具有更好的可移植性。
在 POSIX 出现之前,不同 Unix 系统(如BSD、System V等)在实现细节上存在显著差异,导致软件开发人员需要为每种系统编写特定的代码来处理这些差异。POSIX 通过定义一套统一的 API 标准,使程序代码可以在不同操作系统之间更容易地移植和复用,避免了操作系统之间的兼容性问题。
文件与目录操作:文件创建、删除、读取、写入、权限管理等。
进程控制:进程的创建、终止、同步、通信等。
线程管理:多线程编程接口(如pthread库),包括线程的创建、同步、锁等。
内存管理:共享内存、内存映射(mmap)等。
信号处理:对信号的捕获、屏蔽和处理。
网络通信:套接字(sockets)等网络通信接口。
输入/输出:通用的I/O操作标准,包括标准输入、输出和错误流。
实用工具:标准命令和工具的实现规范(如awk、grep等)。
超越 POSIX:一个时代的终结?
在本文中,我们通过系统回顾可移植操作系统接口 (Portable Operating System Interface,POSIX) 抽象的历史演变,提供对它的全面了解。我们讨论了推动演变的一些关键因素,并找出了在构建现代应用程序时导致它们不可行的缺陷。
POSIX 标准[1]定义了类 Unix 操作系统的接口。Unix 是第一个由程序员为程序员编写的操作系统,POSIX 使开发人员能够编写可在不同的 Unix 操作系统变体和指令集体系结构上运行的可移植应用程序。Unix 的主要用例是多路复用存储(文件系统)并为人类提供交互式环境(shell)[2]、[3]。相比之下,许多当代基于 POSIX 的系统的主要用例是在数据中心的机器上运行的服务,这些机器对延迟的要求要低几个数量级。这些服务不能指望随着 CPU 时钟频率的增加而逐年加快,因为 Dennard 缩放定律在 2004 年左右的结束意味着 CPU 时钟频率不再以 Unix 商品化期间普遍存在的速度增加。此外,许多人认为摩尔定律正在放缓,因此软件不能再指望通过增加晶体管密度来推动硬件优化而变得更快。随着我们迈向后摩尔定律计算时代,系统设计人员开始利用快速可编程 NIC、专用硬件加速器和非易失性主存储器等设备来解决应用程序的严格延迟限制。
| 年 | 抽象 | 示例接口 | 版本 |
|---|---|---|---|
| '69 | 文件系统 | 打开、读取、写入 | V0 |
| '69 | 进程 | fork | V0 |
| '71 | 进程 | 执行 | V1 |
| '71 | 虚拟内存 | break1 | V1 |
| '73 | 管道 | 管道 | V3 |
| '73 | 信号 | 信号 | V4 |
| '79 | 信号 | 杀 | V7 |
| '79 | 虚拟内存 | vfork2 | 3BSD |
| '83 | 网络 | 套接字,接收,发送 | 4.2BSD |
| '83 | I/O 多路复用 | select | 4.2BSD |
| '83 | 虚拟内存 | mmap3 | 4.2BSD |
| '83 | IPC | msgget、semget、shmget | SRV1 |
| '87 | I/O 多路复用 | 轮询 | SRV3 |
| '88 | 虚拟内存 | mmap | SunOS 4.0 |
| '93 | 异步 I/O | aio_submit | POSIX.1b |
| '95 | 线程 | pthread_create | POSIX.1c |
POSIX 抽象和接口的时间线。
这些抽象是在 20 世纪 70 年代到 90 年代之间在不同的 Unix 变体中引入的。文件系统和进程是 V0 中已经存在的基本接口。虚拟内存在 20 世纪 70 年代末的 3BSD 中引入,并在 20 世纪 80 年代的 4.2BSD 和 SunOS 4.0 中完成。20 世纪 80 年代,4.2BSD 添加了网络支持。异步 I/O 和线程在 20 世纪 90 年代的 POSIX 标准中引入。
break系统调用后来重命名为 brk,并添加了另一个变体 sbrk。它们现在都已弃用。
3BSD添加了对基于分页的虚拟内存的支持。他们添加了 vfork 系统调用以避免为 fork 实现写时复制[4]。
尽管 mmap 是在 1983 年设计的,但提议的设计在 1986 年才得到了完整实现[5]。
POSIX 的演进
POSIX 的抽象(进程、文件系统、虚拟内存、套接字和线程)基于 20 世纪 70 年代至 80 年代开发的不同 Unix 变体的操作系统抽象,例如 Research Unix、System V、BSD、SunOS 等。
各自时代的用例和硬件功能影响了抽象。例如,早期的 Unix 运行在 PDP-11/20 上,这是一台 16 位计算机,只有一个 CPU,主内存高达 248KB [6]。由于 PDP-11/20 缺乏内存保护,因此与 Multics 等当时的当代操作系统不同,Unix 不支持虚拟内存。尽管后来的 PDP-11 变体(如 PDP-11/70)具有内存映射单元(MMU)[7],但直到 20 世纪 70 年代后期 VAX 架构出现后,虚拟内存才被添加到 Unix 中[4],VAX 架构当时成为 Unix 的主要架构。同样,直到 20 世纪 80 年代初互联网出现后,Unix 才有了网络抽象,当时 4.2BSD 引入了用于远程进程间通信的套接字抽象来抽象 TCP/IP 网络协议。同样,直到 20 世纪 90 年代初期,当多处理器机器成为主流时,Unix 才有了线程抽象[8]。
文件系统
文件系统是访问和组织存储设备上的数据字节的抽象。此抽象及其 I/O 接口主要源自 Multics [9],它被认为是 Unix 中最重要的抽象[2]、[10]。然而,与仅支持同步 I/O 的 Unix 不同,Multics 还支持异步 I/O [11]、[12],这一功能最终成为 POSIX 的一部分。
文件系统抽象还包括文件、目录、特殊文件 [2] 以及硬链接和符号链接[13]。文件系统中的文件是操作系统无法以任何方式解释的字节序列 [2]。这使得操作系统能够将硬件设备表示为特殊文件,而操作文件的接口已成为 I/O 设备的事实接口。
文件系统抽象使 I/O 设备的集成变得容易。然而,它可能成为快速 I/O 设备的瓶颈[14]、[15]、[16]、[17]。
进程
进程是系统中应用程序执行的抽象。具体来说,应用程序表示为一个映像,该映像抽象了其执行环境,其中包括程序代码(文本)、处理器寄存器值和打开的文件[2] 。此映像存储在文件系统中,操作系统确保进程映像的执行部分驻留在内存中。进程抽象自早期 Unix [2]以来就已存在,它对于计算和 I/O 资源的分时共享至关重要。
这种抽象的根源在于多道程序设计,这是 20 世纪 50 年代中期为提高执行 I/O 时的硬件利用率而开发的一种技术[18]。在 PDP-7 上运行的早期 Unix 仅支持两个进程,每个连接到机器的终端一个进程[19];后来设计为在 PDP-11 上运行的 Unix 版本可以在内存中保留多个进程。
进程是一个以处理器为中心的抽象概念,对于假设进程映像的执行仅在 CPU 上完成的应用程序非常有用。然而,图形处理单元 (GPU)、张量处理单元 (TPU) 等硬件设备的普及,以及用于卸载计算的各种其他专用加速器,正在挑战这一假设。
虚拟内存
虚拟内存是一种抽象,它创造了一个与存储空间一样大的内存空间的假象[20]。它源于自动利用主存储器速度和廉价存储容量的需求。虚拟内存的概念可以追溯到 20 世纪 60 年代初:基于页面的虚拟内存于 1962 年在 Atlas Supervisor [21]中首次引入,Multics 也支持虚拟内存[22]。
虚拟内存于 20 世纪 70 年代末加入 Unix,此时距离其诞生已过去了近 10 年。在 Unix 诞生之初,进程地址空间被分为三段:所有进程共享但不可写的程序文本(代码)段、可读写但私有的进程数据段和堆栈段。sbrk 系统调用可以增大和缩小进程数据段。然而,由于需要运行当时需要比主内存容量更大的存储空间的程序(例如 Lisp),VAX-11 架构中的 MMU 使基于分页的虚拟内存成为可能[4] , [23]。
这种抽象将两个相关的概念解耦:地址空间,即用于寻址内存的标识符,和内存空间,即存储数据的物理位置。从历史上看,这种解耦有三个主要目标:(1)通过独立于物理内存空间的地址空间促进机器独立性,(2)通过允许程序员由在执行时链接在一起的独立模块编写程序来促进模块化,(3)可以运行物理内存无法容纳的大型程序(例如,Lisp 程序)。虚拟内存的其他好处包括运行任意大小的程序、运行部分加载的程序以及无需重新编译程序即可更改内存配置。虚拟内存被认为是一种基本的操作系统抽象,但当前的硬件和应用程序趋势正在挑战其核心假设。
进程间通信 (IPC)
进程间通信的抽象使一个或多个进程能够相互交互。早期版本的 Unix 支持信号和管道[2]。信号使程序员能够以编程方式处理硬件故障,并且此机制被推广为允许进程通知其他进程。例如,shell 进程可以使用信号来停止进程。管道是允许进程之间交换数据的特殊文件。管道不允许任意进程交换数据,因为两个进程之间的管道必须由它们的共同祖先设置。
由于管道和信号的限制,BSD 中加入了套接字,为本地和远程进程(即在不同主机上运行的进程)提供统一的 IPC 机制。套接字已成为联网的标准方式,但它们的使用并不像针对本地 IPC 的平台特定 IPC 机制那样广泛[24]。
共享内存的 mmap 接口被设想为一种 IPC 机制[25]、[26],但从未真正流行起来。1993 年发布的 POSIX.1b 中添加了其他 IPC 机制(信号量、共享内存的 IPC 特定接口和消息队列),但此后已被供应商特定的 IPC 机制大量取代[24]。
线程和异步 I/O
线程和异步 I/O 是 POSIX 中用于满足并行和并发需求的后来者抽象。
传统的 UNIX 进程只提供单线程执行。由于无法支持并发执行线程,单个 UNIX 进程无法充分利用多个计算核心提供的并行性。充分利用并行性的一种方法是分叉多个进程,但这要求分叉的进程使用 IPC 机制相互通信,而这又效率低下。
POSIX 异步 I/O (AIO) 接口旨在满足日益增长的非阻塞 I/O 接口需求,该接口可用于提高并发性。此接口允许进程调用异步执行的 I/O 操作。但是,它可能会在各种情况下阻塞,并且每个 I/O 至少需要两个系统调用:一个用于提交请求,另一个用于等待请求完成。
1-on-1:每个线程都在自己的内核线程中运行;
N-on-1:所有线程都在一个内核线程中运行;
N-on-M:N 个线程在 M 个内核线程中运行[27]、[28]、[29]。
管理用户空间的并行性对于高性能至关重要[27]。然而,主流 POSIX 操作系统采用了 1-on-1 线程模型,理由是实现简单[30]、[31]。无论如何,使用大量线程的应用程序架构,如分阶段事件驱动架构(SEDA)[32],由于线程开销而效率低下[33]。因此,许多高性能应用程序采用每核线程模型,其中线程数等于处理核心数,并提供自己的并发接口[34],[35]。
超越 POSIX
卸载计算
POSIX 进程是一个以 CPU 为中心的抽象,因为在 Unix 数十年的演进过程中,CPU 一直是核心和主要的计算资源。然而,将计算从 CPU 卸载到特定领域的协处理器和加速器(如用于图形和并行计算的 GPU 以及用于卸载数据包处理的 NIC)已成为主流[36]。因此,CPU 越来越成为协调这些资源之间计算的协调者,而应用程序越来越多地将 CPU 的计算能力仅用于协调各种硬件资源之间的计算。
但是,POSIX 没有处理协处理器或加速器的机制。因此,所有非 CPU 的计算元素都被视为 I/O 设备。因此,应用程序需要使用用户空间 API 将代码和数据上传到加速器,该 API 通过不透明的系统调用(如 fcntl())与操作系统内核集成。例如,有用于 GPGPU 的 OpenCL 和 CUDA 等 API,以及用于图形编程的 Vulkan 等 API [37]。这些 API 必须处理内存和资源管理等问题,因为 POSIX 本身并不支持这种类型的硬件。
| Caching | Sync | Copies | Complexity | |
|---|---|---|---|---|
| read/write | kernel | yes | yes | low |
| mmap | kernel | yes | no | medium |
| DIO | user | yes | no | medium |
| AIO/DIO | user | no | no | high |
| io_uring | kernel/user | no | yes/no | high |
Linux 中的 I/O 访问方法
异步 I/O
异步 I/O 源于 Multics [11]、[12]。但是,POSIX I/O 调用源于 Unix,其 I/O 接口是同步的。因此,POSIX 读/写系统调用是同步的,它们会导致从内核页面缓存复制数据。同步接口是快速 I/O 的瓶颈,它们要求应用程序使用线程实现应用程序级并发和并行。mmap 接口比传统的读/写更快,因为它避免了系统调用开销并在内核和用户空间之间进行复制。但是,使用 mmap 的 I/O 是同步的,并且具有更复杂的错误处理。例如,在磁盘已满时,写入将返回错误代码,而基于 mmap 的 I/O 则需要处理信号。相比之下,直接 I/O (DIO) 允许应用程序使用相同的读写系统调用,同时绕过页面缓存。但是缓冲区管理和缓存是在用户空间中执行的。异步 I/O (AIO) 接口提供了一组新的系统调用,允许用户空间应用程序使用 io_submit 系统调用异步提交 I/O,并使用 io_getevents 系统调用轮询 I/O 完成情况。但是,Linux 的 AIO 实现存在一些问题:它在每个系统调用中复制最多 104 个字节的描述符和完成元数据,并且系统调用有时会阻塞[38]。
Linux 的 io_uring 接口旨在解决这些缺点,并提供真正的异步 I/O 接口[38]。它首次出现在 Linux 内核 5.1 版中,它使用两个无锁单生产者单消费者 (SPSC) 队列用于内核和用户空间之间的通信[38]。一个队列用于 I/O 提交,由应用程序写入并由内核读取,而另一个队列用于 I/O 完成,该队列由内核写入并由应用程序读取。根据使用情况,应用程序可以将 io_uring 实例配置为中断驱动、轮询或内核轮询。io_uring 接口允许线程提交 I/O 请求并继续执行其他任务,直到操作系统通知它 I/O 操作已完成。
绕过(Bypassing) POSIX I/O
POSIX I/O 模型假设内核执行 I/O,将数据传输到用户空间进行进一步处理。然而,该模型对于高到达率的扩展性不是很好,因此绕过 POSIX I/O 接口的早期示例之一是 BSD 数据包过滤器 (BPF)。BPF 通过在内核中运行的伪机器内过滤数据包来促进用户级数据包捕获 [39]。数据包捕获应用程序首先命令内核复制到达网络接口卡 (NIC) 的数据包:一份副本穿过网络协议栈,另一份副本穿过伪机器。BPF 伪机器执行从高级描述语言编译的数据包过滤代码,然后将过滤后的数据包发送到用户空间。扩展伯克利数据包过滤器 (eBPF) 建立在 BPF 之上,允许应用程序在内核虚拟机或能够运行程序的硬件上执行沙盒程序[40]。这使得应用程序能够卸载 I/O 活动,例如网络协议处理和在用户空间中实现文件系统。具体来说,eBPF 使应用程序能够完全绕过 I/O 的 POSIX 抽象并在用户空间中实现它们。eBPF 补充了现有的内核旁路方法,例如 DPDK 和 SPDK,使应用程序能够绕过内核进行网络和存储 I/O [41]、[42]。
超越机器抽象
POSIX 提供了以可移植方式在类 Unix 操作系统变体和机器架构之间编写应用程序的抽象。然而,当代应用程序很少在一台机器上运行。它们越来越多地使用远程过程调用 (RPC)、HTTP 和 REST API、分布式键值存储和数据库,所有这些都是用 JavaScript 或 Python 等高级语言实现的,在托管运行时上运行。这些托管运行时和框架公开了隐藏其底层 POSIX 抽象和接口细节的接口。此外,它们还允许使用除 C(Unix 和 POSIX 的语言)以外的编程语言编写应用程序。因此,对于许多当代系统和服务的开发人员来说,POSIX 基本上已经过时了,因为它的抽象是低级的并且与单台机器绑定。
然而,云和无服务器平台现在面临着 POSIX 之前的操作系统所面临的一个问题:它们的 API 是碎片化的和平台特定的,这使得编写可移植的应用程序变得困难。此外,这些 API 仍然主要是以 CPU 为中心的,这使得很难有效地利用专用加速器和分解的硬件,除非诉诸定制解决方案。例如,JavaScript 今天的地位可能与 POSIX 过去的地位相似:它将应用程序逻辑与底层操作系统和机器架构分离。然而,JavaScript 运行时仍然以 CPU 为中心,这使得很难将 JavaScript 应用程序的某些部分卸载到 NIC 或存储设备上的加速器上运行。具体来说,我们需要一种语言来表达应用程序逻辑,使编译器和语言运行时能够有效地利用硬件堆栈不同部分中出现的大量硬件资源的功能。同时,思考如果没有 POSIX 中的 CPU 中心主义,这些设备的硬件设计会有什么不同,这将是一个有趣的思想实验。
结束语
几十年来,POSIX 已成为操作系统抽象和接口的标准。硬件约束和当时的用例是抽象设计的两个驱动因素。如今,I/O 和计算之间的速度平衡正在向 I/O 倾斜,这也是协处理器和专用加速器越来越主流的原因之一。因此,我们认为 POSIX 时代已经结束,未来的设计需要超越 POSIX,在更高层次上重新思考抽象和接口。我们还认为,操作系统接口必须改变以支持这些更高层次的抽象。
致谢
作者要感谢 Kirk McKusick、Babak Falsafi、Matt Fleming、Rik Farrow 以及 OSDI '20、EuroSys '21、HotOS XVIII、ApSys '21 和 ASPLOS '22 的匿名审阅者提出的深刻意见,使本文更加完善。
References: