操作系统


文档摘要

操作系统 操作系统(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 工作的基本单位。每个进程拥有:

    • 代码(code)(程序指令,只读)。
    • 数据(data)(全局变量、堆上的分配)。
    • 栈(stack)(函数调用帧、局部变量)。
    • 状态(state)(寄存器值、程序计数器、打开的文件等)。
  • 进程控制块(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、修改环境变量或丢弃权限。

进程状态转换:新建 → 就绪 → 运行 → 阻塞/终止,含抢占和 I/O 等待

  • 进程状态:一个进程处于以下几种状态之一:
    • 运行(running):当前正在某个 CPU 核上执行。
    • 就绪(ready):在等 CPU 核(可运行但还没被调度)。
    • 阻塞(blocked,等待 waiting):在某事件发生前无法继续(I/O 完成、获取锁、定时器到点)。
    • 终止(terminated):执行结束,等待父进程回收它的退出状态。

线程

  • **线程(thread)**是进程内的轻量级执行单元。一个进程内的所有线程共享同一份代码、数据和堆,但每个线程有自己的栈和寄存器状态。

  • 相比多进程的优势:线程共享内存,所以线程间通信很快(直接读写共享变量即可)。进程则需要进程间通信(管道、套接字、共享内存映射),更慢也更复杂。

  • 劣势:共享内存是危险的。两个线程同时写同一个变量会引发竞态条件(race condition)(结果取决于哪个线程先跑),这就把我们引向了第 4 节要讲的同步。

  • **内核线程(kernel threads)**由 OS 调度器管理。每个线程被独立调度到 CPU 核上。创建和切换内核线程要涉及系统调用,开销与进程上下文切换相近(但更小)。

  • **用户线程(user threads,绿色线程 green threads)**由用户空间的运行时库管理,对 OS 不可见。它们的创建和切换更便宜(无需系统调用),但一个用户线程的阻塞操作会阻塞该进程中的所有线程(因为 OS 只看到一个内核线程)。

  • 现代系统采用混合模型:把大量用户线程映射到较少数量的内核线程上(M:N 线程)。Go 的 goroutine 和 Erlang 的进程就是由语言运行时调度到 OS 线程上的用户级线程。

  • **线程池(thread pools)**预先创建固定数量的线程,让它们等待任务。任务到来时分配给一个空闲线程。这避免了为每个任务反复创建和销毁线程的开销。Web 服务器、数据库引擎和 ML 推理服务器都使用线程池。

CPU 调度

  • **调度器(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 mallocjemalloctcmalloc)**把这些大块切成更小的分配。分配器的设计会影响性能:碎片浪费空间,线程间争用浪费时间。

文件系统

  • **文件系统(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 在两种模式下运行:

    • 用户态(user mode):受限。程序可以执行自己的代码、访问自己的内存,但不能直接访问硬件、其它进程的内存或 OS 数据结构。
    • 内核态(kernel mode):不受限。OS 内核可以访问所有硬件和内存。系统调用是从用户态进入内核态的受控通道。
  • 当程序调用 read() 时,会发生以下过程:

    1. 程序把参数放进寄存器,触发一次陷入(trap,软件中断)
    2. CPU 切换到内核态,跳到系统调用处理程序。
    3. 内核校验参数、执行 I/O 操作,把数据拷贝到用户的缓冲区。
    4. 内核切回用户态,返回结果。
  • 常见的系统调用:openreadwriteclose(文件),forkexecwaitexit(进程),mmapbrk(内存),socketbindlistenaccept(网络)。

  • **中断(interrupts)**是硬件信号,会强制 CPU 暂时停下正在做的事,转去运行一个中断处理程序(在内核里)。按键、网络包到达、定时器节拍都会产生中断。定时器中断尤其重要:正是它让 OS 能够抢占一个正在运行的进程并切换到另一个(抢占式多任务)。

网络基础

  • 网络协议栈是 OS 的一个子系统,使机器之间能够通信。理解它能解释分布式训练如何同步梯度、模型服务如何处理请求,以及为什么延迟很重要。

TCP/IP 协议栈:应用层、传输层、网络层、链路层,每层都加上自己的首部

  • TCP/IP 模型把网络组织成若干层,每一层为上一层提供抽象:

    • 链路层(link layer):处理单条物理链路上的通信(以太网、Wi-Fi)。涉及 MAC 地址和帧。
    • 网络层(IP):在多个网络之间把包从源路由到目的。每台机器有一个 IP 地址(如 IPv4 的 192.168.1.1,或 128 位的 IPv6 地址)。路由器根据目的 IP 逐跳转发包。
    • 传输层(TCP/UDP):在应用程序之间提供端到端通信。
    • 应用层(application layer):HTTP、DNS、gRPC 等应用程序直接使用的协议。
  • 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。

VM 在虚拟硬件上各跑独立的客户机 OS;容器共享宿主内核,因而轻得多

  • **容器(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)限制进程能发起哪些系统调用。AppArmorSELinux 定义强制访问控制策略。容器把命名空间、cgroups 和 seccomp 结合起来,提供多层隔离。

  • 地址空间布局随机化(Address Space Layout Randomisation,ASLR):每次程序运行时随机化栈、堆和库的内存位置。这让攻击者更难利用内存破坏类漏洞(缓冲区溢出),因为他们无法预测代码或数据会在内存的什么位置。

  • 安全是一个全系统层面的关切:一条链子的强度只取决于它最薄弱的环节。一个模型服务系统需要安全的网络通信(TLS/HTTPS)、经过认证的 API 访问(API 密钥、OAuth)、输入校验(防止对抗性输入)以及隔离的执行(最小权限的容器)。

编程练习(使用 CoLab 或 notebook)

  1. 探索进程创建。用 Python 的 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() # 等待子进程结束
  1. 模拟轮转调度。给定一组带 CPU 区间的进程,模拟调度过程并计算平均等待时间。
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)
  1. 用 LRU 模拟页面置换。给定一串页面访问和固定数量的帧,统计缺页次数。
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)

作者与出处
原作者: HenryNdubuaku
来源:HenryNdubuaku
许可证:Apache-2.0
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: HenryNdubuaku 转发
评论区 (0)
U