3.4 进程描述与创建
内核用什么数据结构表示一个进程,它凭什么有 9 KiB?fork() 复制了整个地址空间,为什么 return 得那么快,而写一个字节却可能触发一次拷贝?线程和进程在内核眼里差在哪几个 CLONE_* 标志?一个进程退出后,它的 task_struct 什么时候才真正消失?
画出 task_struct 的关键字段与它们指向的对象;按顺序说出 copy_process() 的十几个步骤;解释 COW 的触发点与代价;用 clone() 手写一个线程并说明它和 fork() 的差别;读懂 /proc/PID/status 里的 Tgid、NSpgid 与僵尸状态。
3.3 讲了怎么进入内核。现在问:进来之后,内核怎么知道「当前是谁」?current 这个宏从哪里取到指针?一个进程和另一个进程共享哪些东西、独享哪些东西?
进程是内核里最复杂的一个对象。Linux 的 task_struct 把「执行流」与「资源」揉在一起,再用一组 CLONE_* 标志决定 fork 时哪些资源共享、哪些复制——线程不过是「共享得更多的进程」。理解了这套标志,fork/vfork/clone/pthread_create/容器里的 PID 1 就都能用同一张图解释。
本章顺序:先看对象(task_struct、内核栈、current),再看共享规则(CLONE_* 与 pid/tgid),然后走 kernel_clone() → copy_process() 的创建路径、COW 的触发点、do_exit() 的退出路径,最后用两个实验把「共享」与「复制」的差别测出来。母本对照:奔跑吧 3.1(进程的诞生)、李治军第 3 章(多进程)、Kerrisk 第 24–27 章(用户态接口)。
3.4.1 task_struct:9 KiB 里的全部状态
task_struct 是内核描述「一个可调度实体」的结构体,定义在 include/linux/sched.h。它同时承担三种角色:调度单位(sched_entity、prio、policy)、资源持有者(mm、files、fs、nsproxy、cred)、状态容器(__state、退出码、信号、统计)。x86-64 上 6.8 的 sizeof(struct task_struct) 约 9 KiB(随配置浮动),从 task_struct_cachep 这个 slab 缓存分配。
字段太多,按「指向什么」分组记:
┌──────────────────────────────┐
│ task_struct │
├──────────────────────────────┤
调度 ─────────────────┤ __state prio static_prio │
│ policy cpus_ptr mems_allowed
│ se (sched_entity) rt dl │
身份 ─────────────────┤ pid tgid comm[TASK_COMM_LEN]│
│ cred *real_cred *cred │
地址空间 ─────────────┤ mm ───────────► mm_struct │
│ active_mm(内核线程借用) │
文件 ─────────────────┤ files ─────────► files_struct│
│ fs ────────────► fs_struct │
命名空间 ─────────────┤ nsproxy ───────► nsproxy │
信号 ─────────────────┤ signal sighand blocked │
执行 ─────────────────┤ stack(内核栈基址) │
│ thread(thread_struct:sp/ip/fsbase)
│ thread_info(嵌入在结构体头部)│
└──────────────────────────────┘
几个容易记混的点:
__state不是state。6.x 里字段名是__state,配套宏task_is_running()、READ_ONCE(p->__state)。直接读p->state在 5.14 之后编译不过。pid与tgid:pid是内核给这个 task 的唯一号(线程各有各的),tgid是线程组号(等于主线程的pid)。用户态getpid()返回的是tgid,gettid()返回pid。mm与active_mm:用户进程的mm非空;内核线程mm == NULL,借用上一个用户进程的active_mm,避免每次切换都换页表(lazy TLB)。thread_info嵌在task_struct头部(CONFIG_THREAD_INFO_IN_TASK),不再是内核栈的起点。所以「栈溢出踩到 thread_info」这种老故事在 6.8 上不成立,栈溢出会直接踩到相邻内存或 guard page。
current 是「当前 CPU 上正在运行的 task」的宏。x86-64 上它展开为 get_current(),读 per-CPU 变量 current_task(this_cpu_read_stable(current_task)),不再像 2.6 那样用栈指针按 THREAD_SIZE 对齐后掩码计算。
每个 task 有一个内核栈,x86-64 默认 THREAD_SIZE = 16 KiB(order-2 的 4 页)。CONFIG_VMAP_STACK=y 时栈从 vmalloc 区分配,前后各有一页 guard page,溢出会触发 #PF 而不是静默踩坏数据。task_struct->stack 指向栈基址,task_struct->thread.sp 保存切换时的栈指针。
内核栈只有 16 KiB,这是个硬约束:任何在栈上放大数组(char buf[8192])的代码都在赌命。内核有 CONFIG_DEBUG_STACK_USAGE 与 check_stack_usage() 统计每个任务用过的最大栈深,/proc/PID/stack 能看到内核栈上的调用链(需要 CONFIG_STACKTRACE 与 root)。
进程状态迁移:新建 → 就绪 → 运行 → 阻塞 → 就绪 → 运行 → 退出。开启 JavaScript 可播放动画。
3.4.2 进程与线程:只差一组标志
Linux 不区分「进程」与「线程」两种对象,只有 task。区别在于创建时共享了哪些资源:
| 资源 | 字段 | fork() | pthread_create()(clone 标志) |
|---|---|---|---|
| 地址空间 | mm | 复制(COW) | 共享(CLONE_VM) |
| 文件描述符表 | files | 复制 | 共享(CLONE_FILES) |
| 文件系统信息(cwd/root) | fs | 复制 | 共享(CLONE_FS) |
| 信号处理函数表 | sighand | 复制 | 共享(CLONE_SIGHAND) |
| 信号待处理队列 | signal | 复制 | 共享(CLONE_THREAD 隐含) |
| 凭据 | cred | 复制 | 共享(CLONE_THREAD 隐含) |
| 命名空间 | nsproxy | 复制引用 | 共享(默认) |
| 线程组 | tgid | 新组 | 同组(CLONE_THREAD) |
pthread_create() 最终调用的是:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID, ...);
CLONE_THREAD 是「成为线程」的关键:它让新 task 与调用者同属一个线程组,共享 tgid,并且不产生新的 SIGCHLD——线程退出不会通知父进程,只有组内最后一个线程退出时才会。CLONE_CHILD_CLEARTID 配合 futex 实现 pthread_join() 的等待:线程退出时内核把 tid 清零并唤醒等在 futex 上的 joiner。
因为内核里根本没有为线程单独设计一个结构。所谓线程,只是「fork 时把 mm/files/fs/sighand 的指针复制一份而不是深拷贝,并打上 CLONE_THREAD 标记」。这样做的好处是调度器只面对一种对象,坏处是用户态必须自己维护 TLS、栈、线程局部存储——这些在 pthread 库里,不在内核里。
pid 的组织方式也随之分层。struct pid 不直接存一个整数,而是存「在每个命名空间里的编号」:
/* include/linux/pid.h —— 6.8 节选 */
struct pid {
refcount_t count;
unsigned int level; /* 所在命名空间的层数 */
spinlock_t lock;
struct hlist_head tasks[PIDTYPE_MAX]; /* PID/PGID/SID/TGID 四种用途 */
struct rcu_head rcu;
struct upid numbers[]; /* 每层一个编号 */
};
struct upid {
int nr; /* 在该命名空间里的数值 */
struct pid_namespace *ns;
};
同一个进程在宿主命名空间里是 12345,在容器里可能是 1。pid_nr_ns(pid, ns) 按命名空间取对应的 nr,task_pid_nr() 取初始命名空间的值。这也解释了为什么容器里的 PID 1 在宿主上只是普通进程。
3.4.3 创建:kernel_clone 与 copy_process
所有创建路径——fork()、vfork()、clone()、clone3()、pthread_create()、kernel_thread()——最终都进 kernel/fork.c: kernel_clone():
/* kernel/fork.c: kernel_clone() —— 6.8 节选,已简化 */
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
int trace = 0;
pid_t nr;
if ((clone_flags & (CLONE_NEWUSER | CLONE_FS)) == (CLONE_NEWUSER | CLONE_FS))
return -EINVAL;
...
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
if (IS_ERR(p))
return PTR_ERR(p);
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
...
wake_up_new_task(p);
...
return nr;
}
copy_process() 是真正的重头戏,顺序不能乱:
/* kernel/fork.c: copy_process() —— 6.8 节选,省略错误处理 */
retval = dup_task_struct(current, node); /* task_struct + 内核栈 */
retval = copy_creds(p, clone_flags); /* 凭据(COW 或复制) */
retval = sched_fork(clone_flags, p); /* 调度器初始化,状态 TASK_NEW */
retval = copy_files(clone_flags, p); /* fd 表 */
retval = copy_fs(clone_flags, p); /* cwd/root */
retval = copy_sighand(clone_flags, p); /* 信号处理函数表 */
retval = copy_signal(clone_flags, p); /* 信号队列与统计 */
retval = copy_mm(clone_flags, p); /* 地址空间 → dup_mm() */
retval = copy_namespaces(clone_flags, p); /* 命名空间 */
retval = copy_io(clone_flags, p); /* io_context(AIO/io_uring) */
retval = copy_thread(p, args); /* 寄存器与栈,返回地址 ret_from_fork */
pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid, args->set_tid_size);
...
wake_up_new_task(p); /* 入队,可能抢占当前 */
每一步都能单独写一章,这里只抓三条主线:
dup_task_struct()复制的是「描述」,不是「内存」。它分配一个新的task_struct,把父进程的字段整体拷过来,再分配一个新的内核栈,把父进程的pt_regs复制到栈顶——子进程从ret_from_fork开始执行,fork()在用户态返回 0 就是这么来的。copy_mm()只复制页表,不复制物理页。它调用dup_mm()→dup_mmap(),遍历父进程的每个 VMA,复制 VMA 结构,复制页表项,并把两边的 PTE 都改成只读。物理页的引用计数加一。这就是 COW(copy-on-write)。alloc_pid()分配的是struct pid与各层编号。idr_alloc_cyclic()在pid_namespace->idr里找一个空号,默认上限pid_max = 32768(systemd 常调到 4194304)。号用完不会立即复用,pid_max越大越不容易回绕。
4.x 与 5.9 之前,这个函数叫 do_fork(),copy_process() 的签名也多一个 struct pt_regs *regs 参数。5.10 引入 kernel_clone(struct kernel_clone_args *) 统一了 fork/vfork/clone 的入口,do_fork 被删除。读旧书时看到 do_fork,把它当 kernel_clone 即可。
三种用户态入口的差别:
| 调用 | 关键标志 | 语义 | 典型用途 |
|---|---|---|---|
fork() | 无 | 完整复制,父子并发 | 经典多进程 |
vfork() | CLONE_VM | CLONE_VFORK | 共享地址空间,父进程阻塞到子进程 exec/exit | posix_spawn 的快速路径 |
clone() | 任意组合 | 按标志共享 | 线程、命名空间 |
clone3() | 同上 + struct clone_args | 可扩展的结构体参数 | 新代码(5.3+) |
vfork() 的历史价值是「不复制页表」,在 fork 昂贵的年代很快;今天 fork + COW 已经足够快,vfork 的共享地址空间反而危险(子进程改一个变量会直接影响父进程),新代码应优先 posix_spawn() 或 clone3()。
3.4.4 COW:fork 快的真正原因
读图:fork 之后子进程进入 TASK_RUNNING(就绪队列),退出时先到 TASK_ZOMBIE,等父进程 wait() 才到 EXIT_DEAD。图里没有画 COW,但它是「就绪」之前发生的事。
fork() 时,内核让父子进程的页表项指向同一批物理页,并把两边的 PTE 都设为只读(清除 _PAGE_RW),同时把物理页的 _refcount 加一。任何一方写入时触发 #PF(保护错误),do_wp_page() 发现这是 COW 页,分配一个新页、复制内容、把写方的 PTE 指向新页并恢复可写。没被写过的页始终共享。
内核侧的两个关键函数:
/* mm/memory.c: copy_present_pte() —— 6.8 节选,已简化 */
static inline void copy_present_pte(struct vm_area_struct *dst_vma,
struct vm_area_struct *src_vma,
pte_t *dst_pte, pte_t *src_pte,
pte_t pte, unsigned long addr)
{
struct page *page = vm_normal_page(src_vma, addr, pte);
if (page)
get_page(page); /* 引用计数 +1,两边共享 */
...
pte = pte_wrprotect(pte); /* 两边都改成只读 */
pte = pte_mkold(pte);
set_pte_at(dst_vma->vm_mm, addr, dst_pte, pte);
}
写侧的入口是缺页异常:
/* mm/memory.c: do_wp_page() —— 6.8 节选,已简化 */
vm_fault_t do_wp_page(struct vm_fault *vmf)
{
struct folio *folio = page_folio(vmf->page);
if (unlikely(folio_test_ksm(folio)))
return wp_page_copy(vmf); /* KSM 页必须复制 */
...
if (folio_ref_count(folio) + folio_mapcount(folio) == 2 &&
!folio_test_swapcache(folio)) {
/* 只有当前映射,独占:直接复用,不必复制 */
folio_make_writable(folio);
...
return 0;
}
return wp_page_copy(vmf); /* 共享:分配新页并复制 */
}
代价要算清楚:
fork()的成本 = 复制页表 + 复制 VMA + 复制task_struct。与进程的虚拟内存大小成正比,与 RSS 关系不大。一个映射了 10 GiB 但只碰了几页的进程,fork要复制几万个 PTE,仍然慢。- 第一次写每一页的成本 = 一次缺页 + 一次 4 KiB 复制。写 1 GiB 匿名内存,等于 26 万次缺页。
fork之后立刻exec是最常见的组合,此时 COW 的复制全部白做。posix_spawn()/vfork()就是为这个场景优化的。
- 「
fork()会复制内存,所以大进程 fork 很慢」:复制的是页表,不是内存。慢在页表项数量,不在数据量。 - 「COW 只发生在匿名页」:文件私有映射(
MAP_PRIVATE)也走 COW;共享映射(MAP_SHARED)不复制,写入直接进 page cache。 - 「
fork()之后父子谁先跑是确定的」:不确定。调度器决定,vfork才保证父进程阻塞。 - 「
vfork()的子进程可以随便改变量」:它和父进程共享地址空间,改变量会直接影响父进程,行为未定义。子进程只能立刻execve或_exit。 - 「
MADV_WIPEONFORK能省掉 COW」:它是让子进程看到清零的页(用于密钥等敏感数据),不是共享优化。真正省 COW 的是MADV_DONTFORK(子进程不映射该区域)。
3.4.5 新进程什么时候开始跑
copy_process() 返回时,新 task 已经完整,但还没被调度。它的 __state 是 TASK_NEW,不在任何运行队列里。wake_up_new_task() 做三件事:
p->__state = TASK_RUNNING;activate_task(rq, p, ENQUEUE_WAKEUP)把sched_entity插入运行队列(EEVDF 下是红黑树,按deadline排序);check_preempt_curr(rq, p, WF_FORK)判断是否该抢占当前进程。
第 3 步是「子进程可能先于父进程运行」的原因:如果新进程的权重更高、或者父进程的切片已用完,resched_curr() 会置 TIF_NEED_RESCHED,父进程从 fork() 返回后不久就被抢占。
copy_thread() 决定了子进程从哪里开始执行:
/* arch/x86/kernel/process.c: copy_thread() —— 6.8 节选,已简化 */
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
unsigned long clone_flags = args->flags;
struct inactive_task_frame *frame;
struct fork_frame *fork_frame;
struct pt_regs *childregs;
childregs = task_pt_regs(p);
fork_frame = container_of(childregs, struct fork_frame, regs);
frame = &fork_frame->frame;
frame->bp = encode_frame_pointer(childregs);
frame->ret_addr = (unsigned long) ret_from_fork_asm;
p->thread.sp = (unsigned long) fork_frame;
p->thread.io_bitmap = NULL;
...
if (unlikely(p->flags & PF_KTHREAD)) {
/* 内核线程:从 kthread 函数开始,不返回用户态 */
p->thread.pkru = 0;
memset(childregs, 0, sizeof(struct pt_regs));
frame->bx = (unsigned long) args->fn;
frame->r12 = (unsigned long) args->fn_arg;
} else {
*childregs = *current_pt_regs();
childregs->ax = 0; /* 子进程 fork() 返回 0 */
...
}
return 0;
}
ret_from_fork_asm → ret_from_fork() 是新 task 的第一段内核代码。对用户进程,它走 syscall_exit_to_user_mode() 回到用户态;对内核线程,它调用 args->fn(fn_arg),返回后 do_exit()。
3.4.6 退出:do_exit 与僵尸
退出路径的核心矛盾是:资源要立刻还,但退出码要留着给父进程。do_exit() 把这两件事分开:
/* kernel/exit.c: do_exit() —— 6.8 节选,已简化 */
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
...
exit_signals(tsk); /* 设置 PF_EXITING,处理信号 */
exit_mm(); /* 释放地址空间(若 mm_users 归零) */
exit_sem(tsk);
exit_files(tsk); /* 关 fd */
exit_fs(tsk); /* 释放 cwd/root */
exit_task_namespaces(tsk);
exit_task_work(tsk);
exit_thread(tsk);
...
exit_notify(tsk, group_dead); /* 通知父进程,重新托孤 */
...
do_task_dead(); /* 设置 EXIT_DEAD,schedule() 不再返回 */
}
exit_notify() 做两件容易被忽略的事:
- 重新托孤:如果父进程已死,子进程要么交给
PR_SET_CHILD_SUBREAPER设置的 subreaper,要么交给 PID 1(find_child_reaper())。这就是「孤儿进程被 init 收养」的实现。 - 决定是否变僵尸:如果父进程还在且没有设
SA_NOCLDWAIT,task 进入EXIT_ZOMBIE,保留task_struct、退出码、统计信息,等wait4()取走。父进程不 wait,僵尸就一直在——每个僵尸占一个task_struct,不占内存页。
wait4() 的路径:kernel_wait4() → do_wait() → 遍历子进程 → wait_consider_task() → wait_task_zombie() 取退出码,最后 release_task() 做 __exit_signal()、__unhash_process()、put_task_struct()。
退出码的编码是 POSIX 的位约定,用宏读,不要手算:
int status;
pid_t pid = waitpid(child, &status, 0);
if (pid > 0) {
if (WIFEXITED(status))
printf("exited, code=%d\n", WEXITSTATUS(status)); /* (status>>8)&0xff */
else if (WIFSIGNALED(status))
printf("killed by signal %d, core=%d\n",
WTERMSIG(status), WCOREDUMP(status)); /* status & 0x7f */
}
ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/'找到僵尸。- 看
PPID:父进程是谁。僵尸本身杀不掉(已经死了),只能等父进程wait()。 - 父进程正常却不 wait:查代码里
SIGCHLD是否被设为SIG_IGN(那会自动回收)或SA_NOCLDWAIT;也可能是父进程卡在别处。 - 父进程是 PID 1 且僵尸长期不消失:容器里的 PID 1 没实现
wait(),这是「容器不回收僵尸」的经典原因,解法是让 PID 1 用tini/dumb-init。
3.4.7 命名空间:同一棵树的不同视图
nsproxy 把一组命名空间指针打包,copy_namespaces() 按 CLONE_NEW* 决定新建还是共享:
/* include/linux/nsproxy.h —— 6.8 节选 */
struct nsproxy {
refcount_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns;
};
三个常被问到的细节:
CLONE_NEWPID影响的是子进程,不是调用者。调用者仍在原命名空间,新建的命名空间由它的子进程「进入」。所以unshare(CLONE_NEWPID)之后getpid()不变,只有fork()出来的孩子看到 PID 1。- PID 1 在命名空间里是特殊的:没有父进程可托孤,收到未被处理的信号时内核直接忽略(除了
SIGKILL/SIGSTOP由内核自己处理),这解释了「容器里 kill -9 pid 1 有时没用」。 setns()只能进入已有的命名空间,不能进 PID 命名空间改变自己的 PID(只能影响后代)。这也是nsenter需要fork的原因。
pidfd(5.3+)是这套机制的现代接口:pidfd_open(pid, 0) 得到一个指向 struct pid 的 fd,可以 poll() 等退出、pidfd_send_signal() 发信号,避免了「pid 复用导致杀错进程」的经典竞态。clone3() 的 CLONE_PIDFD 可以在创建时直接拿到 pidfd。
3.4.8 实验:把「共享」与「复制」测出来
第一个实验是一个内核模块:遍历 for_each_process,打印每个进程的 pid、tgid、状态、mm 是否存在、comm。它演示 current、RCU 保护的遍历、以及内核线程 mm == NULL。
// ps_demo.c — 内核态版的 ps(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/sched/signal.h>
#include <linux/mm.h>
static int __init ps_demo_init(void)
{
struct task_struct *p;
int count = 0;
rcu_read_lock(); /* for_each_process 要求 RCU 读锁 */
for_each_process(p) {
/* task_struct 在 RCU 宽限期内有效,字段用 READ_ONCE 读 */
pr_info("pid=%-6d tgid=%-6d state=%-2u mm=%c comm=%s\n",
task_pid_nr(p), task_tgid_nr(p),
READ_ONCE(p->__state),
p->mm ? 'Y' : 'N', p->comm);
if (++count >= 20)
break;
}
rcu_read_unlock();
pr_info("ps_demo: printed %d tasks (current=%s pid=%d)\n",
count, current->comm, current->pid);
return 0;
}
static void __exit ps_demo_exit(void)
{
pr_info("ps_demo: unloaded\n");
}
module_init(ps_demo_init);
module_exit(ps_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("walk task list and dump basic fields");
加载后 dmesg 里应看到 kthreadd 的 mm=N、systemd 的 mm=Y,以及内核线程的 tgid == pid。这个模块只读,不改任何状态,是安全的实验。
第二个实验是用户态程序:验证 COW。父进程写一个全局变量,fork() 后子进程改它,父进程看不到变化;再用 mmap(MAP_SHARED) 做对照,子进程的修改父进程能看到。
/* cow_demo.c — 验证 fork 的 COW 与 MAP_SHARED 的差别
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o cow_demo cow_demo.c
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
static int global_var = 100;
int main(void)
{
int *shared = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (shared == MAP_FAILED) { perror("mmap"); return 1; }
*shared = 200;
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
global_var = 999; /* 触发 COW:复制一页 */
*shared = 888; /* 共享映射:直接写同一页 */
printf("child : global=%d shared=%d\n", global_var, *shared);
_exit(0);
}
waitpid(pid, NULL, 0);
printf("parent: global=%d shared=%d\n", global_var, *shared);
munmap(shared, 4096);
return 0;
}
预期输出:
child : global=999 shared=888
parent: global=100 shared=888
global 在父进程仍是 100(COW 隔离),shared 变成 888(真共享)。把 MAP_SHARED 改成 MAP_PRIVATE,父进程会看到 200——私有映射的写也走 COW。
一个只读大文件、RSS 很小的进程,fork() 的耗时主要花在复制页表上。8 GiB 的虚拟地址、4 KiB 页、每页一个 PTE,约 200 万个 PTE;每个 PTE 复制约几纳秒,加上 VMA 遍历与锁,实测在 20–80 ms 量级。如果这 8 GiB 是已映射且已触碰的匿名内存,fork 本身不变,但子进程写每个页都会触发一次 COW 缺页,写完 8 GiB 需要额外几十万次缺页,耗时以秒计。
结论:fork 的成本由映射规模决定,写时复制的成本由实际写入量决定。优化大内存进程的 fork,要么改用 vfork/posix_spawn(立刻 exec),要么用线程(共享地址空间),要么把大块内存放到 MAP_SHARED 文件映射里(fork 不复制页表项?仍然复制 PTE,但页不复制)。
3.4.9 观测:/proc 里的进程视图
| 路径 | 内容 | 关键字段 |
|---|---|---|
/proc/PID/status | 概要 | State、Tgid、Pid、PPid、Threads、VmRSS、VmPTE |
/proc/PID/stat | 一行数字 | 第 4 字段是 ppid,第 14/15 是 utime/stime |
/proc/PID/task/ | 线程目录 | 每个 TID 一个子目录 |
/proc/PID/maps | VMA 列表 | 地址范围、权限、偏移、路径 |
/proc/PID/smaps | VMA + 统计 | Rss、Pss、Shared_Clean、Private_Dirty |
/proc/PID/ns/ | 命名空间 | 符号链接指向 pid:[4026531836] 形式 |
/proc/PID/stack | 内核栈回溯 | 需 root + CONFIG_STACKTRACE |
/proc/PID/syscall | 当前系统调用 | 号、参数、栈指针、PC |
/proc/PID/wchan | 睡眠点 | 内核函数名(futex_wait_queue 等) |
三个实用组合:
# 1. 进程的所有线程及其状态(L 显示线程,-o 选字段)
ps -eLf -o pid,tid,psr,stat,comm | awk '$2 != $1'
# 2. 谁在等 futex(Java/Go 程序卡住时先看这个)
for t in /proc/$PID/task/*; do
printf '%s %s\n' "$(basename "$t")" "$(cat "$t"/wchan 2>/dev/null)"
done | sort -k2 | uniq -c -f1 | sort -rn | head
# 3. 线程数与 VmPTE(页表占用)
grep -E 'Threads|VmPTE|VmRSS' /proc/$PID/status
VmPTE 是页表本身占用的内存。一个 fork 频繁、映射很大的服务,VmPTE 可能到几百 MiB——这部分内存不在 VmRSS 里,容易被漏算。VmPMD 是页中间表,同样要看。
3.4.11 task_struct 的字段级解剖:9 KiB 里还有什么
这一节回答:除了 3.4.1 那张分组图,task_struct 里还有哪些字段决定了行为、哪些字段是「每线程」而哪些是「每进程」、thread_info 与内核栈在 6.8 怎么摆。
3.4.1 给了骨架。真正读代码时,下面这些字段出现频率最高,且最容易和相邻概念混淆。
| 字段 | 类型(6.8) | 含义 | 每线程/每进程 |
|---|---|---|---|
__state | unsigned int | 任务状态(TASK_RUNNING 等) | 每线程 |
stack | void * | 内核栈基址 | 每线程 |
flags | unsigned int | PF_* 标志(PF_EXITING、PF_KTHREAD、PF_VCPU…) | 每线程 |
ptrace | unsigned int | 被调试状态(PT_PTRACED、PT_SEIZED) | 每线程 |
on_cpu / on_rq | int | 是否正在 CPU 上/在运行队列里 | 每线程 |
prio / static_prio / normal_prio | int | 动态/静态/普通优先级 | 每线程 |
rt_priority | unsigned int | 实时优先级(1–99) | 每线程 |
policy | unsigned int | SCHED_NORMAL/FIFO/RR/DEADLINE/IDLE | 每线程 |
se / rt / dl | 调度实体 | CFS/RT/DL 的调度数据 | 每线程 |
sched_class | const struct sched_class * | 当前调度类 | 每线程 |
mm / active_mm | struct mm_struct * | 地址空间/借用的地址空间 | 每线程(线程共享 mm) |
files | struct files_struct * | fd 表 | 可共享(CLONE_FILES) |
fs | struct fs_struct * | cwd/root/umask | 可共享(CLONE_FS) |
nsproxy | struct nsproxy * | 命名空间集合 | 可共享 |
signal | struct signal_struct * | 线程组共享的信号/统计/定时器 | 每进程(线程组共享) |
sighand | struct sighand_struct * | 信号处理函数表 | 可共享(CLONE_SIGHAND) |
blocked / real_blocked | sigset_t | 阻塞信号集 | 每线程 |
cred / real_cred | const struct cred * | 有效/真实凭据 | 每线程指针,线程共享同一 cred |
pid / tgid | pid_t | 线程 ID / 线程组 ID | 每线程 |
group_leader | struct task_struct * | 线程组组长 | 每线程指针 |
thread_group / thread_node | list_head | 线程组链表 | 每线程 |
parent / real_parent | struct task_struct * | 当前父/亲生父(ptrace 会改 parent) | 每线程 |
children / sibling | list_head | 子进程/兄弟链 | 每线程 |
exit_state / exit_code | int | 退出状态与退出码 | 每线程 |
utime / stime / gtime | u64 | 用户态/内核态/guest 时间(ns) | 每线程 |
start_time / start_boottime | u64 | 创建时刻(monotonic/boottime) | 每线程 |
nvcsw / nivcsw | unsigned long | 自愿/非自愿上下文切换 | 每线程 |
seccomp | struct seccomp | seccomp 模式与过滤器 | 每线程 |
security | void * | LSM 私有数据 | 每线程 |
cgroups | struct css_set * | 所属 cgroup 集合 | 每线程 |
rcu | struct rcu_head | 延迟释放用 | 每线程 |
「每进程」那一列的 signal 是理解线程组的关键:utime/stime 虽然记在每个 task 上,但 getrusage(RUSAGE_SELF) 读的是 signal->stats 里汇总后的值;alarm()、setitimer(ITIMER_REAL) 也是线程组共享的,只有 ITIMER_VIRTUAL/PROF 按线程计。
thread_info 与内核栈
6.8 的 thread_info 很小,且嵌在 task_struct 里(CONFIG_THREAD_INFO_IN_TASK=y):
/* arch/x86/include/asm/thread_info.h —— 6.8 节选 */
struct thread_info {
unsigned long flags; /* TIF_* 低层标志 */
unsigned long syscall_work; /* 系统调用返回时要处理的工作 */
u32 status; /* 线程同步状态 */
u32 cpu; /* 当前 CPU(CONFIG_SMP) */
};
它不再位于内核栈顶端,所以 current 不能靠「栈指针与 THREAD_SIZE 对齐」算出来。x86-64 的 current 读 per-CPU 的 current_task:
/* arch/x86/include/asm/current.h —— 6.8 节选 */
static __always_inline struct task_struct *get_current(void)
{
return this_cpu_read_stable(current_task);
}
#define current get_current()
this_cpu_read_stable() 告诉编译器「这个 per-CPU 变量在上下文切换后由内核更新」,允许它把值缓存到寄存器——比每次都读 %gs 快,但要求切换代码正确更新 current_task(__switch_to() 里做)。
内核栈的布局:
高地址
┌──────────────────────────────┐
│ pt_regs(用户态寄存器) │ ← task_pt_regs(p)
├──────────────────────────────┤
│ 内核栈(向下增长) │
│ ... │
│ 当前 RSP │
└──────────────────────────────┘
低地址
thread.sp 指向切换时保存的栈帧(inactive_task_frame)
THREAD_SIZE 在 x86-64 是 16 KiB(4 * PAGE_SIZE,order-2)。CONFIG_VMAP_STACK=y 时栈从 vmalloc 区分配,前后各加一页 guard page;栈溢出触发 #PF,而不是静默踩坏相邻内存。CONFIG_DEBUG_STACK_USAGE 会在 check_stack_usage() 里统计「历史最低剩余」,启动日志里的 stack guard gap 与 /proc/PID/stack 都依赖它。
task_struct 本身从 task_struct_cachep 分配,fork_init() 按物理内存大小算 max_threads,并创建这个 slab cache。9 KiB 的对象跨越 3 个 4 KiB 页,SLUB 会为它单独建 order-2 的 slab。
- 「
p->state还能用」:5.14 起字段改名为__state,直接访问编译失败;用READ_ONCE(p->__state)或task_is_running()。 - 「
current在任何上下文都指向一个用户进程」:中断上下文里current是被中断的任务,不代表它还在运行;内核线程的mm == NULL。 - 「
task_struct里存了内核栈」:只存了基址stack;栈内存在alloc_thread_stack_node()里单独分配,CONFIG_VMAP_STACK下是 vmalloc。 - 「线程的
cred是独立的」:CLONE_THREAD时copy_creds()直接共享父线程的cred指针;一个线程setuid()会影响整个线程组,这是 POSIX 的要求。 - 「
task_struct释放靠kfree」:它从task_struct_cachep分配,必须kmem_cache_free();栈要用put_task_stack()单独释放。
3.4.12 copy_process 的失败路径:每个 goto 都是一次回滚
这一节回答:copy_process() 中途失败会留下什么、错误标签为什么按相反顺序排列、clone3() 比 clone() 多了什么。
3.4.3 给了 copy_process() 的成功顺序。真正体现功力的是失败路径:十几步里任何一步失败,都要把前面已建立的资源按相反顺序拆掉。
错误标签链
/* kernel/fork.c: copy_process() —— 6.8 节选,只保留标签 */
bad_fork_free_pid:
if (pid != &init_struct_pid)
free_pid(pid);
bad_fork_cancel_cgroup:
cgroup_cancel_fork(p);
bad_fork_cleanup_thread:
exit_thread(p);
bad_fork_cleanup_io:
if (p->io_context)
exit_io_context(p);
bad_fork_cleanup_namespaces:
exit_task_namespaces(p);
bad_fork_cleanup_mm:
if (p->mm) {
mmap_write_lock(p->mm);
mmput(p->mm);
mmap_write_unlock(p->mm);
}
bad_fork_cleanup_signal:
if (!(clone_flags & CLONE_THREAD))
free_signal_struct(p->signal);
bad_fork_cleanup_sighand:
__cleanup_sighand(p->sighand);
bad_fork_cleanup_fs:
exit_fs(p);
bad_fork_cleanup_files:
exit_files(p);
bad_fork_cleanup_semundo:
exit_sem(p);
bad_fork_cleanup_creds:
if (retval == -EAGAIN)
p->cred = p->real_cred = get_cred(p->cred);
bad_fork_cleanup_sched:
put_task_struct(p);
bad_fork_free_task:
free_task(p);
读这段代码的方法:从失败点往上倒着看。比如 alloc_pid() 失败跳到 bad_fork_cancel_cgroup,它只取消 cgroup 与 pid,不会碰 mm/files——因为那些还没建立。每一步的标签只清理「这一步之前已经成功的事」,多清一个就是 double free,少清一个就是泄漏。
bad_fork_cleanup_creds 的 -EAGAIN 分支值得注意:copy_creds() 在 CLONE_THREAD 且 cred 已被别的线程替换时会返回 -EAGAIN,此时新 task 还没持有自己的 cred 引用,必须补一个 get_cred() 再走清理,否则 put_cred() 会把共享的 cred 释放掉。
失败原因与 errno
| 失败点 | 原因 | errno | 现象 |
|---|---|---|---|
dup_task_struct | 内存不足/线程数超 max_threads | ENOMEM/EAGAIN | fork: Resource temporarily unavailable |
copy_creds | CLONE_THREAD 与 cred 竞争 | EAGAIN | 极罕见,重试即可 |
sched_fork | 调度器初始化失败 | EAGAIN | 实时任务超出 RT 带宽 |
copy_files | fd 表复制/RLIMIT_NOFILE | ENOMEM/EMFILE | 打开文件过多 |
copy_sighand | 信号处理表分配 | ENOMEM | 内存不足 |
copy_mm | dup_mmap 失败 | ENOMEM | 页表/VMA 分配失败 |
copy_namespaces | 命名空间创建/权限 | EPERM/ENOMEM | CLONE_NEWUSER 不允许 |
copy_io | io_context 分配 | ENOMEM | — |
copy_thread | 架构相关 | ENOMEM | — |
alloc_pid | PID 耗尽 | EAGAIN | fork: Cannot allocate memory(实际是号用完) |
sched_cgroup_fork | cgroup 迁移 | EAGAIN | — |
三条实践结论:
EAGAIN不等于「内存不足」:RLIMIT_NPROC、max_threads、PID 耗尽、sched_fork都可能返回它。fork()失败时先看ulimit -u与/proc/sys/kernel/pid_max。- PID 耗尽是「慢故障」:
pid_max默认 32768,回绕后新进程复用旧号;如果有进程长期不回收,alloc_pid会一直失败。/proc/sys/kernel/ns_last_pid能看到当前分配位置。 - 错误分支里的
mmput()要拿mmap_write_lock:释放地址空间会改页表,必须与并发的缺页/mmap串行化;这也是copy_process里少数几处显式拿mmap_write_lock的地方。
clone3() 与 clone() 的差别
clone() 的参数是「标志 + 栈 + 5 个可选参数」,无法扩展;clone3() 用结构体:
/* include/uapi/linux/sched.h —— 6.8 节选 */
struct clone_args {
__aligned_u64 flags;
__aligned_u64 pidfd;
__aligned_u64 child_tid;
__aligned_u64 parent_tid;
__aligned_u64 exit_signal;
__aligned_u64 stack;
__aligned_u64 stack_size;
__aligned_u64 tls;
__aligned_u64 set_tid;
__aligned_u64 set_tid_size;
__aligned_u64 cgroup;
};
clone3() 多出的能力:
| 字段 | 作用 | 对应旧接口 |
|---|---|---|
pidfd + CLONE_PIDFD | 创建时直接拿到 pidfd | 之后 pidfd_open() |
set_tid + set_tid_size | 指定 PID(需要 CAP_CHECKPOINT_RESTORE) | 无 |
cgroup + CLONE_INTO_CGROUP | 直接放进目标 cgroup | 创建后写 cgroup.procs |
stack_size | 显式栈大小 | 由调用者算 |
exit_signal | 单独字段,语义更清楚 | 放在 flags 低位 |
set_tid 是 CRIU 热迁移的关键:恢复进程时要保持原来的 PID;CLONE_INTO_CGROUP 则避免了「创建后再迁移」的窗口期。两者都需要额外权限,普通进程用不到。
vfork() 的语义是「共享地址空间 + 父进程阻塞」,子进程只能 execve 或 _exit。posix_spawn() 在 glibc 里优先用 clone(CLONE_VM | CLONE_VFORK) + execve,失败再退回 fork。新代码用 posix_spawn(),不要直接 vfork()——后者在子进程里调用任何非 async-signal-safe 函数都是未定义行为。
3.4.13 struct pid 与 PID 命名空间:一个进程为什么有多个号
这一节回答:struct pid 为什么不存一个整数、task_pid_nr() 与 pid_vnr() 有什么区别、pidfd 怎么避免「杀错进程」。
3.4.7 提到 struct pid 存「每层一个编号」。展开看它的查找与生命周期。
数据结构
/* include/linux/pid.h —— 6.8 节选 */
struct pid {
refcount_t count;
unsigned int level; /* 命名空间层数,初始命名空间为 0 */
spinlock_t lock;
struct hlist_head tasks[PIDTYPE_MAX];
struct rcu_head rcu;
struct upid numbers[];
};
struct upid {
int nr;
struct pid_namespace *ns;
};
level 是「这个 pid 属于几层命名空间」:初始命名空间 level=0,容器里创建的进程 level=1,嵌套容器 level=2。numbers[] 是柔性数组,长度 level+1,每层一个 upid。
tasks[PIDTYPE_MAX] 的四个槽位对应四种用途:
| 槽位 | 含义 | 查找宏 |
|---|---|---|
PIDTYPE_PID | 线程 ID | task_pid_ptr() |
PIDTYPE_TGID | 线程组 ID | task_tgid() |
PIDTYPE_PGID | 进程组 ID | task_pgrp() |
PIDTYPE_SID | 会话 ID | task_session() |
同一个 struct pid 可以同时挂在四个槽位里(比如一个进程既是线程组长又是进程组长)。hlist_head 里挂的是 task_struct 的 pid_links[] 节点。
编号的三种读法
| API | 返回 | 用在 |
|---|---|---|
task_pid_nr(task) | 初始命名空间里的 tid | 内核日志、/proc 的全局视图 |
task_pid_vnr(task) | 当前命名空间里的 tid | 系统调用返回值(getpid/gettid) |
pid_nr_ns(pid, ns) | 指定命名空间里的号;不在该 ns 返回 0 | nsenter、跨 ns 查找 |
pid_vnr(pid) | 当前命名空间的号 | kernel_clone() 的返回值 |
task_pid_nr() 对内核线程返回 0(它们不在用户命名空间里)。容器里 ps 看到 PID 1,宿主上 ps 看到的是它的全局号——两个号指向同一个 struct pid,只是 numbers[] 的不同项。
查找:RCU 与哈希
find_task_by_vpid(vnr) 在当前命名空间里查找:
find_task_by_vpid(vnr)
→ find_pid_ns(vnr, task_active_pid_ns(current))
→ idr_find(&ns->idr, vnr) /* 返回 struct pid */
→ pid_task(pid, PIDTYPE_PID) /* 取 tasks[PIDTYPE_PID] 第一个 */
idr_find() 是 RCU 读,不需要锁;pid_task() 返回的 task_struct 可能下一秒退出,所以调用者要么持有 rcu_read_lock(),要么用 get_pid_task() 增加引用。kill(2) 的路径就是这样:
/* kernel/signal.c: kill_pid_info() —— 6.8 节选,已简化 */
rcu_read_lock();
p = pid_task(pid, PIDTYPE_PID);
if (p)
error = group_send_sig_info(sig, info, p, PIDTYPE_TGID);
rcu_read_unlock();
rcu_read_lock() 保证 task_struct 在发送信号期间不被释放;信号入队后,即使 task 立刻退出,signal_struct 的引用也会保证队列有效。
pidfd:把「号」变成「引用」
PID 会回绕。经典的竞态是:
进程 A: pid = fork(); 记录 pid=1234
进程 B: 1234 退出,系统分配新进程也用 1234
进程 A: kill(1234, SIGKILL) ← 杀错了
pidfd 用 fd 持有 struct pid 的引用,彻底消除这个窗口:
int pidfd = pidfd_open(1234, 0); /* 5.3+ */
if (pidfd >= 0) {
/* 之后 1234 即使被复用,pidfd 仍指向原进程 */
pidfd_send_signal(pidfd, SIGKILL, NULL, 0);
poll(&(struct pollfd){.fd = pidfd, .events = POLLIN}, 1, -1); /* 等退出 */
}
| 系统调用 | 号 | 作用 |
|---|---|---|
pidfd_open | 434 | pid → pidfd |
pidfd_send_signal | 424 | 通过 pidfd 发信号 |
pidfd_getfd | 438 | 复制目标进程的 fd(需 ptrace 权限) |
clone3(CLONE_PIDFD) | 435 | 创建时直接拿 pidfd |
pidfd 让「等待进程退出」变成 poll(),可以和 socket、timer 一起进 epoll——这是 systemd、容器运行时替代「waitpid + 信号」的关键。
- 「
getpid()返回内核的pid字段」:返回的是tgid(线程组号);gettid()才是pid。 - 「PID 号全局唯一」:只在同一命名空间内唯一;不同 ns 可以重复,回绕后也会复用。
- 「
kill(pid, 0)能安全探测进程是否存在」:只能探「当前 ns 里这个号是否存在」,有复用竞态;用 pidfd。 - 「
CLONE_NEWPID后调用者变成 PID 1」:不是,只有它fork()出的子进程进入新 ns;调用者仍在原 ns。 - 「
/proc/sys/kernel/pid_max可以随便调大」:上限PID_MAX_LIMIT(4194304),调大增加struct pid内存与回绕周期,不是越大越好。
3.4.14 凭证、seccomp 与 LSM:谁能改变「我是谁」
这一节回答:cred 为什么是 RCU 替换而不是就地修改、线程之间共享 cred 意味着什么、seccomp 与 LSM 在哪个钩子上拦住系统调用。
struct cred:不可变的身份快照
/* include/linux/cred.h —— 6.8 节选 */
struct cred {
atomic_long_t usage;
kuid_t uid; /* 真实 UID */
kgid_t gid;
kuid_t suid; /* 保存的 UID(setuid 用) */
kgid_t sgid;
kuid_t euid; /* 有效 UID:权限检查看它 */
kgid_t egid;
kuid_t fsuid; /* 文件访问 UID(NFS 用) */
kgid_t fsgid;
unsigned securebits;
kernel_cap_t cap_inheritable;
kernel_cap_t cap_permitted;
kernel_cap_t cap_effective;
kernel_cap_t cap_bset; /* bounding set */
kernel_cap_t cap_ambient;
...
struct user_struct *user;
struct user_namespace *user_ns;
struct group_info *group_info;
struct rcu_head rcu;
};
cred 一旦发布就不再修改。要改身份,走「复制—修改—替换」:
prepare_creds() 复制当前 cred,返回可写副本
→ 修改 uid/cap/...
commit_creds(new) RCU 替换 current->cred,旧 cred 延迟释放
→ 失败:abort_creds(new)
commit_creds() 里用 rcu_assign_pointer(current->real_cred, new) 与 rcu_assign_pointer(current->cred, new),旧 cred 通过 put_cred() → call_rcu() 释放。这样所有「正在用旧 cred 做权限检查」的读者不会看到半更新的字段。
override_creds()/revert_creds() 是临时借用:nfsd、overlayfs 在内核里以「调用者的身份」访问文件时用。它们也返回旧 cred,必须配对。
线程组共享 cred
copy_creds() 的分支:
/* kernel/cred.c: copy_creds() —— 6.8 节选,已简化 */
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
struct cred *new;
int ret;
if (clone_flags & CLONE_THREAD) {
/* 线程共享线程组的 cred */
p->real_cred = get_cred(p->cred);
p->cred = get_cred(p->cred);
return 0;
}
new = prepare_creds();
if (!new)
return -ENOMEM;
...
p->cred = p->real_cred = get_cred(new);
return 0;
}
所以一个线程调用 setuid(),整个线程组都变——POSIX 要求如此。反过来,setresuid() 在多线程程序里必须用 glibc 的 nptl 协调(向所有线程发信号让它们同步),否则会出现「一个线程已降权、另一个还没降」的窗口。
cap_effective 是实际生效的权能集合;cap_permitted 是允许的上限;cap_bset(bounding set)是「就算 setuid root 也拿不到」的硬上限。容器运行时通过 capset() 与 prctl(PR_CAPBSET_DROP) 收缩这些集合。
seccomp:在系统调用入口做过滤
seccomp 的三种模式:
| 模式 | 值 | 行为 |
|---|---|---|
SECCOMP_MODE_DISABLED | 0 | 不过滤 |
SECCOMP_MODE_STRICT | 1 | 只允许 read/write/_exit/sigreturn |
SECCOMP_MODE_FILTER | 2 | 用 BPF 程序决定每个系统调用 |
过滤器结构:
/* include/linux/seccomp.h —— 6.8 节选 */
struct seccomp_filter {
refcount_t refs;
refcount_t users;
bool log;
bool wait_killable_recv;
struct action_cache cache;
struct seccomp_filter *prev; /* 过滤器链,后装的先执行 */
struct bpf_prog *prog;
struct notification *notif;
struct mutex notify_lock;
};
seccomp(SECCOMP_SET_MODE_FILTER, flags, &prog) 把 BPF 程序挂到当前线程(SECCOMP_FILTER_FLAG_TSYNC 可同步到整个线程组)。之后每次系统调用,syscall_trace_enter() 里 seccomp_run_filters() 依次执行过滤器链,返回一个 SECCOMP_RET_* 动作:
| 动作 | 值 | 效果 |
|---|---|---|
KILL_PROCESS | 0x80000000 | 杀死整个线程组 |
KILL_THREAD | 0x00000000 | 杀死当前线程 |
TRAP | 0x00030000 | 发 SIGSYS,可被 handler 捕获 |
ERRNO | 0x00050000 | 直接返回指定 errno,不执行系统调用 |
USER_NOTIF | 0x7fc00000 | 交给用户态 supervisor 决定 |
TRACE | 0x7ff00000 | 通知 ptrace |
LOG | 0x7ffc0000 | 记日志后允许 |
ALLOW | 0x7fff0000 | 允许 |
USER_NOTIF 是容器运行时的关键:runc 用它在执行危险系统调用前做检查(如 mount),把决定权交给宿主上的 supervisor。SECCOMP_RET_ERRNO 则让「拒绝」看起来像普通错误,避免应用崩溃。
启用 filter 需要 no_new_privs=1(或 CAP_SYS_ADMIN):prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)。这是为了防止「setuid 程序被过滤器绕过」——一旦 no_new_privs,execve 不再提权,过滤器无法被甩掉。
LSM:另一层钩子
seccomp 过滤的是「系统调用号与参数」,LSM 过滤的是「语义」:
| LSM 钩子 | 时机 | 典型检查 |
|---|---|---|
security_task_alloc | copy_process | 分配 task 安全 blob |
security_bprm_creds_for_exec | execve | 文件权限、capability |
security_inode_permission | 文件访问 | SELinux/AppArmor 标签 |
security_task_fix_setuid | setuid | 是否允许降权/提权 |
security_mmap_file | mmap | PROT_EXEC 映射限制 |
security_socket_connect | connect | 网络访问控制 |
/proc/PID/status 里能看到结果:
CapEff: 00000000a80425fb
NoNewPrivs: 1
Seccomp: 2
Seccomp_filters: 3
Seccomp: 2 表示 filter 模式,Seccomp_filters: 3 是过滤器链长度。CapEff: 0 表示没有有效权能;容器里 CapEff 通常是 a80425fb(去掉 CAP_SYS_ADMIN 等)。
- 「
setuid()只影响当前线程」:CLONE_THREAD共享 cred,整个线程组一起变;多线程程序要自己同步。 - 「
commit_creds()之后旧 cred 立刻释放」:RCU 延迟释放;持旧指针的读者可能在宽限期结束前仍能读。 - 「seccomp 能限制所有资源」:它只看系统调用号与参数,看不到文件路径、socket 地址;路径级控制要用 LSM。
- 「
SECCOMP_RET_ERRNO能让应用以为文件不存在」:可以,但errno是人为指定的,应用可能据此走错误分支;调试时先看Seccomp状态。 - 「
no_new_privs只影响 setuid」:它还阻止execve提权、限制ptrace、让 seccomp 过滤器不可绕过,是容器安全的基础之一。
3.4.15 进程的时间:utime、stime 与 POSIX 定时器
这一节回答:ps 的 TIME 列从哪来、/proc/PID/stat 的 utime/stime 单位是什么、nanosleep 与 setitimer 在内核里用哪种定时器。
进程的时间记账分三层:调度器账本(sum_exec_runtime)、用户可见账本(utime/stime)、定时器(hrtimer/posix-cpu-timer)。
字段与更新点
| 字段 | 位置 | 含义 |
|---|---|---|
utime / stime | task_struct | 用户态/内核态累计时间(ns) |
gtime | task_struct | guest 时间(虚拟化) |
prev_cputime | task_struct | 上次记账的快照,用于增量 |
sum_exec_runtime | sched_entity | 调度器看到的累计运行时间 |
prev_sum_exec_runtime | sched_entity | 上次切换时的快照 |
cputimer | signal_struct | 线程组级 POSIX CPU timer |
stats | signal_struct | 汇总的 utime/stime、缺页、切换次数 |
更新点在上下文切换与 tick:
__schedule()
→ finish_task_switch()
→ account_user_time(prev, delta) / account_system_time(...)
→ acct_update_integrals(prev)
scheduler_tick()
→ task_tick_*()
→ update_curr() 累计 sum_exec_runtime
account_user_time() 把 delta 加到 p->utime 与 signal->utime;account_system_time() 加到 stime。acct_update_integrals() 把 sum_exec_runtime 的增量换算成 utime/stime,并更新 prev_cputime。这套记账在 CONFIG_VIRT_CPU_ACCOUNTING_GEN 下更精确(读 CPU 的 accounting 寄存器),否则按 tick 采样,精度是 1/HZ。
用户态读到的三个口径
| 来源 | 单位 | 含义 |
|---|---|---|
/proc/PID/stat 第 14–17 字段 | USER_HZ(通常 100)的 tick | utime、stime、cutime、cstime |
getrusage(RUSAGE_SELF) | timeval(µs) | 自己 + 已回收子进程 |
clock_gettime(CLOCK_PROCESS_CPUTIME_ID) | timespec(ns) | 线程组 CPU 时间 |
clock_gettime(CLOCK_THREAD_CPUTIME_ID) | timespec(ns) | 当前线程 CPU 时间 |
times() | USER_HZ tick | 进程 + 子进程 |
USER_HZ 固定为 100(include/asm-generic/param.h),不随 CONFIG_HZ 变;所以 /proc/PID/stat 的时间分辨率是 10 ms,而 clock_gettime(CLOCK_PROCESS_CPUTIME_ID) 是 ns(实际精度取决于记账粒度)。
ps 的 TIME 列是把 utime+stime 按 USER_HZ 换算后格式化的;top 的 %CPU 是「一秒内 utime+stime 的增量」。两者都不包含已回收子进程的时间(那在 cutime/cstime 里)。
定时器:三种时钟、三种信号
| 接口 | 时钟 | 信号 | 内核实现 |
|---|---|---|---|
alarm(n) | ITIMER_REAL | SIGALRM | itimer + hrtimer |
setitimer(ITIMER_REAL) | 墙钟 | SIGALRM | hrtimer |
setitimer(ITIMER_VIRTUAL) | 用户 CPU 时间 | SIGVTALRM | posix-cpu-timer |
setitimer(ITIMER_PROF) | 用户+内核 CPU 时间 | SIGPROF | posix-cpu-timer |
timer_create(CLOCK_REALTIME) | 墙钟 | sigevent | hrtimer |
timer_create(CLOCK_PROCESS_CPUTIME_ID) | 线程组 CPU | sigevent | posix-cpu-timer |
timerfd_create | 多种 | fd 可读 | hrtimer |
nanosleep/clock_nanosleep | 多种 | 无(返回) | hrtimer + schedule_hrtimeout_range |
ITIMER_REAL 用 hrtimer,因为它是墙钟;ITIMER_VIRTUAL/PROF 用 posix-cpu-timer,因为它们依赖 CPU 时间记账,只能在 tick 或返回用户态时检查。
nanosleep 的路径:
nanosleep(req, rem)
→ hrtimer_nanosleep()
→ do_nanosleep()
→ schedule_hrtimeout_range(&expires, slack, HRTIMER_MODE_ABS)
→ schedule() 直到 hrtimer 到期或被信号唤醒
→ 被信号打断:返回 -EINTR,剩余时间写回 rem
nanosleep 的 rem 是「被信号打断后还剩多久」,这是它与 sleep() 的关键差别(sleep() 不返回剩余时间)。clock_nanosleep 支持 TIMER_ABSTIME,避免「相对时间 + 调度延迟」的累积误差。
sched_clock() 与 ktime_get()
内核里有两套时间:
ktime_get()/ktime_get_mono_fast_ns():基于 clocksource(TSC、HPET、ACPI PM)的墙钟/单调钟,可能被 NTP 调整;sched_clock():调度器专用,要求「快、单调、跨 CPU 可比」,通常直接用 TSC,不参与 NTP。
task->start_time 用 sched_clock() 记录(/proc/PID/stat 的 starttime 是它换算成 tick),task->start_boottime 用 boottime。两者在系统休眠/时间调整后可能分叉,比较「进程活了多久」时要用 monotonic 口径(clock_gettime(CLOCK_MONOTONIC) - start_time)。
- 「
/proc/PID/stat的 utime 是毫秒」:是USER_HZtick(10 ms),要除以sysconf(_SC_CLK_TCK)。 - 「
getrusage包含所有子进程」:只包含已回收的子进程;还在跑的、或已被wait但未统计的,口径不同。 - 「
sleep()能被信号打断并返回剩余时间」:sleep()返回未睡完的秒数(非 0 表示被打断),nanosleep用rem返回;两者语义不同。 - 「
timer_create(CLOCK_PROCESS_CPUTIME_ID)精确到纳秒」:记账粒度受 tick/虚拟化影响,实际精度可能是毫秒级。 - 「
sched_clock()与ktime_get()可以混用」:两者基准不同,混用会算出负的时间差;比较时间戳要同一时钟源。
3.4.16 任务释放与 RCU:task_struct 什么时候真的消失
这一节回答:release_task() 之后 task_struct 为什么还在、put_task_struct_rcu_user() 与 put_task_struct() 差在哪、内核栈什么时候释放。
3.4.6 讲到 do_exit() 把 task 变成僵尸,wait4() 调 release_task()。释放分两段:
release_task(task)
→ __exit_signal(task) 从 signal_struct 摘除,统计退出
→ __unhash_process(task) 从 pid 哈希、线程组链表摘除
→ put_task_struct_rcu_user(task) 挂 RCU 回调
→ call_rcu(&task->rcu, delayed_put_task_struct)
→ 宽限期后:put_task_struct(task)
→ refcount 归零:__put_task_struct()
→ exit_creds(task)
→ put_task_stack(task) 释放内核栈(vmap stack)
→ free_task(task)
→ kmem_cache_free(task_struct_cachep, task)
为什么要 RCU?因为 find_task_by_vpid() 之类的查找是 RCU 读:读者拿到 task_struct 指针后不加锁就访问字段。如果 release_task() 立刻 kfree(),读者就踩空。call_rcu() 等到所有「已经开始的 RCU 读临界区」结束后再释放。
两个 put_task_struct 变体容易混:
| 函数 | 用途 | 是否延迟 |
|---|---|---|
put_task_struct(t) | 普通引用释放 | 否,立即 __put_task_struct |
put_task_struct_rcu_user(t) | 从 pid 哈希摘除后的「最后引用」 | 是,RCU 回调 |
put_task_struct_many(t, n) | 批量释放 | 否 |
get_task_struct(t) | 增加引用 | — |
delayed_put_task_struct() 是回调函数,它在宽限期后调用 put_task_struct()。如果还有别的引用(如 ptrace、/proc 打开着),refcount 不会归零,free_task() 推迟到最后一个引用释放。
free_task() 的顺序也有讲究:
/* kernel/fork.c: free_task() —— 6.8 节选,已简化 */
void free_task(struct task_struct *tsk)
{
...
put_task_stack(tsk); /* vmap stack:释放 guard page 与映射 */
...
arch_release_task_struct(tsk); /* 架构相关(FPU、xstate) */
free_task_struct(tsk); /* kmem_cache_free(task_struct_cachep) */
}
内核栈不能早于 task_struct 释放:free_task() 自己还在栈上跑。put_task_stack() 在 CONFIG_VMAP_STACK 下走 vfree(),并释放 guard page;在普通配置下走 free_pages()。
僵尸与「已死但未释放」
三种状态要分清:
| 状态 | task_struct | 内核栈 | 用户可见 |
|---|---|---|---|
EXIT_ZOMBIE | 还在 | 还在 | ps 显示 <defunct>,等 wait |
EXIT_DEAD | 还在(引用未归零) | 还在 | ps 可能看不到(已摘除 pid) |
| RCU 宽限期后 | 释放 | 释放 | 完全消失 |
/proc/PID/status 的 State: Z 是僵尸;/proc/PID/stat 的 exit_code 是退出码。僵尸本身不占内存页,只占一个 task_struct 与 pid 号;大量僵尸会耗尽 pid_max,导致新进程 fork 失败。
- 「
waitpid返回后 task_struct 立刻没了」:还有 RCU 宽限期与可能的其他引用;/proc打开着的进程会延迟释放。 - 「
put_task_struct()是幂等的」:不是,多调一次就是 refcount underflow;必须与get_task_struct()配对。 - 「
release_task()可以在中断上下文调」:它会call_rcu()、可能睡眠;只在进程上下文调。 - 「内核线程没有
task_struct释放路径」:一样走do_exit→release_task,只是没有用户态资源要清。 - 「
/proc/PID持有 task 引用」:打开/proc/PID/stat会get_task_struct,在 fd 关闭前阻止释放;这是「已删除进程仍出现在 /proc」的原因之一。
3.4.17 观测:fork/exec/exit 的 tracepoint 与 /proc 视图
这一节回答:怎么量出「谁在疯狂 fork」「copy_process 慢在哪」「一个进程的 cred、cgroup、栈、调度信息分别在哪看」。
sched tracepoint
include/trace/events/sched.h 定义了进程生命周期的四个事件:
| 事件 | 参数 | 时机 |
|---|---|---|
sched:sched_process_fork | parent_comm、parent_pid、child_comm、child_pid | copy_process() 成功后 |
sched:sched_process_exec | filename、pid、old_pid | execve 替换成功后 |
sched:sched_process_exit | comm、pid、prio | do_exit() |
sched:sched_process_free | comm、pid、prio | release_task() 后 |
# 每秒 fork 最多的进程
sudo bpftrace -e '
tracepoint:sched:sched_process_fork { @[str(args->parent_comm)] = count(); }
interval:s:5 { print(@); clear(@); }'
# copy_process 的耗时分布(微秒),含失败
sudo bpftrace -e '
kprobe:copy_process { @s[tid] = nsecs; }
kretprobe:copy_process /@s[tid]/ {
@us = hist((nsecs - @s[tid]) / 1000);
delete(@s[tid]);
}'
# 谁在执行什么程序
sudo bpftrace -e '
tracepoint:sched:sched_process_exec {
printf("%-16s pid=%-6d file=%s\n", comm, pid, str(args->filename));
}'
perf 侧可以用 perf trace -e clone,clone3,execve,exit_group 看系统调用级视图,perf sched latency 看调度延迟。perf sched record 记录后 perf sched script 能还原每次 sched_switch 的调用栈。
/proc/PID 的关键文件
| 文件 | 看什么 |
|---|---|
/proc/PID/status | State、Tgid、Pid、PPid、Threads、SigQ、CapEff、Seccomp、NoNewPrivs、Cpus_allowed_list |
/proc/PID/stat | utime/stime、优先级、线程数、starttime、vsize、rss、exit_code |
/proc/PID/sched | se.sum_exec_runtime、nr_switches、nr_voluntary_switches、nr_involuntary_switches、se.avg.* |
/proc/PID/schedstat | 运行时间、等待时间、时间片(纳秒) |
/proc/PID/stack | 内核栈回溯(需 root + CONFIG_STACKTRACE) |
/proc/PID/wchan | 睡眠在哪个内核函数 |
/proc/PID/limits | Max processes、Max open files 等 |
/proc/PID/cgroup | 所属 cgroup(v2 统一层级) |
/proc/PID/ns/* | 各命名空间的 inode 号(比较两个进程是否同 ns) |
/proc/PID/sched 里的 nr_voluntary_switches 与 nr_involuntary_switches 是判断「进程是被抢还是自己让」的关键:
- 自愿切换高:进程在等 I/O、锁、
sleep; - 非自愿切换高:CPU 竞争激烈或实时任务抢占;
se.sum_exec_runtime是调度器账本,与utime+stime不一定相等(后者按 tick 采样)。
一个进程的「身份快照」
pid=$$
grep -E '^(Name|State|Tgid|Pid|PPid|Threads|CapEff|Seccomp|NoNewPrivs)' /proc/$pid/status
readlink /proc/$pid/ns/pid /proc/$pid/ns/mnt
cat /proc/$pid/sched | head -8
这四组输出分别对应:task 基本状态、权能/过滤器、命名空间、调度统计。排查容器问题时,先比较两个进程的 /proc/PID/ns/* inode 是否相同——相同才是同一个命名空间。
- 它在不在:
/proc/PID是否存在、State是什么(R/S/D/Z)。 - 它在等什么:
wchan+/proc/PID/stack;D状态看内核栈,S状态看wchan。 - 它被谁管:
PPid、cgroup、ns/*;容器里还要看NSpid。 - 它花了多少:
stat的 utime/stime、sched的 switches、schedstat的等待时间。
3.4.18 实验:clone 的共享与 fork 的复制
这一节给两个可运行实验:一个用户态程序对比 clone(CLONE_VM|...) 与 fork() 的可见性;一个内核模块打印 task_struct 的关键字段。均在 QEMU(Linux 6.8,x86-64)验证流程,未在真机实测。
实验一:共享一个全局变量
/* clone_share.c — clone 共享 vs fork 复制
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o clone_share clone_share.c
*/
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
static int global_var = 1;
static int thread_fn(void *arg)
{
(void)arg;
global_var = 42; /* CLONE_VM:改的是同一块内存 */
return 0;
}
int main(void)
{
const size_t stack_size = 1 << 20;
char *stack;
pid_t pid;
stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
if (stack == MAP_FAILED) {
perror("mmap");
return 1;
}
pid = clone(thread_fn, stack + stack_size,
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD,
NULL);
if (pid < 0) {
perror("clone");
return 1;
}
sleep(1); /* 等线程跑完;生产代码应使用 futex/join */
printf("after clone : global_var=%d (expect 42)\n", global_var);
global_var = 1;
pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
global_var = 42; /* fork:改的是自己的副本 */
_exit(0);
}
waitpid(pid, NULL, 0);
printf("after fork : global_var=%d (expect 1)\n", global_var);
return 0;
}
预期输出:
after clone : global_var=42 (expect 42)
after fork : global_var=1 (expect 1)
两个注意点:
- 这个
clone()没有设置 TLS,子函数里不能用errno、printf、malloc等依赖线程局部存储的函数;真实线程库会先建 TLS。 CLONE_THREAD要求同时有CLONE_SIGHAND与CLONE_VM,否则clone()返回EINVAL。这是内核强制的组合约束。
把 CLONE_VM 去掉,clone() 立即失败;把 CLONE_THREAD 去掉但保留 CLONE_VM,得到的是一个「共享地址空间但不属于同一线程组」的怪东西——getpid() 不同、退出会发 SIGCHLD,这正是 vfork 的形态。
实验二:内核态看 task_struct
// taskinfo.c — 打印 task_struct 关键字段(Linux 6.8)
#include <linux/cred.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/sched/signal.h>
#include <linux/sched/task_stack.h>
#include <asm/thread_info.h>
static int max_tasks = 8;
module_param(max_tasks, int, 0444);
MODULE_PARM_DESC(max_tasks, "how many tasks to print");
static int __init taskinfo_init(void)
{
struct task_struct *p;
int n = 0;
rcu_read_lock();
for_each_process(p) {
if (n++ >= max_tasks)
break;
pr_info("task pid=%d tgid=%d comm=%s state=%u flags=%#x mm=%px uid=%u\n",
task_pid_nr(p), task_tgid_nr(p), p->comm,
READ_ONCE(p->__state), p->flags, p->mm,
from_kuid(&init_user_ns, p->cred->uid));
}
rcu_read_unlock();
pr_info("current pid=%d tgid=%d comm=%s\n",
current->pid, current->tgid, current->comm);
pr_info("stack=%px sp=%#lx THREAD_SIZE=%lu in_interrupt=%d preempt=%#x\n",
current->stack, current->thread.sp, (unsigned long)THREAD_SIZE,
in_interrupt(), preempt_count());
return 0;
}
static void __exit taskinfo_exit(void) { }
module_init(taskinfo_init);
module_exit(taskinfo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("dump task_struct fields");
加载后 dmesg 输出(节选):
task pid=1 tgid=1 comm=systemd state=1 flags=0x400040 mm=00000000a1b2c3d4 uid=0
task pid=2 tgid=2 comm=kthreadd state=1 flags=0x402040 mm=0000000000000000 uid=0
...
current pid=312 tgid=312 comm=insmod
stack=ffffc90000a1c000 sp=0xffffc90000a1fd20 THREAD_SIZE=16384 in_interrupt=0 preempt=0x0
三个观察:
kthreadd的mm=0,证明内核线程没有用户地址空间;THREAD_SIZE=16384是 16 KiB;stack在ffffc9...区间说明CONFIG_VMAP_STACK=y;insmod的preempt=0x0、in_interrupt=0,说明模块 init 在进程上下文。
把 for_each_process 换成 for_each_process_thread 能看到每个线程;task_pid_nr() 与 task_tgid_nr() 在单线程进程里相等,在线程里不同。
- 「glibc 的
clone()可以直接当pthread_create用」:不会建 TLS、不会设CLONE_SETTLS,子函数里用 libc 函数可能崩。实验只碰全局变量。 - 「
CLONE_VM可以单独用」:可以,但此时子进程与父进程共享地址空间却不共享信号处理表,clone()的语义非常接近vfork,容易出错。 - 「
for_each_process不需要锁」:需要rcu_read_lock()或tasklist_lock;否则遍历期间进程退出会踩空。 - 「
p->cred可以随便读」:读cred要在 RCU 读临界区里,且uid要用from_kuid()转成用户命名空间可见的值。
3.4.19 陷阱清单:进程与线程篇
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 多线程程序 fork 后子进程死锁 | 只复制调用线程,其他线程持有的锁没人释放 | fork 后立刻 exec;或 pthread_atfork 加锁 |
| 2 | fork 后 malloc 卡住 | 堆锁被其他线程持有 | 同上;子进程只用 async-signal-safe 函数 |
| 3 | clone 返回 EINVAL | CLONE_THREAD 缺 CLONE_SIGHAND/CLONE_VM | 补齐标志组合 |
| 4 | 线程里 setuid 只改了一个线程 | 误以为 cred 每线程独立 | 用 nptl 的同步机制;或先单线程降权 |
| 5 | vfork 子进程改坏父进程 | 共享地址空间,子进程做了非 exec 操作 | 改 posix_spawn |
| 6 | 容器里 PID 1 不回收僵尸 | PID 1 没实现 wait | 用 tini/dumb-init;或主进程自己 wait |
| 7 | kill 杀错进程 | PID 回绕/复用 | 用 pidfd |
| 8 | fork 失败 EAGAIN | RLIMIT_NPROC、max_threads、PID 耗尽 | 查 ulimit -u、pid_max、僵尸数 |
| 9 | 子进程继承了不该继承的 fd | 没设 O_CLOEXEC | 所有 fd 加 O_CLOEXEC,或 close_range |
| 10 | waitpid(-1) 抢了别人的子进程 | 多线程共享子进程列表 | 每个线程只 wait 自己的 pid |
| 11 | clone3 返回 ENOSYS | 内核 < 5.3 或 seccomp 拦截 | 回退 clone;检查 seccomp 白名单 |
| 12 | 僵尸数量持续增长 | 父进程没 wait 且没设 SIGCHLD=SIG_IGN | 加 wait 循环或 SA_NOCLDWAIT |
3.4.20 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
说出 task_struct 的五组字段 | 调度/身份/地址空间/文件/信号 | 3.4.1 |
解释 current 怎么取到 | per-CPU current_task,不是栈掩码 | 3.4.1 |
| 列出线程与进程的共享差异 | 至少说出 mm/files/sighand/tgid | 3.4.2 |
按顺序说出 copy_process 的关键步骤 | 页表、VMA、pid、wake_up_new_task 的位置 | 3.4.3 |
| 解释 COW 的两次触发 | fork 改只读、写时复制 | 3.4.4 |
| 说清僵尸的回收链 | do_exit → EXIT_ZOMBIE → wait4 → release_task | 3.4.6 |
| 用代码验证共享与复制 | 跑通 cow_demo 并解释输出 | 3.4.8 |
| 区分每线程/每进程字段 | cred、signal、mm | 3.4.11 |
说出 copy_process 的回滚顺序 | 标签与资源配对 | 3.4.12 |
| 解释 pid 的多层编号 | pid_nr_ns 与 pidfd | 3.4.13 |
| 说出 utime/stime 的单位 | USER_HZ 与 getrusage | 3.4.15 |
| 画出 task_struct 释放链 | RCU → free_task | 3.4.16 |
Q1 fork() 返回两次,子进程返回值 0 是在哪里设置的?父进程的返回值又是在哪里设置的?
Q2 一个进程 fork() 之后,子进程的 task_struct->pid 与父进程的 tgid 是什么关系?getpid() 返回哪一个?
Q3 为什么 fork() 一个映射了 100 GiB 但只碰过 1 MiB 的进程仍然很慢?慢在哪一步?
Q4 父进程不 wait() 就退出,子进程会怎样?如果子进程先退出而父进程还活着呢?
Q5 clone(CLONE_VM) 但不带 CLONE_THREAD 会发生什么?这算线程吗?
答案
A1 子进程的 0 在 copy_thread() 里设置:childregs->ax = 0,子进程从 ret_from_fork 返回用户态时 rax = 0。父进程的返回值是 kernel_clone() 里的 nr = pid_vnr(pid),即子进程在父命名空间里的 PID。
A2 子进程是新的线程组,child->pid 是新号,child->tgid == child->pid;父进程的 tgid 不变。getpid() 返回 tgid,gettid() 返回 pid。fork() 之后父子 tgid 不同。
A3 慢在 dup_mmap() 复制页表:100 GiB 虚拟地址对应约 2600 万个 PTE,即使只碰过 1 MiB,页表项也要逐项复制(并做 pte_wrprotect)。复制量与虚拟映射规模成正比,与 RSS 无关。
A4 父进程先退出:子进程成为孤儿,被 subreaper 或 PID 1 收养,正常运行。子进程先退出:进入 EXIT_ZOMBIE,保留退出码等父进程 wait();父进程一直不 wait,僵尸就一直存在,直到父进程退出后被 init 接管并回收。
A5 CLONE_VM 让两个 task 共享 mm_struct,但不带 CLONE_THREAD 时它们属于不同线程组,各自有独立的 tgid、独立的信号处理(除非也带 CLONE_SIGHAND),退出时各自向自己的父进程发 SIGCHLD。这在内核看来仍是两个「进程」,只是共享地址空间——vfork() 就是这个组合,所以它要求子进程立刻 exec,否则两个执行流在同一栈上跑必然出错。
母本对应
| 母本 | 章节/小节 | 原书页(PDF) | 本书落点 | 程度 |
|---|---|---|---|---|
| 奔跑吧 Linux 内核 | 第 3 章 3.1 进程的诞生 | p341–365 | 3.4.3–3.4.5、3.4.12 | 完整(copy_process、COW、wake_up_new_task) |
| 奔跑吧 Linux 内核 | 第 3 章 3.2 CFS 调度器(task_struct 调度字段) | p366–409 | 3.4.11 | 背景(调度算法在 3.5) |
| 李治军 | 第 3 章 多进程(实践项目 3、4) | 章级 | 3.4.2、3.4.5 | 对照(0.11 的 copy_process 与 TSS 切换) |
| Kerrisk | 第 24 章 进程的创建:fork() | 章级 | 3.4.3、3.4.4 | 完整(用户态语义与 COW) |
| Kerrisk | 第 25 章 进程的终止 | 章级 | 3.4.6、3.4.16 | 完整(exit/_exit、退出处理程序) |
| Kerrisk | 第 26 章 监控子进程 | 章级 | 3.4.6 | 完整(wait/僵尸/SIGCHLD) |
| Kerrisk | 第 27 章 程序的执行 | 章级 | 3.4.3、3.4.12 | 精选(vfork、posix_spawn、clone3) |
| OSTEP | 第 4–6 章 进程抽象与受限直接执行 | p25–62 | 3.4.2 | 背景(进程/线程的概念框架) |
下一站:3.5 调度器 接手就绪队列:
task_struct里的sched_entity怎么被排序、vruntime与 EEVDF 的deadline有什么区别、实时进程凭什么抢占普通进程。