task_struct 与进程生命周期

本页目录

在 shell 中启动一个程序,通常要经历创建子进程、准备重定向、替换程序映像、等待退出。沿着这一次启动,能看到 Linux 进程管理的三个核心边界:⁠任务身份、资源共享、退出回收⁠。

你想弄清的现象阅读位置
两个线程共享内存,为什么还能同时执行?第 1 节:任务与资源对象
fork 后文件偏移会共享,内存写入却隔离?第 1~2 节:引用关系与 COW
exec 后 PID 没变,程序却全换了?第 4 节:映像替换
子进程已经退出,为什么还显示 Z?第 5 节:退出与回收实验

按顺序阅读可串起生命周期;CPU 分配另见调度器,执行交接另见上下文切换。

1. task_struct:任务身份与资源关系的连接点

每个可调度线程都有自己的 task_struct、内核栈和调度状态。线程组中的线程可以共享地址空间、文件描述符表和信号处置,但仍有各自的 TID、寄存器上下文、信号屏蔽字及线程定向待决信号。共享资源不会把它们合成一个调度实体:两个线程可以同时在两个 CPU 上执行。

先确认日志里的“PID”指什么。 getpid() 返回线程组的 TGID,gettid() 返回调用线程的 TID;线程组首线程的两者相同,其他线程通常不同。

这些数值还受 PID namespace 影响:容器里的 PID 1,在宿主可能是另一个数值。关联日志时,需要同时明确进程/线程身份和观察它的 namespace。

task_struct 不是把一个进程的所有内容内嵌进去的大容器。它通过指针和引用连接不同寿命的对象:

对象或字段负责什么不能由此推断什么
mm用户地址空间、页表及 VMA 管理相同 mm 不意味着相同内核栈
files描述符编号到打开文件的映射不同表也可能引用同一个打开文件
fs当前目录、根目录、umask 等与文件描述符表不是同一对象
sighand / signal信号处置、线程组相关信号和统计状态信号屏蔽字仍是线程自己的
调度字段与实体策略、优先级、运行队列记账TASK_RUNNING 不等于此刻占有 CPU
cred、namespace 引用身份权限及观察到的资源视图创建任务不自动新建 namespace

推演:父子各有 fd 3,为何会推进同一个偏移

fork 后父子拥有不同的描述符表,但两个 fd 3 可以指向同一个 struct file。假设这个打开文件的初始偏移为 0,按下面的顺序操作:

操作描述符表发生什么共享对象发生什么
父进程成功读到 100 字节父子 fd 3 都仍存在文件偏移推进到 100
子进程接着读取使用子表中的 fd 3从共享的偏移 100 继续
子进程关闭 fd 3只移除子表中的引用父表仍引用这个打开文件

图中的两条箭头指向同一个对象,这正是共享偏移的原因:

fork 复制描述符表,仍引用同一打开文件描述 父任务 独立 task_struct 子任务 独立 task_struct 父 fd 表 fd 3 子 fd 表 fd 3 同一 struct file 共享文件偏移与状态标志 关闭一个描述符只移除一个引用;CLOEXEC 属于描述符,文件偏移属于打开文件描述。

要获得独立偏移,通常需要独立 open();dup() 和 fork() 会继续引用原来的打开文件描述。

正在呈现知识画面
下一次读取,从哪里开始?

fork 继承的描述符引用同一个偏移,任一进程读取都会推进它;分别 open 时各自拥有独立偏移。可按任意顺序读取,并保留操作历史比较两种方式。

源码定位: 内核通过 current 取得当前任务,具体实现依架构而异。不要依赖内核栈取整公式或写死的 task_struct 字段偏移。

2. 创建任务:复制的是关系,数据不一定立即复制

fork() 的语义是产生一个具有独立执行流的子进程。父进程得到子 PID,子进程得到返回值 0;之后谁先运行没有保证。创建过程中,内核必须建立任务身份、资源引用、调度实体和初始执行上下文。用户私有内存通常通过写时复制降低立即复制数据页的成本,但页表建立和其他元数据操作仍有成本,不能把 fork 概括成“只复制一个 task_struct”。

私有页面:先共享,写入时保持隔离

写时复制需要同时满足隔离与延迟复制。假设父子起初看到同一物理页,父进程第一次向自己的私有映射写入时触发写保护异常,内核建立符合私有写语义的页面映射。子进程仍看到原内容。这里的“共享同一物理页”是实现手段,“父子写入互不污染”才是私有映射的语义;MAP_SHARED 映射应另行分析。页表和缺页处理的细节留到虚拟内存展开。

clone 标志:选择哪些资源共享

clone()/clone3() 用标志表达资源关系,但标志存在依赖:CLONE_THREAD 要求 CLONE_SIGHAND,后者又要求 CLONE_VM。

创建方式地址空间与文件表每个任务仍独立持有
典型 POSIX 线程共享 mm、文件表、信号处置栈、线程身份、执行上下文
普通 fork独立 mm 和文件表,底层仍有共享对象子任务身份、栈、执行上下文

namespace 通常被继承;只有相应创建标志、权限等条件满足时才建立新 namespace。

还要区分 glibc clone() 包装接口和原始系统调用:前者接受入口函数和栈等参数,原始 ABI 随架构而异。对照源码调用链时,kernel_clone()、copy_process() 等名字是定位实现的入口,不是应用程序应直接依赖的接口。clone(2)

多线程 fork:一个没有解锁者的锁

设线程 B 正持有一把用户态锁,线程 A 此时 fork。子进程只有 A,却继承了可能仍为“已锁定”的内存状态。A 若再去获取这把锁,就可能一直等待一个已经不在子进程里的 B。

fork 后:锁的状态留下了,持锁线程没有留下 父进程 子进程 线程 A 线程 B 锁:已占用 仅线程 A 锁:仍占用 fork 没有 B 继续解锁 示意未由 atfork 等专门协议修复的用户态锁;多线程 fork 后的子进程应尽快 exec。

因此,多线程 fork 后到 exec 前,子进程应遵守仅调用异步信号安全函数等限制。把复杂准备工作交给 posix_spawn() 往往更容易控制。fork(2)

vfork 的约束更强: 子进程暂时共享地址空间,调用者线程被挂起,但父进程其他线程仍可能执行。不能在子进程中随意运行普通 C 逻辑。vfork(2)

3. 运行与等待:状态表达的是调度条件

TASK_RUNNING 同时覆盖正在运行和已经可以运行的任务。一个单 CPU 系统上有十个此状态任务,通常也只有一个当前在执行。等待 I/O、锁或事件的任务可能睡眠;事件发生后,唤醒路径使其重新具备运行资格,至于何时拿到 CPU,要由调度器决定。

常见观测含义解释边界
R正在运行或可运行不足以证明 CPU 饱和,也不能判断排队多久
S可中断睡眠信号可能结束等待,但系统调用是否返回 EINTR 还取决于接口和重启规则
D不可中断类等待不只磁盘 I/O;需结合内核等待点判断
T / t作业控制停止 / 跟踪停止与普通睡眠、退出不同
Z已退出、尚待回收已停止执行,仍保留回收所需信息

内核的任务状态是位与协议,退出状态还由 exit_state 等字段管理,不是把这张用户可见状态表当成一个简单枚举直接赋值。TASK_KILLABLE 允许致命信号中断相应等待,也不是“从某个较新版本开始 D 状态都可杀”。

等待操作的核心是避免事件落在“检查条件后、登记睡眠前”的缝隙里。仅用 READ_ONCE() 读一个变量并调用 schedule() 不够;应使用 wait_event*() 等等待接口,并按条件本身的锁或内存序要求发布数据。具体丢失唤醒推演见上下文切换。

4. exec:保留身份,替换程序映像

成功的 execve() 不创建第二个进程,也不返回原调用点。它在现有进程身份下装入新程序的地址空间、用户栈和入口状态。动态链接 ELF 的首次入口通常在解释器/动态链接器,随后经过运行时初始化才到 main(),并不是内核直接调用 main。

对照:一次 exec 前后,哪些对象改变

对象exec 成功之后对应用的直接影响
私有地址空间原堆、栈和映射被替换旧指针不能继续指向新程序的数据
其他线程原线程组中的其他线程被终止非首线程 exec 时,其 TID 还会调整
文件描述符通常保留未设置 FD_CLOEXEC 的描述符新程序可沿用预先安排的标准输入、输出
信号状态处理函数重置;忽略处置通常保留,屏蔽字保留旧函数地址不能成为新映像的回调
凭据是否改变受文件属性及执行约束共同影响需结合 no_new_privs、挂载属性和跟踪状态判断

shell 重定向正好利用了描述符的保留。 子进程先把管道端复制到标准输出,再 exec;新程序无需知道父进程怎样创建管道,写标准输出就能进入同一个打开文件对象。

正在呈现知识画面
程序换了,进程为什么还是它?

exec 在同一进程身份下替换用户程序的代码、数据和栈。未设置 CLOEXEC 的标准输出继续连接原有管道,新程序可沿用它输出。

应用一般把 exec 返回当成失败并处理 errno,但内核极少数越过不可回退点的失败可能终止进程,而非恢复原映像继续运行。execve(2)

5. exit 与 wait:为什么任务已经死了,还留下 zombie

子进程的父任务可能正在做其他事情。若内核在子进程退出瞬间连退出状态也全部丢弃,父进程稍后就无法知道它是成功、返回错误码,还是被信号终止。因此退出会释放地址空间、文件等资源的相应引用,同时暂存退出结果以及关联元数据,等待有权等待者消费。

zombie 不再执行用户代码,但并非不占任何内核内存。反过来,进程退出也不代表它引用过的共享对象全部销毁:父进程可能仍持有相同打开文件,其他进程可能仍映射共享内存。资源的最后一次引用释放,才决定许多对象的最终销毁时间。

执行结束与回收结束,是两个不同的边界 创建子任务 分配任务身份,复制或共享资源,进入可运行状态 1 执行与等待 运行、睡眠、被唤醒;exec 可以替换程序映像 2 退出并释放资源引用 停止执行,保留退出状态及回收所需的内核信息 3 父任务观察与回收 WNOWAIT 可以只观察;正常 wait 最终消费退出状态 4 图示普通可等待子进程;自动回收、线程组退出与 ptrace 会影响具体路径。

先区分用户态清理与内核退出

用户态 exit() 会执行退出处理函数并刷新标准 I/O;_exit() 不做这些用户态清理。在 Linux 的 glibc 中,_exit() 包装通常通过 exit_group 结束线程组,而原始 SYS_exit 只退出调用线程。多线程程序分析“调用 exit 后为何其他线程也消失”,必须区分 libc 接口和原始系统调用。

再区分观察与回收

waitpid() 或 waitid() 消费退出结果;waitid(..., WNOWAIT) 则可以只观察,保留后续可等待状态。下面的实验会先观察退出码 7,再正式回收,最后验证该子进程已经不能再次等待。

先用下面的交互区分观察与消费。这里加上 WNOHANG,尚未退出时也会立即返回;回收操作本身不会终止子进程。

正在呈现知识画面
读到退出码,等于回收了吗?

退出后内核保留结果。WNOWAIT 让父进程取得退出码但不消费结果;回收消费可等待结果,父进程仍保有已取得的值。

两种情况会改变通常的回收关系:显式设置 SIGCHLD 为 SIG_IGN 或使用 SA_NOCLDWAIT 会影响自动回收;父进程先退出时,由适用的 subreaper 或 PID namespace init 接管孤儿。

SIGCHLD 的“默认动作是忽略”,与显式设置 SIG_IGN 的回收行为不能混为一谈。waitpid(2)

实验:沿一条管道观察完整生命周期

下面的 Linux 单线程实验把 fork、重定向、exec、观察退出和最终回收连在一起。保存为 lifecycle.c,以 cc -std=c11 -O2 -Wall -Wextra -Werror lifecycle.c -o lifecycle 编译。父进程收到的 PID 来自 exec 后的程序;前两次等待会阻塞到退出结果可用,分别验证“可观察”和“已消费”;这与上方交互中的非阻塞尝试不同。

#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>

int main(int argc, char **argv) {
    (void)argv;
    if (argc == 2) {
        printf("%ld\n", (long)getpid());
        return 7;
    }
    struct sigaction sa = { .sa_handler = SIG_DFL };
    sigemptyset(&sa.sa_mask);
    assert(sigaction(SIGCHLD, &sa, NULL) == 0);
    int p[2];
    assert(pipe2(p, O_CLOEXEC) == 0);
    /* 本实验需要标准输入、输出、错误描述符已经打开。 */
    assert(p[0] > STDERR_FILENO && p[1] > STDERR_FILENO);
    pid_t child = fork();
    assert(child >= 0);
    if (child == 0) {
        close(p[0]);
        if (dup2(p[1], STDOUT_FILENO) < 0) _exit(126);
        close(p[1]);
        execl("/proc/self/exe", "lifecycle", "child", (char *)NULL);
        _exit(127);
    }
    close(p[1]);
    FILE *in = fdopen(p[0], "r");
    assert(in != NULL);
    long reported;
    assert(fscanf(in, "%ld", &reported) == 1);
    assert(reported == (long)child);
    assert(fclose(in) == 0);
    siginfo_t info = {0};
    assert(waitid(P_PID, child, &info, WEXITED | WNOWAIT) == 0);
    assert(info.si_code == CLD_EXITED && info.si_status == 7);
    int status;
    assert(waitpid(child, &status, 0) == child);
    assert(WIFEXITED(status) && WEXITSTATUS(status) == 7);
    errno = 0;
    assert(waitpid(child, &status, WNOHANG) == -1 && errno == ECHILD);
    puts("exec preserved PID; exit 7 observed; child reaped");
}

dup2() 在这里建立一个没有 CLOEXEC 标志的标准输出描述符;原始管道端带 CLOEXEC,且被显式关闭。若误把最终标准输出也设为 CLOEXEC,新程序就无法通过它报告 PID。示例用断言表达实验前提,编译时不要定义 NDEBUG;实际服务还应为中断、资源不足及清理路径提供正常错误处理。

6. 内核线程与现场定位

内核线程通常 mm == NULL,运行时可能借用 active_mm。它仍有 task、内核栈和调度策略,也会阻塞;其地址空间上下文不能写死为永远使用 init_mm。

停止请求需要线程合作完成。 kthread_stop() 发出请求、唤醒并等待退出;线程函数需要检查 kthread_should_stop(),且等待路径必须让它有机会检查。如果它卡在永远无法完成的等待中,仅发出停止请求无法完成收尾。

workqueue 则把工作项交给受管理的工作线程池执行,不是每提交一个工作就新建一个专用内核线程。对于退出顺序,模块或设备必须先阻止新工作进入,再取消/等待在途工作,最后释放它们使用的内存;否则“线程已请求停止”仍不足以证明数据不再被访问。

现场查阅可先看 /proc/PID/status 的 Pid、Tgid、NSpid、Threads,再看 /proc/PID/task/TID/ 的线程视图。/proc/PID/stat 的 comm 字段带括号且可能含空格,不能直接按空格拆分后固定索引取字段。看到 R、D、Z 后,再分别去问排队时间、等待对象、父任务回收逻辑,才能从状态符号推进到原因。