跳到主要内容

进程、地址空间与内存

同一个程序可以同时运行两次,两次运行中的变量却各有各的值。要理解这种现象,需要把执行中的状态、程序看到的地址和机器实际使用的内存分开看。这也能解释为什么重启一个 Notebook 内核会丢掉变量,而给容器设定内存上限又是另一件事。

程序、进程、线程与操作系统​

OSTEP 的 Processes 章把进程定义为运行中的程序:除了指令,还包括当前的执行状态和打开的资源。Concurrency: An Introduction 章进一步区分了进程与线程。

对象表示什么共享什么
程序(program)可供执行的指令和静态数据同一份程序可以供多个进程运行
进程(process)一次运行的资源和状态,包括地址空间、线程、打开的文件同一台机器上的进程共同使用 CPU 和物理内存
线程(thread)进程内的一条执行流,有自己的指令位置、寄存器和栈同一进程内的线程共享地址空间,能访问同一份堆数据

线程各有栈,是为了分别记录函数调用;这些栈仍在同一个地址空间内,不能当作线程之间的内存隔离墙。共享数据的并发更新需要协调,否则执行先后顺序可能改变结果。

操作系统负责调度执行、管理内存和提供文件等服务。内核是其中以特权运行的部分;普通应用通过系统调用请求内核服务。OSTEP 的操作系统导论解释了用户态与内核态之间受控的切换。Jupyter 架构中的“内核”则是执行 Python 或 R 的用户进程,与操作系统内核不是同一个概念。

从创建到退出​

以 Unix 的接口为例,Process API 章把几个动作分开:

  1. fork() 创建子进程。父子进程从调用返回处继续执行,但各有自己的进程身份和私有内存状态。
  2. exec() 用另一个程序替换当前进程的程序映像,重新建立代码、数据、堆和栈。成功时不会返回到旧程序;它本身不创建新进程。
  3. 父进程可以用 wait() 或 waitpid()等待子进程结束。阻塞的是发起调用的线程;同一进程的其他线程仍可运行。如果子进程已经退出且状态尚未收取,调用可以立即返回。等待也可能被信号中断或出错,不能把每次返回都当作子进程已结束。
  4. 子进程退出后不再执行。Unix 通常保留一小份退出记录,父进程用 wait() 或 waitpid() 收取它;尚未收取的已退出进程称为僵尸进程。Linux 的 wait() 文档还说明,显式忽略 SIGCHLD 或设置 SA_NOCLDWAIT 时,退出的子进程不会成为僵尸。

Processes 章用三个状态解释运行过程:“就绪”表示可以执行、正在等 CPU;“运行”表示正在执行;“阻塞”表示要等 I/O 等事件。调度器可以把运行中的任务换下,让另一项就绪任务运行。子进程何时先运行,不能只靠源代码的排列来判断。

地址空间、栈与堆​

Address Spaces 章中的地址空间,是进程能看到的内存地址及其映射。指针通常保存虚拟地址;同一个地址数值在两个进程里,可以指向不同的物理内存。私有地址空间提供访问隔离,不代表系统为每个进程准备了一整块独占 RAM。

地址空间里有代码、静态数据、栈、堆,也可以有映射文件和共享区域。栈保存调用帧,例如返回位置和局部状态;堆容纳动态分配的数据。Memory API 章区分了两层管理:分配器在进程内部管理块,操作系统管理进程的内存映射。malloc() 是库调用,可能先复用已有空间,未必每次都向内核申请内存。释放一块对象内存也未必立即把相应的页交还操作系统;例如,glibc 对堆顶空闲空间的回收要满足 mallopt() 文档所述的阈值条件。

两个进程可以主动映射同一块共享内存。例如 Linux 的 mmap() 文档规定,MAP_SHARED 映射上的更新对映射同一区域的其他进程可见。地址空间仍然各自独立,只是某些映射指向共同的存储;共享写入依然需要协调。

虚拟内存、分页与缺页​

虚拟内存让地址空间与物理内存分开。Address Translation 章解释了分工:操作系统建立映射和访问权限,硬件的内存管理单元(MMU)在访问时完成地址翻译。

Paging 章把虚拟地址空间分成固定大小的页,把物理内存分成同样大小的页框。页表记录虚拟页对应哪个物理页框,以及访问条件。因此,虚拟地址连续的一段数据可以分散在不连续的物理页框中。

算一次地址翻译​

设一个教学模型的页大小为 4096 字节,即 4 KiB;这里指定的是模型参数,不是本机页大小。要访问虚拟地址 12345,先除以页大小:商是虚拟页号 3,余数是页内偏移 57。若进程 A 把页 3 映射到页框 7,物理地址就是 7 * 4096 + 57 = 28729;进程 B 若映射到页框 19,同一个虚拟地址就翻译成 77881。

下面还计算一块从页边界开始、独占所需页的 10000 字节缓冲区。它需要 3 页,页容量合计 12288 字节,最后一页剩下 2288 字节。复制到 Python 3 中运行:

page_size = 4096
va = 12345
vpn, offset = divmod(va, page_size)
print(f"VPN={vpn}, offset={offset}")
for process, frame in [("A", 7), ("B", 19)]:
print(f"{process}: physical address={frame * page_size + offset}")
size = 10000
pages = (size + page_size - 1) // page_size
print(f"buffer: pages={pages}, capacity={pages * page_size}, slack={pages * page_size - size}")
print(f"two resident pages: {2 * page_size} bytes")

输出:

VPN=3, offset=57
A: physical address=28729
B: physical address=77881
buffer: pages=3, capacity=12288, slack=2288
two resident pages: 8192 bytes

如果缓冲区只有两页驻留在 RAM 中,这部分驻留数据是 8192 字节。10000 是请求的数据量,12288 是这个模型中映射区域的容量,8192 是假设中的驻留量;计算没有计入页表和分配器开销。它也不是对真实 Python 对象内存占用的测量。由此可见,申请量、虚拟映射量和物理驻留量回答的是不同问题。

缺页时发生什么​

访问有效但尚未驻留的页,会触发缺页异常,让内核接手。Beyond Physical Memory: Mechanisms 章描述了页已换出到磁盘时的处理:读回数据、更新页表,再重试指令。等待磁盘期间,操作系统可以运行其他就绪任务。没有有效映射或违反访问权限的访问,则不能按“把页读回来”处理。

缺页也可能完全不读磁盘。Complete Virtual Memory Systems 章中的按需置零,在首次访问时才分配并清零物理页;写时复制(copy-on-write,COW)先共享页,在某个进程要写入时才为它复制。Linux 的 fork() 文档确认其采用写时复制,因此创建子进程不需要立即复制全部私有物理页。COW 的写入不会变成父子进程都能看到的共享更新。

文件描述符、管道与继承​

在 Unix 中,文件描述符是进程用来引用打开资源的非负整数。标准输入、标准输出和标准错误在程序启动时通常分别使用 0、1、2。fork() 后,子进程的描述符副本引用与父进程相同的打开文件描述,包括共享的文件偏移。私有内存与共享的打开资源可以同时存在。

切换程序也有继承规则:Linux 的 execve() 文档说明,打开的描述符通常会保留,标记了 close-on-exec 的则会关闭。fork() 创建的子进程继承父进程的环境副本和工作目录;execve() 保留工作目录,但新程序的环境由调用者传入。像 Python 的 subprocess 这样的启动器还可以关闭不需要的描述符,或指定新的环境和工作目录。“另起一个进程”本身没有撤销这些资源带来的访问能力。

管道提供单向字节流,连接读端与写端。它不是两边共同操作的变量,也不自带消息边界;发送结构化数据需要约定格式。读端耗尽缓冲数据后,只有所有写端描述符都关闭,才能读到文件结束。多留一个无用的写端,就可能让读者一直等下去。

运行一个子进程,收取数据与退出状态​

这个例子只用 Python 标准库。父进程把列表编码成 JSON,经标准输入管道交给新的 Python 进程;子进程解码出自己的列表、追加 7,把结果写到标准输出,把环境中的示例值写到标准错误,最后主动以状态 3 退出。它展示的是通过序列化传递数据,不是直接共享父进程的列表。

subprocess 文档规定,communicate() 发送输入、读取输出并等待进程退出。它适合这里的小数据;输出会缓存在父进程内存里,不适合无限数据流。

import os
import subprocess
import sys
import json

numbers = [2, 3, 5]
child_code = r'''
import json, os, sys
numbers = json.load(sys.stdin)
numbers.append(7)
print(json.dumps({"numbers": numbers, "sum": sum(numbers)}))
print("mode=" + os.environ["DEMO_MODE"], file=sys.stderr)
raise SystemExit(3)
'''
env = dict(os.environ, DEMO_MODE="worker")
child = subprocess.Popen(
[sys.executable, "-c", child_code],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=env,
)
out, err = child.communicate(json.dumps(numbers))
print("child stdout:", out.strip())
print("child stderr:", err.strip())
print("exit status:", child.returncode)
print("parent numbers:", numbers)

输出(Python 3.13.15,macOS):

child stdout: {"numbers": [2, 3, 5, 7], "sum": 17}
child stderr: mode=worker
exit status: 3
parent numbers: [2, 3, 5]

结果中的 17 是 2 + 3 + 5 + 7;父进程的列表保持原值。状态 3 是代码主动选择的非零状态,不能因为标准输出里有结果就把它当作成功。这里先收完数据并等待退出,才打印四行,所以显示顺序不依赖父子进程的调度顺序。

若只调用 wait(),却不读取管道,大量输出可能填满管道:子进程等待读者,父进程等待子进程,双方都无法继续。communicate() 同时处理这些管道,避免这种等待。Python 启动子进程可能采用 posix_spawn() 等机制,不能把这段代码当作证明底层一定调用了 fork() 的跟踪记录。

进程、容器与虚拟机的边界​

Docker 对容器与虚拟机的说明区分了共享内核的容器与运行自己内核的虚拟机。对 Linux 容器而言,共享的是承载容器的 Linux 内核;如果这个 Linux 环境本身在虚拟机里,容器共享的就是那台虚拟机的内核。

边界隔离或组织的对象内核关系
进程执行状态和地址空间;文件等资源仍可共享通过系统调用使用同一个操作系统内核
Linux 容器一个或多个进程及其文件、进程编号等资源视图容器间共享承载它们的 Linux 内核
虚拟机(VM)提供虚拟硬件,让来宾操作系统运行自己的进程来宾运行自己的内核,由虚拟化层承载

Docker 架构文档说明容器使用命名空间(namespace)建立隔离的资源视图;资源限制文档另行说明内存和 CPU 的限制。Docker 容器默认没有资源上限,实际能使用多少仍受宿主内核约束;相应限制也需要内核支持。独立的文件系统视图不自动附带一个独占或受限的内存池。

使用这些概念时,可以按问题选择边界:

  • Notebook 的变量属于执行它的进程;文件与运行状态的关系见 Jupyter 架构。
  • 为服务配置容器和持久化存储,见 Docker Engine 与 Compose;内存预算还要明确限制对象是单个进程还是容器中的整组进程。
  • Agent 的工具命令即使运行在独立进程里,仍可能拥有继承的环境、打开资源和账户权限。执行范围如何由外部系统约束,见 Agent Harness。
  • 需要把这些概念接回表示、执行和资源的整体学习路径时,见计算机科学基础。
探索关联打开关联网络