Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 里的全部状态

定义 3.4.1 · 任务控制块(task_struct)

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。
定义 3.4.2 · current 与内核栈

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。

为什么 Linux 敢说「线程就是进程」

因为内核里根本没有为线程单独设计一个结构。所谓线程,只是「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);                       /* 入队,可能抢占当前 */

每一步都能单独写一章,这里只抓三条主线:

  1. dup_task_struct() 复制的是「描述」,不是「内存」。它分配一个新的 task_struct,把父进程的字段整体拷过来,再分配一个新的内核栈,把父进程的 pt_regs 复制到栈顶——子进程从 ret_from_fork 开始执行,fork() 在用户态返回 0 就是这么来的。
  2. copy_mm() 只复制页表,不复制物理页。它调用 dup_mm() → dup_mmap(),遍历父进程的每个 VMA,复制 VMA 结构,复制页表项,并把两边的 PTE 都改成只读。物理页的引用计数加一。这就是 COW(copy-on-write)。
  3. alloc_pid() 分配的是 struct pid 与各层编号。idr_alloc_cyclic() 在 pid_namespace->idr 里找一个空号,默认上限 pid_max = 32768(systemd 常调到 4194304)。号用完不会立即复用,pid_max 越大越不容易回绕。
5.10 之前的名字

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/exitposix_spawn 的快速路径
clone()任意组合按标志共享线程、命名空间
clone3()同上 + struct clone_args可扩展的结构体参数新代码(5.3+)

vfork() 的历史价值是「不复制页表」,在 fork 昂贵的年代很快;今天 fork + COW 已经足够快,vfork 的共享地址空间反而危险(子进程改一个变量会直接影响父进程),新代码应优先 posix_spawn() 或 clone3()。

3.4.4 COW:fork 快的真正原因

进程状态与迁移 运行、可中断睡眠、不可中断睡眠、停止、僵尸与退出。 进程状态机(Linux 6.x,task_struct->__state)TASK_RUNNING可运行 / 正在运行TASK_INTERRUPTIBLE可被信号唤醒TASK_UNINTERRUPTIBLE不可被信号唤醒TASK_STOPPEDSIGSTOP / 调试TASK_ZOMBIE已退出,等待 waitTASK_TRACED被 ptrace 暂停EXIT_DEAD彻底释放等待事件事件就绪 / 唤醒等待 I/OI/O 完成SIGSTOPSIGCONTexit()wait() 回收ptrace就绪队列只收 TASK_RUNNING;睡眠状态通过等待队列唤醒;ZOMBIE 占的是 task_struct,不是内存页
读图:重点看两条环:可运行 ↔ 可中断睡眠(等待队列 + 唤醒),以及运行 → 僵尸 → 回收。不可中断睡眠(D 状态)不响应信号,常见于磁盘 I/O;大量 D 状态通常意味着 I/O 或文件系统卡住。

读图:fork 之后子进程进入 TASK_RUNNING(就绪队列),退出时先到 TASK_ZOMBIE,等父进程 wait() 才到 EXIT_DEAD。图里没有画 COW,但它是「就绪」之前发生的事。

定义 3.4.3 · 写时复制(copy-on-write, 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 与 COW 的五个误解
  1. 「fork() 会复制内存,所以大进程 fork 很慢」:复制的是页表,不是内存。慢在页表项数量,不在数据量。
  2. 「COW 只发生在匿名页」:文件私有映射(MAP_PRIVATE)也走 COW;共享映射(MAP_SHARED)不复制,写入直接进 page cache。
  3. 「fork() 之后父子谁先跑是确定的」:不确定。调度器决定,vfork 才保证父进程阻塞。
  4. 「vfork() 的子进程可以随便改变量」:它和父进程共享地址空间,改变量会直接影响父进程,行为未定义。子进程只能立刻 execve 或 _exit。
  5. 「MADV_WIPEONFORK 能省掉 COW」:它是让子进程看到清零的页(用于密钥等敏感数据),不是共享优化。真正省 COW 的是 MADV_DONTFORK(子进程不映射该区域)。

3.4.5 新进程什么时候开始跑

copy_process() 返回时,新 task 已经完整,但还没被调度。它的 __state 是 TASK_NEW,不在任何运行队列里。wake_up_new_task() 做三件事:

  1. p->__state = TASK_RUNNING;
  2. activate_task(rq, p, ENQUEUE_WAKEUP) 把 sched_entity 插入运行队列(EEVDF 下是红黑树,按 deadline 排序);
  3. 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() 做两件容易被忽略的事:

  1. 重新托孤:如果父进程已死,子进程要么交给 PR_SET_CHILD_SUBREAPER 设置的 subreaper,要么交给 PID 1(find_child_reaper())。这就是「孤儿进程被 init 收养」的实现。
  2. 决定是否变僵尸:如果父进程还在且没有设 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 */
}
排查僵尸进程
  1. ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/' 找到僵尸。
  2. 看 PPID:父进程是谁。僵尸本身杀不掉(已经死了),只能等父进程 wait()。
  3. 父进程正常却不 wait:查代码里 SIGCHLD 是否被设为 SIG_IGN(那会自动回收)或 SA_NOCLDWAIT;也可能是父进程卡在别处。
  4. 父进程是 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。

例 3.4.1 · fork 一个 8 GiB 的进程要多久

一个只读大文件、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/mapsVMA 列表地址范围、权限、偏移、路径
/proc/PID/smapsVMA + 统计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)含义每线程/每进程
__stateunsigned int任务状态(TASK_RUNNING 等)每线程
stackvoid *内核栈基址每线程
flagsunsigned intPF_* 标志(PF_EXITING、PF_KTHREAD、PF_VCPU…)每线程
ptraceunsigned int被调试状态(PT_PTRACED、PT_SEIZED)每线程
on_cpu / on_rqint是否正在 CPU 上/在运行队列里每线程
prio / static_prio / normal_prioint动态/静态/普通优先级每线程
rt_priorityunsigned int实时优先级(1–99)每线程
policyunsigned intSCHED_NORMAL/FIFO/RR/DEADLINE/IDLE每线程
se / rt / dl调度实体CFS/RT/DL 的调度数据每线程
sched_classconst struct sched_class *当前调度类每线程
mm / active_mmstruct mm_struct *地址空间/借用的地址空间每线程(线程共享 mm)
filesstruct files_struct *fd 表可共享(CLONE_FILES)
fsstruct fs_struct *cwd/root/umask可共享(CLONE_FS)
nsproxystruct nsproxy *命名空间集合可共享
signalstruct signal_struct *线程组共享的信号/统计/定时器每进程(线程组共享)
sighandstruct sighand_struct *信号处理函数表可共享(CLONE_SIGHAND)
blocked / real_blockedsigset_t阻塞信号集每线程
cred / real_credconst struct cred *有效/真实凭据每线程指针,线程共享同一 cred
pid / tgidpid_t线程 ID / 线程组 ID每线程
group_leaderstruct task_struct *线程组组长每线程指针
thread_group / thread_nodelist_head线程组链表每线程
parent / real_parentstruct task_struct *当前父/亲生父(ptrace 会改 parent)每线程
children / siblinglist_head子进程/兄弟链每线程
exit_state / exit_codeint退出状态与退出码每线程
utime / stime / gtimeu64用户态/内核态/guest 时间(ns)每线程
start_time / start_boottimeu64创建时刻(monotonic/boottime)每线程
nvcsw / nivcswunsigned long自愿/非自愿上下文切换每线程
seccompstruct seccompseccomp 模式与过滤器每线程
securityvoid *LSM 私有数据每线程
cgroupsstruct css_set *所属 cgroup 集合每线程
rcustruct 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。

task_struct 的五个坑
  1. 「p->state 还能用」:5.14 起字段改名为 __state,直接访问编译失败;用 READ_ONCE(p->__state) 或 task_is_running()。
  2. 「current 在任何上下文都指向一个用户进程」:中断上下文里 current 是被中断的任务,不代表它还在运行;内核线程的 mm == NULL。
  3. 「task_struct 里存了内核栈」:只存了基址 stack;栈内存在 alloc_thread_stack_node() 里单独分配,CONFIG_VMAP_STACK 下是 vmalloc。
  4. 「线程的 cred 是独立的」:CLONE_THREAD 时 copy_creds() 直接共享父线程的 cred 指针;一个线程 setuid() 会影响整个线程组,这是 POSIX 的要求。
  5. 「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_threadsENOMEM/EAGAINfork: Resource temporarily unavailable
copy_credsCLONE_THREAD 与 cred 竞争EAGAIN极罕见,重试即可
sched_fork调度器初始化失败EAGAIN实时任务超出 RT 带宽
copy_filesfd 表复制/RLIMIT_NOFILEENOMEM/EMFILE打开文件过多
copy_sighand信号处理表分配ENOMEM内存不足
copy_mmdup_mmap 失败ENOMEM页表/VMA 分配失败
copy_namespaces命名空间创建/权限EPERM/ENOMEMCLONE_NEWUSER 不允许
copy_ioio_context 分配ENOMEM—
copy_thread架构相关ENOMEM—
alloc_pidPID 耗尽EAGAINfork: Cannot allocate memory(实际是号用完)
sched_cgroup_forkcgroup 迁移EAGAIN—

三条实践结论:

  1. EAGAIN 不等于「内存不足」:RLIMIT_NPROC、max_threads、PID 耗尽、sched_fork 都可能返回它。fork() 失败时先看 ulimit -u 与 /proc/sys/kernel/pid_max。
  2. PID 耗尽是「慢故障」:pid_max 默认 32768,回绕后新进程复用旧号;如果有进程长期不回收,alloc_pid 会一直失败。/proc/sys/kernel/ns_last_pid 能看到当前分配位置。
  3. 错误分支里的 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()` 的现代替代

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线程 IDtask_pid_ptr()
PIDTYPE_TGID线程组 IDtask_tgid()
PIDTYPE_PGID进程组 IDtask_pgrp()
PIDTYPE_SID会话 IDtask_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 返回 0nsenter、跨 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_open434pid → pidfd
pidfd_send_signal424通过 pidfd 发信号
pidfd_getfd438复制目标进程的 fd(需 ptrace 权限)
clone3(CLONE_PIDFD)435创建时直接拿 pidfd

pidfd 让「等待进程退出」变成 poll(),可以和 socket、timer 一起进 epoll——这是 systemd、容器运行时替代「waitpid + 信号」的关键。

PID 与命名空间的五个坑
  1. 「getpid() 返回内核的 pid 字段」:返回的是 tgid(线程组号);gettid() 才是 pid。
  2. 「PID 号全局唯一」:只在同一命名空间内唯一;不同 ns 可以重复,回绕后也会复用。
  3. 「kill(pid, 0) 能安全探测进程是否存在」:只能探「当前 ns 里这个号是否存在」,有复用竞态;用 pidfd。
  4. 「CLONE_NEWPID 后调用者变成 PID 1」:不是,只有它 fork() 出的子进程进入新 ns;调用者仍在原 ns。
  5. 「/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_DISABLED0不过滤
SECCOMP_MODE_STRICT1只允许 read/write/_exit/sigreturn
SECCOMP_MODE_FILTER2用 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_PROCESS0x80000000杀死整个线程组
KILL_THREAD0x00000000杀死当前线程
TRAP0x00030000发 SIGSYS,可被 handler 捕获
ERRNO0x00050000直接返回指定 errno,不执行系统调用
USER_NOTIF0x7fc00000交给用户态 supervisor 决定
TRACE0x7ff00000通知 ptrace
LOG0x7ffc0000记日志后允许
ALLOW0x7fff0000允许

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_alloccopy_process分配 task 安全 blob
security_bprm_creds_for_execexecve文件权限、capability
security_inode_permission文件访问SELinux/AppArmor 标签
security_task_fix_setuidsetuid是否允许降权/提权
security_mmap_filemmapPROT_EXEC 映射限制
security_socket_connectconnect网络访问控制

/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 等)。

凭证与过滤的五个坑
  1. 「setuid() 只影响当前线程」:CLONE_THREAD 共享 cred,整个线程组一起变;多线程程序要自己同步。
  2. 「commit_creds() 之后旧 cred 立刻释放」:RCU 延迟释放;持旧指针的读者可能在宽限期结束前仍能读。
  3. 「seccomp 能限制所有资源」:它只看系统调用号与参数,看不到文件路径、socket 地址;路径级控制要用 LSM。
  4. 「SECCOMP_RET_ERRNO 能让应用以为文件不存在」:可以,但 errno 是人为指定的,应用可能据此走错误分支;调试时先看 Seccomp 状态。
  5. 「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 / stimetask_struct用户态/内核态累计时间(ns)
gtimetask_structguest 时间(虚拟化)
prev_cputimetask_struct上次记账的快照,用于增量
sum_exec_runtimesched_entity调度器看到的累计运行时间
prev_sum_exec_runtimesched_entity上次切换时的快照
cputimersignal_struct线程组级 POSIX CPU timer
statssignal_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)的 tickutime、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_REALSIGALRMitimer + hrtimer
setitimer(ITIMER_REAL)墙钟SIGALRMhrtimer
setitimer(ITIMER_VIRTUAL)用户 CPU 时间SIGVTALRMposix-cpu-timer
setitimer(ITIMER_PROF)用户+内核 CPU 时间SIGPROFposix-cpu-timer
timer_create(CLOCK_REALTIME)墙钟sigeventhrtimer
timer_create(CLOCK_PROCESS_CPUTIME_ID)线程组 CPUsigeventposix-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)。

时间记账的五个坑
  1. 「/proc/PID/stat 的 utime 是毫秒」:是 USER_HZ tick(10 ms),要除以 sysconf(_SC_CLK_TCK)。
  2. 「getrusage 包含所有子进程」:只包含已回收的子进程;还在跑的、或已被 wait 但未统计的,口径不同。
  3. 「sleep() 能被信号打断并返回剩余时间」:sleep() 返回未睡完的秒数(非 0 表示被打断),nanosleep 用 rem 返回;两者语义不同。
  4. 「timer_create(CLOCK_PROCESS_CPUTIME_ID) 精确到纳秒」:记账粒度受 tick/虚拟化影响,实际精度可能是毫秒级。
  5. 「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 失败。

任务释放的五个坑
  1. 「waitpid 返回后 task_struct 立刻没了」:还有 RCU 宽限期与可能的其他引用;/proc 打开着的进程会延迟释放。
  2. 「put_task_struct() 是幂等的」:不是,多调一次就是 refcount underflow;必须与 get_task_struct() 配对。
  3. 「release_task() 可以在中断上下文调」:它会 call_rcu()、可能睡眠;只在进程上下文调。
  4. 「内核线程没有 task_struct 释放路径」:一样走 do_exit → release_task,只是没有用户态资源要清。
  5. 「/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_forkparent_comm、parent_pid、child_comm、child_pidcopy_process() 成功后
sched:sched_process_execfilename、pid、old_pidexecve 替换成功后
sched:sched_process_exitcomm、pid、priodo_exit()
sched:sched_process_freecomm、pid、priorelease_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/statusState、Tgid、Pid、PPid、Threads、SigQ、CapEff、Seccomp、NoNewPrivs、Cpus_allowed_list
/proc/PID/statutime/stime、优先级、线程数、starttime、vsize、rss、exit_code
/proc/PID/schedse.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/limitsMax 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 是否相同——相同才是同一个命名空间。

进程问题的四步定位
  1. 它在不在:/proc/PID 是否存在、State 是什么(R/S/D/Z)。
  2. 它在等什么:wchan + /proc/PID/stack;D 状态看内核栈,S 状态看 wchan。
  3. 它被谁管:PPid、cgroup、ns/*;容器里还要看 NSpid。
  4. 它花了多少: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() 在单线程进程里相等,在线程里不同。

实验的四个坑
  1. 「glibc 的 clone() 可以直接当 pthread_create 用」:不会建 TLS、不会设 CLONE_SETTLS,子函数里用 libc 函数可能崩。实验只碰全局变量。
  2. 「CLONE_VM 可以单独用」:可以,但此时子进程与父进程共享地址空间却不共享信号处理表,clone() 的语义非常接近 vfork,容易出错。
  3. 「for_each_process 不需要锁」:需要 rcu_read_lock() 或 tasklist_lock;否则遍历期间进程退出会踩空。
  4. 「p->cred 可以随便读」:读 cred 要在 RCU 读临界区里,且 uid 要用 from_kuid() 转成用户命名空间可见的值。

3.4.19 陷阱清单:进程与线程篇

#现象原因修复
1多线程程序 fork 后子进程死锁只复制调用线程,其他线程持有的锁没人释放fork 后立刻 exec;或 pthread_atfork 加锁
2fork 后 malloc 卡住堆锁被其他线程持有同上;子进程只用 async-signal-safe 函数
3clone 返回 EINVALCLONE_THREAD 缺 CLONE_SIGHAND/CLONE_VM补齐标志组合
4线程里 setuid 只改了一个线程误以为 cred 每线程独立用 nptl 的同步机制;或先单线程降权
5vfork 子进程改坏父进程共享地址空间,子进程做了非 exec 操作改 posix_spawn
6容器里 PID 1 不回收僵尸PID 1 没实现 wait用 tini/dumb-init;或主进程自己 wait
7kill 杀错进程PID 回绕/复用用 pidfd
8fork 失败 EAGAINRLIMIT_NPROC、max_threads、PID 耗尽查 ulimit -u、pid_max、僵尸数
9子进程继承了不该继承的 fd没设 O_CLOEXEC所有 fd 加 O_CLOEXEC,或 close_range
10waitpid(-1) 抢了别人的子进程多线程共享子进程列表每个线程只 wait 自己的 pid
11clone3 返回 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/tgid3.4.2
按顺序说出 copy_process 的关键步骤页表、VMA、pid、wake_up_new_task 的位置3.4.3
解释 COW 的两次触发fork 改只读、写时复制3.4.4
说清僵尸的回收链do_exit → EXIT_ZOMBIE → wait4 → release_task3.4.6
用代码验证共享与复制跑通 cow_demo 并解释输出3.4.8
区分每线程/每进程字段cred、signal、mm3.4.11
说出 copy_process 的回滚顺序标签与资源配对3.4.12
解释 pid 的多层编号pid_nr_ns 与 pidfd3.4.13
说出 utime/stime 的单位USER_HZ 与 getrusage3.4.15
画出 task_struct 释放链RCU → free_task3.4.16
即时自测 · 3.4

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–3653.4.3–3.4.5、3.4.12完整(copy_process、COW、wake_up_new_task)
奔跑吧 Linux 内核第 3 章 3.2 CFS 调度器(task_struct 调度字段)p366–4093.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–623.4.2背景(进程/线程的概念框架)

下一站:3.5 调度器 接手就绪队列:task_struct 里的 sched_entity 怎么被排序、vruntime 与 EEVDF 的 deadline 有什么区别、实时进程凭什么抢占普通进程。