在 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 | 只移除子表中的引用 | 父表仍引用这个打开文件 |
图中的两条箭头指向同一个对象,这正是共享偏移的原因:
要获得独立偏移,通常需要独立 open();dup() 和 fork() 会继续引用原来的打开文件描述。
正在呈现知识画面
下一次读取,从哪里开始?fork 继承的描述符引用同一个偏移,任一进程读取都会推进它;分别 open 时各自拥有独立偏移。可按任意顺序读取,并保留操作历史比较两种方式。
{"id":"process-file-sharing","title":"下一次读取,从哪里开始?","summary":"fork 继承的描述符引用同一个偏移,任一进程读取都会推进它;分别 open 时各自拥有独立偏移。可按任意顺序读取,并保留操作历史比较两种方式。","height":950,"html":"<p class=\"eyebrow\" data-i18n=\"eyebrow\"></p><h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p>\n<div class=\"presets\" role=\"group\" data-i18n-label=\"models\"><button data-mode=\"fork\" data-i18n=\"fork\"></button><button data-mode=\"open\" data-i18n=\"open\"></button></div>\n<div class=\"read-size\"><label for=\"read-size\" data-i18n=\"readSize\"></label><output id=\"read-size-value\" for=\"read-size\">2</output><input id=\"read-size\" type=\"range\" min=\"1\" max=\"4\" step=\"1\" value=\"2\" data-i18n-label=\"readSize\"></div>\n<div id=\"scene\" class=\"scene\">\n <div class=\"readers\"><div id=\"parent\" class=\"reader\"><span data-i18n=\"parent\"></span><small data-i18n=\"readResult\"></small><strong id=\"parent-result\">AB</strong><code>fd 3</code><button data-reader=\"parent\" data-i18n=\"read\"></button></div><div id=\"child\" class=\"reader\"><span data-i18n=\"child\"></span><small data-i18n=\"readResult\"></small><strong id=\"child-result\">CD</strong><code>fd 3</code><button data-reader=\"child\" data-i18n=\"read\"></button></div></div>\n <svg class=\"links\" viewBox=\"0 0 600 64\" preserveAspectRatio=\"none\" aria-hidden=\"true\"><path id=\"parent-link\"/><path id=\"child-link\"/></svg>\n <div class=\"open-files\"><div id=\"file-a\" class=\"open-file\"><div class=\"file-title\"><span data-i18n=\"opened\"></span><b>A</b></div><div class=\"bytes\" aria-hidden=\"true\"><span>A</span><span>B</span><span>C</span><span>D</span><span>E</span><span>F</span><span>G</span><span>H</span></div><div class=\"position\"><i id=\"cursor-a\"></i></div><div class=\"offset\"><span data-i18n=\"next\"></span><strong id=\"offset-a\">4</strong></div></div><div id=\"file-b\" class=\"open-file\"><div class=\"file-title\"><span data-i18n=\"opened\"></span><b>B</b></div><div class=\"bytes\" aria-hidden=\"true\"><span>A</span><span>B</span><span>C</span><span>D</span><span>E</span><span>F</span><span>G</span><span>H</span></div><div class=\"position\"><i id=\"cursor-b\"></i></div><div class=\"offset\"><span data-i18n=\"next\"></span><strong id=\"offset-b\">2</strong></div></div></div>\n</div>\n<p class=\"caption\" data-i18n=\"contents\"></p>\n<div class=\"copy\"><p id=\"conclusion\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"sharedConclusion\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"separateConclusion\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"beforeRead\"></p></div>\n<div class=\"demo-controls\"><button id=\"auto-play\" aria-pressed=\"false\"><span id=\"auto-label\"></span><span class=\"reserve\" aria-hidden=\"true\" data-i18n=\"auto\"></span><span class=\"reserve\" aria-hidden=\"true\" data-i18n=\"pause\"></span><span class=\"reserve\" aria-hidden=\"true\" data-i18n=\"replayAuto\"></span></button><button id=\"restart\" data-i18n=\"restart\"></button></div><p class=\"note\" data-i18n=\"note\"></p>\n","css":".intro{font-size:14px;color:var(--muted);margin:8px 0 18px}.note{font-size:12px;color:var(--muted);margin-top:16px}.presets{display:flex;flex-wrap:wrap;gap:4px}.presets button{flex:1 1 auto;min-width:min-content}.copy{display:grid;margin-top:16px;font-size:14px}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.controls{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.controls button{min-height:42px}.muted{color:var(--muted)}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.caption{font-size:12px;color:var(--muted)}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n\n.demo-controls{display:flex;gap:10px;align-items:stretch;margin-top:18px}.demo-controls button{min-width:0;min-height:42px;font-size:13px}#auto-play{display:grid;flex:1}#auto-play>span{grid-area:1/1}#auto-play[aria-pressed=\"true\"]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}#restart{color:var(--muted)}\n.scene{margin-top:22px}.readers{display:grid;grid-template-columns:1fr 1fr;gap:18px}.reader{border:1px solid var(--rule);border-radius:10px;padding:14px;text-align:center;transition:background .3s,border-color .3s}.reader span,.reader small,.reader code{display:block}.reader span{font-weight:600;font-size:14px}.reader small{color:var(--muted);font-size:12px;min-height:2.8em}.reader strong{display:block;font:600 26px var(--viz-font-ui);margin:6px 0}.reader code{font-size:12px;color:var(--muted)}.reader.read{background:var(--accent-soft);border-color:var(--accent)}.links{display:block;width:100%;height:64px;overflow:visible}.links path{fill:none;stroke:var(--accent);stroke-width:2;vector-effect:non-scaling-stroke;transition:d .3s}.open-files{display:grid;grid-template-columns:1fr 1fr;gap:18px;position:relative}.open-file{padding:16px;border:1px solid var(--accent);background:var(--accent-soft);border-radius:10px;min-width:0;transition:opacity .3s,background .3s}.scene.shared #file-a{grid-column:1/3;width:72%;justify-self:center}.scene.shared #file-b{position:absolute;inset:0 0 0 auto;width:calc(50% - 9px);visibility:hidden;opacity:0;pointer-events:none}.file-title{display:flex;justify-content:space-between;align-items:start;gap:6px;font-size:12px;min-height:2.8em;color:var(--muted)}.file-title b{color:var(--accent);font-size:16px}.bytes{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:10px}.bytes span{font:16px var(--viz-font-mono);text-align:center;padding:10px 0;background:var(--paper);border:1px solid var(--rule);transition:background .3s}.bytes span+span{border-left:0}.bytes .latest{background:var(--second-soft);color:var(--ink)}.position{position:relative;height:12px;border-bottom:1px solid var(--accent);margin-bottom:10px}.position i{position:absolute;bottom:-4px;width:8px;height:8px;border-radius:50%;background:var(--accent);transform:translateX(-50%);transition:left .4s}.offset{font-size:12px;display:flex;justify-content:space-between;align-items:center;gap:6px;min-height:2.8em}.offset strong{font:600 20px var(--viz-font-ui)}.caption{margin-top:12px}.controls button{width:100%}\n@media(max-width:480px){.readers,.open-files{gap:10px}.reader{padding:12px 8px}.open-file{padding:12px 8px}.scene.shared #file-a{width:80%}.file-title,.offset{min-height:4.2em}.scene.shared #file-b{width:calc(50% - 5px)}}\n\n.reader button{display:block;width:100%;margin-top:12px;font-size:13px;min-height:42px}.bytes{grid-template-columns:repeat(8,minmax(0,1fr))}.bytes span{font-size:13px}.reader strong{min-height:1.4em}@media(max-width:480px){.bytes span{font-size:10px}.reader button{font-size:12px;padding-inline:5px}}\n\n.read-size{display:grid;grid-template-columns:1fr auto;gap:6px 12px;margin-top:18px;font-size:13px;align-items:center}.read-size input{grid-column:1/3;width:100%;margin:0}.read-size output{color:var(--accent);font-variant-numeric:tabular-nums}\n","js":"const $ = s => document.querySelector(s);\nconst contents = 'ABCDEFGH';\nlet mode = 'fork', history = [], readSize = 2;\nfunction calculate() {\n const offsets = [0, 0], results = {parent: '—', child: '—'}, ranges = [[0,0],[0,0]];\n for (const {who, size} of history) {\n const i = mode === 'fork' || who === 'parent' ? 0 : 1;\n const start = offsets[i], chunk = contents.slice(start, start + size);\n offsets[i] += chunk.length;\n results[who] = chunk || 'EOF';\n ranges[i] = [start, offsets[i]];\n }\n return {offsets, results, ranges};\n}\nfunction finished() {\n const {offsets} = calculate();\n return offsets[0] === contents.length && (mode === 'fork' || offsets[1] === contents.length);\n}\nfunction playbackLabel(running) {\n $('#auto-play').setAttribute('aria-pressed', String(running));\n $('#auto-label').textContent = viz.t(running ? 'pause' : finished() ? 'replayAuto' : 'auto');\n}\nfunction draw() {\n const shared = mode === 'fork', {offsets, results, ranges} = calculate();\n $('#scene').classList.toggle('shared', shared);\n document.querySelectorAll('[data-mode]').forEach(e => e.setAttribute('aria-pressed', String(e.dataset.mode === mode)));\n $('#parent-link').setAttribute('d', shared ? 'M150 0 C150 32 300 32 300 64' : 'M150 0 L150 64');\n $('#child-link').setAttribute('d', shared ? 'M450 0 C450 32 300 32 300 64' : 'M450 0 L450 64');\n for (const who of ['parent', 'child']) {\n $('#'+who+'-result').textContent = results[who];\n $('#'+who).classList.toggle('read', results[who] !== '—');\n }\n for (const [i, file] of ['a','b'].entries()) {\n $('#offset-'+file).textContent = offsets[i];\n $('#cursor-'+file).style.left = offsets[i] / contents.length * 100 + '%';\n document.querySelectorAll('#file-'+file+' .bytes span').forEach((e,j) => e.classList.toggle('latest', j >= ranges[i][0] && j < ranges[i][1]));\n }\n $('#conclusion').textContent = viz.t(!history.length ? 'beforeRead' : shared ? 'sharedConclusion' : 'separateConclusion');\n document.querySelectorAll('[data-reader]').forEach(e => e.textContent = viz.t('read').replace('{n}', readSize));\n $('#read-size-value').textContent = readSize;\n playbackLabel(player.playing);\n}\nconst player = viz.playback({\n delay: 1800,\n canAdvance: () => !finished(),\n advance() {\n let who = history.at(-1)?.who === 'parent' ? 'child' : 'parent';\n const {offsets} = calculate();\n if (mode === 'open' && offsets[who === 'parent' ? 0 : 1] === contents.length) who = who === 'parent' ? 'child' : 'parent';\n history.push({who, size: readSize}); draw();\n },\n onChange: playbackLabel,\n});\ndocument.querySelectorAll('[data-reader]').forEach(e => e.addEventListener('click', () => { player.pause(); history.push({who:e.dataset.reader, size:readSize}); draw(); }));\ndocument.querySelectorAll('[data-mode]').forEach(e => e.addEventListener('click', () => { player.pause(); mode = e.dataset.mode; draw(); }));\n$('#read-size').addEventListener('input', e => { player.pause(); readSize = Number(e.target.value); draw(); });\n$('#auto-play').addEventListener('click', () => {\n if (!player.playing && finished()) { history = []; draw(); }\n player.toggle();\n});\n$('#restart').addEventListener('click', () => { player.pause(); history = []; draw(); });\ndraw();\n","audio":false,"strings":{"auto":"自动演示","beforeRead":"所有偏移从 0 开始。点击任一进程的读取按钮,观察返回内容和引用对象的变化。","child":"子进程","contents":"窗口显示同一文件 ABCDEFGH;高亮是该对象最近读出的字节,圆点是下一次读取的位置。","eyebrow":"01 · 文件引用","fork":"fork 继承","heading":"下一次读取,从哪里开始?","intro":"调整读取长度,直接让父或子进程读取。顺序由你决定;切换打开方式,会用同样的操作历史重新计算。","models":"打开文件的方式","next":"下一次读取的位置","note":"读取按点击顺序成功执行,没有其他访问者。接近末尾时可能少于请求长度;末尾再读返回 EOF。自动演示交替读取,手动操作会暂停演示。","open":"分别 open","opened":"打开文件对象","parent":"父进程","pause":"暂停演示","read":"读 {n} 字节","readResult":"最近一次读到","readSize":"每次读取的字节数","replace":"执行映像替换","replayAuto":"重放演示","restart":"重置","separateConclusion":"每次 open 各自管理偏移。一个进程读取只推进自己引用的对象,另一个进程的位置不变。","sharedConclusion":"两条引用指向同一个偏移。无论谁读取,都会推进这个位置;另一个进程下一次就从这里接着读。","write":"向管道写出","writeCount":"累计收到次数"}}
源码定位: 内核通过 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 后到 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 的标准输出继续连接原有管道,新程序可沿用它输出。
{"id":"process-exec","title":"程序换了,进程为什么还是它?","summary":"exec 在同一进程身份下替换用户程序的代码、数据和栈。未设置 CLOEXEC 的标准输出继续连接原有管道,新程序可沿用它输出。","height":950,"html":"<p class=\"eyebrow\" data-i18n=\"eyebrow\"></p><h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p>\n<div id=\"process\" class=\"process\"><header><strong data-i18n=\"child\"></strong><span class=\"mono\">PID 4201</span></header>\n <div class=\"image-title\"><span data-i18n=\"space\"></span><span id=\"program-name\"></span></div>\n <div class=\"image-stack\"><section id=\"old-image\" class=\"program-image\"><div class=\"code\"><span class=\"caption\" data-i18n=\"code\"></span><button id=\"replace\" class=\"code-action\"><code>execve(\"./sender\", …);</code><span data-i18n=\"replace\"></span></button><code id=\"unreachable\">puts(\"old\");</code></div><div class=\"memory\"><div><span data-i18n=\"data\"></span><code>buffer = \"old\"</code></div><div><span data-i18n=\"stack\"></span><code>launch()</code><code>count = 3</code></div></div></section><section id=\"new-image\" class=\"program-image\" aria-hidden=\"true\"><div class=\"code\"><span class=\"caption\" data-i18n=\"code\"></span><button id=\"write\" class=\"code-action\"><code>write(1, \"ready\\n\", 6);</code><span data-i18n=\"write\"></span></button><code>/* message loop */</code></div><div class=\"memory\"><div><span data-i18n=\"data\"></span><code>message = \"ready\\n\"</code></div><div><span data-i18n=\"stack\"></span><code>main(argc, argv)</code><code>argv[0] = \"sender\"</code></div></div></section></div>\n <div class=\"fd\"><code>fd 1</code><span data-i18n=\"stdout\"></span></div>\n</div>\n<div class=\"pipe-path\"><i id=\"packet\"></i><span>↓</span><span data-i18n=\"pipe\"></span><span>↓</span></div>\n<div class=\"receiver\"><span data-i18n=\"parent\"></span><div><strong id=\"received\">—</strong><small id=\"writes\"></small></div></div>\n<div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"beforeNote\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"afterNote\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"replacedNote\"></p></div>\n<div class=\"demo-controls\"><button id=\"auto-play\" aria-pressed=\"false\"><span id=\"auto-label\"></span><span class=\"reserve\" aria-hidden=\"true\" data-i18n=\"auto\"></span><span class=\"reserve\" aria-hidden=\"true\" data-i18n=\"pause\"></span><span class=\"reserve\" aria-hidden=\"true\" data-i18n=\"replayAuto\"></span></button><button id=\"restart\" data-i18n=\"restart\"></button></div>\n<p class=\"note\" data-i18n=\"note\"></p>\n","css":".intro{font-size:14px;color:var(--muted);margin:8px 0 18px}.note{font-size:12px;color:var(--muted);margin-top:16px}.presets{display:flex;flex-wrap:wrap;gap:4px}.presets button{flex:1 1 auto;min-width:min-content}.copy{display:grid;margin-top:16px;font-size:14px}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.controls{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.controls button{min-height:42px}.muted{color:var(--muted)}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.caption{font-size:12px;color:var(--muted)}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n\n.demo-controls{display:flex;gap:10px;align-items:stretch;margin-top:18px}.demo-controls button{min-width:0;min-height:42px;font-size:13px}#auto-play{display:grid;flex:1}#auto-play>span{grid-area:1/1}#auto-play[aria-pressed=\"true\"]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}#restart{color:var(--muted)}\n.process{border:2px solid var(--accent);border-radius:12px;overflow:hidden}.process header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:14px;background:var(--accent-soft);font-size:14px}.process header span{color:var(--accent)}.image-title{display:flex;justify-content:space-between;gap:10px;padding:16px 16px 8px;color:var(--muted);font-size:12px}.image-title span{flex:1}#program-name{text-align:right}.image-stack{display:grid;margin:0 14px;border:1px solid var(--rule);border-radius:8px;background:var(--surface);overflow:hidden}.program-image{grid-area:1/1;min-width:0;padding:14px;transition:opacity .4s,transform .4s;transform:translateY(0)}#new-image{opacity:0;visibility:hidden;transform:translateY(8px);background:var(--second-soft)}.process.replaced #old-image{opacity:0;visibility:hidden;transform:translateY(-8px)}.process.replaced #new-image{opacity:1;visibility:visible;transform:translateY(0)}.code{min-width:0}.code code{display:block;white-space:normal;overflow-wrap:anywhere;font-size:14px;line-height:1.7}#unreachable{color:var(--muted)}.memory{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px}.memory>div{min-width:0;border-top:1px solid var(--rule);padding-top:10px}.memory span,.memory code{display:block;font-size:12px;overflow-wrap:anywhere}.memory span{color:var(--muted);margin-bottom:4px}.fd{display:flex;align-items:center;gap:12px;margin:14px;padding:10px 12px;background:var(--accent-soft);border:1px solid var(--accent);border-radius:6px;font-size:12px}.fd code{white-space:nowrap;color:var(--accent);font-size:15px}.pipe-path{position:relative;display:flex;flex-direction:column;align-items:center;font-size:12px;color:var(--accent);line-height:1.5;padding:2px 0}.pipe-path span:first-of-type,.pipe-path span:last-of-type{font-size:20px}.pipe-path i{position:absolute;left:calc(50% + 35px);top:0;width:7px;height:7px;border-radius:50%;background:var(--second);opacity:0}.pipe-path.flow i{animation:packet .6s ease-in-out}.receiver{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--accent);background:var(--accent-soft);border-radius:8px;padding:14px;font-size:13px}.receiver strong{font:600 20px var(--viz-font-ui)}.controls button{display:grid;width:100%}.controls button>span{grid-area:1/1}.image-title{min-height:4.2em}\n@keyframes packet{0%{opacity:0;transform:translateY(0)}20%{opacity:1}85%{opacity:1}100%{opacity:0;transform:translateY(65px)}}\n@media(max-width:480px){.program-image{padding:12px}.image-stack{margin:0 10px}.memory{grid-template-columns:1fr}.memory>div{min-height:5.4em}.process header{padding:12px}.code code{font-size:12px}.fd{margin:12px 10px}}\n\n.code-action{width:100%;display:flex;flex-wrap:wrap;justify-content:space-between;text-align:left;gap:6px 12px;padding:10px;margin:6px 0;background:var(--paper);border-color:var(--accent)}.code-action code{font-size:13px}.code-action span{font-size:12px;color:var(--accent)}.receiver>div{text-align:right}.receiver small{display:block;font-size:12px;color:var(--muted);min-height:1.7em}\n","js":"const $ = s => document.querySelector(s);\nlet replaced = false, writes = 0;\nfunction playbackLabel(running) {\n $('#auto-play').setAttribute('aria-pressed', String(running));\n $('#auto-label').textContent = viz.t(running ? 'pause' : writes ? 'replayAuto' : 'auto');\n}\nfunction draw() {\n $('#process').classList.toggle('replaced', replaced);\n $('#old-image').setAttribute('aria-hidden', String(replaced));\n $('#new-image').setAttribute('aria-hidden', String(!replaced));\n $('#program-name').textContent = viz.t(replaced ? 'newProgram' : 'oldProgram');\n $('#received').textContent = writes ? 'ready' : '—';\n $('#writes').textContent = viz.t('writeCount') + ' ' + writes;\n $('#explanation').textContent = viz.t(!replaced ? 'beforeNote' : !writes ? 'replacedNote' : 'afterNote');\n playbackLabel(player.playing);\n}\nfunction replace() { replaced = true; draw(); }\nfunction write() {\n if (!replaced) return;\n writes++; draw();\n const pipe = document.querySelector('.pipe-path');\n pipe.classList.remove('flow'); void pipe.offsetWidth; pipe.classList.add('flow');\n}\nconst player = viz.playback({delay: 1800, canAdvance: () => writes === 0,\n advance() { if (!replaced) replace(); else write(); }, onChange: playbackLabel});\n$('#replace').addEventListener('click', () => { player.pause(); replace(); });\n$('#write').addEventListener('click', () => { player.pause(); write(); });\n$('#auto-play').addEventListener('click', () => {\n if (!player.playing && writes) { replaced = false; writes = 0; document.querySelector('.pipe-path').classList.remove('flow'); draw(); }\n player.toggle();\n});\n$('#restart').addEventListener('click', () => { player.pause(); replaced = false; writes = 0; document.querySelector('.pipe-path').classList.remove('flow'); draw(); });\ndraw();\n","audio":false,"strings":{"afterNote":"PID 4201 和 fd 1 到管道的连接都没换。新程序使用新的代码和栈,通过原有标准输出发出了 ready。","auto":"自动演示","beforeNote":"外框是进程身份,内部是当前程序。exec 成功后,旧代码、变量与栈被替换;下一行 puts(\"old\") 不会执行。","child":"子进程","code":"执行的代码","data":"数据 / 堆","eyebrow":"02 · 映像替换","heading":"程序换了,进程为什么还是它?","intro":"子进程的标准输出已经接到管道。直接点击代码旁的操作:替换程序,再让新程序输出;每一次写出都由你触发。","newProgram":"新程序 sender","note":"示意单线程进程的一次成功 exec,fd 1 未设置 CLOEXEC。代码为节选,省略动态链接和运行时初始化;新程序停留在消息处理循环中,可反复写出 ready。","oldProgram":"原启动程序","parent":"父进程收到","pause":"暂停演示","pipe":"原来的管道 P","read":"读 2 字节","readResult":"最近一次读到","replace":"执行映像替换","replacedNote":"映像已替换,PID 和输出连接保持不变。新程序尚未输出;点击它的“向管道写出”,观察原有连接的作用。","replayAuto":"重放演示","restart":"重置","space":"用户地址空间","stack":"用户栈","stdout":"标准输出:连接保持","write":"向管道写出","writeCount":"累计收到次数"}}
应用一般把 exec 返回当成失败并处理 errno,但内核极少数越过不可回退点的失败可能终止进程,而非恢复原映像继续运行。execve(2)
5. exit 与 wait:为什么任务已经死了,还留下 zombie
子进程的父任务可能正在做其他事情。若内核在子进程退出瞬间连退出状态也全部丢弃,父进程稍后就无法知道它是成功、返回错误码,还是被信号终止。因此退出会释放地址空间、文件等资源的相应引用,同时暂存退出结果以及关联元数据,等待有权等待者消费。
zombie 不再执行用户代码,但并非不占任何内核内存。反过来,进程退出也不代表它引用过的共享对象全部销毁:父进程可能仍持有相同打开文件,其他进程可能仍映射共享内存。资源的最后一次引用释放,才决定许多对象的最终销毁时间。
先区分用户态清理与内核退出
用户态 exit() 会执行退出处理函数并刷新标准 I/O;_exit() 不做这些用户态清理。在 Linux 的 glibc 中,_exit() 包装通常通过 exit_group 结束线程组,而原始 SYS_exit 只退出调用线程。多线程程序分析“调用 exit 后为何其他线程也消失”,必须区分 libc 接口和原始系统调用。
再区分观察与回收
waitpid() 或 waitid() 消费退出结果;waitid(..., WNOWAIT) 则可以只观察,保留后续可等待状态。下面的实验会先观察退出码 7,再正式回收,最后验证该子进程已经不能再次等待。
先用下面的交互区分观察与消费。这里加上 WNOHANG,尚未退出时也会立即返回;回收操作本身不会终止子进程。
正在呈现知识画面
读到退出码,等于回收了吗?退出后内核保留结果。WNOWAIT 让父进程取得退出码但不消费结果;回收消费可等待结果,父进程仍保有已取得的值。
{"id":"process-exit-wait","title":"读到退出码,等于回收了吗?","summary":"退出后内核保留结果。WNOWAIT 让父进程取得退出码但不消费结果;回收消费可等待结果,父进程仍保有已取得的值。","height":950,"html":"<p class=\"eyebrow\" data-i18n=\"eyebrow\"></p><h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p>\n<div class=\"world\"><section id=\"child\" class=\"actor\"><header><strong data-i18n=\"child\"></strong><code>PID 4201</code></header><div class=\"work\" id=\"work\"><span class=\"pulse\"></span><span id=\"work-label\"></span></div><div class=\"record\" id=\"record\"><span data-i18n=\"record\"></span><strong id=\"record-value\">—</strong><small id=\"record-label\"></small></div><button id=\"exit\" data-i18n=\"exit\"></button></section>\n<div class=\"transfer\" aria-hidden=\"true\"><span>→</span><i id=\"transit\">7</i></div>\n<section class=\"actor parent\"><header><strong data-i18n=\"parent\"></strong><span data-i18n=\"waiting\"></span></header><div class=\"parent-result\"><span data-i18n=\"received\"></span><strong id=\"parent-value\">—</strong><small id=\"parent-label\"></small></div><div class=\"parent-actions\"><button id=\"observe\" data-i18n=\"observe\"></button><button id=\"reap\" data-i18n=\"reap\"></button></div></section></div>\n<div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"liveNote\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"zombieNote\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"observedNote\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"reapedNote\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"earlyNote\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"againNote\"></p></div>\n<details><summary data-i18n=\"calls\"></summary><div class=\"call\"><code id=\"call\"></code><p id=\"return-value\"></p></div><p class=\"note\" data-i18n=\"nonblocking\"></p></details>\n<div class=\"controls\"><button id=\"reset\" data-i18n=\"reset\"></button></div><p class=\"note\" data-i18n=\"note\"></p>\n","css":".intro{font-size:14px;color:var(--muted);margin:8px 0 18px}.note{font-size:12px;color:var(--muted);margin-top:16px}.presets{display:flex;flex-wrap:wrap;gap:4px}.presets button{flex:1 1 auto;min-width:min-content}.copy{display:grid;margin-top:16px;font-size:14px}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.controls{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.controls button{min-height:42px}.muted{color:var(--muted)}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.caption{font-size:12px;color:var(--muted)}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.world{display:grid;grid-template-columns:1fr 30px 1fr;align-items:stretch}.actor{min-width:0;padding:16px;border:1px solid var(--rule);border-radius:10px;display:flex;flex-direction:column;gap:14px}.actor header strong,.actor header code,.actor header span{display:block}.actor header strong{font-size:14px}.actor header code,.actor header span{font-size:12px;color:var(--muted)}.work{display:flex;align-items:center;gap:8px;font-size:12px;min-height:3em;color:var(--accent)}.pulse{width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0}.record{border:1px dashed var(--rule);border-radius:8px;padding:12px;transition:background .3s,opacity .3s;min-width:0}.record>span,.parent-result>span{display:block;font-size:12px;color:var(--muted);min-height:2.8em}.record strong,.parent-result strong{display:block;font:600 30px var(--viz-font-ui);margin:10px 0}.record small,.parent-result small{display:block;font-size:12px;color:var(--muted);min-height:4.2em;overflow-wrap:anywhere}.actor button{font-size:13px;min-width:0;min-height:44px}.actor>button{margin-top:auto}.parent-actions{display:grid;gap:8px;margin-top:auto}.parent-result{padding:12px;border:1px solid var(--rule);border-radius:8px;background:var(--surface);min-width:0}.transfer{position:relative;display:flex;align-items:center;justify-content:center;color:var(--muted)}.transfer>span{font-size:20px}.transfer i{position:absolute;top:45%;font-style:normal;color:var(--second);font-weight:700;opacity:0}.transfer.moving i{animation:result-copy .45s ease-in-out}#child.zombie .pulse,#child.reaped .pulse{background:var(--muted)}#child.zombie .work,#child.reaped .work{color:var(--muted)}#child.zombie .record{border-style:solid;border-color:var(--second);background:var(--second-soft)}#child.reaped .record{opacity:.45}#child.reaped header code{text-decoration:line-through}.parent.has-result .parent-result{border-color:var(--accent);background:var(--accent-soft)}details{margin-top:16px;font-size:12px;color:var(--muted)}summary{cursor:pointer;padding:10px 0}.call code{white-space:normal;overflow-wrap:anywhere}.call p{font-family:var(--viz-font-mono);overflow-wrap:anywhere}.controls button{color:var(--muted)}\n@keyframes result-copy{0%{opacity:0;transform:translateX(-10px)}20%{opacity:1}80%{opacity:1}100%{opacity:0;transform:translateX(10px)}}\n@media(max-width:480px){.world{grid-template-columns:1fr 20px 1fr}.actor{padding:10px 8px;gap:10px}.record,.parent-result{padding:10px 8px}.record>span,.parent-result>span{min-height:4.2em}.record small,.parent-result small{min-height:7em}.actor header{min-height:4.5em}.work{min-height:4.2em}.actor button{padding-inline:7px;font-size:12px;min-height:5.2em}}\n","js":"const $ = s => document.querySelector(s);\nlet state = 'live', parentHasResult = false, observed = false, message = 'liveNote', call = '', result = '—';\nfunction draw() {\n $('#child').className = 'actor ' + state;\n $('#work-label').textContent = viz.t(state === 'live' ? 'running' : state === 'zombie' ? 'stopped' : 'gone');\n $('#record-value').textContent = state === 'zombie' ? '7' : '—';\n $('#record-label').textContent = viz.t(state === 'live' ? 'pending' : state === 'reaped' ? 'consumed' : observed ? 'stillHere' : 'waitable');\n $('#parent-value').textContent = parentHasResult ? '7' : '—';\n $('#parent-label').textContent = viz.t(parentHasResult ? 'saved' : 'notReceived');\n document.querySelector('.parent').classList.toggle('has-result', parentHasResult);\n $('#exit').disabled = state !== 'live';\n $('#explanation').textContent = viz.t(message);\n $('#call').textContent = call || viz.t('noCall');\n $('#return-value').textContent = result;\n}\nfunction transfer() {\n const el = document.querySelector('.transfer');\n el.classList.remove('moving'); void el.offsetWidth; el.classList.add('moving');\n}\n$('#exit').addEventListener('click', () => { state = 'zombie'; message = 'zombieNote'; call = '_exit(7)'; result = viz.t('noReturn'); draw(); });\nfor (const kind of ['observe', 'reap']) $('#'+kind).addEventListener('click', () => {\n call = kind === 'observe' ? 'waitid(P_PID, 4201, …, WEXITED | WNOWAIT | WNOHANG)' : 'waitpid(4201, &status, WNOHANG)';\n if (state === 'live') { message = 'earlyNote'; result = kind === 'observe' ? '0; si_pid = 0' : '0'; }\n else if (state === 'reaped') { message = 'againNote'; result = '−1; errno = ECHILD'; }\n else {\n parentHasResult = true;\n if (kind === 'observe') { observed = true; message = 'observedNote'; result = '0; si_pid = 4201; si_status = 7'; }\n else { state = 'reaped'; message = 'reapedNote'; result = '4201; WEXITSTATUS(status) = 7'; }\n transfer();\n }\n draw();\n});\n$('#reset').addEventListener('click', () => { state = 'live'; parentHasResult = false; observed = false; message = 'liveNote'; call = ''; result = '—'; draw(); });\ndraw();\n","audio":false,"strings":{"againNote":"这个子进程已经回收,再次等待返回 ECHILD。父进程之前取得的退出码仍然是 7。","calls":"查看对应系统调用与返回值","child":"子进程","consumed":"已消费,不再可等待","earlyNote":"现在没有退出结果。这里的等待不阻塞,直接返回;子进程继续执行。","exit":"退出,返回 7","eyebrow":"03 · 退出与回收","gone":"已回收","heading":"读到退出码,等于回收了吗?","intro":"子进程保存的退出结果,和父进程取得的结果,放在两边同时看。先让子进程退出,再比较“只观察”和“回收”。","liveNote":"子进程还在执行,没有退出结果。父进程尝试等待,不会替子进程结束运行。","noCall":"尚未调用","noReturn":"不返回;子进程停止执行","nonblocking":"等待调用均加 WNOHANG,便于在子进程尚未退出时尝试;WNOWAIT 控制是否消费结果,WNOHANG 控制是否阻塞。","notReceived":"尚未取得结果","note":"普通可等待的单线程子进程,SIGCHLD 为默认处置。左侧方框表示等待所需的信息,不表示一个独立的内核结构;省略自动回收、ptrace 和延迟释放细节。","observe":"只观察 · WNOWAIT","observedNote":"两边都有 7:父进程读到了结果,但 WNOWAIT 没有消费它。左边仍保留结果,子进程还是 Z。","parent":"父进程","pending":"尚未产生","reap":"回收 · waitpid","reapedNote":"右边保留了取得的 7,左边的可等待结果已被消费,子进程完成回收。","received":"父进程取得的退出码","record":"内核保留的退出结果","reset":"重新开始","running":"程序仍在执行","saved":"父进程已取得该值","stillHere":"仍保留,可再次等待","stopped":"Z · 执行已停止","waitable":"保留中,等待获取","waiting":"执行等待操作的一方","zombieNote":"子进程已停止执行,退出码 7 留在内核中等待处理;父进程还没有取得它。这就是这里的 Z 状态。"}}
两种情况会改变通常的回收关系:显式设置 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 后,再分别去问排队时间、等待对象、父任务回收逻辑,才能从状态符号推进到原因。