Linux内核分析之进程管理-00
8.1 task_struct 核心字段详解
task_struct 是 Linux 内核中最核心的数据结构,定义在 include/linux/sched.h 的第 820 至 1654 行。本节将逐组深入分析其全部关键字段,涵盖从线程信息到架构相关状态的全部内容。理解 task_struct 是阅读 Linux 内核源码的基石——几乎所有内核子系统都通过 current 宏获取当前 task_struct 指针,再从中读取或写入信息。
1 | task_struct 代码位置概览 |
8.1.1 线程信息与任务状态
1 | // include/linux/sched.h, 第 820-837 行 |
1.1 thread_info —— 必须是第一个字段
thread_info 被放置在 task_struct 的绝对起始位置。源码注释明确指出:”this must be the first element of task_struct”。这个约束来自一个性能优化:内核需要极快地从当前栈指针推导出对应的 task_struct 指针。
在 CONFIG_THREAD_INFO_IN_TASK 启用的情况下(大多数现代架构默认启用),thread_info 嵌入在 task_struct 内部。thread_info 本身非常轻量,主要包含 flags 字段(TIF_NEED_RESCHED、TIF_SIGPENDING 等标志),用于在陷入内核态时快速检查是否需要调度或处理信号。
thread_info 在 task_struct 中的位置保证了一件重要的事:知道了 thread_info 的地址,也就知道了 task_struct 的地址(它们相同)。这使得通过栈指针快速获取当前任务成为可能——内核栈的底部(或顶部,取决于架构)存放着 task_struct 指针,通过简单的位运算即可从栈指针推算出来。
1.2 __state —— 任务当前状态
__state 字段存储任务当前的运行状态。注意前缀是双下划线 __,表示这是一个内部字段,内核代码不应直接写入它,而应使用 set_current_state()、set_task_state() 等辅助函数,以确保正确的内存屏障语义。
Linux 7.0.10 中定义的任务状态常量如下(include/linux/sched.h 第 107-141 行):
1 | // include/linux/sched.h, 第 107-141 行 |
这些状态可以分为以下几类:
1 | 任务状态转换图 |
一些重要的组合状态:
- TASK_KILLABLE =
TASK_WAKEKILL | TASK_UNINTERRUPTIBLE:只响应致命信号的可中断睡眠,解决了 D 状态(TASK_UNINTERRUPTIBLE)进程无法被杀死的问题。这个状态由 Matt Mackall 在 2.6.25 引入,大大改善了系统的可靠性 - TASK_STOPPED =
TASK_WAKEKILL | __TASK_STOPPED:被 SIGSTOP 停止的进程 - TASK_IDLE =
TASK_UNINTERRUPTIBLE | TASK_NOLOAD:CPU 空闲时的 idle 任务状态,不计入系统负载
1.3 saved_state —— 自旋锁睡眠者的状态保存
saved_state 字段在第 831 行定义,注释说明它是为 “spinlock sleepers” 保存的状态。这是一个相对较新的机制(5.x 系列引入并逐步完善),用于解决以下场景:
当任务在持有自旋锁的情况下需要睡眠时(在 PREEMPT_RT 配置下,自旋锁会变成可睡眠的 rt_mutex),任务的 __state 需要改变。但为了在释放锁后能够恢复原来的状态,原始状态被保存在 saved_state 中。这个机制对于实时内核(PREEMPT_RT)的正确性至关重要。
8.1.2 栈与引用计数
1 | // include/linux/sched.h, 第 839-843 行 |
2.1 stack —— 内核栈指针
stack 指向该任务的内核栈。每个任务(包括用户进程和内核线程)都有自己独立的内核栈,大小通常是两个页(8KB on x86_64,THREAD_SIZE = 16KB in many configurations)。当任务通过系统调用、中断或异常陷入内核态时,它使用这个栈来执行内核代码。
在 CONFIG_VMAP_STACK 启用(默认)的情况下,内核栈通过 vmalloc 分配,存储在一个连续的虚拟地址空间中(物理页面不必连续)。对应的 stack_vm_area 字段(第 1569 行)保存了 vmalloc 分配器的元数据。同时,stack_refcount(第 1573 行)用于跟踪栈的引用计数。
内核栈的安全性是内核防御的重要一环。CONFIG_KSTACK_ERASE(第 1591-1593 行)会在每次系统调用返回用户态时擦除栈上残留数据,CONFIG_RANDOMIZE_KSTACK_OFFSET(第 1598-1600 行)会在系统调用入口随机化栈指针位置,增加攻击者预测栈地址的难度。
2.2 usage —— 引用计数
usage 是 refcount_t 类型(带饱和检测的原子引用计数),跟踪 task_struct 本身的生命周期。引用计数的增加场景包括:
- 创建子进程时,子进程的
usage初始化为 1 get_task_struct()宏会将usage加 1(例如/proc文件系统读取进程信息时)put_task_struct()将usage减 1,当减到 0 时,task_struct的内存被回收
refcount_t 比原始的 atomic_t 更安全——当检测到引用计数溢出(从接近 0 减到负值)时,会触发内核警告而不是默默地继续使用已释放的内存。
2.3 flags —— PF_* 进程标志
flags 字段存储进程级别的标志位,使用 PF_* 前缀的宏定义(第 1759-1792 行)。这些标志描述了进程的性质和当前行为。以下是完整的 PF_* 标志列表:
1 | // include/linux/sched.h, 第 1759-1792 行 |
几个特别重要的 PF_* 标志值得详细说明:
PF_EXITING (0x00000004):当进程开始执行 do_exit() 时设置。这个标志一旦设置就不会被清除。内核中的许多路径会检查此标志——例如,OOM killer 会跳过正在退出的进程,调度器在看到此标志后会加速进程的退出流程。
PF_KTHREAD (0x00200000):标识内核线程。内核线程没有用户地址空间(mm == NULL),不处理用户态信号,不与任何用户态资源关联。通过 kthread_create() 创建的所有内核线程都设置此标志。
PF_FORKNOEXEC (0x00000040):在 fork() 后设置,在 exec() 后清除。这个标志帮助内核区分”刚 fork 出来但还没 exec 的进程”与”已经 exec 过的进程”。例如,core dump 时需要知道进程是否已经执行了新的程序映像。
PF_MEMALLOC (0x00000800):当内核正在进行内存回收(page reclaim)操作时设置。持有此标志的任务在内存分配时享有特权——可以使用紧急内存储备(memory reserves),确保内存回收过程不会因为缺少内存而死锁。这是一个典型的”为了释放内存而需要先分配内存”的解决方案。
PF_IO_WORKER (0x00000010):io_uring 的工作线程标志。Linux 5.x 引入的 io_uring 异步 I/O 框架使用这种特殊类型的内核线程来处理异步 I/O 操作。它们与普通内核线程(PF_KTHREAD)不同,因为它们需要访问用户地址空间。
2.4 ptrace —— ptrace 标志
ptrace 字段存储与 ptrace 调试相关的标志。当进程被 gdb 或 strace 等调试工具追踪时,此字段记录追踪的状态和选项。主要的 ptrace 标志定义在 include/linux/ptrace.h 中,包括 PT_PTRACED(正在被追踪)、PT_SEIZED(使用现代的 ptrace seize 模式)等。
8.1.3 调度器核心
调度器相关的字段是 task_struct 中最复杂的部分之一。Linux 7.0.10 支持 5 种调度策略(SCHED_NORMAL、SCHED_FIFO、SCHED_RR、SCHED_BATCH、SCHED_IDLE、SCHED_DEADLINE、SCHED_EXT),对应 4 种调度类(fair、rt、deadline、ext)。
1 | // include/linux/sched.h, 第 849-928 行 |
3.1 on_cpu 与 on_rq —— 运行状态指示器
这两个字段提供了任务在调度框架中的即时状态:
- on_cpu:布尔值,表示任务当前是否正在某个 CPU 上执行。当任务被
context_switch()切换到 CPU 上时设为 1,被切出时设为 0。注意on_cpu的更新使用了smp_acquire__after_ctrl_barrier()等内存屏障原语,确保多核间的正确可见性 - on_rq:表示任务是否在运行队列(runqueue)上。可能的值定义在
kernel/sched/sched.h中,包括 0(不在队列上)、MQ_rq(在普通队列上)等
一个任务可能的状态组合:
1 | on_cpu=0, on_rq=0 → 睡眠中(阻塞或等待事件) |
3.2 优先级系统 —— 四个优先级字段的关系
Linux 的优先级系统是初学者最容易困惑的部分之一。task_struct 中有四个不同的优先级字段:prio、static_prio、normal_prio、rt_priority。它们各有不同的用途:
1 | Linux 优先级体系结构 |
prio(动态优先级):这是调度器实际使用的优先级。对于普通进程,通常等于 static_prio;对于实时进程,由 rt_priority 计算得出。但 prio 可以被调度器临时修改——例如,在优先级继承(Priority Inheritance)中,当高优先级任务等待低优先级任务持有的锁时,低优先级任务的 prio 会被临时提升。这就是为什么它被称为”动态”的。
static_prio(静态优先级):由用户通过 nice() 系统调用设置的优先级。范围是 100(nice=-20,最高)到 139(nice=19,最低)。默认值 120 对应 nice=0。转换公式定义在 include/linux/sched/prio.h 中:
1 | // include/linux/sched/prio.h, 第 27-28 行 |
normal_prio(”正常”优先级):基于进程的调度策略和静态优先级计算出的”归一化”优先级。对于 SCHED_NORMAL 进程,normal_prio == static_prio。对于 SCHED_FIFO/SCHED_RR 进程,normal_prio 由 rt_priority 计算而来。这个字段的作用是:当 prio 因优先级继承被临时修改后,仍然可以通过 normal_prio 恢复到原始值。
rt_priority(实时优先级):用户空间设置的实时优先级,范围 1(最低)到 99(最高)。0 表示非实时进程。内核内部将其转换为 prio 的公式是 prio = MAX_RT_PRIO - 1 - rt_priority,即 rt_priority=99 对应 prio=0(最高),rt_priority=1 对应 prio=98。
四者之间的关系总结如下:
| 调度策略 | static_prio | normal_prio | rt_priority | prio |
|---|---|---|---|---|
| SCHED_NORMAL (nice=0) | 120 | 120 | 0 | 120 (可被临时修改) |
| SCHED_NORMAL (nice=5) | 125 | 125 | 0 | 125 |
| SCHED_FIFO (prio=50) | 120 (不使用) | 49 | 50 | 49 (可被 PI 提升) |
| SCHED_RR (prio=99) | 120 (不使用) | 0 | 99 | 0 |
| SCHED_IDLE | 139 (MAX_PRIO-1) | 139 | 0 | 139 |
3.3 调度实体(se, rt, dl, scx)
每种调度类有自己的调度实体结构,嵌入在 task_struct 中:
- se(struct sched_entity):公平调度器(CFS/EEVDF)的调度实体。包含虚拟运行时间(vruntime)、权重(load_weight)、在红黑树中的节点等。在 Linux 7.0 中,EEVDF 调度模型已替代传统的 CFS 虚拟运行时间模型
- rt(struct sched_rt_entity):实时调度器的调度实体。包含在优先级链表中的节点、时间片剩余量等
- dl(struct sched_dl_entity):Deadline 调度器的调度实体。包含运行时间预算(runtime)、截止时间(deadline)、周期(period)等参数
- scx(struct sched_ext_entity):可扩展调度器类的调度实体。sched_ext 是 Linux 6.6 引入的 BPF 可编程调度框架,允许通过加载 BPF 程序来实现自定义调度策略
3.4 sched_class —— 调度类指针
sched_class 指向该任务所属的调度类。Linux 内核的调度器采用了面向对象的设计——每种调度策略对应一个 sched_class 结构体,其中包含了一组函数指针(如 enqueue_task、dequeue_task、pick_next_task、put_prev_task 等)。调度类按照优先级排列:
1 | 调度类优先级(从高到低): |
3.5 policy —— 调度策略
policy 字段定义在 include/uapi/linux/sched.h 中(第 114-121 行):
1 | // include/uapi/linux/sched.h, 第 114-121 行 |
3.6 CPU 亲和性(cpus_ptr, cpus_mask)
- cpus_ptr:指向允许该任务运行的 CPU 集合。通常指向
cpus_mask - cpus_mask:实际的 CPU 位掩码,记录该任务可以在哪些 CPU 上运行
- user_cpus_ptr:用户空间通过
sched_setaffinity()设置的 CPU 集合 - nr_cpus_allowed:允许的 CPU 数量
- migration_disabled:迁移禁用计数(用于内核内部,如执行 per-CPU 操作时临时禁止迁移)
8.1.4 内存管理
1 | // include/linux/sched.h, 第 958-959 行 |
虽然只有两个字段,但它们蕴含着精妙的设计思想。
4.1 mm —— 用户地址空间描述符
mm 指向该任务的完整用户空间内存描述符。struct mm_struct 包含了页表(pgd)、内存区域链表(mmap/VMA 链表)、代码段/数据段/堆/栈的边界、RSS 统计、锁等所有与用户地址空间相关的信息。
对于普通用户进程,mm 总是非 NULL 的。对于内核线程,mm 总是 NULL——内核线程不拥有用户地址空间,它们只运行在内核空间中。
4.2 active_mm 与 lazy TLB 优化
active_mm 是为内核线程设计的字段。当内核线程被调度上 CPU 时,它需要一个 mm_struct 来设置页表基址寄存器(x86 上的 CR3)。但内核线程没有自己的 mm,所以它”借用”前一个任务的 mm,存储在 active_mm 中。
这就是 lazy TLB(延迟 TLB 刷新)优化:内核线程借用 active_mm 时,不需要刷新 TLB,因为内核空间的映射在所有进程间是共享的。这避免了每次切换到内核线程时的 TLB 失效,显著提升了上下文切换的性能。
1 | 用户进程 A → 内核线程 K → 用户进程 B 的调度序列 |
当 mm != NULL 时,active_mm == mm(普通用户进程自身拥有 mm)。当 mm == NULL 时(内核线程),active_mm 指向借用的 mm。在 context_switch() 中,这个逻辑体现在 kernel/sched/core.c 的 switch_mm_irqs_off() 调用条件判断中。
同一进程的多个线程共享同一个 mm_struct——通过 CLONE_VM 标志在 clone() 时实现。mm_struct 自身有引用计数 mm_count(对 mm_struct 本身的引用)和 mm_users(对地址空间的引用,如线程数)。
8.1.5 进程退出与信号
1 | // include/linux/sched.h, 第 961-967 行 |
5.1 exit_state —— 退出阶段
exit_state 记录进程在退出过程中的阶段。它使用与 __state 不同的命名空间,定义在第 113-115 行:
1 |
僵尸进程(EXIT_ZOMBIE)是 Linux 进程管理中一个非常重要的概念。当进程调用 do_exit() 后,它释放了几乎所有的资源(内存、文件、信号处理等),但保留了 task_struct 本身,因为:
- 父进程可能需要通过
wait()系统调用读取退出状态(exit_code) - 内核需要保留进程的累计资源使用统计(utime, stime, min_flt, maj_flt 等)
- 进程树结构需要保持完整性(父进程的 children 链表仍然包含这个已退出进程)
只有当父进程调用了 wait()(或等价的 waitpid/waitid)读取了退出信息后,僵尸进程才会转为 EXIT_DEAD 并最终被释放。如果父进程先于子进程退出,子进程会被 “reparent” 到 init 进程(PID 1)或指定的 subreaper。
5.2 exit_code 与 exit_signal
- exit_code:进程的退出码。正常退出时,它是传递给
exit()的值(低 8 位有效);被信号杀死时,它是导致终止的信号编号(通过宏WIFEXITED、WEXITSTATUS、WIFSIGNALED、WTERMSIG解析) - exit_signal:进程退出时发送给父进程的信号。默认是 SIGCHLD。可以通过
clone()的 CLONE_PARENT 标志改变。如果设为 -1,则不发送任何信号(这对于创建”脱管”的内核线程有用)
5.3 pdeath_signal —— 父死亡信号
pdeath_signal 是一个较少为人知但很有用的字段。当一个进程设置了这个值后(通过 prctl(PR_SET_PDEATHSIG, signo)),当它的父进程死亡时,内核会向它发送指定的信号。这个机制常用于守护进程的子进程检测父进程是否还活着。
8.1.6 PID 与身份
1 | // include/linux/sched.h, 第 1059-1060 行 |
这两个字段的区别是理解 Linux 线程模型的关键。
6.1 pid —— 内核标识符
在内核中,每一个 task_struct 都有一个全局唯一的 pid(包括进程和线程)。也就是说,一个拥有 4 个线程的进程,在内核中会有 4 个 task_struct,每个都有不同的 pid(例如 1000、1001、1002、1003)。
这里的 pid 类型是 pid_t(通常为 int),其分配由 PID 命名空间管理。在初始 PID 命名空间中,pid 的范围是 1 到 32768(默认)或 1 到 4194304(CONFIG_PID_NS 启用时上限更大)。
6.2 tgid —— 线程组 ID
tgid 是 “Thread Group ID” 的缩写。对于线程组的 leader(即 group_leader == self 的任务),tgid == pid。对于线程组中的其他线程,tgid 等于 leader 的 pid。
这就是为什么用户空间通过 getpid() 返回的是 tgid 而不是 pid——用户空间期望”进程 ID”是整个多线程程序的唯一标识。而 gettid() 系统调用返回的才是内核中真正的 pid。
1 | 多线程进程示例(bash 中 PID=1000): |
6.3 thread_pid 与 pid_links —— 内核 PID 子系统
在第 1095-1097 行还有三个与 PID 相关的字段:
1 | struct pid *thread_pid; // PID 结构体指针 |
thread_pid 指向 struct pid(定义在 include/linux/pid.h),这是内核 PID 子系统的核心结构。一个 struct pid 包含实际的 ID 数值、引用计数以及在各个 PID 命名空间中的可见性信息。pid_links[PIDTYPE_MAX] 用于将任务链接到 PID 哈希表中——内核通过 PIDTYPE_PID、PIDTYPE_TGID、PIDTYPE_PGID、PIDTYPE_SID 四种类型来索引进程,分别用于按 PID、线程组 ID、进程组 ID、会话 ID 查找。
8.1.7 进程关系
1 | // include/linux/sched.h, 第 1072-1097 行 |
7.1 real_parent 与 parent —— 两个父指针
task_struct 有两个父进程指针,这是一个容易混淆但设计精巧的地方:
- real_parent:真正的父进程——调用
fork()/clone()创建当前任务的进程。即使被 ptrace 追踪,这个指针也不会改变 - parent:当前的”父进程”。在正常情况下,
parent == real_parent。当进程被 ptrace 追踪时,parent指向追踪者(tracer)。wait()系统调用会使用parent来报告子进程状态
这两个字段都使用 __rcu 注解,表示它们的读取需要 RCU 保护。当进程被 reparent(例如父进程死亡后被过继给 init 进程)或被 ptrace attach/detach 时,这些指针会被更新。
7.2 children 与 sibling —— 子进程链表
children 是当前进程所有子进程的链表头,sibling 是当前进程在父进程 children 链表中的节点。这构成了 Linux 的进程树:
1 | 进程树结构示例 |
7.3 group_leader —— 线程组 leader
group_leader 指向线程组中的 leader 任务。对于单线程进程,group_leader 指向自身。对于多线程进程中的线程,group_leader 指向调用 pthread_create() 的主线程。线程组的所有成员通过 thread_node(第 1097 行)链接在一起。
7.4 ptraced 与 ptrace_entry —— 调试追踪链
- ptraced:当前任务正在用 ptrace 追踪的所有任务的链表头
- ptrace_entry:当前任务在被追踪者
ptraced链表中的节点
这两个链表构成了 ptrace 追踪关系。当一个进程被 PTRACE_ATTACH 后,它会被添加到追踪者的 ptraced 链表中。
8.1.8 安全凭证
1 | // include/linux/sched.h, 第 1146-1160 行 |
Linux 的安全凭证模型采用了”客观/主观凭证”(objective/subjective credentials)的设计模式:
- real_cred:任务的”客观”凭证,代表任务的真实身份。不受临时权限提升的影响
- cred:任务的”有效”凭证,代表当前操作使用的权限身份。可以临时被覆盖(例如通过
setfsuid()或capable()操作) - ptracer_cred:当任务被 ptrace 追踪时,记录 tracer 在 attach 时刻的凭证。这用于防止被追踪任务获得追踪者的权限
struct cred(定义在 include/linux/cred.h)包含了完整的权限信息:UID、GID、辅助组、POSIX 能力集(cap_effective、cap_permitted、cap_inheritable、cap_bounding、cap_ambient)、安全标签(SELinux context)、密钥环等。
凭证使用了写时复制(COW,Copy-On-Write)机制。当多个任务共享相同的凭证时,它们指向同一个 cred 结构,该结构的引用计数跟踪共享者数量。当某个任务需要修改凭证时,才复制一份独立的副本。
8.1.9 文件系统与文件
1 | // include/linux/sched.h, 第 1185-1196 行 |
9.1 fs_struct —— 文件系统上下文
fs 指向包含当前工作目录(cwd)和根目录(root)信息的结构体。它记录了进程对文件系统路径的”视图”——即 pwd 和 chroot 设置。同一进程的多个线程共享 fs_struct。
9.2 files_struct —— 打开文件表
files 指向进程的打开文件描述符表。每个文件描述符对应一个 struct file 指针,包含了文件偏移量、打开模式、引用计数等信息。在多线程程序中,默认共享此表(CLONE_FILES),所以一个线程打开的文件在另一个线程中也可见。
9.3 nsproxy —— 命名空间代理
nsproxy 是所有命名空间的聚合入口。Linux 的命名空间(Namespace)机制是容器技术的基础:
1 | struct nsproxy 中的命名空间指针: |
8.1.10 信号处理
1 | // include/linux/sched.h, 第 1198-1208 行 |
Linux 的信号处理采用了共享与私有相结合的模型:
1 | 信号处理的层次结构 |
- signal(
struct signal_struct):整个线程组共享的信号信息。由kill()或sigqueue()发送的信号首先进入共享待处理队列。signal_struct还包含线程组级别的资源限制、退出码等信息 - sighand(
struct sighand_struct):信号处理函数表,记录了每个信号的处理方式(默认、忽略、用户自定义处理函数)。同一线程组的线程共享此表 - blocked:每个线程独立的信号掩码,记录当前被阻塞的信号集合。通过
sigprocmask()/pthread_sigmask()修改 - pending(
struct sigpending):每个线程独立的待处理信号队列。由tgkill()或pthread_kill()发送的信号进入这个私有队列 - sas_ss_sp / sas_ss_size:通过
sigaltstack()设置的替代信号栈。某些应用在主栈空间有限时,需要为信号处理函数提供一个独立的栈空间
8.1.11 时间记账
1 | // include/linux/sched.h, 第 1110-1133 行 |
11.1 CPU 时间统计
- utime:任务在用户态执行的累计时间。当系统时钟中断发生时,如果当前任务正在用户态运行,则增加
utime - stime:任务在内核态执行的累计时间。当系统时钟中断发生时,如果当前任务正在内核态运行,则增加
stime - gtime:作为虚拟机客户机运行的时间。当任务作为 VCPU 运行时(PF_VCPU 标志设置),时间被计入
gtime而非utime
prev_cputime 用于 cgroup 带宽节流场景。当任务的 cgroup 超出 CPU 带宽限制被节流时,需要保存被节流前的 CPU 时间快照,以避免在节流期间丢失时间统计。
11.2 上下文切换计数
- nvcsw(voluntary context switches):任务主动调用
schedule()或阻塞在 I/O、锁等资源上导致的上下文切换次数 - nivcsw(involuntary context switches):任务被调度器强制抢占(时间片用完、更高优先级任务就绪等)导致的上下文切换次数
这两个值可以通过 /proc/[pid]/status 中的 voluntary_ctxt_switches 和 nonvoluntary_ctxt_switches 字段读取。高 nivcsw 值通常意味着 CPU 竞争激烈或任务优先级设置不当。
11.3 创建时间
- start_time:基于单调时钟(CLOCK_MONOTONIC)的任务创建时间,不受系统时间调整的影响
- start_boottime:基于启动时钟(CLOCK_BOOTTIME)的任务创建时间,包含系统休眠时间。这用于计算进程的精确年龄
8.1.12 cgroups
1 | // include/linux/sched.h, 第 1322-1330 行 |
cgroups(Control Groups)是 Linux 的资源隔离和限制框架。每个任务通过 cgroups 字段(指向 struct css_set)归属于一组 cgroup。
struct css_set 是一个聚合结构,包含了任务在各个 cgroup 子系统(controller)中的归属关系。多个任务如果属于完全相同的 cgroup 组合,可以共享同一个 css_set。
1 | cgroup 关联示意图 |
cgroup 子系统(如 cpu、memory、io、pids、freezer 等)通过 task_struct->cgroups 找到对应的 cgroup,再施加资源限制。例如,CPU cgroup 限制 CPU 使用带宽,memory cgroup 限制内存使用量,freezer cgroup 可以冻结/解冻整组任务。
8.1.13 锁
1 | // include/linux/sched.h, 第 1227-1230 行 |
13.1 alloc_lock —— 资源分配锁
alloc_lock 是一个普通的 spinlock_t,保护 task_struct 中可被并发修改的资源指针:mm、files、fs、mempolicy、mems_allowed 等。在 exec 系统调用执行期间(需要替换整个地址空间和文件描述符表)、或 prctl 修改进程属性时,需要持有此锁。
13.2 pi_lock —— 优先级继承锁
pi_lock 是一个 raw_spinlock_t(不可被抢占的自旋锁),用于保护实时互斥锁(rt_mutex)的优先级继承(Priority Inheritance, PI)数据结构。当高优先级任务等待低优先级任务持有的 rt_mutex 时,低优先级任务会被”提升”到高优先级,以避免优先级反转问题。pi_lock 保护的就是这个提升/恢复过程中的数据结构(pi_waiters 红黑树、pi_top_task、pi_blocked_on 等字段)。
pi_lock 使用 raw_spinlock_t 而非 spinlock_t,是因为 PI 操作发生在调度器的核心路径中,此时普通自旋锁可能导致死锁(调度器本身持有 rq lock)。
8.1.14 重要的位字段
在 exit_state/exit_signal 之后、pid/tgid 之前,有一组密集的位字段(bit fields)。这些字段使用 C 语言的位域语法(:1 表示 1 位),紧密打包以节省内存空间:
1 | // include/linux/sched.h, 第 973-1051 行 |
几个关键字段的详细说明:
sched_reset_on_fork:当父进程设置了 SCHED_RESET_ON_FORK 标志后,其子进程在 fork 时会被自动重置为 SCHED_NORMAL 策略。这防止了一个具有实时优先级的进程通过 fork 创建大量实时子进程来”霸占” CPU。
sched_contributes_to_load:指示此任务是否计入系统平均负载(load average)。当任务进入 TASK_UNINTERRUPTIBLE 状态时设置此位,退出时清除。TASK_UNINTERRUPTIBLE 的任务被认为是”在等待资源”的,因此增加了系统的负载感知。但 TASK_IDLE(idle 线程)设置了 TASK_NOLOAD,不会计入负载。
in_execve:标识任务当前正在 exec 系统调用中。这在安全模块(如 TOMOYO)和审计子系统中被使用。由于 exec 需要替换整个进程映像,内核需要知道哪些操作发生在 exec 过程中以正确处理权限和命名空间。
in_iowait:当任务在等待 I/O 完成时设置。这个标志影响 CPU idle 统计——当 CPU 上唯一的可运行任务处于 I/O 等待时,CPU 时间被计入 iowait 而非 idle。in_iowait 还会影响调度器的 CPA(cache power aware)决策。
frozen:由 cgroup freezer 子系统使用。当 cgroup 被冻结时,所有属于该 cgroup 的任务的 frozen 标志被设置,调度器会跳过这些任务。这与系统挂起(suspend)的冻结机制是分开的。
8.1.15 架构相关字段
1 | // include/linux/sched.h, 第 1646-1647 行 |
thread 是 task_struct 中最后一个(也是最特殊的)字段之一。它是完全架构相关的,每种 CPU 体系结构都有自己的定义。在 x86_64 上,它定义在 arch/x86/include/asm/processor.h 的第 448-517 行:
1 | // arch/x86/include/asm/processor.h, 第 448-517 行(x86_64) |
thread_struct 中的关键字段说明
sp(栈指针):保存任务被切换出 CPU 时的内核栈指针。这是上下文切换的关键——switch_to 宏会保存当前 sp 到 prev->thread.sp,然后从 next->thread.sp 恢复。thread_struct.sp 就是上下文切换的锚点。
fsbase / gsbase:x86_64 的 FS 和 GS 段寄存器基地址。在用户空间,FS 基地址通常指向线程局部存储(TLS)区域(glibc 用它实现 __thread 变量和 pthread_getspecific)。GS 基地址在内核中用于 per-CPU 数据访问(通过 swapgs 指令在用户/内核 GS 之间切换)。
cr2:保存最近一次页面错误的线性地址。当缺页异常处理程序需要知道是哪个地址触发了异常时,读取此值。
trap_nr 与 error_code:保存最近一次异常/陷阱的编号和错误码。用于信号生成和 ptrace 追踪。
io_bitmap:I/O 端口权限位图。当任务通过 iopl() 或 ioperm() 获得了直接访问 I/O 端口的权限时,这个位图记录哪些端口被允许访问。出于安全考虑,现代 Linux 通过 TSS 中的 I/O bitmap 来模拟 IOPL 权限,而不是真正赋予用户态 CPL=0 的权限。
pkru:用户空间保护密钥寄存器(Protection Keys for Userspace)的值。Intel MPK(Memory Protection Keys)允许用户态程序对内存页设置额外的访问控制密钥,pkru 寄存器控制哪些密钥允许读/写。上下文切换时需要保存和恢复此寄存器。
在 thread_struct 之后,还有一个极其重要的数据——FPU 状态。x86 的 FPU/SSE/AVX 寄存器状态不是通过 thread_struct 内部的字段保存的,而是通过一个巧妙的技巧:FPU 状态被放置在 task_struct 的内存区域之后。从第 522 行可以看到:
1 | // arch/x86/include/asm/processor.h, 第 522 行 |
这意味着 FPU 状态紧接在 task_struct 之后分配。这种设计使得 FPU 状态的访问非常高效(只需一次加法),同时也方便在 fork 时通过 fpu_clone() 复制 FPU 上下文。
8.1.16 其他重要字段
16.1 comm —— 命令名(line 1172)
1 | char comm[TASK_COMM_LEN]; // TASK_COMM_LEN = 16 |
可执行文件名(不含路径),最长 15 个字符加一个 NULL 终止符。通过 prctl(PR_SET_NAME) 或 pthread_setname_np() 设置,通过 /proc/[pid]/comm 读取。内核内部通过 set_task_comm() 修改,该函数使用 task_lock() 保证原子性,并使用 strscpy_pad() 确保零填充。
16.2 stack_canary —— 栈保护金丝雀(line 1064)
1 |
|
栈溢出保护(stack canary)机制使用的随机值。在每个函数的栈帧底部放置此值的副本,函数返回前检查是否被覆盖。如果被修改,说明发生了栈缓冲区溢出攻击。stack_canary 放在 task_struct 中(而不是全局变量),使得每个任务拥有不同的金丝雀值,增加了攻击难度。
16.3 tasks —— 全局任务链表(line 954)
1 | struct list_head tasks; |
所有活跃任务的链表节点。内核通过 init_task.tasks 作为链表头,遍历所有 task_struct。这是 for_each_process() 和 for_each_thread() 宏的基础。在内核的进程管理代码(如 kill -9 向所有进程发送信号)中广泛使用。
16.4 vfork_done —— vfork 同步(line 1099)
1 | struct completion *vfork_done; |
vfork() 创建子进程后,父进程会阻塞等待子进程调用 exec() 或 _exit()。vfork_done 指向一个 completion 结构,子进程在 mm_release() 中通过 complete_vfork_done() 通知父进程可以继续运行。
16.5 self_exec_id 与 parent_exec_id(lines 1223-1224)
1 | u64 parent_exec_id; |
这两个字段用于检测 exec 前后进程身份的变化。每当进程调用 exec() 时,self_exec_id 递增。parent_exec_id 记录父进程最近一次 exec 时的 ID。某些操作(如 ptrace)需要检查这些 ID 来确保进程关系没有因 exec 而改变。
总结:task_struct 的内存布局
1 | task_struct 的内存布局(概念性) |
task_struct 以 __attribute__((aligned(64))) 结尾(第 1654 行),确保它与缓存行对齐,这对调度器性能至关重要——task_struct 在上下文切换路径中被密集访问。
整个结构体的随机化(randomized_struct_fields_start/end)是 Linux 内核对结构体布局随机化(struct layout randomization)的实现。编译时通过插件随机排列标记区域内的字段顺序,使得攻击者无法基于已知偏移量来篡改特定字段。只有 thread_info、__state、saved_state 三个字段因为性能要求被排除在随机化之外。
这就是 task_struct —— 一个承载了 Linux 内核三十余年设计智慧的数据结构。它不仅仅是一个”进程控制块”,而是内核几乎所有子系统的信息交汇点。理解了 task_struct 的每一个字段,你就拥有了理解 Linux 内核运作方式的钥匙。
源码参考位置汇总:
include/linux/sched.h第 820-1654 行 —— task_struct 完整定义include/linux/sched.h第 107-127 行 —— 任务状态常量(TASK_*)include/linux/sched.h第 113-115 行 —— 退出状态常量(EXIT_*)include/linux/sched.h第 1759-1792 行 —— PF_* 进程标志include/linux/sched/prio.h第 1-46 行 —— 优先级系统定义include/uapi/linux/sched.h第 114-121 行 —— SCHED_* 调度策略arch/x86/include/asm/processor.h第 448-517 行 —— x86 thread_struct
8.2 进程状态机 —— TASK_RUNNING 到 TASK_DEAD
8.2.1 概述:两个独立的状态字段
Linux 内核的进程状态管理是操作系统中最精妙的设计之一。在 task_struct 结构体中,进程的状态并非由单一字段描述,而是通过两个独立的字段分别管理:
1 | // include/linux/sched.h, line 828 |
为什么需要两个独立的字段?内核源码中的注释给出了清晰的解释:
1 | /* |
__state 描述进程当前的调度状态——是否可运行、是否在睡眠等待某种事件。exit_state 则描述进程的退出阶段——是僵尸态还是最终死亡态。两者使用不同的位域,存储在不同的字段中,确保了状态转换的原子性和正确性。
8.2.2 状态宏定义:位掩码设计
Linux 7.0.10 中所有进程状态定义位于 include/linux/sched.h 的第 106 至 127 行:
1 | /* Used in tsk->__state: */ |
观察这些定义可以发现一个关键设计:所有状态值都是 2 的幂(单比特位),这使得它们可以用位掩码进行组合。例如 EXIT_TRACE 就是 EXIT_ZOMBIE | EXIT_DEAD,表示一个被追踪进程同时处于僵尸和死亡的组合状态。
此外,内核在第 136 至 144 行定义了一组便利宏,用于常见的状态组合:
1 | /* Convenience macros for the sake of set_current_state: */ |
其中 TASK_KILLABLE 是一个极其重要的组合状态——它本质上是一个不可中断睡眠,但允许被 SIGKILL 信号唤醒。这解决了经典 “D 状态”(不可中断磁盘睡眠)进程无法被杀死的难题。TASK_NORMAL 则是 wake_up() 系列函数默认能唤醒的状态集合。
8.2.3 状态字符表示
内核将进程状态映射为单字符表示,用于 /proc/[pid]/stat 和 ps 命令输出。映射表定义在 include/linux/sched.h 第 1699 行:
1 | static const char state_char[] = "RSDTtXZPI"; |
各字符的含义如下表所示:
| 字符 | 状态 | 值 | 说明 |
|---|---|---|---|
| R | TASK_RUNNING | 0x00000000 | 正在运行或在运行队列中等待 |
| S | TASK_INTERRUPTIBLE | 0x00000001 | 可中断睡眠,可被信号唤醒 |
| D | TASK_UNINTERRUPTIBLE | 0x00000002 | 不可中断睡眠,忽略信号 |
| T | __TASK_STOPPED | 0x00000004 | 被信号停止 |
| t | __TASK_TRACED | 0x00000008 | 被调试器追踪 |
| X | EXIT_DEAD | 0x00000010 | 最终死亡态,即将被回收 |
| Z | EXIT_ZOMBIE | 0x00000020 | 僵尸态,等待父进程 wait() |
| P | TASK_PARKED | 0x00000040 | 内核线程已停泊 |
| I | TASK_IDLE | (0x02|0x400) | 空闲线程睡眠 |
8.2.4 核心状态详解
TASK_RUNNING (0x00000000) —— 唯一的”可执行”状态
TASK_RUNNING 的值为 0,这并非巧合,而是精心设计的。它意味着 task_struct 在创建时(__state 字段被初始化为零)就处于 TASK_RUNNING 状态。TASK_RUNNING 是进程唯一一个”可执行”的状态——进程要么正在某个 CPU 上执行,要么在运行队列(runqueue)上等待被调度器选中。所有其他状态都意味着进程在某种意义上”不可运行”。
判断一个进程是否正在运行可以使用辅助函数:
1 | // include/linux/sched.h, line 152 |
当一个进程调用 schedule() 让出 CPU 时,它并不会自动离开 TASK_RUNNING 状态——只有当进程主动设置了自己的 __state 为其他睡眠/停止状态后,调度器才会在下一次调度时将其从运行队列移除。
TASK_INTERRUPTIBLE (0x00000001) —— 可中断睡眠
这是最常见的睡眠状态。处于此状态的进程正在等待某个事件(如 I/O 完成、锁释放、信号量可用等),可以被两种方式唤醒:
- 事件到来:等待的条件满足,其他内核代码调用
wake_up_process()唤醒 - 信号到来:内核向进程投递一个信号,进程被唤醒以处理该信号
典型的使用模式如下(即内核中无处不在的”wait loop”惯用法):
1 | /* |
绝大多数阻塞操作(如等待队列、信号量、互斥锁)都使用 TASK_INTERRUPTIBLE 状态。这种设计使得进程在长时间等待时可以被信号中断,从而能够响应用户的终止请求(如 Ctrl+C)。
TASK_UNINTERRUPTIBLE (0x00000002) —— 不可中断睡眠
与 TASK_INTERRUPTIBLE 不同,处于 TASK_UNINTERRUPTIBLE 状态的进程会忽略所有信号——即使你发送 SIGKILL(kill -9)也无法杀死它。这就是系统管理员痛恨的”D 状态”(Disk sleep)。
内核在以下场景使用此状态:
- 等待磁盘 I/O 完成(此时信号可能导致数据损坏)
- 等待某些不可回滚的内核操作完成
- 内存分配时等待页面回收
虽然这种状态有其必要性,但如果设备驱动存在 bug 导致 I/O 永远无法完成,进程就会永远卡在 D 状态。为此,内核引入了 TASK_KILLABLE:
1 |
TASK_KILLABLE 本质上是一个”增强版的不可中断睡眠”——它忽略普通信号,但响应 SIGKILL。wake_up_process() 只唤醒 TASK_NORMAL 状态的进程,但 wake_up_state(p, TASK_KILLABLE) 可以唤醒 TASK_KILLABLE 状态的进程。TASK_WAKEKILL (0x100) 位的作用就是作为掩码,让唤醒逻辑可以识别”仅响应致命信号”的睡眠。
__TASK_STOPPED (0x00000004) —— 停止状态
当进程收到以下信号之一时会进入停止状态:
- SIGSTOP:无条件停止
- SIGTSTP:终端停止(通常由 Ctrl+Z 触发)
- SIGTTIN:后台进程尝试读取终端
- SIGTTOU:后台进程尝试写入终端
注意宏名带有双下划线前缀 __TASK_STOPPED,而便利宏 TASK_STOPPED 包含了 TASK_WAKEKILL 位:
1 |
这意味着停止状态可以被 SIGKILL 唤醒。进程只能通过 SIGCONT 信号恢复正常执行。
值得注意的是,内核中真正判断进程是否处于停止状态的依据不是 __state 字段,而是 jobctl 标志:
1 | // include/linux/sched.h, line 155 |
jobctl 字段(line 967)由信号系统维护,包含 JOBCTL_STOPPED、JOBCTL_TRACED 等标志,提供了比 __state 更精确的进程控制状态信息。
__TASK_TRACED (0x00000008) —— 追踪状态
当进程被 ptrace 系统调用追踪(通常由调试器如 GDB 使用)时进入此状态。调试器可以通过 ptrace 控制被追踪进程的执行,包括设置断点、单步执行、检查和修改内存等。
与停止状态类似,真正判断追踪状态的依据也是 jobctl:
1 | // include/linux/sched.h, line 154 |
辅助宏 task_is_stopped_or_traced 同时检查两个标志:
1 | // include/linux/sched.h, line 156 |
EXIT_ZOMBIE (0x00000020) —— 僵尸状态
僵尸态是进程生命周期中一个独特的阶段。当进程调用 do_exit() 退出时,它并不会立即从系统中消失。在 kernel/exit.c 的 exit_notify() 函数中(第 749 行):
1 | // kernel/exit.c, line 749 |
此时进程已经停止执行,但它的 task_struct 结构体仍然保留在系统中,原因如下:
- 保存退出状态:父进程需要通过
wait()/waitpid()系统调用获取子进程的退出码 - 保存资源统计:父进程可能需要子进程的运行时间、内存使用等统计信息
- 维持家族关系:进程的父子、兄弟关系链表仍然保持完整
在 ps 命令中,僵尸进程显示为 “Z” 状态。僵尸进程不占用 CPU 资源,也不占用内存地址空间,但 task_struct 结构体本身(约 9.5KB)仍占用内核内存。如果父进程从不调用 wait(),僵尸进程就会一直存在,造成内存泄漏——这就是所谓的”僵尸进程问题”。
当父进程调用 wait() 后,内核在 wait_task_zombie() 函数中将 EXIT_ZOMBIE 转换为 EXIT_DEAD(第 1199-1201 行):
1 | // kernel/exit.c, lines 1199-1201 |
cmpxchg 操作确保只有一个线程能执行这个状态转换,避免了竞态条件。
EXIT_DEAD (0x00000010) —— 最终死亡态
EXIT_DEAD 是进程的最终状态。当 exit_state 被设置为 EXIT_DEAD 后,进程的 task_struct 将被释放:
1 | // kernel/exit.c, lines 766-769 |
在某些情况下(如被追踪的进程组 leader),退出状态会是 EXIT_TRACE:
1 |
EXIT_TRACE 表示进程既是僵尸又是死亡的组合——这是 ptrace 追踪场景下的特殊状态。调试器需要先处理被追踪进程的退出信息,之后进程才会被最终回收。
8.2.5 特殊状态
TASK_PARKED (0x00000040) —— 停泊状态
TASK_PARKED 专门用于内核线程(kthread)。内核线程可以”停泊”自身,进入一种临时空闲状态。停泊后的内核线程不在运行队列上,但可以通过 kthread_unpark() 唤醒。这种机制常用于 per-CPU 内核线程——当 CPU 离线(hotplug)时,对应的内核线程停泊;CPU 重新上线时,线程被解除停泊。
TASK_DEAD (0x00000080) —— 调度器死亡态
TASK_DEAD 存储在 __state 字段中(不是 exit_state),是进程在调度器中的最终状态。它在 do_task_dead() 函数中设置(kernel/sched/core.c 第 6904 行):
1 | // kernel/sched/core.c, lines 6901-6915 |
TASK_DEAD 由 set_special_state() 设置(需要持有 pi_lock),确保在状态转换过程中不会被并发唤醒干扰。当调度器执行上下文切换后,finish_task_switch() 检测到前一个任务的状态为 TASK_DEAD,会调用 put_task_struct() 释放其 task_struct。
TASK_NEW (0x00000800) —— 新建态
进程刚被 fork() 创建但尚未被 wake_up_new_task() 唤醒时的状态。在 copy_process() 中,新进程的 __state 初始化为 TASK_NEW。当 wake_up_new_task() 被调用时,状态被设置为 TASK_RUNNING:
1 | // kernel/sched/core.c, lines 4765-4772 |
TASK_NEW 状态的进程对调度器完全不可见——它不在任何运行队列上,也不会被任何唤醒操作影响。
TASK_WAKING (0x00000200) —— 唤醒中
TASK_WAKING 是一个瞬时过渡状态。当 try_to_wake_up() 正在唤醒一个进程时,会暂时将状态设为 TASK_WAKING,表示进程正在被迁移到目标 CPU 的运行队列上。这个状态存在的时间极短,仅用于防止多个 CPU 同时唤醒同一个进程导致的竞态。
TASK_IDLE —— 空闲线程睡眠
1 |
TASK_IDLE 是空闲线程(idle thread)的专属状态。每个 CPU 都有一个空闲线程,当没有其他可运行进程时执行。空闲线程睡眠时使用 TASK_IDLE 而非 TASK_UNINTERRUPTIBLE,关键区别在于 TASK_NOLOAD 标志:
- TASK_NOLOAD (0x00000400):进程虽然睡眠,但不计入系统负载平均值
如果空闲线程的睡眠被计入负载,系统就会永远显示 100% 负载,这显然不合理。
TASK_FROZEN (0x00008000) —— 冻结状态
TASK_FROZEN 由 cgroup freezer 或系统挂起(suspend)机制使用。被冻结的进程不会被执行,直到被解冻。在 __task_state_index() 函数中,TASK_FROZEN 被映射为 TASK_UNINTERRUPTIBLE 对外显示:
1 | // include/linux/sched.h, lines 1688-1689 |
这是为了不向用户空间暴露全新的进程状态,同时保持 /proc 接口兼容性。
TASK_RTLOCK_WAIT (0x00001000) —— RT 锁等待
这是 PREEMPT_RT(实时抢占)补丁集引入的状态。在 RT 内核中,自旋锁被替换为可睡眠的互斥锁变体,当进程在 RT 锁上等待时会进入此状态。对用户空间而言,此状态同样被报告为 TASK_UNINTERRUPTIBLE。
8.2.6 saved_state 字段 —— “自旋锁睡眠者”的状态保存
task_struct 的第 831 行有一个与 __state 紧邻的字段:
1 | // include/linux/sched.h, lines 828-831 |
saved_state 是 PREEMPT_RT 内核中一个精巧的机制。考虑如下场景:
- 进程 A 正在 TASK_UNINTERRUPTIBLE 状态下睡眠,等待某个 I/O 完成
- 进程 A 同时持有一把 RT 自旋锁
- 此时 RT 自旋锁的等待机制需要将进程 A 的状态改为 TASK_RTLOCK_WAIT
- 但进程 A 的原始睡眠状态(TASK_UNINTERRUPTIBLE)不能丢失
解决方案是使用 saved_state 保存原始状态:
1 | // include/linux/sched.h, lines 296-305 |
当 RT 锁获取成功后,恢复原始状态:
1 | // include/linux/sched.h, lines 307-314 |
在恢复时,saved_state 被设为 TASK_RUNNING,这样任何在锁等待期间被重定向到 saved_state 的唤醒操作都会自然失败,避免重复唤醒。
8.2.7 完整状态转换图
下面是 Linux 进程从创建到消亡的完整状态转换图:
1 | fork() / clone() |
8.2.8 状态设置 API
内核提供了三组 API 用于设置进程状态,每组都有其特定的使用场景和并发保护机制。
set_current_state() —— 带内存屏障的状态设置
1 | // include/linux/sched.h, lines 247-252 |
set_current_state() 使用 smp_store_mb(),这是一个写入操作 + 全内存屏障的组合。内存屏障的作用至关重要——它确保在设置状态之前的所有内存操作(如设置条件变量)都完成后,才将新状态写入 __state。
这解决了一个经典的竞态问题:如果条件设置和状态设置被 CPU 乱序执行,唤醒方可能先看到睡眠状态而尝试唤醒,但此时条件尚未设置,导致唤醒丢失。内存屏障确保了正确的顺序:
1 | 等待方(set_current_state): 唤醒方(try_to_wake_up): |
__set_current_state() —— 不带内存屏障
1 | // include/linux/sched.h, lines 240-245 |
__set_current_state() 只使用 WRITE_ONCE() 进行原子写入,不包含内存屏障。它适用于以下场景:
- 已经持有相关锁,锁操作本身提供了足够的内存屏障
- 在
schedule()返回后设置回 TASK_RUNNING(此时不需要与唤醒方同步)
set_special_state() —— 特殊状态设置
1 | // include/linux/sched.h, lines 260-269 |
set_special_state() 专门用于设置”特殊状态”——即那些不遵循常规 wait-loop 模式的状态:
1 | // include/linux/sched.h, lines 162-164 |
这些状态的共同特点是:状态转换不能被常规的唤醒操作干扰。set_special_state() 通过获取 pi_lock 自旋锁来序列化与 try_to_wake_up() 的并发访问——因为 try_to_wake_up() 也需要获取 pi_lock,两者自然互斥。
唤醒 API
1 | // kernel/sched/core.c, lines 4372-4375 |
wake_up_process() 调用 try_to_wake_up(),默认只唤醒处于 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 状态的进程(因为 TASK_NORMAL = TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE)。
1 | // kernel/sched/core.c, lines 4378-4381 |
wake_up_state() 允许指定唤醒哪些状态的进程。例如,要唤醒 TASK_KILLABLE 状态的进程,可以调用 wake_up_state(p, TASK_KILLABLE)。
try_to_wake_up() 是整个唤醒机制的核心(kernel/sched/core.c 第 4092 行),其概念性操作为:
1 | // kernel/sched/core.c, lines 4061-4063 |
即:如果进程的当前状态与指定的唤醒掩码匹配,就将进程设为 TASK_RUNNING 并放入运行队列。该函数在访问 p->__state 之前执行全内存屏障(smp_mb__after_spinlock()),与 set_current_state() 中的 smp_store_mb() 配对,构成完整的同步方案。
8.2.9 状态报告与 task_state_index
内核需要将两个独立的状态字段(__state 和 exit_state)合并为一个用户空间可见的状态值。这通过 __task_state_index() 函数完成:
1 | // include/linux/sched.h, lines 1672-1689 |
其中 TASK_REPORT 定义了用户空间可见的状态位掩码:
1 | // include/linux/sched.h, lines 147-150 |
fls(state) 返回 state 中最高有效位的位置(从 1 开始计数),减 1 后得到 0 开始的索引值,用于查找 state_char[] 数组中的对应字符。
8.2.10 小结
Linux 进程状态机的设计体现了几个核心工程原则:
- 分离关注点:
__state和exit_state分开存储,防止误操作 - 位掩码组合:状态定义为 2 的幂,允许灵活组合(如 TASK_KILLABLE)
- 内存屏障:
set_current_state()通过smp_store_mb()确保与唤醒方的正确同步 - 锁保护:特殊状态通过
pi_lock序列化,防止与唤醒操作的竞态 - 渐进退出:从 EXIT_ZOMBIE 到 EXIT_DEAD 的两阶段退出机制,确保父进程能获取退出信息
- saved_state 机制:在 PREEMPT_RT 场景下保存原始睡眠状态,实现状态恢复
理解进程状态机是理解 Linux 调度器、信号处理和进程生命周期管理的基础。每一个状态的引入都解决了特定的实际问题——从经典的”不可中断睡眠”到现代的”可杀死睡眠”,从简单的”运行/停止”到复杂的”追踪/停泊/冻结”,这些状态的演化反映了 Linux 内核在实际工作负载中积累的工程智慧。
8.3 进程标识 —— PID、TGID、PGID 与 SID
8.3.1 概述:从简单整数到多维度标识体系
进程标识符(PID)是操作系统中最基本的概念之一。在用户看来,PID 只是一个简单的整数——getpid() 返回当前进程号,kill 命令后跟一个数字发送信号。但在 Linux 内核内部,进程标识是一个精密的多维度体系,涉及线程级 PID、线程组 TGID、进程组 PGID、会话 SID,以及贯穿这一切的 PID 命名空间和 pidfd 机制。
Linux 7.0.10 的 PID 子系统在 include/linux/pid.h 的开篇注释中精辟地阐述了其设计动机:
1 | /* |
这段注释揭示了三个关键问题:
- 直接存储 pid_t 有 PID 复用风险:PID 值会被回收再分配,可能导致引用错误
- 直接持有 task_struct 引用代价太大:约 10KB 的 task_struct 加上内核栈不应被长期持有
- struct pid 是最佳折中:约 64 字节,轻量且安全
8.3.2 四种 PID 类型:enum pid_type
Linux 定义了四种不同的 PID 类型,对应进程标识的四个维度:
1 | // include/linux/pid_types.h, lines 5-11 |
这四种类型的含义和用途各不相同:
PIDTYPE_PID —— 线程级标识
这是最细粒度的标识——每个线程(task_struct)都有唯一的 PID。在内核中,task_struct.pid 字段(第 1059 行)存储的就是这个线程级 PID:
1 | // include/linux/sched.h, line 1059 |
对于单线程进程,PID 就是传统意义上的进程号。对于多线程进程,每个线程有不同的 PID,但共享同一个 TGID。
PIDTYPE_TGID —— 线程组标识
线程组标识(Thread Group ID)是多线程编程的核心概念。在 Linux 中,一个多线程进程的所有线程共享相同的 TGID,它等于线程组 leader(即调用 pthread_create() 创建其他线程的那个主线程)的 PID。
1 | // include/linux/sched.h, line 1060 |
用户空间的 getpid() 系统调用返回的是 TGID 而非 PID:
1 | // include/linux/pid.h, lines 250-253 |
而 gettid() 返回的是线程级的 PID:
1 | // include/linux/pid.h, lines 234-237 |
对于单线程进程,PID == TGID,所以 getpid() 和 gettid() 返回相同的值。
线程组的概念引入是为了兼容 POSIX 线程模型。在 POSIX 中,一个多线程进程内部的所有线程共享同一个进程 ID,信号也以进程(而非线程)为单位管理。Linux 通过 TGID 实现了这一语义。
PIDTYPE_PGID —— 进程组标识
进程组(Process Group)是作业控制(Job Control)的基础。Shell 中执行的管道命令共享同一个 PGID:
1 | # 以下三个命令属于同一个进程组 |
进程组的主要用途:
- 信号分发:
kill -SIGTERM -1234(注意负号)向 PGID 为 1234 的进程组中所有进程发送信号 - 作业控制:前台/后台作业管理,
fg、bg、Ctrl+Z等操作以进程组为单位 - 终端 I/O:只有前台进程组可以从控制终端读取输入
设置和获取 PGID 的系统调用:
setpgid(pid, pgid):设置进程的进程组getpgid(pid):获取进程的进程组
在内核中,PGID 存储在 signal_struct.pids[PIDTYPE_PGID] 中(而非 task_struct 中),因为进程组是线程组级别的属性——同一线程组的所有线程共享相同的 PGID。
PIDTYPE_SID —— 会话标识
会话(Session)是比进程组更高层次的抽象。一个会话包含一个或多个进程组,通常对应一次完整的登录会话。
会话的主要用途:
- 登录管理:每次 SSH 登录或终端打开创建一个新会话
- 控制终端:每个会话至多有一个控制终端,会话 leader 控制终端的分配
- 守护进程:
daemon()函数调用setsid()创建新会话,脱离控制终端
系统调用:
setsid():创建新会话,调用者成为会话 leader 和新进程组 leadergetsid(pid):获取进程的会话 ID
与 PGID 类似,SID 也存储在 signal_struct.pids[PIDTYPE_SID] 中。
8.3.3 struct pid —— 内核的 PID 内部表示
struct pid 是内核跟踪进程标识的核心数据结构:
1 | // include/linux/pid.h, lines 58-75 |
struct pid 的设计包含几个精妙之处:
- 引用计数(
count):允许多个 task_struct 安全地引用同一个 struct pid,引用计数降至零时才释放 - tasks 数组:
tasks[PIDTYPE_MAX]包含四个哈希链表头,每种 PID 类型各一个。一个 struct pid 可以被多个 task_struct 引用(例如,同一线程组的所有线程通过tasks[PIDTYPE_TGID]链接到同一个 struct pid) - 灵活数组(
numbers[]):这是 PID 命名空间支持的关键——数组中每个元素对应一层命名空间,存储该层命名空间中可见的 PID 数值
struct upid —— 命名空间中的 PID 表示
1 | // include/linux/pid.h, lines 53-56 |
struct upid 是 PID 命名空间的基石。一个 struct pid 的 numbers[] 数组中存储了 level + 1 个 upid 结构体,每个对应一层命名空间。例如,在一个两层嵌套的 PID 命名空间中:
1 | struct pid (level=1) |
同一个进程在不同命名空间中有不同的 PID 数值。level 字段记录了最深层命名空间的索引。
8.3.4 pid_t 与 struct pid 的关系
内核中同时存在两种 PID 表示形式:
| 形式 | 类型 | 大小 | 用途 |
|---|---|---|---|
| 数值 PID | pid_t(int) |
4 字节 | 系统调用参数、用户空间接口 |
| 结构体 PID | struct pid * |
~64 字节 | 内核内部引用、防止 PID 复用 |
在 task_struct 中,两种表示同时存在:
1 | // include/linux/sched.h, lines 1059-1060 |
thread_pid 指向该 task_struct 的 PIDTYPE_PID 对应的 struct pid 实例。pid_links[] 数组有四个元素,每个元素作为哈希链节点将 task_struct 挂入对应 struct pid 的 tasks[type] 链表。
对于 TGID、PGID、SID 三种类型,对应的 struct pid 指针存储在 signal_struct 中:
1 | // include/linux/sched/signal.h, line 166 |
这是因为这三种类型都是线程组级别的属性。task_pid_ptr() 辅助函数封装了这个区分:
1 | // kernel/pid.c, lines 373-378 |
当 type 为 PIDTYPE_PID 时返回 task->thread_pid 的地址;否则返回 task->signal->pids[type] 的地址。
8.3.5 PID 哈希查找机制
内核需要高效地根据数值 PID 找到对应的 struct pid 和 task_struct。查找链路如下:
1 | 数值 PID + 命名空间 → struct pid → task_struct |
第一步:数值 PID → struct pid
内核使用 IDR(Integer ID Range)机制管理 PID 到 struct pid 的映射。每个 PID 命名空间都有自己的 IDR 树:
1 | // kernel/pid.c, lines 361-364 |
find_vpid() 是 find_pid_ns() 的包装,自动使用当前进程的 PID 命名空间:
1 | // kernel/pid.c, lines 367-370 |
第二步:struct pid → task_struct
1 | // kernel/pid.c, lines 457-469 |
pid_task() 通过 pid->tasks[type] 哈希链表找到第一个关联的 task_struct。由于 task_struct 通过 pid_links[type] 节点挂入该链表,hlist_entry() 可以从链表节点反推出 task_struct 的地址。
组合查找
将两步组合,就得到了完整的查找函数:
1 | // kernel/pid.c, lines 474-479 |
这些函数必须在 RCU 读锁保护下调用,因为 PID 哈希表和 task_struct 可能被其他 CPU 并发修改。
查找链路图
1 | pid_t (数值) + pid_namespace |
8.3.6 PID 分配机制
RESERVED_PIDS —— 内核保留 PID
1 | // include/linux/pid.h, line 49 |
PID 0 到 299 为内核保留,用户空间进程从 300 开始分配。PID 0 是 idle 进程(每个 CPU 一个),PID 1 是 init 进程(用户空间第一个进程),PID 2 通常是 kthreadd(内核线程守护进程)。
alloc_pid() —— PID 分配流程
1 | // kernel/pid.c, lines 160-161 |
PID 分配的关键步骤:
第一步:分配 struct pid 结构体
1 | // kernel/pid.c, lines 189-201 |
注意 struct pid 是从命名空间对应的 slab 缓存(ns->pid_cachep)分配的。由于不同命名空间层级需要不同大小的 numbers[] 数组,每个层级有不同的 slab 缓存。level = ns->level 记录了当前 PID 所在的最深命名空间层级。
第二步:在每一层命名空间中分配数值 PID
1 | // kernel/pid.c, lines 239-302 |
分配从最深层命名空间开始,向上遍历到根命名空间。使用 idr_alloc_cyclic() 进行循环分配——当 PID 达到上限时回绕到最小值继续分配。这种循环策略将 PID 复用的时间间隔最大化。
第三步:使 PID 可被查找
1 | // kernel/pid.c, lines 318-322 |
初始时,IDR 中存储的是 NULL 指针。当所有层级的 PID 都成功分配后,才将 NULL 替换为 struct pid 指针。这个”先分配后发布”的策略确保 find_pid_ns() 永远不会找到一个半初始化的 struct pid。
PID 释放
1 | // kernel/pid.c, lines 110-146 |
释放过程需要从所有命名空间的 IDR 树中移除对应的条目,并通过 RCU 延迟释放 struct pid 结构体。当命名空间中只剩 1 或 2 个进程时,唤醒 child_reaper(通常是该命名空间的 init 进程),因为 zap_pid_ns_processes() 可能在等待所有进程退出。
8.3.7 PID 命名空间交互
PID 命名空间允许同一进程在不同命名空间中拥有不同的 PID 数值。这是容器技术的基础之一。
命名空间层级
1 | init_pid_ns (level=0) |
在上面的例子中,nginx 进程在全局命名空间中 PID 为 1501,但在 child_ns 中 PID 为 100。其 struct pid 的 numbers[] 数组为:
1 | numbers[0] = { nr=1501, ns=&init_pid_ns } |
PID 数值转换
内核提供了在不同命名空间视角下查看 PID 的辅助函数:
1 | // 全局 ID(init 命名空间视角) |
pid_vnr() 返回当前进程所在命名空间视角下的 PID,pid_nr_ns() 返回指定命名空间视角下的 PID:
1 | // include/linux/pid.h, lines 187-188 |
对应的 task 级别辅助函数族:
1 | // include/linux/pid.h |
获取父进程 PID 的辅助函数(通过 RCU 保护访问 real_parent):
1 | // include/linux/pid.h, lines 301-311 |
这里使用 pid_alive() 检查进程是否仍然存活——它通过检查 thread_pid != NULL 来判断:
1 | // include/linux/pid.h, lines 265-268 |
如果进程已经退出,其 thread_pid 会被清空,此时访问 real_parent 可能导致悬挂指针。
8.3.8 attach/detach 机制:task_struct 与 struct pid 的关联
attach_pid() —— 将 task 挂入 PID 链表
1 | // kernel/pid.c, lines 383-391 |
attach_pid() 将 task_struct 添加到 struct pid 对应类型的哈希链表头部。使用 RCU 版本的链表操作(hlist_add_head_rcu)确保并发读取的安全。此函数必须在持有 tasklist_lock 写锁的情况下调用。
detach_pid() —— 从 PID 链表摘除
1 | // kernel/pid.c, lines 393-413 |
__change_pid() 不仅摘除 task_struct,还检查旧的 struct pid 是否还有其他 task 引用。如果所有四种类型的链表都为空(pid_has_task() 返回 false),说明该 struct pid 已经没有任何进程使用,需要被释放。
change_pid() 与 transfer_pid()
1 | // kernel/pid.c, lines 447-455 |
exchange_tids() 用于在 de_thread()(exec 执行时线程组合并)中交换两个线程的 PID。transfer_pid() 用于将 PID 从旧 task 转移到新 task,通常在 fork 或 reparent 操作中使用。
8.3.9 init_struct_pid —— 系统的第一个 PID
系统启动时,内核静态定义了一个初始 struct pid:
1 | // kernel/pid.c, lines 49-61 |
这是 PID 0 的 struct pid,被 idle 进程(swapper)使用。它不需要动态分配,在编译时就已经初始化完毕。注意 level 为 0,numbers[] 只有一个元素——因为 idle 进程只存在于根命名空间中。
同样,init_pid_ns(根 PID 命名空间)也是静态定义的:
1 | // kernel/pid.c, lines 72-83 |
child_reaper 指向 init_task(PID 1 的 task_struct),它是根命名空间中的”孤儿收割者”——当任何进程的父进程死亡时,该进程会被 reparent 到 child_reaper。
8.3.10 pidfd —— 基于文件描述符的进程引用
设计动机
传统的 PID 数值引用有一个根本性缺陷:PID 复用(PID recycling)。考虑以下竞态场景:
- 进程 A(PID 1234)正在运行
- 进程 B 通过
kill(1234, SIGTERM)发送信号 - 在 kill 调用到达内核之前,进程 A 退出
- 新进程 C 被分配了 PID 1234
- 信号被发送给了无辜的进程 C
pidfd 通过提供基于文件描述符的进程引用来解决此问题。文件描述符天然具有引用语义——只要 fd 打开着,它引用的 struct pid 就不会被释放。
pidfd_open() 系统调用
1 | // kernel/pid.c, lines 688-707 |
pidfd_open() 接受一个数值 PID,返回对应的文件描述符。支持的标志:
PIDFD_NONBLOCK:非阻塞模式PIDFD_THREAD:线程级 pidfd(默认只接受线程组 leader)
pidfd_create() 内部调用 pidfd_prepare() 创建一个匿名 inode 和关联的 file 结构体:
1 | // kernel/pid.c, lines 662-673 |
pidfd 通知机制
struct pid 中的 wait_pidfd 等待队列用于通知 pidfd 的持有者进程状态变化:
1 | // include/linux/pid.h, line 72 |
当进程退出时,do_notify_pidfd() 被调用:
1 | // kernel/exit.c, line 763 |
这会唤醒所有在 pid->wait_pidfd 上等待的进程——通常是通过 poll() 或 epoll 监听 pidfd 的进程。当 pidfd 变为可读状态时,表示目标进程已退出。
pidfd 的优势
| 特性 | 数值 PID | pidfd |
|---|---|---|
| PID 复用安全 | 不安全 | 安全 |
| 进程退出通知 | 需要 wait() 轮询 | poll/epoll 通知 |
| 引用语义 | 无 | fd 引用计数 |
| 命名空间可见 | 受限 | 自动处理 |
| 跨进程传递 | 需约定 | 通过 SCM_RIGHTS |
pidfd 还可以与 clone3() 系统调用配合使用,在创建子进程的同时获取其 pidfd(CLONE_PIDFD 标志),从创建时刻起就建立安全的进程引用。
8.3.11 PID 相关宏和遍历辅助
do_each_pid_task / while_each_pid_task
这两个宏用于遍历关联到同一个 struct pid 的所有 task_struct:
1 | // include/linux/pid.h, lines 190-204 |
对于 PIDTYPE_PID 类型,由于每个 struct pid 最多关联一个 task_struct,循环在第一次迭代后立即 break。对于 TGID、PGID、SID 类型,可能有多个 task_struct 关联到同一个 struct pid,需要遍历整个链表。
do_each_pid_thread / while_each_pid_thread
1 | // include/linux/pid.h, lines 206-214 |
这个宏组合了两层遍历:外层遍历关联到 struct pid 的所有线程组 leader,内层遍历每个线程组内的所有线程。
is_global_init —— 判断 init 进程
1 | // include/linux/pid.h, lines 338-341 |
通过检查 TGID 是否为 1 来判断进程是否为全局 init。由于 init 可能有多个线程,所以检查 TGID 而非 PID。
is_child_reaper —— 判断命名空间 init
1 | // include/linux/pid.h, lines 163-166 |
在命名空间的最深层级检查 PID 是否为 1。每个 PID 命名空间的 init 进程(PID 1)就是该命名空间的 child_reaper。
8.3.12 数据结构关系总图
1 | PID 命名空间层级 |
8.3.13 小结
Linux PID 子系统的设计体现了多层次的工程智慧:
四种 PID 类型(PID/TGID/PGID/SID)分别服务于线程管理、进程管理、作业控制和会话管理,各自独立存储但共享同一套查找和分配基础设施。
struct pid 与 pid_t 的双轨制解决了 PID 复用的经典问题——struct pid 作为稳定引用,在 PID 数值被重新分配时自动失效(新分配的是不同的 struct pid 实例),避免了引用错误。
PID 命名空间通过
numbers[]灵活数组优雅地实现了多层嵌套——同一个 struct pid 在不同命名空间中呈现不同的数值,容器只需看到自己命名空间内的 PID 空间。pidfd 机制将传统的”PID 数值 + 信号”模式升级为”文件描述符 + 事件通知”模式,从根本上解决了 PID 复用竞态问题,并为异步进程监控提供了标准接口。
IDR + RCU 的查找架构在保证并发安全的同时提供了高效的查找性能——
find_pid_ns()通过 IDR 树 O(1) 定位 struct pid,pid_task()通过 RCU 保护的哈希链表 O(1) 定位 task_struct。
从全局 init 进程(PID 1)到容器内的命名空间 init,从传统的 kill 命令到现代的 pidfd,Linux PID 子系统在保持向后兼容的同时不断演进,是内核设计中”抽象层次恰当”的典范。
8.4 进程亲属关系与进程组
在 Linux 内核中,每个进程都不是孤立存在的。进程之间通过父子关系、兄弟关系、线程组关系以及进程组/会话关系紧密联系在一起,构成一棵完整的进程树。这些关系通过 task_struct 中的多个字段来维护。本节将深入分析这些亲属关系的数据结构、实现机制以及在内核中的实际应用。
8.4.1 核心字段概览
在 include/linux/sched.h 的第 1067-1108 行,task_struct 定义了维护进程亲属关系的核心字段:
1 | // include/linux/sched.h (lines 1072-1105) |
此外,用于线程组跟踪的 exec_id 字段位于第 1222-1224 行:
1 | // include/linux/sched.h (lines 1222-1224) |
下表总结了这些字段的分类:
| 分类 | 字段 | 类型 | 说明 |
|---|---|---|---|
| 父进程 | real_parent |
struct task_struct __rcu * |
真实父进程 |
| 父进程 | parent |
struct task_struct __rcu * |
当前”父进程”(可能被 ptrace 修改) |
| 子进程 | children |
struct list_head |
子进程链表头 |
| 兄弟 | sibling |
struct list_head |
在父进程 children 链表中的节点 |
| 线程组 | group_leader |
struct task_struct * |
线程组组长 |
| 线程组 | thread_node |
struct list_head |
线程组链表节点 |
| PID | thread_pid |
struct pid * |
内核 PID 结构体 |
| PID | pid_links[PIDTYPE_MAX] |
struct hlist_node[] |
PID 哈希表链接 |
| ptrace | ptraced |
struct list_head |
被 ptrace 跟踪的子任务链表 |
| ptrace | ptrace_entry |
struct list_head |
在跟踪者 ptraced 链表中的节点 |
| vfork | vfork_done |
struct completion * |
vfork 完成通知 |
| TID | set_child_tid |
int __user * |
子进程设置 TID 的用户空间地址 |
| TID | clear_child_tid |
int __user * |
子进程退出时清除 TID 的用户空间地址 |
8.4.2 父子关系 (Parent-Child Relationship)
real_parent 与 parent 的区别
real_parent 和 parent 在绝大多数情况下指向同一个进程。real_parent 始终记录通过 fork()/clone() 创建此进程的那个父进程,永不改变(除非父进程死亡导致重新托付)。而 parent 则是接收 SIGCHLD 信号和 wait4() 报告的目标进程。当进程被 ptrace 跟踪时,parent 会被修改为跟踪者(tracer),但 real_parent 保持不变。
1 | // 正常情况下二者相同 |
这种设计使得在 ptrace 跟踪结束后,内核能够恢复正确的父子关系。
父进程死亡时的子进程托付 (Reparenting)
当一个进程退出时,其子进程必须被”托付”给另一个进程,否则子进程将成为孤儿,永远无法被 wait() 回收。这个托付过程由 kernel/exit.c 中的 forget_original_parent() 函数完成。
1 | // kernel/exit.c (lines 698-731) |
find_new_reaper() 函数按照以下优先级选择新的父进程:
- 同线程组的存活线程:如果父进程所在线程组中还有其他存活的线程,优先选择它
- 子进程收割者 (child subreaper):如果设置了
PR_SET_CHILD_SUBREAPER的祖先存在,选择最近的 subreaper - PID 命名空间的 child_reaper(通常是 PID 1 的 init 进程)
1 | // kernel/exit.c (lines 636-669) |
child subreaper 机制
Linux 3.4 引入了 PR_SET_CHILD_SUBREAPER 这个 prctl 选项。当一个进程设置了这个标志后,它的后代在发生托付时,会沿进程树向上搜索,找到最近的 subreaper 来接管子进程,而不是直接交给 init。这对系统服务管理器(如 systemd)非常重要,因为服务管理器需要确保所有服务子进程都能被正确追踪和清理。
signal_struct 中的相关字段定义在 include/linux/sched/signal.h 中:
1 | // include/linux/sched/signal.h (lines 133-134) |
is_child_subreaper:本进程是否是一个 subreaperhas_child_subreaper:本进程的祖先链中是否存在 subreaper(优化搜索性能)
8.4.3 子进程与兄弟链表 (Children & Sibling Lists)
children 和 sibling 是标准的内核双向链表节点,它们共同构建了进程树的核心数据结构。
链表结构
children:作为父进程的”链表头”,指向第一个子进程的sibling节点sibling:作为子进程在父进程children链表中的链接节点
这是一个典型的内核 list_head 使用模式。children 是一个空的链表头(不嵌入在任何数据中),而 sibling 嵌入在每个子进程的 task_struct 中。
1 | 父进程 task_struct |
用 ASCII 图表示一个完整的进程树:
1 | init_task (PID 0, swapper) |
遍历子进程
内核中遍历某进程的所有子进程使用标准的 list_for_each_entry 宏:
1 | struct task_struct *child; |
而 for_each_thread 宏用于遍历线程组中的所有线程:
1 | struct task_struct *t; |
8.4.4 线程组 (Thread Groups)
线程组概念
在 Linux 中,线程是通过共享大量资源的轻量级进程来实现的。一个多线程进程中的所有线程构成一个”线程组”。线程组组长 (thread group leader) 是调用 pthread_create() 的祖先线程(实际上是最初执行 clone() 并创建线程组的那个进程)。
相关字段:
group_leader:指向线程组组长的task_struct。对于单线程进程,group_leader指向自身thread_node:将线程链接到signal_struct.thread_head链表中tgid(线程组 ID):等于group_leader->pid
线程间资源共享与隔离
1 | 线程组组长 (tgid=1000, pid=1000) |
在 init/init_task.c 中,init_signals 初始化了线程组链表:
1 | // init/init_task.c (lines 20-50) |
而 init_task 的 thread_node 被链接到 init_signals.thread_head:
1 | // init/init_task.c (line 174) |
线程组的判断
内核提供了多个辅助函数来判断线程组关系:
1 | // 判断 task 是否是线程组组长 |
8.4.5 进程组与会话 (Process Groups & Sessions)
进程组 (Process Group)
进程组是一个或多个进程的集合,用于 shell 的作业控制 (job control)。同一进程组中的进程可以通过 kill(-pgid, sig) 同时接收信号。
进程组 ID (PGID) 等于进程组组长的 PID。在 task_struct 中,PGID 通过 pid_links[PIDTYPE_PGID] 哈希链表维护:
1 | // include/linux/pid_types.h |
每个 task_struct 的 pid_links 数组有 4 个元素,分别将进程链接到 4 种 PID 哈希表中。
会话 (Session)
会话是进程组的集合。会话 leader 是创建该会话的进程(通常通过 setsid() 系统调用)。会话主要用于将一组作业与一个控制终端关联起来。
Shell 作业控制示意
1 | Session (SID=1000, 控制终端=/dev/tty1) |
当用户按下 Ctrl+C 时,内核将 SIGINT 发送给前台进程组的所有进程:
1 | // kill(-pgid, sig) 的实现路径 |
终端驱动程序通过 tiocspgrp ioctl 设置前台进程组,仅前台进程组会接收终端产生的信号(SIGINT、SIGQUIT、SIGTSTP)。
8.4.6 ptrace 关系 (ptrace Relationships)
ptrace 概述
ptrace 是 Linux 提供的进程跟踪机制,是 gdb、strace、ltrace 等调试工具的基础。通过 ptrace,跟踪者(tracer)可以观察和控制被跟踪者(tracee)的执行,包括拦截系统调用、读写内存、单步执行等。
ptrace 相关字段
1 | // task_struct 中的 ptrace 相关字段 |
parent 与 real_parent 在 ptrace 下的变化
当进程 A 通过 PTRACE_ATTACH 跟踪进程 B 时,内核修改 B->parent 指向 A,但 B->real_parent 保持不变。这使得跟踪结束后能恢复原始的父子关系。
1 | 正常状态: |
在 forget_original_parent() 中,内核在托付子进程时会正确处理 ptrace 状态:
1 | // kernel/exit.c (lines 714-717) |
这段 BUG_ON 验证一个不变量:没有被 ptrace 的进程,其 parent 必须等于退出的父进程 father。而被 ptrace 的进程,其 parent 已经指向 tracer,不需要修改。
ptraced 链表结构
1 | Tracer (gdb, PID=500) |
8.4.7 vfork_done 与子进程 TID
vfork_done
vfork() 是 fork() 的一个高效变体。调用 vfork() 后,子进程与父进程共享地址空间,父进程会被阻塞直到子进程调用 exec() 或 _exit()。
1 | // task_struct 中的 vfork_done 字段 |
在 kernel/fork.c 中,当检测到 vfork 时:
1 | // kernel/fork.c (简化) |
子进程调用 exec() 或退出时,会通过 complete_vfork_done() 唤醒父进程:
1 | // kernel/fork.c (lines 1415-1426) |
set_child_tid 与 clear_child_tid
这两个用户空间指针用于 NPTL (Native POSIX Thread Library) 的高效线程管理:
1 | // task_struct 中 |
set_child_tid:当使用clone()并指定CLONE_CHILD_SETTID标志时,子进程在创建后会将自身的 TID 写入该地址。这对应pthread_create()中clone()的ctid参数clear_child_tid:当指定CLONE_CHILD_CLEARTID标志时,子进程在退出时内核会将该地址处的值清零并唤醒在此 futex 上等待的线程。这实现了pthread_join()的底层机制
1 | // 线程退出时的处理 (kernel/fork.c 中的 mm_release) |
parent_exec_id 与 self_exec_id
1 | u64 parent_exec_id; |
这两个字段用于跟踪 exec 操作的唯一性。每次调用 execve() 时,self_exec_id 会递增。当进程检查自身是否仍然是创建者时,会比较当前的 self_exec_id 与创建时保存的值。这在 POSIX 异步 I/O (aio) 和定时器等场景中用于检测进程是否执行了 exec,从而需要清理某些资源。
8.4.8 init_task:进程树的根
init_task 是所有进程的最终祖先。它代表 PID 0,即 idle/swapper 进程,在系统启动时静态初始化。其定义位于 init/init_task.c:
1 | // init/init_task.c (lines 96-253) |
需要注意的关键点:
init_task.real_parent和init_task.parent都指向自身 —— 它是进程树的根,没有父进程init_task.group_leader也指向自身 —— 它是唯一的单例线程组init_task.flags包含PF_KTHREAD,标识为内核线程init_task.usage初始化为 2(一个用于自身,一个确保永不释放)init_task.thread_pid指向init_struct_pid,这个 PID 结构体关联所有四种 PID 类型
系统启动后的初始进程树
1 | init_task (PID 0, swapper/0) |
8.4.9 进程树遍历
/proc 文件系统的进程枚举
/proc 文件系统通过遍历 PID 命名空间来枚举进程。fs/proc/base.c 中的实现使用 PID 哈希表而非直接遍历进程树:
1 | // fs/proc/base.c (简化) |
PID 哈希表链接
pid_links[PIDTYPE_MAX] 数组将进程挂接到 4 个不同的哈希表上:
1 | // pid_links 数组的含义: |
每个 struct pid 包含 tasks[PIDTYPE_MAX] 哈希链表头,所有使用该 PID 的进程通过各自的 pid_links[type] 链接到对应的哈希桶中。
1 | struct pid (PID=1000) |
这种设计使得通过任何类型的 PID 查找进程都变得非常高效,时间复杂度为 O(1)(哈希查找)加上 O(k)(遍历该 PID 下的所有进程)。
8.4.10 小结
Linux 的进程亲属关系通过精心设计的数据结构维护了一棵完整的进程树。real_parent 和 parent 的分离巧妙地支持了 ptrace 调试场景;children 和 sibling 链表实现了高效的父子关系遍历;thread_node 和 group_leader 构建了线程组结构;pid_links 数组将进程同时挂接到多个 PID 哈希表上,支持按不同维度快速查找。init_task 作为这棵树的根,是系统中唯一一个 real_parent 指向自身的进程。当父进程死亡时,find_new_reaper() 按照”同线程组 -> 子进程收割者 -> init”的优先级托付子进程,确保不会产生无法回收的僵尸进程。而 vfork_done、set_child_tid、clear_child_tid 等字段则为 vfork 和 pthread 提供了高效的同步机制。
8.5 per-CPU 数据与 current 宏
在 Linux 内核中,current 宏是最基本也最频繁使用的操作之一。它返回当前 CPU 上正在运行的进程的 task_struct 指针。由于内核几乎在每个子系统中都需要访问当前进程的信息——处理系统调用、发送信号、调度任务、管理内存——current 的性能至关重要。在 SMP(对称多处理器)系统中,每个 CPU 运行不同的进程,因此 current 必须能够以 O(1) 的复杂度获取当前 CPU 对应的任务指针,且无需加锁。本节将深入分析 Linux 7.0.10 中不同架构如何实现 current,以及背后的 per-CPU 变量机制和 thread_info 设计。
8.5.1 为什么 current 宏的性能如此关键
内核代码中 current 的使用频率极高,几乎渗透到每一个子系统。以下是一些典型的使用场景:
1 | // 系统调用入口:获取调用者信息 |
据统计,内核中 current 的引用点超过数千处。在每次系统调用、每次中断处理、每次调度决策中,内核都需要访问 current。如果每次访问都需要搜索数据结构或获取锁,那将严重拖累整个系统的性能。
因此,current 的实现必须满足以下要求:
- O(1) 复杂度:不能涉及任何搜索或遍历操作
- 无锁访问:在 SMP 系统中,不能使用自旋锁或互斥锁
- 架构优化:充分利用每个 CPU 架构提供的硬件特性
- 上下文切换时更新:在进程切换时高效更新
8.5.2 per-CPU 变量机制
概念
per-CPU 变量是 Linux 内核中一种关键的数据结构优化机制。每个 CPU 拥有同一个变量的独立副本,因此访问 per-CPU 变量时不需要加锁——因为每个 CPU 只访问自己的副本,不存在并发冲突。
定义与声明
per-CPU 变量的基础设施定义在 include/linux/percpu-defs.h 中:
1 | // include/linux/percpu-defs.h (lines 113-114) |
DECLARE_PER_CPU_CACHE_HOT 将变量放入 ..hot.. 节,链接器会将其安排在缓存友好的位置,减少缓存未命中。
内存布局
per-CPU 变量在内存中的布局如下:
1 | per-CPU 内存区域: |
每个 CPU 的 per-CPU 块中,变量的相对偏移量相同。要访问某个 CPU 上的变量,只需知道该 CPU 的 per-CPU 基地址加上变量的偏移量。
访问接口
1 | // 读取当前 CPU 的 per-CPU 变量 |
这些接口在 include/linux/percpu.h 和 asm/percpu.h 中定义。它们会编译为非常高效的指令序列。
8.5.3 x86_64 架构的实现
源码分析
x86_64 的 current 实现位于 arch/x86/include/asm/current.h:
1 | // arch/x86/include/asm/current.h (完整文件) |
两条实现路径
x86_64 提供了两条实现路径,根据 CONFIG_USE_X86_SEG_SUPPORT 配置选择:
路径一:段寄存器支持 (CONFIG_USE_X86_SEG_SUPPORT)
这是较新的优化路径。x86_64 架构支持通过 GS 段寄存器来访问 per-CPU 数据。内核为每个 CPU 设置不同的 GS 段基址,使得 %gs:offset 形式的地址自动映射到当前 CPU 的 per-CPU 数据区域。
1 | CPU 0: GS base → per-CPU block 0 |
const_current_task 是 current_task 的一个 const 限定别名,由链接器创建。__percpu_seg_override 属性告知编译器使用段寄存器覆盖访问该变量。this_cpu_read_const() 最终编译为一条 mov 指令,使用 %gs: 前缀:
1 | ; this_cpu_read_const(const_current_task) 编译结果: |
路径二:传统 per-CPU 读取
当不支持段寄存器优化时,使用 this_cpu_read_stable()。该函数通过 __per_cpu_offset[smp_processor_id()] 计算当前 CPU 的 per-CPU 基地址,加上变量偏移来定位数据:
1 | // this_cpu_read_stable 的简化实现 |
由于 current_task 被声明为 DECLARE_PER_CPU_CACHE_HOT,它被放置在缓存友好的位置,在大多数情况下这只是一次缓存的 L1 命中。
上下文切换时的更新
在进程上下文切换时,x86_64 的 __switch_to() 函数更新 per-CPU 变量:
1 | // arch/x86/kernel/process_64.c (lines 610-671) |
关键语句是 raw_cpu_write(current_task, next_p),它将当前 CPU 的 current_task per-CPU 变量更新为下一个进程。这是一次直接的内存写入,开销极小。
8.5.4 ARM64 架构的实现——SP_EL0 寄存器技巧
源码分析
ARM64 的 current 实现位于 arch/arm64/include/asm/current.h:
1 | // arch/arm64/include/asm/current.h (完整文件) |
SP_EL0 的巧妙利用
ARM64 架构定义了多个异常级别 (Exception Level, EL),以及对应的栈指针寄存器:
1 | ARM64 异常级别与栈指针: |
当 CPU 处于 EL1(内核态)时,使用 SP_EL1 作为栈指针,而 SP_EL0 处于”闲置”状态。内核巧妙地利用了这个闲置的寄存器来存储当前进程的 task_struct 指针。
1 | ┌───────────────────────────────────────────┐ |
为什么这样做是安全的
一个自然的疑问是:SP_EL0 在用户态是有用的(它是用户态栈指针),当内核态修改它后,返回用户态时不会出问题吗?
答案是:ARM64 的硬件会自动处理这个问题。当异常从 EL0 进入 EL1 时,SP_EL0 的值会被自动保存(在 pt_regs 中),内核可以自由使用 SP_EL0。在返回 EL0 之前,内核会从 pt_regs 中恢复原始的 SP_EL0 值。所以内核对 SP_EL0 的”借用”对用户态完全透明。
上下文切换时的更新
ARM64 的 __switch_to() 位于 arch/arm64/kernel/process.c:
1 | // arch/arm64/kernel/process.c (lines 572-577) |
ARM64 同时维护了两种机制:per-CPU 变量 __entry_task(用于内核入口代码)和 SP_EL0(用于 current 宏)。SP_EL0 的写入发生在内核从 EL0 进入 EL1 的入口路径中,从 __entry_task per-CPU 变量加载。
编译器缓存优化
注释中特别提到”We don’t use read_sysreg() as we want the compiler to cache the value where possible”。这是因为 read_sysreg() 通常包含编译器屏障 (compiler barrier),会阻止编译器缓存寄存器值。而直接使用内联汇编 "mrs %0, sp_el0" 允许编译器在同一个函数中多次使用 current 时复用之前读取的值,避免重复执行 MRS 指令。
8.5.5 RISC-V 架构的实现——tp 寄存器
源码分析
RISC-V 的 current 实现位于 arch/riscv/include/asm/current.h:
1 | // arch/riscv/include/asm/current.h (完整文件) |
tp (Thread Pointer) 寄存器
RISC-V 架构定义了 32 个通用寄存器(x0-x31),其中 x4 也称为 tp (thread pointer)。RISC-V 的调用约定规定 tp 寄存器由运行时环境使用,通常用于线程局部存储 (TLS)。
Linux 内核将 tp 寄存器专门用于存储当前进程的 task_struct 指针:
1 | RISC-V 寄存器 tp (x4): |
GCC register 变量机制
源码中使用了一种高级的 GCC 特性:
1 | register struct task_struct *riscv_current_is_tp __asm__("tp"); |
这行代码声明了一个全局寄存器变量,将 C 语言变量 riscv_current_is_tp 绑定到 RISC-V 的 tp 寄存器。此后,所有对 riscv_current_is_tp 的访问都直接操作 tp 寄存器,不需要任何内存操作或特殊指令。
1 | // get_current() 编译后的代码: |
thread_info 偏移量为零的约束
注释中特别指出了一个关键约束:struct thread_info 必须位于 struct task_struct 的偏移量 0 处。这个约束在 include/linux/sched.h 中通过 CONFIG_THREAD_INFO_IN_TASK 选项保证:
1 | // include/linux/sched.h (lines 820-827) |
这意味着 (struct thread_info *)task == (struct task_struct *)task,因此 tp 寄存器中的值既可以解释为 task_struct 指针,也可以解释为 thread_info 指针。
8.5.6 三种架构实现的对比
| 特性 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| 存储位置 | per-CPU 变量(通过 GS 段寄存器) | SP_EL0 系统寄存器 | tp 通用寄存器 |
| 读取指令 | mov %gs:offset, %rax |
mrs x0, sp_el0 |
直接使用寄存器值 |
| 读取开销 | 1 次内存读取(通常命中 L1 缓存) | 1 次 MRS 系统寄存器读取 | 0 开销(已在寄存器中) |
| 更新方式 | raw_cpu_write(current_task, next) |
MSR SP_EL0 或通过入口路径 | mv tp, next |
| 编译器优化 | 受限(内存操作) | 部分优化(内联汇编) | 完全优化(寄存器变量) |
| 硬件依赖 | GS 段基址机制 | SP_EL0 双栈指针设计 | tp 寄存器约定 |
| 内核栈保护 | 通过 GS 段隔离 | SP_EL0 与 SP_EL1 独立 | tp 与 sp 独立 |
| 配置选项 | CONFIG_USE_X86_SEG_SUPPORT |
无(固定实现) | 无(固定实现) |
从性能角度看,RISC-V 的实现最为高效——tp 是通用寄存器,读取成本为零,且编译器可以将其缓存在函数内。ARM64 次之,MRS 指令虽然是系统寄存器读取,但通常只需几个时钟周期。x86_64 需要一次内存访问(即便命中 L1 缓存也有约 4 个时钟周期的延迟),但其段寄存器方案在历史上是最成熟的设计。
8.5.7 thread_info 与 task_struct 的融合 (CONFIG_THREAD_INFO_IN_TASK)
历史演变
在早期的 Linux 内核中,thread_info 存储在每个进程内核栈的底部。内核通过栈指针的掩码操作来找到 thread_info,进而访问当前进程信息。但这种方式存在安全隐患——内核栈溢出可能覆盖 thread_info 中的关键数据。
从 Linux 4.9 开始,引入了 CONFIG_THREAD_INFO_IN_TASK 选项,将 thread_info 嵌入 task_struct 的第一个字段。Linux 7.0.10 中,几乎所有主要架构都默认启用此选项。
当前设计
1 | // include/linux/sched.h (lines 820-827) |
这保证了 (struct thread_info *)task == (struct task_struct *)task,即二者的指针值完全相同。内核提供了辅助宏来访问:
1 | // include/linux/sched.h (lines 1971-1972) |
current_thread_info() 的实现
由于 thread_info 在 task_struct 的偏移量为 0,current_thread_info() 的实现极为简单:
1 | // 在 CONFIG_THREAD_INFO_IN_TASK 下 |
thread_info 中的 TIF 标志
thread_info 中最重要的字段是 flags,它存储了各种线程信息标志 (Thread Information Flags):
1 | // include/linux/thread_info.h |
关键的 TIF 标志包括:
| 标志 | 说明 | 检查频率 |
|---|---|---|
TIF_NEED_RESCHED |
需要重新调度 | 每次中断返回、系统调用返回 |
TIF_SIGPENDING |
有待处理信号 | 系统调用返回、中断返回 |
TIF_NOTIFY_RESUME |
返回用户态前需处理 | 中断返回 |
TIF_SECCOMP |
启用 seccomp 过滤 | 系统调用入口 |
TIF_SYSCALL_TRACE |
系统调用跟踪 (ptrace) | 系统调用入口/出口 |
1 | // 典型的检查代码(在中断返回路径中) |
由于 thread_info 嵌入在 task_struct 中,这些检查等价于直接检查 current 指向的结构体中的标志位。
8.5.8 内核栈与 task_struct 的关系
task_struct.stack 字段
每个 task_struct 通过 stack 字段指向该进程的内核栈:
1 | // include/linux/sched.h (line 839) |
内核栈的大小通常为 16KB(THREAD_SIZE),即 4 个页面。
VMAP 栈 (CONFIG_VMAP_STACK)
Linux 7.0.10 默认启用 CONFIG_VMAP_STACK,从 vmalloc 区域分配内核栈。这带来了一个重要的安全特性——保护页 (guard page):
1 | VMAP 栈的内存布局: |
当栈溢出时,写操作会命中保护页,触发页错误 (page fault),内核可以检测到栈溢出并报告错误,而非静默地破坏其他数据。
thread_union:旧式栈布局
在未启用 CONFIG_THREAD_INFO_IN_TASK 的架构中,thread_union 将 task_struct 和内核栈放在同一块内存中:
1 | // include/linux/sched.h (lines 1957-1963) |
这种设计中,task_struct(或 thread_info)位于栈的底部,通过栈指针对齐到 THREAD_SIZE 边界即可找到。但这已经是过时的设计。
从栈指针找到 task_struct
在某些场景下(例如栈溢出处理),内核需要从栈指针反推 task_struct。在 VMAP 栈模式下:
1 | // 通过栈指针找到 task_struct |
Stack Canary
task_struct 中还有一个与栈安全相关的字段:
1 | // task_struct 中的栈金丝雀 |
当内核启用 -fstack-protector 编译选项时,编译器会在函数序言中在栈上放置一个金丝雀值,函数返回前检查该值是否被篡改。stack_canary 字段为每个进程提供独立的金丝雀值,使得栈缓冲区溢出攻击更难成功。
8.5.9 汇编代码中的 current 访问
x86_64 入口代码
在 x86_64 的汇编入口代码(如 entry_64.S)中,需要访问 current_task 来获取当前进程信息。由于不能直接调用 C 函数,汇编代码通过 per-CPU 段寄存器来访问:
1 | ; x86_64 汇编中获取 current_task |
在早期初始化阶段(GS 段基址尚未设置时),内核使用特殊的引导代码来确保 current_task 可用。
ARM64 入口代码
ARM64 的入口代码直接从 SP_EL0 或 per-CPU 变量获取当前任务:
1 | ; ARM64 汇编中获取当前任务 |
RISC-V 入口代码
RISC-V 的入口代码可以直接使用 tp 寄存器,因为它始终持有当前 task_struct 指针:
1 | ; RISC-V 汇编中 tp 始终可用 |
引导阶段的初始化
在系统启动的极早期,per-CPU 基址(x86_64 的 GS 段基址、ARM64 的 SP_EL0)和 RISC-V 的 tp 寄存器需要被设置为指向 init_task。这发生在架构特定的早期启动代码中。
1 | // init/init_task.c (lines 96-253) |
每个 CPU 在启动时会被初始化为运行 init_task(或 idle 任务),之后调度器接管,开始正常的进程调度。
8.5.10 current 宏的使用模式
典型用法
1 | // 1. 获取当前进程 PID |
在中断上下文中的行为
需要注意的是,在中断上下文中(硬中断处理程序、softirq、tasklet 等),current 仍然指向被中断的进程。这是因为中断处理不涉及进程切换,CPU 上的 current 指针不变。
1 | 用户进程 A 在运行 |
进程上下文 vs 中断上下文
内核通过 in_interrupt() 和 in_task() 来区分当前执行上下文:
1 | // 安全的使用模式 |
8.5.11 小结
Linux 7.0.10 中 current 宏的实现体现了内核设计中对极致性能的追求。三种主流架构各自利用了不同的硬件特性来实现零开销或近零开销的当前进程查找:
- x86_64 通过 GS 段寄存器和 per-CPU 变量实现,利用段覆盖寻址自动选择当前 CPU 的数据副本,一次缓存友好的内存访问即可获取
current - ARM64 创造性地复用了 SP_EL0 系统寄存器,该寄存器在内核态处于闲置状态,通过一条 MRS 指令即可读取,完全不需要内存访问
- RISC-V 则使用了最直接的方案——将
task_struct指针常驻tp通用寄存器,通过 GCC 的全局寄存器变量特性实现零开销访问
这三种实现的共同特点是:O(1) 复杂度、无锁、架构专用优化。CONFIG_THREAD_INFO_IN_TASK 将 thread_info 嵌入 task_struct 的第一个字段,使得 current 同时也就是 current_thread_info(),消除了从栈指针推算 thread_info 的历史做法,提升了安全性。VMAP_STACK 通过保护页机制进一步增强了内核栈的安全性。stack_canary 为每个进程提供独立的栈保护值,与编译器的 -fstack-protector 配合,构建了多层防御体系。这些设计共同构成了 Linux 内核中进程身份识别和栈安全保护的完整架构。
- Title: Linux内核分析之进程管理-00
- Author: 韩乔落
- Created at : 2026-05-29 11:05:32
- Updated at : 2026-05-29 16:37:26
- Link: https://jelasin.github.io/2026/05/29/Linux内核分析之进程管理-00/
- License: This work is licensed under CC BY-NC-SA 4.0.