TaterLi 个人博客

[内核学习]进程核心数据结构

内核眼里的进程是一个 task_struct,几千行的大结构,身份,亲属,内存,调度,文件,信号全在里面.这篇不逐字段背书,挑最常用的几组 — 状态,PID 体系,亲缘关系 — 全部在板上现场制造现场观察:一个测试程序同时造出睡眠,僵尸,停止三种状态,再加一组多线程,拿 /proc 逐个解剖.

这篇回答几件事:

先造现场.一个小程序,fork 一个子进程立刻退出(父进程故意不 wait,制造僵尸),再 fork 一个子进程给它发 SIGSTOP(制造停止态),自己起两个 pthread 后 pause 睡大觉.两秒后解剖,三种状态同屏:

pid=1778 | State: S (sleeping) | comm=states
pid=1780 | State: Z (zombie)   | comm=states
pid=1781 | State: T (stopped)  | comm=states

S 睡眠,等事件(这里是 pause 等信号);Z 僵尸,进程已死但父进程没收尸,task_struct 还占着坑 — 这是给父进程留的"死亡证明",退出码和资源统计要等人来读;T 停止,SIGSTOP 送达后的冻结态,SIGCONT 解封.加上 R 运行和 D 不可中断睡眠,五个字母就是 ps 里那列 stat 的全部主角.R 和 S 大家熟,D 是在做不可中断 IO(读 NFS 断网最经典)的态,信号都叫不醒,kill -9 也没用,只能等 IO 超时或硬件恢复.实验里还顺带抓到停止态进程的上下文切换账本:/proc/PID/status 里 voluntary_ctxt_switches: 2,nonvoluntary: 0 — 自愿切换(主动睡眠)和被抢占各记各的,后面讲调度时这对数字还能用.

PID 体系是第二个重点,概念坑密度最高.先把实验数据摆出来,主进程 1874 带两个线程:

Threads: 3
Tgid:    1874
Pid:     1874
/proc/1874/task/ -> 1874 1878 1879

内核里每个线程一个 task_struct,task 的 pid 字段其实是"线程号"(TID);线程组组长的 TID 兼任组长 PID,同一组所有成员的 tgid 字段都指向它./proc 顶层目录按 TGID 列(所以 ps 看到的是"进程"),组内每个线程的详情在 /proc/TGID/task/TID 下 — 1878 和 1879 这两个线程没有顶层目录,但它们的 sched,status 一应俱全.用户态 API 对应关系:getpid() 拿 TGID,全组一致;gettid() 拿自己的 TID,每线程不同;kill(pid) 默认发给整个组,tkill/tgkill 才能精确打线程.这条对应链弄通,"线程是轻量进程"就不只是口号:在内核里它们就是同一种对象,差在组指针和资源共享量.

亲缘关系,task_struct 里三把链表:real_parent(生我的),parent(平时同 real,被 ptrace 时换人),children(双向),再加 sibling 串起同父的兄弟.实测僵尸进程 1876 的 PPid: 1874 — 父进程活着但从不 wait,僵尸就永远挂在父亲的 children 链上.父母先死孩子未亡的,孤儿被过继给最近的 subreaper 或 PID 1(板上 Debian 的 init 是 systemd),这是容器里"僵尸泄漏"问题的根源:容器里 PID 1 如果不 wait,僵尸在容器内永远清不掉,而这些僵尸在宿主机看来又在 systemd 名下 — 子收mitter 的责任链要一路通到会收尸的祖先.内核线程是另一族:全员的祖先是 PID 2 的 kthreadd(2.2 篇看过它的诞生),它们没有用户地址空间,/proc/2/task 下只有一个 tid,和用户线程不混编.

名字的两组字段别混.comm 是内核视角的名字,16 字节截断,/proc/PID/status 的 Name 和 ps 默认显示的都是它;cmdline 是 argv 原文,NUL 分隔,一个进程可以带着一长串参数跑,comm 却只有程序名.实验里 comm=states 而 cmdline=./states.改 comm 用 prctl(PR_SET_NAME) — 有些多线程服务给每个线程改 comm 标角色,top 里一眼分清谁是谁,调试期特别好使.

最后留几个容易忽略的细节:

退出移动版