蒋炎岩操作系统 笔记2

12 min

进程管理

将物理计算机抽象成虚拟计算机

进程:程序的运行时状态随时间的演进;除了程序状态,OS 还会保存一些额外的(只读)信息,比如 pid;程序希望知道这样的信息 \to 系统调用 syscall,如 getpid() getcwd() getgid() getuid() getegid() geteuid() getpriority() 等等

查看进程

ps aux | grep <pid>

进程编号在不断递增,然而进程号一定是有限的。现代 linux 内核支持 PID 命名空间,在容器或者虚拟环境中隔离 PID 空间

Windows 中

  • 创建状态机:spawn(path, argv)
  • 销毁状态机:_exit()

UNIX 中

  • 复制状态机:fork()
  • 复位状态机:execve

复制状态机时,原本的状态机(父进程)返回 pid,新的副本(子进程)返回 0

int pid = fork();
if (pid < 0) {
    // 异常处理
}

if (pid == 0) {
    // 子进程
} else {
    // 父进程
}

子进程有一部分状态是直接复制自父进程,还有一部分会被操作系统处理(如 pid、打开的文件等等)

pstree 来查看进程树

如果一个子进程的父进程被中止了,会有托孤机制,将子进程的状态返回给 1 号进程,或者准确来说 systemd 进程

fork 只能创建一样的进程,如果要创建新的进程,需要用 execve

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

argv 最后一位需要是 NULL,用于定位参数结束

一般来说

pid = fork();

if (pid == 0) {
    execve(...);
} else {
    // 继续父进程
}

注意操作系统维护的那部分状态,在 execve 之后仍然是保持不变的;在 execve 之后的代码都不会被执行(因为已经让系统重置了状态机,原有的状态机不存在了)

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

进程的地址空间

进程的初始状态:execve 之后的状态

ELF 文件中有一个 entry point address,是程序开始运行的地址

在实际中,系统调用不一定需要进入内核

/proc/<pid>/maps 记录了所有内存映射状态;/proc/<pid>/mem

一定有一个系统调用可以改变进程的地址空间

// 映射
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

// 修改映射权限
int mprotect(void *addr, size_t length, int prot);
  • addr:映射起始地址,通常为 NULL,操作系统自动选择合适的位置,是 page-aligned 的位置;即使设置了值,也不一定会在指定的位置进行分配,而是在附近寻找一个 page-aligned 的位置分配
  • length:映射区域长度,单位为字节
  • prot:内存保护方式(权限),PROT_READ PROT_WRITE
  • flags:控制映射行为,MAP_SHARED 共享映射,MAP_PRIVATE 私有映射
  • fd:要映射的文件描述符
  • offset:文件中的偏移量,表示从文件的哪个位置开始映射
void *mem = mmap(
    NULL, // let the kernel decides
    4096, // allocate 4kb
    PROT_READ | PROT_WRITE, // read and write
    MAP_PRIVATE | MAP_ANONYMOUS, // private and anonymous
    -1, // no file descriptor
    0 // no offset
)

将文件加载进地址空间:

int fd = open(argv[0], O_RDONLY);
if (fd == -1) {
    perror("OPEN");
    return 1;
}

struct stat st;
if (fstat(fd, &st) == -1) {
    perror("fstat");
    close(fd);
    return 1;
}

void *exe_map = mmap(
    NULL,
    st.st_size,
    PROT_READ,
    MAP_PRIVATE,
    fd,
    0
)

可以用 munmap 来回收

munmap(mem, 4096);
munmap(exe_map, st.st_size)
close(fd);

先使用 mmap 分配内存,但此时操作系统不会真正的分配给程序,要等到程序运行到需要这个内存的时候,才抛出 segment fault 的异常,然后从物理内存中进行分配;因此可以实现瞬间分配内存

mmapmalloc 的区别:

特性mallocmmap
内存控制权程序分配,系统释放完全程序控制
默认初始化一般初始化为可用内存块(实现相关)可能为脏页,需自己初始化
分配位置堆上虚拟地址空间,通常在 heap 之外
适用场景小块频繁分配大块内存、文件映射、共享内存

在使用 mmap 申请了一块内存之后,在这块内存上的所有操作都需要程序员手动管理(内存使用方式、生命周期、初始化、同步……)

访问操作系统对象

文件:有名字的数据对象

  • 字节流(终端,/dev/urandom
  • 字节序列(普通文件)

文件描述符:

  • 指向操作系统对象的“指针”(但不是指针,而是 handle)
  • 对象的访问都需要指针

/proc/<pid>/fd

ssize_t read(int fd, void *buf, size_t count);

对文件描述符而言,需要用 openclose 等进行打开、关闭,dup 用于备份

int fd = open("sample.txt", O_RDWR | O_CREAT)

0 1 2 分别是 stdin、stdout、stderr,新打开的文件从 3 开始分配

  • 文件描述符是进程文件描述符表的索引
  • 关闭文件后,该描述符号可以被重新分配

文件描述符实际上是指向了一个文件中的 offset,顺带了一个对象;当 dup 时,会共享这个 offset。这是因为每个文件描述符指向一个文件表项,dup 之后两个文件描述符指向了同一个文件表项

NOTE

操作系统打开一个文件时,涉及到三层数据结构:进程 \to 文件描述符 \to i-node(文件元数据)。文件描述符是进程私有的整数句柄,各指向一个文件表项(包括 offset 和打开标志 O_RDONLY 等)。而文件表项又指向 i-node,表示文件元信息,包括实际磁盘地址等

在 Windows 系统中,文件描述符叫作 handle

任何“可读写”的东西都可以是文件,如真实设备 /dev/sda /de/tty,以及虚拟设备 /dev/urandom

管道:一个特殊的“文件”(流),读口支持 read,写口支持 write

int pipe(int pipefd[2], int flags);

结合文件描述符的特点,可以用来实现进程之间的通信:先创建一个 pipe 对象,然后 fork 进程,如果想父进程写,子进程读,就在父进程中 close 读口,子进程中 close 写口

man 2 pipe 来查看创建管道时的约定

#include <fcntl.h>
#include <unistd.h>

int main() {
    int pipefds[2];
    int result = pipe(pipefds);
    if (result == -1) {
        // 异常处理
    }

    // 开始 fork
    int pid = fork();
    if (pid == -1) {
        // 异常处理
    }

    if (pid == 0) {
        // 子进程
        close(pipefds[1]); // 关闭写口
    } else {
        // 父进程
        close(pipefds[0]); // 关闭读口
    }
}

上面的管道是匿名的,可以用 mkfifo 来显式创建一个管道

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <string.h>

#define FIFO_PATH "myfifo"

int main() {
    if (mkfifo(FIFO_PATH, 0666) == -1) {
        perror("mkfifo");
        // 若已存在可以继续,不一定算错误
    }

    pid_t pid = fork(); // pid_t 就是 int

    if (pid < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // 子进程:读取数据
        int fd = open(FIFO_PATH, O_RDONLY);
        if (fd == -1) {
            perror("open (child)");
            exit(EXIT_FAILURE);
        }

        char buf[128];
        ssize_t n = read(fd, buf, sizeof(buf) - 1);
        if (n > 0) {
            buf[n] = '\0';
            printf("Child (Reader) received: %s\n", buf);
        } else {
            printf("Child: nothing read\n");
        }

        close(fd);
        exit(0);
    } else {
        // 父进程:写入数据
        sleep(1); // 确保子进程先打开 FIFO

        int fd = open(FIFO_PATH, O_WRONLY);
        if (fd == -1) {
            perror("open (parent)");
            exit(EXIT_FAILURE);
        }

        const char* msg = "Hello from parent!";
        write(fd, msg, strlen(msg));
        printf("Parent (Writer) sent: %s\n", msg);
        close(fd);

        wait(NULL); // 等待子进程结束

        // 删除 FIFO 文件
        unlink(FIFO_PATH);
    }

    return 0;
}

Everything is a file 缺点:

  • 和各种 API 紧密耦合(如文件描述符的 offset)
  • 对高速设备不好
    • 额外的延迟和内存拷贝
    • 单线程 I/O

终端和 UNIX Shell

现在的一般都是伪终端 pty,由一对主/从设备构成(在 /dev/pts/ 下),用软件模拟了物理

  1. 主设备:终端模拟器直接控制的端点
    1. read():获取从设备的输出
    2. write():发送键盘输入到从设备
  2. 从设备:行为和物理终端完全一致(如 /dev/tty
    1. Shell 等程序通过该设备获取输入,输出显示内容
  3. 主从设备通过内核双向管道连接

对伪终端 /dev/pts/5,可以人为写入:

echo Hello > /dev/pts/5

创建:openpty() 通过 /dev/ptmx(pseudo-terminal master and slave) 申请一个新终端,返回两个文件描述符

有了主从设备之后,就可以通过对主设备进行修改实现许多功能

  • Ctrl-C:主设备接收到后,向从设备发送 SIGINT
  • ssh 远程终端,一个机器允许多个终端连接

进程组(process group):最开始启动一个进程,默认是一个进程组,之后以 fork 的方式创建的进程就算在一个进程组中。在 UNIX 的每个会话(session)中,需要有一个 controlling terminal。

  • Ctrl-z:最小化按钮

  • fg %1:最大化按钮

  • 会话组:每个进程继承父进程的 session ID (SID),一个 session ID 管理一个控制终端,当这个终端退出时,所有的进程都被发送 SIGHUP 的信号(nohup 依赖的原理)

  • 进程组:每个进程继承父进程的 process group ID (PGID),同一时刻只能有一个前台进程组。当操作系统收到 Ctrl-c 时,会向前台进程组使所有进程发送 SIGINT 信号

联想之前的进程树的概念,即使某个子进程的父进程结束了(从而导致托孤),当 ctrl-c 时,这个子进程仍然会被杀掉,正是因为有 PGID 的存在

shell 语言:只有字符串,所有操作都是对字符串的处理(类似 C 语言中的预编译)

  • 预处理:$()<()
  • 重定向:cmd > file < file 2> /dev/null> file 重定向标准输出,< file 重定向标准输入,2> file 重定向标准错误流
  • 顺序结构:cmd1; cmd2cmd1 && cmd2cmd1 || cmd2(短路求值)
  • 管道:cmd1 | cmd2