Skip to content

操作系统

操作系统是位于硬件和应用程序之间的软件层,负责管理资源、提供抽象并强制执行隔离。本文涵盖操作系统的功能、进程、线程、CPU调度、内存管理、文件系统以及系统调用。

  • 没有操作系统的计算机就像没有厨师的厨房:食材(硬件)都在那里,但没有人协调谁使用炉灶、盘子放在哪里,或者如何防止两个人同时去抓同一把刀。操作系统就是那个协调者。

  • 对于机器学习从业者来说,操作系统概念解释了:为什么 nvidia-smi 能显示每个进程的GPU内存使用情况,为什么训练会因“内存不足”而崩溃,为什么 fork() 会复制你的Python进程,以及为什么Docker容器能提供隔离的环境。

操作系统的作用

  • 操作系统有三个核心职责:

    • 抽象:将复杂的硬件隐藏在简洁的接口之后。程序读写“文件”时无需知道底层存储是SSD、HDD还是网络驱动器。它们分配“内存”时无需管理物理RAM芯片。它们运行在“CPU”上时无需担心中断和缓存一致性。

    • 资源管理:多个程序共享CPU、内存、磁盘和网络。操作系统决定谁获得什么资源、何时获得、获得多久。一个公平高效的分配策略能让系统保持响应及时。

    • 隔离与保护:程序之间不得相互干扰。你的网页浏览器中的一个bug不应导致内核崩溃。恶意程序不应读取另一个程序的密码。操作系统利用硬件支持(特权级、虚拟内存)来强制执行边界。

进程

  • 进程是正在运行的程序。它是操作系统的基本工作单元。每个进程拥有:

    • 代码(程序指令,只读)。
    • 数据(全局变量、堆分配)。
    • (函数调用帧、局部变量)。
    • 状态(寄存器值、程序计数器、打开的文件等)。
  • 进程控制块(PCB) 是操作系统用于跟踪进程的数据结构。它存储进程ID(PID)、状态、程序计数器、寄存器内容、内存映射、打开的文件描述符以及调度优先级。当操作系统从一个进程切换到另一个进程时,它会将当前进程的状态保存到其PCB中,并加载下一个进程的状态。这就是上下文切换

  • 上下文切换代价高昂:保存和恢复寄存器、刷新缓存、使TLB条目无效需要微秒级时间。在一个运行数千个进程的系统上,开销可能非常显著。这就是为什么每个请求创建一个进程的服务器架构(如旧的Apache)被基于线程或事件驱动的架构所取代。

  • Unix中的进程创建使用 fork()exec()

    • fork() 创建当前进程的一个副本。子进程获得父进程内存、文件描述符和状态的副本。两个进程从相同点继续执行,但 fork() 在子进程中返回0,在父进程中返回子进程的PID。

    • exec() 用新程序替换当前进程的代码。在 fork() 之后,子进程通常会调用 exec() 来运行不同的程序。

    • 这种“先fork后exec”模型非常优雅:创建新进程(fork)和加载新程序(exec)是独立的操作,可以分别定制。在fork和exec之间,子进程可以重定向I/O、更改环境变量或放弃特权。

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

  • 进程状态:一个进程处于以下几种状态之一:
    • 运行:当前在某个CPU核心上执行。
    • 就绪:等待CPU核心(可运行但尚未被调度)。
    • 阻塞(等待):在某个事件发生(I/O完成、锁获取、定时器到期)之前无法继续。
    • 终止:执行结束,等待父进程收集其退出状态。

线程

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

  • 相较于多进程的优势:线程共享内存,因此它们之间的通信很快(只需读写共享变量)。进程之间则需要进程间通信(管道、套接字、共享内存映射),这更慢且更复杂。

  • 劣势:共享内存是危险的。两个线程同时写入同一个变量会产生竞态条件(结果取决于哪个线程先运行)。这引出了同步问题,将在文件4中介绍。

  • 内核线程由操作系统调度器管理。每个线程被独立地调度到CPU核心上。创建和切换内核线程涉及系统调用,其开销类似于(但小于)进程上下文切换。

  • 用户线程(绿色线程)由用户空间的运行时库管理,操作系统看不到它们。创建和切换它们成本更低(无需系统调用),但一个用户线程的阻塞操作会阻塞进程中的所有线程(因为操作系统只看到一个内核线程)。

  • 现代系统使用混合模型:多个用户线程映射到少量内核线程上(M:N线程)。Go的goroutine和Erlang的进程是语言运行时调度到操作系统线程上的用户级线程。

  • 线程池预先创建固定数量的线程,这些线程等待任务。当任务到达时,它被分配给一个空闲线程。这避免了为每个任务创建和销毁线程的开销。Web服务器、数据库引擎和机器学习推理服务器都使用线程池。

CPU调度

  • 调度器决定每个时刻哪个进程/线程在哪个CPU核心上运行。目标是:最大化CPU利用率、最小化响应时间(对交互式任务)、最大化吞吐量(对批处理任务)、并确保公平性。

  • 先来先服务(FCFS):按到达顺序运行进程。简单但有护航效应:一个长进程会阻塞其后所有较短的进程。

  • 最短作业优先(SJF):先运行最短的进程。可证明能最小化平均等待时间,但需要预先知道作业长度(通常不可能)。其抢占版本最短剩余时间优先(SRTF) 会在更短作业到达时中断当前正在运行的作业。

  • 时间片轮转(RR):每个进程获得一个固定的时间片(例如10 ms),然后被抢占并移至队列尾部。公平且响应快,但时间片大小很关键:太小会导致过多的上下文切换,太大会退化为FCFS。

  • 优先级调度:每个进程有一个优先级。高优先级进程先运行。危险是饥饿:如果高优先级进程不断到达,低优先级进程可能永远得不到运行。老化解决了这个问题:进程等待的时间越长,其优先级就越高。

  • 多级反馈队列(MLFQ):多个具有不同优先级和时间片的队列。新进程从最高优先级队列(短时间片)开始。如果进程用尽其全部时间片(表明是CPU密集型),它会被降级到较低优先级队列(长时间片)。交互式进程自然会停留在高优先级队列(因为它们通常在使用完时间片之前就会因I/O而阻塞)。这种机制无需预先知道作业类型就能适应工作负载。

  • 完全公平调度器(CFS):Linux调度器。它维护一棵按“虚拟运行时间”(进程已消耗的CPU时间)排序的红黑树(一种平衡二叉搜索树)。虚拟运行时间最小的进程下一个运行。这确保了随着时间的推移,每个进程获得其公平的份额。CFS每次调度决策的时间复杂度为 \(O(\log n)\)

内存管理

  • 操作系统管理物理RAM,将其分配给进程,并在不再需要时回收。

  • 分页(来自文件2)将虚拟内存划分为固定大小的页,将物理内存划分为帧。页表将页映射到帧。分页消除了外部碎片(分配之间的空闲空间浪费),因为所有页面大小相同。

  • 按需调页仅在首次访问时才将页面加载到RAM中(而不是在进程启动时)。这节省了内存:一个有1 GB代码的程序在典型运行中可能只使用了50 MB。其余部分从未被加载。

  • 当RAM满而需要新页面时,操作系统必须驱逐一个现有页面。页面置换算法(LRU、FIFO、clock算法,来自文件2)决定驱逐哪个页面。好的置换算法能最小化缺页故障;差的置换算法会导致颠簸。

  • 分段将内存划分为可变大小的段(代码、数据、栈、堆),每个段有自己的基地址和长度。段提供逻辑组织,而分页提供物理管理。现代系统最少使用分段(主要用于保护),而依赖分页进行内存管理。

  • 是动态分配内存所在的地方(C中的malloc/free,Java中的new,Python中是隐式的)。操作系统向进程提供大块内存,然后内存分配器(例如glibc mallocjemalloctcmalloc)将这些大块进一步细分为更小的分配。分配器的设计影响性能:碎片浪费空间,线程间的争用浪费时间。

文件系统

  • 文件系统将持久存储(SSD、HDD)上的数据组织为命名文件和目录的层次结构。

  • inode(索引节点)存储文件的元数据:大小、所有者、权限、时间戳以及指向磁盘上数据块的指针。文件名存储在目录中,目录将名称映射到inode号。这种分离意味着一个文件可以有多个名称(硬链接)指向同一个inode。

  • FAT(文件分配表):一种简单的文件系统,用于USB驱动器和SD卡。一个表将每个簇(块)映射到文件中的下一个簇,形成一个链表。简单但不支持权限、日志记录,也不很好地支持大文件。

  • ext4:Linux的默认文件系统。使用带有直接、间接、双重间接和三级间接块指针的inode来处理任意大小的文件。支持区段(连续块的范围)以实现大文件的高效存储。最大文件大小:16 TB,最大分区:1 EB。

  • 日志防止崩溃导致的数据损坏。在修改文件系统结构之前,更改被写入日志。如果系统在操作中途崩溃,重启时会重放日志以完成或撤销该操作。如果没有日志,写入过程中的崩溃可能导致文件系统处于不一致状态(文件的数据块已更新但inode未更新,反之亦然)。

  • 基于B树的文件系统(Btrfs,ZFS)使用B树(平衡搜索树)来组织数据和元数据,实现高效搜索、写时复制快照以及内置校验和以保证数据完整性。这些B树与数据库索引中使用的B树相同。

系统调用与内核模式

  • 系统调用是用户程序与操作系统内核之间的接口。当程序需要执行特权操作(读取文件、分配内存、创建进程、发送网络包)时,它会发起系统调用。

  • CPU运行在两种模式下:

    • 用户模式:受限。程序可以执行自己的代码并访问自己的内存,但不能直接访问硬件、其他进程的内存或操作系统数据结构。
    • 内核模式:无限制。操作系统内核可以访问所有硬件和内存。系统调用是从用户模式进入内核模式的受控网关。
  • 当一个程序调用 read() 时,会发生以下过程:

    1. 程序将参数放入寄存器并触发陷阱(软件中断)。
    2. CPU切换到内核模式并跳转到系统调用处理程序。
    3. 内核验证参数,执行I/O操作,并将数据复制到用户的缓冲区。
    4. 内核切换回用户模式并返回结果。
  • 常见的系统调用:openreadwriteclose(文件),forkexecwaitexit(进程),mmapbrk(内存),socketbindlistenaccept(网络)。

  • 中断是硬件信号,强制CPU暂时停止当前工作并运行中断处理程序(在内核中)。键盘按键、网络包到达或定时器滴答都会产生中断。定时器中断尤其重要:它使操作系统能够抢占正在运行的进程并切换到另一个进程(抢占式多任务)。

网络基础

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

TCP/IP协议栈:应用层、传输层、网络层和链路层,每层添加头部

  • TCP/IP模型将网络组织为多个层次,每层为上层提供一个抽象:

    • 链路层:处理单个物理链路(以太网、Wi-Fi)上的通信。涉及MAC地址和帧。
    • 网络层(IP):将数据包从源端路由到目的端,穿越多个网络。每台机器有一个IP地址(例如IPv4的192.168.1.1,或128位的IPv6地址)。路由器根据目的IP逐跳转发数据包。
    • 传输层(TCP/UDP):提供应用程序之间的端到端通信。
    • 应用层:应用程序直接使用的协议,如HTTP、DNS、gRPC。
  • TCP(传输控制协议)提供可靠、有序的交付。它建立连接(三次握手:SYN、SYN-ACK、ACK),保证所有数据按序到达(使用序列号和确认),重传丢失的数据包,并通过拥塞控制调整发送速率以防止使网络过载。代价是延迟:握手增加了一个往返时间,重传也会增加延迟。

  • UDP(用户数据报协议)提供不可靠、无序的交付。无握手,无重传,无顺序保证。延迟远低于TCP。用于速度比可靠性更重要的场合:视频流、在线游戏、DNS查询。在机器学习中,一些梯度同步协议使用基于UDP的RDMA来降低延迟。

  • 套接字是操作系统提供的网络通信API。套接字是由(IP地址,端口号)标识的端点。服务器创建套接字,将其绑定到一个端口(例如HTTP的80端口),监听连接并接受连接。客户端创建套接字并连接到服务器的地址:端口。然后数据可以通过套接字读写,就像文件一样。

  • DNS(域名系统)将人类可读的名称(google.com)转换为IP地址(142.250.80.46)。它是一个分布式的、分层的数据库:你的机器向本地解析器询问,本地解析器询问根服务器,根服务器将请求委派给每个域的权威服务器。

  • HTTP(超文本传输协议)是万维网的请求-响应协议。客户端发送请求(方法 + URL + 头部 + 可选的消息体),服务器返回响应(状态码 + 头部 + 消息体)。机器学习模型服务(例如TensorFlow Serving、Triton)将模型暴露为HTTP或gRPC端点。

  • 延迟与带宽:延迟是一个数据包从源端到目的端所需的时间(由物理距离和网络跳数决定)。带宽是数据传输速率(每秒多少字节)。高带宽、高延迟的连接(卫星互联网)可以传输大量数据,但每个字节需要很长时间才能到达。对于分布式训练,延迟对同步屏障至关重要(所有GPU必须等待最慢的),而带宽对传输大的梯度张量至关重要(第6章)。

虚拟化与容器

  • 虚拟化在单台物理机器上运行多个操作系统。hypervisor(VMware、KVM、Xen)创建虚拟机(VM),每个虚拟机拥有自己的虚拟CPU、内存、磁盘和网络接口。每个虚拟机运行一个完整的操作系统(客户操作系统),该操作系统认为自己拥有专用硬件。

  • 虚拟机提供了强大的隔离性(一个虚拟机崩溃不会影响其他虚拟机)和灵活性(在同一台机器上运行Linux和Windows,将虚拟机在不同物理主机之间迁移)。代价是开销:每个虚拟机运行一个完整的操作系统内核,消耗内存和CPU来执行与宿主机操作系统冗余的操作系统操作。

虚拟机在虚拟硬件上运行独立的客户操作系统;容器共享宿主内核,轻量得多

  • 容器(Docker、Podman)提供了一种更轻量级的替代方案。容器不是虚拟化整个硬件,而是共享宿主操作系统内核,并利用内核特性来隔离进程:

    • 命名空间隔离进程能看到的内容:每个容器获得自己视角下的进程树(PID命名空间)、网络接口(网络命名空间)、文件系统挂载点(挂载命名空间)和主机名(UTS命名空间)。容器内的进程无法看到其他容器中的进程。

    • Cgroups(控制组)限制进程能使用的资源:CPU时间、内存、磁盘I/O、网络带宽。一个容器不能消耗超过其cgroup允许的资源,从而防止一个容器饿死其他容器。

  • 容器启动只需毫秒级时间(无需启动操作系统),使用极少开销(共享内核),并通过Dockerfile定义(指定基础镜像、依赖项和命令)。这使得它们具有可重现性:docker build 在任何地方都能产生相同的环境。

  • 对于机器学习,容器解决了“在我机器上能跑”的问题。一个包含特定版本CUDA、cuDNN、PyTorch和Python的训练环境被打包成一个容器镜像。任何人都可以在任何机器上精确重现该环境。云端训练平台(AWS SageMaker、GCP Vertex AI)在容器中运行训练作业。

  • Kubernetes(K8s)在大规模场景下编排容器:它将容器调度到机器集群上,重启失败的容器,根据负载进行扩缩容,并管理容器之间的网络。大规模机器学习服务(数千个模型副本处理数百万个请求)运行在Kubernetes上。

安全基础

  • 操作系统通过多种机制强制执行安全性:

  • 权限:每个文件都有一个所有者、一个组以及权限位(所有者、组和其他人的读/写/执行权限)。一个进程以其启动者的用户ID(UID)的身份运行,并且只能访问权限位允许的文件。root用户(UID 0)绕过所有权限检查,这就是以root身份运行危险的原因。

  • 特权分离:进程以完成任务所需的最小特权运行。一个Web服务器不需要root访问权限;它应该以一个受限用户的身份运行,该用户只能读取Web文件并绑定到80端口。如果服务器被攻破,攻击者的访问权限仅限于该受限用户能做的事情。

  • 沙箱:在文件权限之外进一步限制进程能做什么。seccomp(Linux)限制进程可以发起的系统调用。AppArmorSELinux定义强制访问控制策略。容器结合命名空间、cgroups和seccomp提供多层隔离。

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

  • 安全性是一个系统范围的关注点:链子的强度取决于最弱的一环。一个模型服务系统需要安全的网络通信(TLS/HTTPS)、认证的API访问(API密钥、OAuth)、输入验证(防止对抗性输入)以及隔离的执行环境(具有最小特权的容器)。

编码任务(使用 CoLab 或 notebook)

  1. 探索进程创建。使用Python的 os.fork()(仅限Unix)创建一个子进程,并观察父子进程从同一点继续执行。

    import os
    
    pid = os.fork()
    
    if pid == 0:
        # 子进程
        print(f"子进程: 我的PID是 {os.getpid()}, 父进程PID是 {os.getppid()}")
    else:
        # 父进程
        print(f"父进程: 我的PID是 {os.getpid()}, 子进程PID是 {pid}")
        os.wait()  # 等待子进程结束
    

  2. 模拟时间片轮转调度。给定一组进程及其执行时间,模拟调度并计算平均等待时间。

    def round_robin(processes, quantum=3):
        """模拟时间片轮转调度。
        processes: (名称, 执行时间) 元组的列表。
        """
        queue = [(name, burst, 0) for name, burst in processes]  # (名称, 剩余时间, 等待时间)
        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} 运行 {run_time} 个单位 (剩余: {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} 完成 (周转时间: {time})")
    
        for line in log:
            print(line)
    
    print("时间片轮转 (quantum=3):")
    round_robin([("P1", 10), ("P2", 4), ("P3", 6)], quantum=3)
    

  3. 使用LRU模拟页面置换。给定一系列页面访问序列和固定数量的物理帧,统计缺页次数。

    def lru_page_replacement(pages, n_frames):
        """模拟LRU页面置换算法。"""
        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 (驱逐 {evicted})"
                else:
                    status = "MISS (冷启动)"
                frames.append(page)
            print(f"  页面 {page}: {status}  frames={frames}")
    
        print(f"\n总缺页次数: {faults}/{len(pages)} ({faults/len(pages):.0%})")
    
    print("LRU 3个物理帧:")
    lru_page_replacement([1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5], n_frames=3)