操作系统 操作系统(operating system,OS)是夹在硬件与应用程序之间的软件层,负责管理资源、提供抽象并强制隔离。本文件涵盖 OS 的职责、进程、线程、CPU 调度、内存管理、文件系统和系统调用。 一台没有操作系统的计算机就像一间没有厨师的厨房:食材(硬件)都在,但没人来协调谁用灶台、碗碟该放哪、怎么防止两个人抢同一把刀。操作系统(OS)就是这个协调者。 对 ML 从业者来说,OS 概念能解释:为什么 会按进程显示 GPU 内存用量、为什么训练会以"out of memory"崩溃、为什么 会复制你的 Python 进程、为什么 Docker 容器能提供隔离的环境。 操作系统做什么 操作系统有三项核心职责: 抽象(abstraction):用干净的接口把硬件复杂性藏起来。
操作系统(operating system,OS)是夹在硬件与应用程序之间的软件层,负责管理资源、提供抽象并强制隔离。本文件涵盖 OS 的职责、进程、线程、CPU 调度、内存管理、文件系统和系统调用。
一台没有操作系统的计算机就像一间没有厨师的厨房:食材(硬件)都在,但没人来协调谁用灶台、碗碟该放哪、怎么防止两个人抢同一把刀。**操作系统(OS)**就是这个协调者。
对 ML 从业者来说,OS 概念能解释:为什么 nvidia-smi 会按进程显示 GPU 内存用量、为什么训练会以"out of memory"崩溃、为什么 fork() 会复制你的 Python 进程、为什么 Docker 容器能提供隔离的环境。
操作系统有三项核心职责:
抽象(abstraction):用干净的接口把硬件复杂性藏起来。程序读写"文件"时不需要知道底层存储是 SSD、HDD 还是网络盘;它申请"内存"时不用去管物理 RAM 芯片;它跑在"CPU"上时也不必操心中断和缓存一致性。
资源管理(resource management):多个程序共享 CPU、内存、磁盘和网络。OS 决定谁在何时得到什么、用多久。一套公平而高效的分配策略能让系统保持响应迅速。
隔离与保护(isolation and protection):程序之间不能互相干扰。你浏览器里的一个 bug 不应该让内核崩溃;一个恶意程序不应该读到另一个程序的密码。OS 借助硬件支持(特权级、虚拟内存)来强制划定边界。
**进程(process)**是一个正在运行的程序。它是 OS 工作的基本单位。每个进程拥有:
进程控制块(Process Control Block,PCB)是 OS 用来跟踪一个进程的数据结构。它存放进程 ID(PID)、状态、程序计数器、寄存器内容、内存映射、打开的文件描述符和调度优先级。当 OS 从一个进程切换到另一个时,它把当前进程的状态存进它的 PCB,再装入下一个进程的状态。这就是一次上下文切换(context switch)。
上下文切换代价不菲:保存和恢复寄存器、冲刷缓存、作废 TLB 项,要花微秒级的时间。在一个跑着成千上万个进程的系统上,这种开销可能相当可观。这也是为什么"每个请求一个进程"的服务器架构(比如老式 Apache)被基于线程或事件驱动的架构取代了。
在 Unix 中,进程创建使用 fork() 和 exec():
fork() 创建当前进程的一份副本。子进程得到父进程内存、文件描述符和状态的复制。两个进程都从同一点继续执行,但 fork() 在子进程中返回 0,在父进程中返回子进程的 PID。
exec() 用一个新程序替换当前进程的代码。fork() 之后,子进程通常会调用 exec() 来运行另一个程序。
这种"先 fork 再 exec"的模型很优雅:创建新进程(fork)和载入新程序(exec)是两个独立的操作,可以各自定制。在 fork 与 exec 之间,子进程可以重定向 I/O、修改环境变量或丢弃权限。
**线程(thread)**是进程内的轻量级执行单元。一个进程内的所有线程共享同一份代码、数据和堆,但每个线程有自己的栈和寄存器状态。
相比多进程的优势:线程共享内存,所以线程间通信很快(直接读写共享变量即可)。进程则需要进程间通信(管道、套接字、共享内存映射),更慢也更复杂。
劣势:共享内存是危险的。两个线程同时写同一个变量会引发竞态条件(race condition)(结果取决于哪个线程先跑),这就把我们引向了第 4 节要讲的同步。
**内核线程(kernel threads)**由 OS 调度器管理。每个线程被独立调度到 CPU 核上。创建和切换内核线程要涉及系统调用,开销与进程上下文切换相近(但更小)。
**用户线程(user threads,绿色线程 green threads)**由用户空间的运行时库管理,对 OS 不可见。它们的创建和切换更便宜(无需系统调用),但一个用户线程的阻塞操作会阻塞该进程中的所有线程(因为 OS 只看到一个内核线程)。
现代系统采用混合模型:把大量用户线程映射到较少数量的内核线程上(M:N 线程)。Go 的 goroutine 和 Erlang 的进程就是由语言运行时调度到 OS 线程上的用户级线程。
**线程池(thread pools)**预先创建固定数量的线程,让它们等待任务。任务到来时分配给一个空闲线程。这避免了为每个任务反复创建和销毁线程的开销。Web 服务器、数据库引擎和 ML 推理服务器都使用线程池。
**调度器(scheduler)**决定每个时刻哪个进程/线程跑在哪个 CPU 核上。目标是:最大化 CPU 利用率、最小化响应时间(交互任务)、最大化吞吐率(批处理任务)以及保证公平。
先来先服务(First Come First Served,FCFS):按到达顺序运行进程。简单但有护送效应(convoy effect):一个长进程会堵住后面所有短进程。
最短作业优先(Shortest Job First,SJF):先运行最短的进程。可证明能最小化平均等待时间,但需要预先知道作业长度(一般做不到)。其抢占式版本**最短剩余时间优先(Shortest Remaining Time First,SRTF)**会在更短的作业到来时打断当前正在运行的作业。
轮转(Round Robin,RR):每个进程获得一个固定的时间配额(time quantum,如 10 ms),然后被抢占并挪到队尾。公平且响应快,但时间配额很关键:太小会导致上下文切换过多,太大则退化成 FCFS。
优先级调度(priority scheduling):每个进程有一个优先级,高优先级进程先跑。风险是饥饿(starvation):如果高优先级进程不断到来,低优先级进程可能永远得不到运行。**老化(aging)**可以解决:一个进程等待得越久,优先级就越高。
多级反馈队列(Multilevel Feedback Queues,MLFQ):多个队列,各有不同的优先级和时间配额。新进程从最高优先级队列(短配额)开始。如果某个进程用完了整个配额(CPU 密集型),它就被降级到更低优先级的队列(更长配额)。交互式进程自然留在高优先级队列(它们在用完配额前就会因 I/O 而阻塞)。这样无需预先知道作业类型就能适应工作负载。
完全公平调度器(Completely Fair Scheduler,CFS):Linux 的调度器。它维护一棵按"虚拟运行时"(进程已消耗的 CPU 时间)排序的红黑树(一种平衡二叉搜索树)。虚拟运行时最小的进程下一个运行。这保证了长期来看每个进程都得到它应得的份额。CFS 每次调度决策的复杂度是 O(\log n)。
OS 管理物理 RAM,把它分配给进程,并在不再需要时回收。
分页(paging)(来自第 2 节)把虚拟内存划分成定长的页、把物理内存划分成帧。页表把页映射到帧。分页消除了外部碎片(分配之间浪费的空间),因为所有页大小相同。
**按需分页(demand paging)**只在页首次被访问时(而不是进程启动时)才把它装入 RAM。这节省了内存:一个有 1 GB 代码的程序在某次典型运行中可能只用到 50 MB,其余部分根本不会被装入。
当 RAM 已满却又需要新页时,OS 必须**驱逐(evict)**一个现有页。页面置换算法(LRU、FIFO、时钟算法,来自第 2 节)决定驱逐哪一页。好的置换能最小化缺页;差的置换会引发颠簸。
**分段(segmentation)**把内存划分成变长的段(代码、数据、栈、堆),每段有自己的基址和长度。分段提供逻辑组织,而分页提供物理管理。现代系统很少用分段(主要用于保护),而主要依靠分页来管理内存。
**堆(heap)是动态分配的内存所在之处(C 的 malloc/free、Java 的 new、Python 里则是隐式的)。OS 把大块内存交给进程,再由内存分配器(memory allocator,如 glibc malloc、jemalloc、tcmalloc)**把这些大块切成更小的分配。分配器的设计会影响性能:碎片浪费空间,线程间争用浪费时间。
**文件系统(file system)**把持久存储(SSD、HDD)上的数据组织成命名文件和目录的层级结构。
**索引节点(inode,index node)**存放一个文件的元数据:大小、属主、权限、时间戳,以及指向磁盘上数据块的指针。文件名存在目录里,目录把名字映射到 inode 号。这种分离意味着一个文件可以有多个名字(硬链接 hard links)指向同一个 inode。
FAT(文件分配表,File Allocation Table):一种简单的文件系统,用于 U 盘和 SD 卡。一张表把每个簇(块)映射到文件中的下一个簇,形成一条链表。简单但不支持权限、日志和大文件。
ext4:Linux 的默认文件系统。使用带直接、间接、二级间接、三级间接块指针的 inode 来处理任意大小的文件。支持**区段(extents,连续的块范围)**以高效处理大文件。最大文件大小 16 TB,最大分区 1 EB。
日志(journaling)防止因崩溃而损坏。在修改文件系统结构之前,先把变更写入一份日志(journal,log)。如果系统在操作中途崩溃,重启时会重放日志以完成或撤销该操作。没有日志的话,写操作中的一次崩溃可能让文件系统处于不一致状态(一个文件的数据块更新了但它的 inode 没更新,或反之)。
基于 B 树的文件系统(Btrfs、ZFS)用 B 树(平衡搜索树)来组织数据和元数据,支持高效搜索、写时复制快照和用于数据完整性的内建校验和。这些与数据库索引中用的是同一种 B 树。
**系统调用(system call)**是用户程序与 OS 内核之间的接口。当程序需要做某件特权操作(读文件、分配内存、创建进程、发送网络包)时,它就发起一次系统调用。
CPU 在两种模式下运行:
当程序调用 read() 时,会发生以下过程:
常见的系统调用:open、read、write、close(文件),fork、exec、wait、exit(进程),mmap、brk(内存),socket、bind、listen、accept(网络)。
**中断(interrupts)**是硬件信号,会强制 CPU 暂时停下正在做的事,转去运行一个中断处理程序(在内核里)。按键、网络包到达、定时器节拍都会产生中断。定时器中断尤其重要:正是它让 OS 能够抢占一个正在运行的进程并切换到另一个(抢占式多任务)。
TCP/IP 模型把网络组织成若干层,每一层为上一层提供抽象:
TCP(传输控制协议,Transmission Control Protocol)提供可靠、按序的交付。它建立连接(三次握手:SYN、SYN-ACK、ACK),保证所有数据按序到达(用序列号和确认),重传丢失的包,并控制发送速率以免压垮网络(拥塞控制 congestion control)。代价是延迟:握手增加一个往返,重传增加延迟。
UDP(用户数据报协议,User Datagram Protocol)提供不可靠、无序的交付。没有握手、没有重传、没有顺序保证。延迟远低于 TCP。用在速度比可靠性更重要的场合:视频流、在线游戏、DNS 查询。在 ML 中,某些梯度同步协议使用基于 UDP 的 RDMA 来获得更低延迟。
**套接字(sockets)是 OS 提供的网络通信 API。一个套接字(socket)**是由(IP 地址,端口号)标识的一个端点。服务器创建一个套接字,绑定到一个端口(如 HTTP 用 80),监听连接,然后接受连接。客户端创建一个套接字并连接到服务器的"地址:端口"。之后就像读写文件一样通过套接字读写数据。
DNS(域名系统,Domain Name System)把人能读懂的名字(google.com)翻译成 IP 地址(142.250.80.46)。它是一个分布式的、层次化的数据库:你的机器问本地解析器,本地解析器问根服务器,根服务器再委托给各域名的权威服务器。
HTTP(超文本传输协议,HyperText Transfer Protocol)是 Web 的请求-响应协议。客户端发送一个请求(方法 + URL + 首部 + 可选的正文),服务器返回一个响应(状态码 + 首部 + 正文)。ML 模型服务(如 TensorFlow Serving、Triton)把模型暴露为 HTTP 或 gRPC 端点。
延迟与带宽:延迟(latency)是一个包从源到目的所花的时间(由物理距离和网络跳数决定)。带宽(bandwidth)是数据速率(每秒多少字节)。一条高带宽、高延迟的连接(卫星互联网)能传大量数据,但每个字节要很久才到。对分布式训练而言,延迟对同步屏障(所有 GPU 必须等最慢的那个)很关键,而带宽对传输大梯度张量很关键(第 6 章)。
**虚拟化(virtualisation)**在一台物理机上运行多个操作系统。虚拟机管理器(hypervisor,VMware、KVM、Xen)创建虚拟机(virtual machines,VMs),每个都有自己的虚拟 CPU、内存、磁盘和网络接口。每个 VM 跑一个完整的 OS(客户机 OS),自以为拥有专属硬件。
VM 提供强隔离(一个 VM 崩溃不影响其它)和灵活性(在同一台机器上同时跑 Linux 和 Windows,在物理主机之间迁移 VM)。代价是开销:每个 VM 都要跑一个完整的 OS 内核,为那些与宿主 OS 冗余的 OS 操作消耗内存和 CPU。
**容器(containers,Docker、Podman)**提供了一个更轻量的替代方案。容器不虚拟化整台硬件,而是共享宿主 OS 内核,并用内核特性来隔离进程:
**命名空间(namespaces)**隔离一个进程能看到什么:每个容器有自己的进程树视图(PID 命名空间)、网络接口(网络命名空间)、文件系统挂载点(挂载命名空间)和主机名(UTS 命名空间)。容器内的进程看不到其它容器里的进程。
**控制组(cgroups,control groups)**限制一个进程能用什么:CPU 时间、内存、磁盘 I/O、网络带宽。一个容器不能消耗超过其 cgroup 允许的资源,防止一个容器饿死其它容器。
容器在毫秒级启动(无需引导 OS),开销极小(共享内核),并由一个 Dockerfile 定义基础镜像、依赖和命令。这让它具备可复现性:docker build 在任何地方都能产出同样的环境。
对 ML 而言,容器解决了"在我机器上能跑"的问题。一个带有特定版本 CUDA、cuDNN、PyTorch 和 Python 的训练环境被打包成容器镜像。任何人都能在任意机器上复现完全一致的环境。云训练平台(AWS SageMaker、GCP Vertex AI)都在容器里跑训练作业。
Kubernetes(K8s)在集群规模上编排容器:它把容器调度到一组机器上,重启失败的容器,按负载扩缩容,并管理容器之间的网络。大规模 ML 服务(数千个模型副本处理数百万请求)就跑在 Kubernetes 上。
OS 通过多种机制来落实安全:
权限(permissions):每个文件都有一个属主、一个属组和权限位(属主/属组/其它各自的读/写/执行)。进程以启动它的用户的身份(UID)运行,只能访问权限位允许的文件。root 用户(UID 0)绕过所有权限检查,所以以 root 身份运行很危险。
权限分离(privilege separation):进程以所需的最小权限运行。一个 Web 服务器不需要 root 权限;它应当以一个受限用户身份运行,只能读取网页文件并绑定 80 端口。一旦服务器被攻破,攻击者的权限也被限制在这个受限用户能做的范围内。
沙箱(sandboxing):在文件权限之外进一步限制进程能做什么。seccomp(Linux)限制进程能发起哪些系统调用。AppArmor 和 SELinux 定义强制访问控制策略。容器把命名空间、cgroups 和 seccomp 结合起来,提供多层隔离。
地址空间布局随机化(Address Space Layout Randomisation,ASLR):每次程序运行时随机化栈、堆和库的内存位置。这让攻击者更难利用内存破坏类漏洞(缓冲区溢出),因为他们无法预测代码或数据会在内存的什么位置。
安全是一个全系统层面的关切:一条链子的强度只取决于它最薄弱的环节。一个模型服务系统需要安全的网络通信(TLS/HTTPS)、经过认证的 API 访问(API 密钥、OAuth)、输入校验(防止对抗性输入)以及隔离的执行(最小权限的容器)。
os.fork()(仅 Unix)创建一个子进程,观察父子进程都从同一点继续执行。import os pid = os.fork() if pid == 0: # 子进程 print(f"Child: my PID is {os.getpid()}, parent PID is {os.getppid()}") else: # 父进程 print(f"Parent: my PID is {os.getpid()}, child PID is {pid}") os.wait() # 等待子进程结束
def round_robin(processes, quantum=3): """Simulate round-robin scheduling. processes: list of (name, burst_time) tuples. """ queue = [(name, burst, 0) for name, burst in processes] # (name, remaining, wait) time = 0 log = [] while queue: name, remaining, waited = queue.pop(0) waited += (time - waited - (processes[[p[0] for p in processes].index(name)][1] - remaining)) run_time = min(quantum, remaining) log.append(f" t={time:3d}: {name} runs for {run_time} (remaining: {remaining - run_time})") time += run_time remaining -= run_time if remaining > 0: queue.append((name, remaining, time)) else: log.append(f" t={time:3d}: {name} DONE (turnaround: {time})") for line in log: print(line) print("Round Robin (quantum=3):") round_robin([("P1", 10), ("P2", 4), ("P3", 6)], quantum=3)
def lru_page_replacement(pages, n_frames): """Simulate LRU page replacement.""" frames = [] faults = 0 for page in pages: if page in frames: frames.remove(page) frames.append(page) # 移到最近使用过的位置 status = "HIT " else: faults += 1 if len(frames) >= n_frames: evicted = frames.pop(0) # 移除最久未使用的 status = f"MISS (evict {evicted})" else: status = "MISS (cold)" frames.append(page) print(f" Page {page}: {status} frames={frames}") print(f"\nTotal faults: {faults}/{len(pages)} ({faults/len(pages):.0%})") print("LRU with 3 frames:") lru_page_replacement([1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5], n_frames=3)