OS-程序与进程

学习自 B 站 UP 主绿导师原谅你了。

资源

正文

5. 程序与进程

内容回顾

  • 应用视角的操作系统

    • 操作系统 = 对象 + API
  • 硬件视角的操作系统

    • 操作系统 = 程序
  • 数学视角的操作系统

    • 操作系统 = 状态机

正片开始

  • 虚拟化

    • One of the most fundamental abstractions that the OS provides to users: the process

    • 把物理计算机 “抽象” 成 “虚拟计算机”

      • 程序好像独占计算机运行
  • 进入 “每一讲都实现一点什么” 的模式

    • 每次课都感到编程能力的增长

5.1 程序和进程

C
#include <unistd.h>
 
int main() {
    while (1) {
        write(1, "Hello, World!\n", 13);
    }
}

这是一段 C 语言代码,使用系统调用 write() 向文件描述符 1(标准输出)不断写入 "Hello, World!\n"

python
def main():
    while True:
        sys_write("Hello, World!\n")

无论是 C 还是 Python,本质上程序都在调用底层系统调用(syscall)来与操作系统交互。

  • 程序是状态机的静态描述
    • 描述了所有可能的程序状态
    • 程序(动态)运行起来,就成了进程(进行中的程序)

当我们执行这段程序(例如在命令行输入 ./a.out),操作系统会做以下几件事:

  1. 从磁盘读取程序文件到内存;

  2. 为它分配资源(内存空间、寄存器、文件描述符等);

  3. 创建一个进程控制块 (PCB),记录该程序的执行状态;

  4. 启动 CPU 执行第一条指令

    此时,程序不再是静态的文本,而是一个“正在运行的实例”,即——进程(Process)

可以这么比喻:

概念类比
程序 (Program)食谱——写在书上的菜谱
进程 (Process)正在厨房里做菜的厨师

进程:程序的运行时状态随时间的演进

  • 除了程序状态,操作系统还会保存一些额外的 (只读) 状态
  • 我们可以探索:试图获取当前进程的各种信息

我们编写一个 Linux C 程序,可以获取到哪些关于当前进程的信息?

信息类别示例函数/接口说明
身份信息getuid(), geteuid()用户/组 ID
进程标识getpid(), getppid()进程层次结构
工作目录getcwd()当前目录
内存信息/proc/self/maps, /proc/self/status内存布局与使用
CPU使用getrusage()用户态/内核态CPU时间
打开文件/proc/self/fd/文件描述符列表
优先级getpriority()进程调度优先级
线程信息/proc/self/statusThreads 字段
C
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/resource.h>
#include <limits.h>
#include <stdlib.h>
 
int main() {
    char cwd[PATH_MAX];
    struct rusage usage;
 
    printf("=== Basic Process Info ===\n");
    printf("PID: %d\n", getpid());
    printf("PPID: %d\n", getppid());
    printf("PGID: %d\n", getpgrp());
    printf("SID: %d\n", getsid(0));
 
    printf("\n=== Identity Info ===\n");
    printf("Real UID: %d\n", getuid());
    printf("Effective UID: %d\n", geteuid());
    printf("Real GID: %d\n", getgid());
    printf("Effective GID: %d\n", getegid());
 
    printf("\n=== Working Directory ===\n");
    if (getcwd(cwd, sizeof(cwd)) != NULL)
        printf("CWD: %s\n", cwd);
    else
        perror("getcwd");
 
    printf("\n=== Resource Usage ===\n");
    getrusage(RUSAGE_SELF, &usage);
    printf("User CPU time: %ld.%06lds\n", usage.ru_utime.tv_sec, usage.ru_utime.tv_usec);
    printf("System CPU time: %ld.%06lds\n", usage.ru_stime.tv_sec, usage.ru_stime.tv_usec);
    printf("Max resident set size: %ld KB\n", usage.ru_maxrss);
 
    printf("\n=== Priority ===\n");
    int prio = getpriority(PRIO_PROCESS, 0);
    printf("Nice value: %d\n", prio);
 
    printf("\n=== Memory Map (/proc/self/maps) ===\n");
    FILE *maps = fopen("/proc/self/maps", "r");
    if (maps) {
        char line[256];
        int count = 0;
        while (fgets(line, sizeof(line), maps) && count++ < 10)  // Only print first 10 lines
            printf("%s", line);
        fclose(maps);
    } else {
        perror("fopen /proc/self/maps");
    }
 
    printf("\n=== Status (/proc/self/status) ===\n");
    FILE *status = fopen("/proc/self/status", "r");
    if (status) {
        char line[256];
        int count = 0;
        while (fgets(line, sizeof(line), status) && count++ < 15) // Only print first 15 lines
            printf("%s", line);
        fclose(status);
    } else {
        perror("fopen /proc/self/status");
    }
 
    printf("\n=== Open File Descriptors ===\n");
    system("ls -l /proc/self/fd");
 
    return 0;
}
=== Basic Process Info =
PID: 456327
PPID: 456212
PGID: 456327
SID: 456212

= Identity Info =
Real UID: 1001
Effective UID: 1001
Real GID: 1001
Effective GID: 1001

= Working Directory =
CWD: /home/gz/C

= Resource Usage =
User CPU time: 0.000730s
System CPU time: 0.001461s
Max resident set size: 2480 KB

= Priority =
Nice value: 0

= Memory Map (/proc/self/maps) =
6125a0e58000-6125a0e59000 r--p 00000000 fd:01 1377017                    /home/gz/C/self_info
6125a0e59000-6125a0e5a000 r-xp 00001000 fd:01 1377017                    /home/gz/C/self_info
6125a0e5a000-6125a0e5b000 r--p 00002000 fd:01 1377017                    /home/gz/C/self_info
6125a0e5b000-6125a0e5c000 r--p 00002000 fd:01 1377017                    /home/gz/C/self_info
6125a0e5c000-6125a0e5d000 rw-p 00003000 fd:01 1377017                    /home/gz/C/self_info
6125a29f6000-6125a2a17000 rw-p 00000000 00:00 0                          [heap]
77f280400000-77f280428000 r--p 00000000 fd:01 18904                      /usr/lib/x86_64-linux-gnu/libc.so.6
77f280428000-77f2805b0000 r-xp 00028000 fd:01 18904                      /usr/lib/x86_64-linux-gnu/libc.so.6
77f2805b0000-77f2805ff000 r--p 001b0000 fd:01 18904                      /usr/lib/x86_64-linux-gnu/libc.so.6
77f2805ff000-77f280603000 r--p 001fe000 fd:01 18904                      /usr/lib/x86_64-linux-gnu/libc.so.6

= Status (/proc/self/status) =
Name:   self_info
Umask:  0002
State:  R (running)
Tgid:   456327
Ngid:   0
Pid:    456327
PPid:   456212
TracerPid:      0
Uid:    1001    1001    1001    1001
Gid:    1001    1001    1001    1001
FDSize: 256
Groups: 27 1001
NStgid: 456327
NSpid:  456327
NSpgid: 456327

= Open File Descriptors ===
total 0
lrwx------ 1 gz gz 64 Oct 18 02:10 0 -> /dev/pts/1
lrwx------ 1 gz gz 64 Oct 18 02:10 1 -> /dev/pts/1
lrwx------ 1 gz gz 64 Oct 18 02:10 2 -> /dev/pts/1
lr-x------ 1 gz gz 64 Oct 18 02:10 3 -> /proc/456328/fd

5.2 进程(状态机管理)

在计算机中:

  • 程序运行起来后,CPU、内存、文件描述符、寄存器、信号处理函数等,都是“状态”的一部分;
  • 操作系统要同时管理成千上万个这样的“状态机”。

所以我们可以这样看:

“进程管理”其实就是“状态机的创建、复制、切换和销毁”。

操作系统 = 状态机的管理者

  • 进程管理 = 状态机管理

一个直观的想法

  • 创建状态机:spawn(path, argv)
  • 销毁状态机: _exit()
    • 这是一个合理的设计 (例子:Windows)

UNIX 的答案

  • 复制状态机:fork()
    • 做一份状态机完整的复制(内存、寄存器现场)
  • 复位状态机:execve()

fork() 的行为

立即复制状态机

  • 包括所有状态的完整拷贝
    • 寄存器 & 每一个字节的内存
    • Caveat: 进程在操作系统里也有状态: ppid, 文件, 信号, ...
      • 小心这些状态的复制行为
    • 复制失败返回 -1
      • errno 会返回错误原因 (man fork)

完全一样的部分:

  • 所有寄存器、栈、堆、全局变量
  • 所有打开的文件描述符(共享引用)
  • 信号处理状态

不完全一样的部分:

  • PID、PPID 会变化
  • 内核中的调度状态不同

如何区分两个状态机?

  • 新创建进程返回 0
  • 执行 fork 的进程返回子进程的进程号——“父子关系”

进程的创建关系形成了进程树

  • ABCA→B→C,如果 BB 终止了……CC 的 ppid 是什么?
    • 看似简单:我们 “往上提” 一层就行
    • 实际复杂:
      • 子进程结束会通知父进程
        • 通过 SIGCHLD 信号
        • 父进程可以捕获这个信号(参考 testkit 的实现)
      • “往上提” 就发错人了

孤儿进程 (orphan process)” 行为

当父进程 B 结束后,它的子进程 C 会被 init 进程(PID 1 或 systemd)接管,所以 C 的 ppid 会变成 1(或 systemd 的 PID,通常也是 1)。

C
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
 
int main() {
    pid_t pidB, pidC;
 
    // A -> 创建 B
    pidB = fork();
 
    if (pidB == 0) {
        // 进程 B
        printf("B: PID=%d, PPID=%d\n", getpid(), getppid());
 
        pidC = fork();  // B -> 创建 C
 
        if (pidC == 0) {
            // 进程 C
            printf("  C: PID=%d, PPID=%d (created by B)\n", getpid(), getppid());
            sleep(3); // 等待 B 结束
            printf("  C: After B exits, PPID=%d (should be 1 or systemd)\n", getppid());
        } else if (pidC > 0) {
            // B 立即退出,不 wait 子进程
            printf("B: exiting now, leaving C running...\n");
            _exit(0);
        } else {
            perror("fork C failed");
        }
    } 
    else if (pidB > 0) {
        // 进程 A
        printf("A: PID=%d created B=%d\n", getpid(), pidB);
        wait(NULL); // 等待 B 结束
        printf("A: B has exited.\n");
        sleep(5);   // 保持 A 活着一会,方便观察
    } 
    else {
        perror("fork B failed");
    }
 
    return 0;
}
A: PID=458617 created B=458618
B: PID=458618, PPID=458617
B: exiting now, leaving C running...
  C: PID=458619, PPID=458618 (created by B)
A: B has exited.
  C: After B exits, PPID=1 (should be 1 or systemd)

理解 fork

C
pid_t x = fork();
pid_t y = fork();
printf("%d %d\n", x, y);

一些重要问题

  • 到底创建了几个状态机?4 个
  • pid 分别是多少?
    • “状态机视角” 帮助我们严格理解
Mermaid
Loading diagram…
C
for (int i = 0; i < 2; i++) {
    fork();
    printf("Hello\n");
}
Mermaid
Loading diagram…

复位状态机

C
int execve(const char *filename,
           char * const argv[], char * const envp[]);

UNIX 选择只给一个复位状态机的 API

  • 将当前进程重置成一个可执行文件描述状态机的初始状态
  • 操作系统维护的状态不变:进程号、目录、打开的文件……
    • (程序员总犯错,因此打开文件有了 O_CLOEXEC)

当前进程会变成新程序的“初始状态机”,代码、数据、指令计数器(PC)都被替换掉。

重要特点

  • 进程号(PID)不变
  • 父进程关系不变
  • 打开的文件句柄等内核管理的状态不变(除非使用 O_CLOEXEC

execve 是唯一能够 “执行程序” 的系统调用

  • 因此也是一切进程 strace 的第一个系统调用

每个程序执行时,操作系统实际上做了两步:

  1. 创建状态机(fork() 或 shell 创建进程)
  2. 复位状态机(execve() 加载可执行文件)

因此在 strace 跟踪进程 时,execve 是第一个系统调用。

argc & argv: 命令行参数

  • 困扰多年的疑问得到解答:main 的参数是 execve 给的!

envp: 环境变量

  • 使用 env 命令查看
    • PATH, PWD, HOME, DISPLAY, PS1, ...
  • export: 告诉 shell 在创建子进程时设置环境变量
    • 小技巧:export TK_VERBOSE=1

程序被正确加载到内存

  • 代码、数据、PC 位于程序入口

销毁状态机

C
void _exit(int status);
  • 立即摧毁状态机,允许有一个返回值
    • 返回值可以被父进程获取

UNIX 进程的生命周期

UNIX 中实现 “创建新状态机” 的方式

  • Spawn = fork + execve
  • 我们会在之后介绍这些系统调用的灵活应用