返回文章

WRITING

第三章 进程管理(计算)子系统

第三章 进程管理(计算)子系统

3.1 操作系统原理:进程与线程的本质

3.1.1 什么是进程

**进程(Process)**是操作系统进行资源分配和调度的基本单位,是程序的一次执行过程。一个进程包含:

  1. 程序代码:要执行的指令(代码段,通常只读且可共享)。

  2. 数据:程序运行时的数据(数据段、BSS 段、堆、栈)。

  3. 资源:打开的文件、网络连接、信号处理器、地址空间等。

  4. 执行上下文:程序计数器、寄存器、栈指针等 CPU 状态。

进程的核心特征是隔离性:每个进程有独立的地址空间,一个进程的崩溃不会直接影响其他进程(除非通过 IPC 交互)。

3.1.2 什么是线程

**线程(Thread)**是进程内的执行单元,是 CPU 调度的最小单位。同一进程内的多个线程:

  • 共享:地址空间、文件描述符、信号处理器、全局变量等进程资源。

  • 私有:各自的栈、寄存器上下文、程序计数器、线程局部存储(TLS)。

线程的引入是为了在保持资源共享的同时实现并发执行。相比多进程,多线程的上下文切换开销更小(不需要切换地址空间),线程间通信也更简单(直接共享内存),但需要更小心地处理同步问题(竞态条件、死锁)。

3.1.3 Linux 的设计:进程与线程的统一

Linux 内核并不严格区分进程和线程。在内核中,两者都用 task_struct 描述,都通过 clone() 系统调用创建,区别仅在于 clone() 的参数指定了哪些资源共享、哪些资源独立:

clone 标志

含义

进程(fork)

线程(pthread_create)

CLONE_VM

共享地址空间

否(复制)

是

CLONE_FS

共享文件系统信息

否

是

CLONE_FILES

共享文件描述符表

否

是

CLONE_SIGHAND

共享信号处理器

否

是

CLONE_THREAD

加入同一线程组

否

是

CLONE_SETTLS

设置 TLS

否

是

CLONE_PARENT

共享父进程

否

可选

这种统一设计的好处是:内核不需要为进程和线程维护两套独立的逻辑,调度器、内存管理等子系统对两者一视同仁,简化了内核设计。

3.1.4 进程的状态

图 3-1 Linux 进程状态转换

一个进程在其生命周期中会在不同状态之间转换:

                     fork()
                       │
                       ▼
                 ┌──────────┐
            ┌───▶│  RUNNING │◀───┐
            │    │ (运行/就绪) │    │
            │    └──────────┘    │
            │       │  ▲          │
   等待资源/IO │       │  │ 时间片用完 │ 被唤醒
            │       ▼  │          │
            │    ┌──────────┐    │
            │    │ SLEEPING │    │
            │    │(可中断/   │    │
            │    │ 不可中断) │    │
            │    └──────────┘    │
            │       │             │
   收到信号/  │       │ 等待的事件发生
   IO 完成    │       ▼             │
            │    ┌──────────┐    │
            │    │  STOPPED │    │
            │    │ (暂停)    │    │
            │    └──────────┘    │
            │       │             │
            │       │ SIGCONT     │
            │       ▼             │
            │    ┌──────────┐    │
            └────│ ZOMBIE   │────┘
                 │ (僵尸)    │
                 └──────────┘
                       │
                       │ wait()/waitpid()
                       ▼
                    终止
  • TASK_RUNNING:进程正在运行,或在运行队列中等待调度。这是唯一可以在 CPU 上执行的状态。

  • TASK_INTERRUPTIBLE:可中断睡眠,进程等待某事件(如 IO 完成、信号),可以被信号唤醒。

  • TASK_UNINTERRUPTIBLE:不可中断睡眠,常用于必须等待内核事件完成的短临界等待。普通信号不会使这种等待提前返回,任务通常由对应事件的唤醒路径恢复;这不等于“永远杀不掉”,而是信号要等任务离开该状态后才能生效。

  • TASK_STOPPED:进程被暂停(收到 SIGSTOP/SIGTSTP 等信号),等待 SIGCONT 恢复。

  • EXIT_ZOMBIE:僵尸退出状态,进程已退出但父进程尚未调用 wait() 回收其退出状态。此时仍保留内核任务记录和退出信息,等待父进程回收。

3.2 核心数据结构:task_struct 详解

源码位置:include/linux/sched.h

task_struct 是 Linux 内核中最核心、最庞大的数据结构之一,描述了一个进程/线程的全部信息。它有数百个字段,以下按功能分类详解关键字段。

3.2.1 状态与标识

struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
    struct thread_info thread_info;  /* 线程信息(必须是第一个字段) */
#endif
    unsigned int __state;             /* 进程状态(TASK_RUNNING 等) */
    void *stack;                      /* 内核栈指针 */
    refcount_t usage;                 /* 引用计数 */
    unsigned int flags;               /* 进程标志(PF_*) */
    unsigned int ptrace;              /* ptrace 标志 */

    int on_rq;                        /* 是否在运行队列上(0/1/2) */
    int prio;                         /* 动态优先级 */
    int static_prio;                  /* 静态优先级(nice 值决定) */
    int normal_prio;                  /* 正常优先级 */
    unsigned int rt_priority;         /* 实时优先级 */
    unsigned int policy;              /* 调度策略(SCHED_NORMAL 等) */
    const struct sched_class *sched_class;  /* 调度类 */
    struct sched_entity se;           /* CFS 调度实体 */
    struct sched_rt_entity rt;        /* 实时调度实体 */
    struct sched_dl_entity dl;        /* 截止时间调度实体 */
    /* ... */
};

thread_info 的作用:在 CONFIG_THREAD_INFO_IN_TASK 开启时(现代内核默认开启),thread_info 嵌入在 task_struct 的开头。它包含架构相关的线程信息,如标志、当前 CPU 编号等。x86-64 上获取当前进程的 current 宏并不通过栈指针计算,而是直接读取 per-CPU 变量 current_task(详见 3.3.4)。

flags 常见标志:

  • PF_KTHREAD:内核线程

  • PF_EXITING:正在退出

  • PF_EXITPIDONE:退出完成

  • PF_VCPU:虚拟 CPU 线程

  • PF_IO_WORKER:IO 工作线程

  • PF_MEMALLOC:允许使用预留内存(回收上下文)

  • PF_FORKNOEXEC:fork 后尚未 exec

  • PF_SUPERPRIV:使用了超级用户权限

优先级体系:

  • static_prio:静态优先级,由 nice 值(-20~19)决定,启动时设定,可通过 nice()/setpriority() 修改。公式:static_prio = 120 + nice。

  • normal_prio:正常优先级,基于静态优先级和调度策略计算。普通进程等于 static_prio,实时进程基于 rt_priority。

  • prio:动态优先级,调度器实际使用的优先级。可能被临时调整(如优先级继承、PI 互斥量)。

  • rt_priority:实时优先级,1~99(数值越大优先级越高),仅对实时调度策略有效。

3.2.2 进程关系与标识

struct task_struct {
    pid_t pid;                         /* 线程 ID,每个 task_struct 唯一(gettid 返回它) */
    pid_t tgid;                        /* 线程组 ID,即用户态看到的进程 ID(getpid 返回它) */
    struct task_struct __rcu *parent; /* 父进程 */
    struct list_head children;         /* 子进程链表 */
    struct list_head sibling;          /* 兄弟进程链表 */
    struct task_struct *group_leader;  /* 线程组组长 */
    struct list_head thread_group;      /* 线程组链表 */
    struct pid *thread_pid;             /* 线程 PID(内核内部表示) */
    struct pid *pids[PIDTYPE_MAX];      /* 各种 PID 类型 */
    char comm[TASK_COMM_LEN];            /* 进程名(16 字节) */
    /* ... */
};

PID 与 TGID 的区别:

  • 在内核中,每个 task_struct(包括线程)都有一个唯一的 pid(由 thread_pid 表示)。

  • tgid(thread group ID)是线程组的 ID,也就是用户态看到的"进程 ID"。同一进程的所有线程共享同一个 tgid。

  • 对于进程的主线程,pid == tgid;对于其他线程,pid != tgid。

  • 用户态的 getpid() 返回的是 tgid,gettid() 返回的是 pid。

进程关系:

  • parent:指向创建当前进程的父进程。

  • children:当前进程的所有子进程的链表头。

  • sibling:将当前进程链接到父进程的 children 链表中。

  • group_leader:线程组的组长(通常是主线程)。

  • thread_group:将当前线程链接到线程组链表中。

3.2.3 内存管理相关

struct task_struct {
    struct mm_struct *mm;          /* 进程地址空间(内核线程为 NULL) */
    struct mm_struct *active_mm;   /* 活跃地址空间(内核线程借用) */
    /* 页表页数统计在 mm_struct->pgtables_bytes,不在 task_struct 中 */
    /* ... */
};

mm 与 active_mm 的区别:

  • mm:指向进程自己的地址空间。用户进程有独立的 mm_struct;内核线程的 mm 为 NULL,因为内核线程不需要用户地址空间。

  • active_mm:指向当前正在使用的地址空间。对于用户进程,active_mm == mm。对于内核线程,active_mm 指向前一个用户进程的 mm(借用其页表,因为内核空间页表全局共享,内核线程只访问内核空间)。

  • 上下文切换时,如果切换到内核线程,不需要切换页表(switch_mm 跳过),直接借用前一个进程的 active_mm。这是一个重要的优化。

3.2.4 文件系统与文件描述符

struct task_struct {
    struct files_struct *files;     /* 文件描述符表 */
    struct fs_struct *fs;           /* 文件系统信息(根目录、当前目录) */
    struct nsproxy *nsproxy;        /* 命名空间代理(mnt/pid/net/uts/ipc/cgroup) */
    /* ... */
};
  • files:包含文件描述符数组(fd_array),每个 fd 指向一个 struct file。进程通过 fd 引用打开的文件。

  • fs:包含根目录(root)、当前工作目录(pwd)、umask 等。chdir() 修改 pwd,chroot() 修改 root。

  • nsproxy:指向命名空间代理,包含各种命名空间(mount、pid、network、UTS、IPC、cgroup)。命名空间是容器技术的基础。

3.2.5 信号处理

struct task_struct {
    struct signal_struct *signal;    /* 信号处理器(进程级,线程共享) */
    struct sighand_struct *sighand;  /* 信号处理函数表(线程共享) */
    sigset_t blocked;                 /* 阻塞信号集 */
    sigset_t real_blocked;            /* 实际阻塞信号集 */
    struct sigpending pending;         /* 挂起信号(线程私有) */
    unsigned long sas_ss_sp;           /* 备用信号栈地址 */
    size_t sas_ss_size;                /* 备用信号栈大小 */
    /* ... */
};
  • signal:进程级信号信息,包括共享的挂起信号队列、资源限制、会话信息等。同一线程组共享。

  • sighand:信号处理函数表(action[]),定义每个信号的处理方式(默认/忽略/自定义处理函数)。同一线程组共享。

  • blocked:线程私有的阻塞信号集,sigprocmask() 修改。

  • pending:线程私有的挂起信号队列。发送给特定线程的信号放在这里;发送给进程的信号放在 signal->shared_pending,可以被线程组中任意线程处理。

3.2.6 调度与统计

struct task_struct {
    const cpumask_t *cpus_ptr;        /* 实际生效的 CPU 亲和性掩码 */
    cpumask_t *user_cpus_ptr;         /* 用户经 sched_setaffinity 设置的掩码 */
    /* 当前所在 CPU 不保存在 task_struct,而在 thread_info 中,用 task_cpu(p) 读取 */

    struct sched_entity se;           /* CFS 调度实体,exec_start、vruntime 都在其中 */

    struct task_cputime cputime;       /* CPU 时间统计 */
    u64 utime;                          /* 用户态 CPU 时间 */
    u64 stime;                          /* 内核态 CPU 时间 */
    unsigned long nvcsw;                /* 自愿上下文切换次数 */
    unsigned long nivcsw;               /* 非自愿上下文切换次数 */
    /* ... */
};

3.2.7 架构相关:thread_struct

struct task_struct {
    /* ... */
    struct thread_struct thread;  /* 架构相关的线程状态(x86 下在 task_struct 末尾) */
};

thread_struct 是架构相关的,x86 下包含:

  • 寄存器上下文(切换时保存/恢复)

  • 段寄存器(fs/gs,用于 TLS)

  • 扩展寄存器状态(FPU/XMM/YMM/ZMM 等,由 xstate/FPU 代码管理)

  • 调试寄存器

  • IO 权限位图

3.3 进程创建:fork / clone / kernel_clone

3.3.1 系统调用入口

Linux 提供多个创建进程/线程的系统调用,最终都汇聚到 kernel_clone():

系统调用

用途

典型 clone flags

fork()

创建子进程,复制全部资源

SIGCHLD

vfork()

创建子进程,父进程阻塞直到子进程 exec/exit

CLONE_VM | CLONE_VFORK | SIGCHLD

clone()

精细控制共享哪些资源

由用户指定

clone3()

clone 的扩展版本,支持更多参数

由用户指定

kernel_thread()

创建内核线程

CLONE_VM | CLONE_UNTRACED 等

源码位置:kernel/fork.c

SYSCALL_DEFINE0(fork)
{
    struct kernel_clone_args args = {
        .exit_signal = SIGCHLD,
    };
    return kernel_clone(&args);
}

SYSCALL_DEFINE1(clone, unsigned long, clone_flags)
{
    /* 从用户栈读取参数,构造 kernel_clone_args */
    return kernel_clone(&args);
}

3.3.2 kernel_clone() 主流程

pid_t kernel_clone(struct kernel_clone_args *args)
{
    /* 1. 参数检查与准备 */
    if (args->flags & CLONE_PIDFD)
        /* 分配 pidfd */

    /* 2. 核心:创建新进程的 task_struct 并复制资源 */
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);

    /* 3. 唤醒新进程,加入调度队列 */
    wake_up_new_task(p);

    /* 4. 返回子进程 PID(父进程返回值) */
    return pid;
}

kernel_clone() 的逻辑很简洁:调用 copy_process() 创建进程,然后 wake_up_new_task() 唤醒它。核心工作都在 copy_process() 中。

3.3.3 copy_process() 详解

图 3-2 进程创建 copy_process 流程

copy_process() 是进程创建的核心,完成以下工作:

copy_process()
  │
  ├─ 1. 权限与限制检查
  │     - 检查 RLIMIT_NPROC(用户进程数限制)
  │     - 检查用户命名空间权限
  │     - 检查 clone flags 合法性
  │
  ├─ 2. 复制 task_struct 和内核栈
  │     → dup_task_struct()
  │       - 从 task_struct kmem_cache 分配新 task_struct
  │       - 分配内核栈(thread_stack)
  │       - 复制父进程的 task_struct 内容
  │       - 初始化引用计数、统计信息
  │
  ├─ 3. 初始化调度相关
  │     → sched_fork()
  │       - 设置新进程的调度类和优先级
  │       - 初始化调度实体(se/rt/dl)
  │       - 设置新进程状态为 TASK_NEW
  │
  ├─ 4. 逐项复制进程资源
  │     ├─ copy_creds()       复制凭证(UID/GID/能力)
  │     ├─ copy_semundo()     复制 sem_undo 列表
  │     ├─ copy_files()       复制/共享文件描述符表
  │     ├─ copy_fs()          复制/共享文件系统信息
  │     ├─ copy_sighand()     复制/共享信号处理函数表
  │     ├─ copy_signal()      复制/共享信号结构体
  │     ├─ copy_mm()          复制/共享地址空间
  │     ├─ copy_namespaces()  复制/共享命名空间
  │     ├─ copy_io()          复制 IO 上下文
  │     ├─ copy_thread()      复制寄存器上下文和栈(架构相关)
  │     └─ cgroup_fork()     继承并初始化 cgroup 关联;随后由 cgroup_can_fork()/cgroup_post_fork() 校验和收尾
  │
  ├─ 5. 分配 PID
  │     → alloc_pid()
  │       - 从 PID 分配器分配唯一 PID
  │       - 设置 tgid(如果是线程组组长,tgid=pid)
  │
  ├─ 6. 完成初始化
  │     - 设置进程名(comm)
  │     - 初始化锁、等待队列
  │     - 加入进程链表和哈希表
  │     - 设置进程状态为 TASK_RUNNING(但尚未加入运行队列)
  │
  └─ 7. 返回新进程的 task_struct

源码位置:kernel/fork.c

static __latent_entropy struct task_struct *copy_process(
    struct pid *pid, int trace, int node,
    struct kernel_clone_args *args)
{
    /* ... 权限检查 ... */

    /* 2. 复制 task_struct 和内核栈 */
    p = dup_task_struct(current, node);
    if (!p) goto fork_out;

    /* 3. 调度初始化 */
    retval = sched_fork(clone_flags, p);
    if (retval) goto bad_fork_cleanup;

    /* 4. 逐项复制资源 */
    retval = copy_creds(p, clone_flags);
    /* ... */
    retval = copy_files(clone_flags, p);
    /* ... */
    retval = copy_fs(clone_flags, p);
    /* ... */
    retval = copy_sighand(clone_flags, p);
    /* ... */
    retval = copy_signal(clone_flags, p);
    /* ... */
    retval = copy_mm(clone_flags, p);
    /* ... */
    retval = copy_namespaces(clone_flags, p);
    /* ... */
    retval = copy_thread(clone_flags, args->stack, args->stack_size, p, args->tls);
    /* ... */

    /* 5. 分配 PID */
    if (pid != &init_struct_pid) {
        pid = alloc_pid(p->nsproxy->pid_ns_for_children);
        /* ... */
    }
    p->pid = pid_nr(pid);
    if (clone_flags & CLONE_THREAD) {
        p->exit_signal = -1;
        p->group_leader = current->group_leader;
        p->tgid = current->tgid;
    } else {
        p->exit_signal = args->exit_signal;
        p->group_leader = p;
        p->tgid = p->pid;
    }

    /* 6. 完成初始化 */
    /* ... */

    return p;
}

3.3.4 dup_task_struct() 详解

static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
    struct task_struct *tsk;
    unsigned long *stack;
    struct vm_struct *stack_vm;

    /* 1. 从 task_struct 的 kmem_cache 分配新 task_struct */
    tsk = alloc_task_struct_node(node);
    if (!tsk) return NULL;

    /* 2. 分配内核栈 */
    stack = alloc_thread_stack_node(tsk, node);
    if (!stack) goto free_task;
    tsk->stack = stack;

    /* 3. 复制父进程的 task_struct(逐字节复制) */
    err = arch_dup_task_struct(tsk, orig);
    if (err) goto free_stack;

    /* 4. 复制 thread_info(栈底的 thread_info) */
    *tsk->stack = *orig->stack;

    /* 5. 初始化引用计数和统计 */
    refcount_set(&tsk->usage, 1);
    tsk->stack_canary = get_random_canary();  /* 栈溢出保护 */

    return tsk;
}

内核栈的设计:

  • 每个进程有一个内核栈,x86-64 下默认大小为 16KB(THREAD_SIZE = 16KB,即 4 页)。

  • 当进程从用户态陷入内核态(系统调用、中断、异常)时,CPU 自动切换到该进程的内核栈。

  • 内核栈的底部(低地址)存放 thread_info(在 CONFIG_THREAD_INFO_IN_TASK 下,thread_info 移到了 task_struct 中,但栈布局仍保留)。

  • task_struct 和内核栈在内存中是连续分配的,通过栈指针可以快速计算出 task_struct 的地址(current 宏)。

current 宏的实现原理: x86-64 下,current 宏通过 percpu 变量 current_task 获取当前进程的 task_struct。在上下文切换时,调度器更新 current_task。这比通过栈指针计算更快(不需要对齐运算)。

3.3.5 copy_mm() 详解:地址空间的复制与共享

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
    struct mm_struct *mm, *oldmm;

    oldmm = current->mm;
    if (!oldmm) {
        /* 内核线程:mm 为 NULL,不需要复制 */
        tsk->mm = NULL;
        tsk->active_mm = NULL;
        return 0;
    }

    if (clone_flags & CLONE_VM) {
        /* 线程:共享地址空间,增加引用计数 */
        mmget(oldmm);
        mm = oldmm;
        goto good_mm;
    }

    /* 进程:复制地址空间(写时复制) */
    retval = dup_mm(tsk);
    if (retval) return retval;
    return 0;

good_mm:
    tsk->mm = mm;
    tsk->active_mm = mm;
    return 0;
}

dup_mm() 的 COW 实现:

static struct mm_struct *dup_mm(struct task_struct *tsk)
{
    struct mm_struct *mm, *oldmm = current->mm;

    /* 1. 分配新的 mm_struct */
    mm = allocate_mm();
    memcpy(mm, oldmm, sizeof(*mm));  /* 复制 mm_struct 内容 */

    /* 2. 初始化新 mm 的字段 */
    mm->mmap = NULL;
    mm->mm_rb = RB_ROOT;
    mm->mm_users = ATOMIC_INIT(1);
    mm->mm_count = ATOMIC_INIT(1);
    INIT_LIST_HEAD(&mm->mmlist);
    /* ... */

    /* 3. 复制页全局目录(PGD) */
    mm->pgd = pgd_alloc(mm);

    /* 4. 复制所有 VMA */
    retval = dup_mmap(mm, oldmm);
    /* dup_mmap 遍历父进程的所有 VMA,为每个 VMA 创建副本,
       并复制页表项(标记为只读,实现 COW) */

    return mm;
}

COW 的关键:dup_mmap() 复制 VMA 和页表时,将所有页表项标记为只读(清除写位)。这样父子进程都可以读这些页,但任何一方写入时都会触发写保护缺页,内核在缺页处理中执行 wp_page_copy() 复制该页。这就是 fork 高效的根本原因:不复制物理页,只复制页表,通过 COW 按需复制。

3.3.6 copy_thread():架构相关的上下文初始化

copy_thread() 是架构相关的,x86 下在 arch/x86/kernel/process.c(32 位与 64 位共用):

int copy_thread(unsigned long clone_flags, unsigned long sp,
                unsigned long arg, struct task_struct *p, unsigned long tls)
{
    struct pt_regs *childregs = task_pt_regs(p);
    struct fork_frame *fork_frame = container_of(childregs, struct fork_frame, regs);
    struct inactive_task_frame *frame = &fork_frame->frame;

    /* 1. 构造第一次调度时使用的内核栈帧,返回到 ret_from_fork */
    frame->bp = encode_frame_pointer(childregs);
    frame->ret_addr = (unsigned long)ret_from_fork;
    p->thread.sp = (unsigned long)fork_frame;  /* 内核栈指针 */

    /* 2. 复制父进程的寄存器上下文(pt_regs) */
    *childregs = *current_pt_regs();
    childregs->ax = 0;  /* 子进程的 fork 返回值为 0 */

    /* 3. 设置子进程的用户栈指针(clone 传入) */
    if (sp)
        childregs->sp = sp;

    /* 4. 设置线程本地存储 TLS(指定 CLONE_SETTLS 时) */
    if (clone_flags & CLONE_SETTLS)
        ret = set_new_tls(p, tls);

    return 0;
}

关键设计:

  • 子进程的 fork() 返回值被设置为 0(childregs->ax = 0),而父进程的返回值是子进程的 PID。这是通过在 copy_thread 中修改子进程的 ax 寄存器实现的。

  • 子进程第一次被调度时,从 ret_from_fork 开始执行(frame->ret_addr),而不是从 copy_thread 的返回地址。ret_from_fork 会恢复寄存器上下文,然后返回用户态。

  • 内核线程的 copy_thread 不同:内核线程的入口是 kernel_thread_helper,参数是 fn 和 arg,没有用户态上下文。

3.3.7 wake_up_new_task():唤醒新进程

void wake_up_new_task(struct task_struct *p)
{
    struct rq_flags rf;
    struct rq *rq;

    /* 1. 设置进程状态为 TASK_RUNNING(5.15 的状态字段名为 __state) */
    WRITE_ONCE(p->__state, TASK_RUNNING);

    /* 2. 选择运行的 CPU(负载均衡) */
    rq = __task_rq_lock(p, &rf);
    update_rq_clock(rq);

    /* 3. 激活任务,加入运行队列 */
    activate_task(rq, p, ENQUEUE_NOCLOCK);
    /* 内部调用 sched_class->enqueue_task(),CFS 下是 enqueue_entity() */

    /* 4. 检查是否需要抢占当前进程 */
    check_preempt_curr(rq, p, WF_FORK);

    task_rq_unlock(rq, p, &rf);
}

新进程被加入运行队列后,调度器会在合适的时机选择它运行。如果新进程的优先级高于当前运行的进程,check_preempt_curr() 会设置需要调度标志,触发抢占。

3.4 进程退出与回收

3.4.1 进程退出流程

进程退出通过 exit() 系统调用,核心函数是 do_exit():

do_exit()
  │
  ├─ 1. 设置进程标志 PF_EXITING
  │
  ├─ 2. 退出定时器、性能事件、监控
  │
  ├─ 3. 释放当前任务持有的主要资源
  │     ├─ exit_mm()              解除地址空间引用;最后一个用户负责释放 mm
  │     ├─ exit_files()           释放文件描述符表引用
  │     ├─ exit_fs()              释放文件系统上下文引用
  │     ├─ exit_task_namespaces() 释放命名空间引用
  │     └─ cgroup_exit()          完成 cgroup 退出处理
  │
  ├─ 4. exit_notify() 处理父子关系、发送退出通知,并设置最终 exit_state
  │     - 常见情况进入 EXIT_ZOMBIE,保留退出状态等待父进程回收
  │     - 某些无需父进程回收的情况可直接进入 EXIT_DEAD
  │
  ├─ 5. 调用 schedule() 切换到其他进程
  │     (当前任务从此不再返回用户态)
  │
  └─ 6. 父进程 wait() 后由 release_task() 清理剩余 task_struct、PID 和信号结构等资源

3.4.2 僵尸进程与孤儿进程

  • 僵尸进程(Zombie):进程已退出,但父进程尚未调用 wait()/waitpid() 回收其 task_struct 和退出状态。僵尸进程不再占用 CPU,地址空间和打开文件等主要资源也已释放,但仍保留内核任务记录、PID 与退出状态等少量信息。如果父进程不回收,僵尸进程会积累,最终耗尽 PID。

  • 孤儿进程(Orphan):父进程先于子进程退出,子进程会被同一 PID 命名空间中最近的 child subreaper 接管;若不存在这样的 subreaper,通常由该命名空间的 init 进程接管。新的父进程负责后续 wait() 回收。

3.4.3 wait() 回收

父进程通过 wait()/waitpid() 系统调用回收子进程:

  1. 查找子进程链表,找到僵尸状态的子进程。

  2. 读取子进程的退出码和资源使用统计。

  3. 调用 release_task() 释放子进程的 task_struct 和剩余资源。

  4. 释放 PID。

如果没有僵尸子进程且设置了 WNOHANG,立即返回;否则阻塞等待子进程退出。

3.5 上下文切换详解

3.5.1 原理:什么是上下文切换

**上下文切换(Context Switch)**是指 CPU 从一个进程切换到另一个进程的过程。“上下文"指的是进程运行所需的全部 CPU 状态,包括:

  • 程序计数器(PC/RIP):下一条要执行的指令地址

  • 栈指针(SP/RSP):当前栈顶地址

  • 通用寄存器(rax/rbx/rcx/…)

  • 标志寄存器(RFLAGS)

  • 页表基址(CR3)

  • 浮点/向量寄存器(FPU/XMM/YMM)

  • 段寄存器(FS/GS,用于 TLS)

上下文切换是多任务操作系统的核心机制,它让多个进程"分时复用"CPU,营造出"同时运行"的假象。

3.5.2 上下文切换的触发时机

  1. 时间片用完:时钟中断中,调度器发现当前进程时间片用完,触发调度。

  2. 进程主动睡眠:进程调用 sleep()、等待信号量/互斥量/等待队列时,主动调用 schedule()。

  3. 进程唤醒高优先级进程:一个进程唤醒了比当前进程优先级更高的进程,触发抢占。

  4. 系统调用返回:系统调用返回用户态时,检查 _TIF_NEED_RESCHED 标志,如果设置了则调度。

  5. 中断返回:中断处理完成返回时,同样检查调度标志。

3.5.3 context_switch() 详解

图 3-3 上下文切换流程

源码位置:kernel/sched/core.c

context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next)
{
    struct mm_struct *mm = next->mm;
    struct mm_struct *oldmm = prev->active_mm;

    /* 1. 切换地址空间(页表) */
    if (!mm) {
        /* 下一个是内核线程:没有自己的 mm,借用前一个进程的地址空间 */
        next->active_mm = oldmm;
        mmgrab(oldmm);  /* 增加引用计数,防止被释放 */
        enter_lazy_tlb(oldmm, next);  /* 懒 TLB 模式:不刷新用户态 TLB */
    } else {
        /* 下一个是用户进程:切换页表 */
        switch_mm_irqs_off(oldmm, mm, next);
        /* 内部:加载新进程的 pgd 到 CR3,刷新 TLB */
    }

    if (!prev->mm) {
        /* 前一个是内核线程:它借用的地址空间现在可以释放引用了 */
        prev->active_mm = NULL;
        mmdrop(oldmm);
    }

    /* 2. 切换寄存器和栈(架构相关,用汇编实现) */
    switch_to(prev, next, prev);

    /* switch_to 之后,当前进程已经是 next:
       下面的代码是在 next 被切换回来后执行的。
       prev 变量现在指向被切换出去的进程。 */

    /* 3. 切换后的清理(由 next 进程执行) */
    barrier();
    /* finish_task_switch() 会在后续调用中完成,
       释放 prev 的锁、处理 tick 等 */
}

3.5.4 switch_mm():页表切换

switch_mm() 是架构相关的,x86 下核心操作:

static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next,
                              struct task_struct *tsk)
{
    if (prev != next) {
        /* 加载新进程的页全局目录物理地址到 CR3 寄存器 */
        write_cr3(next->pgd);
        /* 这会自动刷新 TLB(Translation Lookaside Buffer,地址翻译缓存) */
    }
    /* 更新 CPU 的 mm 状态、加载 LDT 等 */
}

TLB 的作用:TLB 是 MMU 中的高速缓存,存储最近使用的虚拟页到物理页帧的映射。切换 CR3 后,旧的 TLB 条目失效,需要刷新(x86 下写 CR3 自动刷新非全局 TLB 条目)。TLB 刷新是上下文切换的主要开销之一,因为刷新后后续的内存访问都需要重新遍历页表(慢)。

懒 TLB(Lazy TLB)优化:当切换到内核线程时,内核线程只访问内核空间,而内核空间的页表全局共享且不变。因此不需要刷新用户态 TLB,也不需要切换 CR3(直接借用前一个进程的页表)。这减少了 TLB 刷新的开销。

3.5.5 switch_to():寄存器与栈切换

switch_to 是一个宏,由架构相关的汇编代码实现(x86 下在 arch/x86/include/asm/switch_to.h)。它是上下文切换中最贴近硬件、也最依赖体系结构的部分。

x86-64 的 switch_to 实现逻辑:

#define switch_to(prev, next, last)                 \
do {                                                  \
    /* 1. 保存 prev 的 FPU 状态(延迟保存,按需) */ \
    /* ... */                                          \
                                                       \
    /* 2. 调用 __switch_to_asm(汇编) */             \
    ((struct task_struct *(*)                         \
        (struct task_struct *, struct task_struct *)) \
        __switch_to_asm)(prev, next);                 \
} while (0)

__switch_to_asm 汇编逻辑:

__switch_to_asm:
    /* 保存 prev 的通用寄存器到 prev->thread */
    pushq %rbp
    pushq %rbx
    pushq %r12
    pushq %r13
    pushq %r14
    pushq %r15

    /* 切换内核栈:将 next->thread.sp 加载到 rsp */
    movq %rsp, TASK_thread_sp(%rdi)  /* 保存 prev 的栈指针 */
    movq TASK_thread_sp(%rsi), %rsp  /* 加载 next 的栈指针 */

    /* 调用 C 函数 __switch_to 做剩余工作(TLS、FS/GS 等) */
    call __switch_to

    /* 恢复 next 的通用寄存器(从 next->thread) */
    popq %r15
    popq %r14
    popq %r13
    popq %r12
    popq %rbx
    popq %rbp

    ret  /* 返回到 next 的指令指针(next 上次被切换时保存的返回地址) */

为什么需要三个参数:switch_to(prev, next, prev) 使用了三个参数,其中 prev 出现两次。这是因为:

  • 调用 switch_to 时,prev 是当前进程(被切换出去),next 是下一个进程。

  • switch_to 内部切换栈后,执行流跳到了 next 的上下文。

  • 当 prev 将来被重新调度回来时,它从 switch_to 的返回点继续执行,但此时它已经不知道自己是谁了(因为栈切换了)。

  • 第三个参数 last(也是 prev)通过寄存器传递,在 __switch_to 中被设置为实际被切换出去的进程。这样 prev 进程被切换回来后,可以通过 last 知道自己之前切换到了哪个进程。

这是一个经典的"你以为你在调用函数,其实你在切换执行流"的设计。

3.5.6 x86 扩展寄存器状态的保存与恢复

FPU、XMM、YMM、ZMM 等扩展寄存器状态体积较大,由 x86 的 xstate/FPU 代码单独管理。Linux 5.15.220 的上下文切换不是早期内核那种“设置 CR0.TS,等任务首次执行浮点指令触发 #NM 后再保存前任状态”的传统 lazy FPU switching。

当前路径在 __switch_to() 周围调用 switch_fpu_prepare() 保存需要保存的前一任务状态,并由 switch_fpu_finish() 为下一任务建立所有权/装载状态。若下一任务的寄存器映像尚未装入,内核设置 TIF_NEED_FPU_LOAD,在返回用户态前通过 switch_fpu_return() 完成恢复。这样既保持状态隔离,又尽量避免不必要的重复装载。相关代码位于 arch/x86/kernel/process_64.c、arch/x86/include/asm/fpu/internal.h 和 arch/x86/include/asm/entry-common.h。

3.6 中断与异常处理

3.6.1 中断的分类

中断是 CPU 与外设交互、处理异常的核心机制。Linux 中的中断分为:

  1. 硬件中断(IRQ):由外设通过中断控制器(APIC)触发,如网卡收包、磁盘 IO 完成、键盘输入。

  2. 软件中断(异常/Exception):由 CPU 执行指令时触发,如缺页异常(#PF)、除零(#DE)、系统调用(#SYSCALL)。

  3. 软中断(Softirq):内核内部的延迟处理机制,用于将中断处理的耗时部分推迟到开中断环境执行。

3.6.2 硬中断处理流程

图 3-4 硬中断顶半部与软中断底半部
外设触发中断 → 中断控制器(APIC)路由到 CPU
  → x86 IDT 入口代码建立 pt_regs 上下文并进入 irqentry 框架
  → common_interrupt()
    → 根据本 CPU 的 vector_irq[vector] 找到 irq_desc
    → handle_irq() → generic_handle_irq_desc()
      → irq_desc 的 flow handler 处理屏蔽/确认等芯片细节
      → handle_irq_event() 调用驱动注册的 irqaction
        → 驱动 hardirq 快速确认中断、取走状态并安排后续处理
      → 如需延后处理,raise_softirq() 标记相应软中断
  → irqentry_exit() / 汇编返回路径处理待办工作并恢复寄存器
  → 返回被中断的内核或用户上下文

3.6.3 软中断(Softirq)机制

硬中断上下文应尽量快速完成,但耗时工作通常需要延后。Linux 常把设备中断处理拆成:

  • 硬中断处理(hardirq):在中断上下文中完成确认设备、读取状态和安排后续处理;本地中断何时重新打开由入口代码和体系结构实现控制,不能笼统理解为整个 hardirq 都“全局关中断”。

  • 延后处理:网络等子系统常用 softirq/NAPI 批量处理。softirq 运行时通常允许硬中断进入,但 softirq 上下文本身不可睡眠,是否可被普通任务抢占还取决于内核抢占模型和是否由 ksoftirqd 在线程上下文处理。

软中断类型包括:

  • NET_RX_SOFTIRQ:网络收包处理

  • NET_TX_SOFTIRQ:网络发包处理

  • BLOCK_SOFTIRQ:块 IO 完成处理

  • TIMER_SOFTIRQ:定时器处理

  • TASKLET_SOFTIRQ:tasklet 处理

  • SCHED_SOFTIRQ:调度器相关

软中断的执行时机:

  1. 硬中断返回时(irq_exit → invoke_softirq)。

  2. ksoftirqd 内核线程(当软中断处理时间过长时,由内核线程接管)。

  3. 主动调用 local_bh_enable() 时。

3.6.4 系统调用

系统调用是用户态程序请求内核服务的唯一合法入口。x86-64 下使用 syscall 指令:

用户态调用约定:

  • rax:系统调用号

  • rdi:第 1 个参数

  • rsi:第 2 个参数

  • rdx:第 3 个参数

  • r10:第 4 个参数(不是 rcx,因为 syscall 指令会用 rcx 保存返回地址)

  • r8:第 5 个参数

  • r9:第 6 个参数

  • 返回值在 rax

内核处理流程:

用户态执行 syscall 指令
  → CPU 自动切换到内核态,加载内核栈(MSR_LSTAR 指向入口)
  → 保存返回地址(rcx)和标志(r11)
  → entry_SYSCALL_64(汇编入口)
    → 保存用户态寄存器到 pt_regs
    → 检查系统调用号合法性
    → 调用系统调用表(sys_call_table)中对应的内核函数
      → 如 sys_read()、sys_write()、sys_fork() 等
    → 将返回值写入 pt_regs.ax
    → syscall_return_slowpath / syscall_return_fastpath
      → 检查是否需要调度、信号处理
      → 恢复用户态寄存器
      → sysretq 返回用户态

系统调用表:sys_call_table 是一个函数指针数组,索引是系统调用号,值是对应的内核处理函数。x86-64 下定义在 arch/x86/entry/syscall_64.c,系统调用号定义在 arch/x86/entry/syscalls/syscall_64.tbl。

3.7 进程管理子系统总结

用户态 fork()/clone()/pthread_create()
        │
        ▼  系统调用
kernel_clone()
  → copy_process()
    ├─ dup_task_struct()   分配 task_struct + 内核栈
    ├─ sched_fork()         调度初始化
    ├─ copy_mm()            复制/共享地址空间(COW)
    ├─ copy_files()         复制/共享文件描述符
    ├─ copy_sighand()       复制/共享信号处理
    ├─ copy_namespaces()    复制/共享命名空间
    ├─ copy_thread()        架构相关:设置寄存器和栈
    └─ alloc_pid()          分配 PID
  → wake_up_new_task()      加入运行队列

调度器选中新进程
  → context_switch()
    ├─ switch_mm()          切换页表(CR3),刷新 TLB
    └─ switch_to()          切换寄存器和内核栈(汇编)
      → 新进程从 ret_from_fork 开始执行

进程退出
  → do_exit()
    ├─ 释放 mm/files/fs/signal 等资源
    └─ 进入僵尸状态,等待父进程 wait() 回收

本章要点:

  1. 统一抽象:进程和线程都用 task_struct,通过 clone flags 区分,简化内核设计。

  2. 写时复制:fork 不复制物理页,只复制页表并标记只读,按需复制,大幅提高效率。

  3. 惰性优化:懒 TLB(内核线程不刷新 TLB)、延迟 FPU 保存(不使用浮点就不保存)。

  4. 分层中断处理:硬中断快速响应,软中断延迟处理,兼顾响应性和吞吐量。

  5. 资源逐项复制:copy_process 逐项复制/共享各类资源,每项独立可控。


文章目录

DISCUSSION

评论

0 条已通过评论

留下评论

评论提交后需要审核。邮箱仅用于联系,不会公开。