找回密码
立即注册
搜索
发回帖 发新帖

5212

积分

0

好友

660

主题
发表于 1 小时前 | 查看: 6| 回复: 0

LINUX KERNEL · 入门图解

从 fork 到 copy_process,拆解一个新任务的诞生过程

在终端中启动一个程序,看起来只是敲下一条命令。到了内核里,却需要准备任务对象、地址空间、文件描述符和执行现场,最后让它参与 CPU 调度。

上一张图回答了"进程里有什么"。一图读懂 Linux 进程

这一次,我们沿着 fork() 的创建路径,看看这些资源是怎样组织起来的。

本文阅读路线

创建全景 → 统一入口 → copy_process → 资源复制与共享 → 写时复制 → 调度与返回 → 线程对照 → 动手验证

本文根据项目《linux内核知识学习.md》中"进程是如何创建的?"整理,以 Linux 6.1 为源码参照,主要讨论有 MMU 的系统。函数调用与步骤按讲解需要简化,具体入口会随架构、内核和 libc 版本变化。

01 · 一张图看懂进程创建

Linux进程创建机制图解:创建路径与父子资源关系

图:上半部对比进程与线程的创建路径,下半部展示普通 fork 后的父子资源关系。

读图时先从上往下看:进程与线程通过不同的创建参数,进入相同的内核任务创建机制。再看下半部:普通 fork 后,父子各有任务对象和资源视图,部分底层对象仍然共享。

先记住主线: 进入内核 → 准备新任务 → 建立资源和身份 → 加入运行队列 → 父子分别继续执行。

02 · fork、clone:走向同一个创建入口

fork() 的语义是创建一个子进程,让它继承调用者的大量运行状态。clone() 和 clone3() 则提供更细的资源共享控制。

在 Linux 6.1 中,这些内核入口汇入 kernel_clone(),再由 copy_process() 完成新任务的准备。较早版本常见 do_fork() 或 _do_fork() 等名字,阅读源码时要先确认版本。

/* 概念调用链,省略架构相关入口与参数 */
fork / clone / clone3 系统调用
    → kernel_clone()
        → copy_process()
        → wake_up_new_task()

用户程序调用的 fork() 通常是 libc 提供的封装。glibc 可以使用具有 fork 语义的 clone 系统调用,因此抓取系统调用时,看到 clone 并不意味着程序创建的是线程。

源码参照:Linux 6.1 · kernel/fork.c;接口与 libc 差异见 fork(2)。

03 · copy_process:准备一个能运行的新任务

Linux 用 task_struct 描述任务。创建子进程,首先要有新的任务对象,但复制一个结构体只是起点:里面许多指针和状态,还需要根据创建方式重新处理。

准备内容 关键函数或对象 作用
任务骨架 dup_task_struct() 准备新的任务对象和内核栈
调度属性 sched_fork() 初始化新任务的调度状态
资源关系 copy_mm()、copy_files() 等 按标志复制或引用已有资源
执行现场 copy_thread() 准备架构相关的寄存器与启动上下文
身份与关系 PID、TGID、父任务、线程组 让新任务有身份,并接入任务关系

current 指向当前执行的任务,也是创建时继承状态的重要来源。普通 fork 创建的新任务成为自己的线程组组长,内核中的 pid 与 tgid 相等。

创建过程中还会检查参数、权限和资源限制。某一步失败,内核会释放已经准备的资源,向调用者返回错误;图里展示的是成功路径。

04 · copy 的关键:复制,还是共享?

copy_* 这个名字容易让人以为所有资源都会完整复制。实际行为由 clone 标志决定:有些资源创建新对象,有些资源增加引用后直接共享。

资源 普通 fork 相关共享标志
地址空间 mm_struct 新地址空间;私有可写页采用 COW CLONE_VM
目录上下文 fs_struct 复制根目录、工作目录等上下文 CLONE_FS
文件描述符表 files_struct 新 fd 表,表项引用原打开文件对象 CLONE_FILES
信号处理动作表 复制处理动作 CLONE_SIGHAND
命名空间 默认继承现有命名空间 CLONE_NEW* 请求新建对应命名空间

fd 表独立,不代表文件对象独立。 父子进程对应的 fd 通常指向同一个打开文件描述,因此共享文件偏移量和文件状态标志。子进程关闭自己的 fd,不会直接关闭父进程 fd 表中的对应项。

创建进程也不等于创建容器。 普通 fork 不会自动建立新的 PID、网络或挂载命名空间;隔离需要额外机制和创建参数。

标志语义见 clone(2)。

05 · 内存复制:先独立视图,再按需分离

普通 fork 没有设置 CLONE_VM。在 copy_mm() 中,它会走向 dup_mm(),准备新的 mm_struct 和相应的内存映射、页表结构。

/* copy_mm 的逻辑示意,不是完整源码 */
if (flags & CLONE_VM)
    引用并共享 current->mm;
else
    通过 dup_mm() 准备新的地址空间;

这不意味着立即把所有物理内存复制一遍。对于常见的私有可写映射,父子可以暂时指向同一物理页,相关页表项受到写保护。

当其中一方尝试写入时,会触发写保护缺页异常。内核在需要时分配新页、复制内容,并更新写入方的映射。这就是写时复制(Copy-on-Write,COW)。

fork 后,尚未写入:
父地址空间 ──→ 物理页 A ←── 子地址空间

子进程写入,需要分离时:
父地址空间 ──→ 物理页 A
子地址空间 ──→ 物理页 B(复制后修改)

独立地址空间与暂时共享物理页,可以同时成立。 对普通私有内存,子进程后续修改变量不会改变父进程中的变量。

这里讨论私有映射。MAP_SHARED 等共享映射仍遵循共享语义;COW 也不意味着 fork 没有任务对象、页表和映射管理的开销。

06 · wake_up_new_task:准备好,才能参与调度

copy_process() 返回时,新任务已经准备好,但尚需进入可调度的队列。kernel_clone() 随后调用 wake_up_new_task(),将它变为可运行任务,并加入选定 CPU 的运行队列。

可运行,不等于已经占用 CPU。 只有被调度器选中,新任务才真正获得执行机会。父进程和子进程先运行谁没有固定保证,不能用"通常打印顺序"替代同步。

首次调度时,子任务沿着架构相关的返回路径回到用户空间。普通 fork 的子进程从 fork 返回处继续执行,并不会重新从 main() 开始。

07 · 一次调用,为什么会有两个返回值?

fork 成功后,多了一个可以独立执行的任务。内核为它准备执行上下文时,安排了不同的返回结果;父子随后执行同一份程序里的分支代码。

执行者 fork 返回值 常见用途
父进程 子进程的 PID,大于 0 记录子任务、等待退出
子进程 0 执行子任务逻辑或加载新程序
创建失败时的调用者 -1,并设置 errno 处理资源不足或数量限制等错误

若要让子进程执行另一个程序,常见方式是随后调用 execve():它替换当前进程的程序映像,成功后不会返回原来的调用位置。普通单线程子进程的 PID 保持不变。

创建与加载分两步理解: fork 创建执行实例;execve 为已有进程换上新的程序映像。

加载行为见 execve(2)。Shell 的实际启动方式可能使用其他接口,本文采用 fork + exec 的典型模型。

08 · 进程与线程:内核里都是 task_struct

从应用程序的视角看,进程提供运行程序的资源环境,线程是其中的一条执行路径。一个进程可以只有一个线程,也可以有多个线程。

线程在内核中也是一个 task_struct。 每个线程都有自己的任务对象,包括通常所说的"主线程"。Linux 将这些执行实体统一抽象为任务(task),调度时分别考虑各个可运行任务。

因此,一个有三个线程的普通进程,对应三个 task_struct,而不是一个任务对象里面装着三条执行路径。这三个任务通过线程组关系组织在一起,并共享地址空间等资源。

同样是任务,区别在哪里?

对比项 普通 fork 创建的父子进程 同一进程中的 POSIX 线程
内核表示 各有独立的 task_struct 每个线程也各有独立的 task_struct
地址空间 不同的 mm_struct;私有可写页可暂时通过 COW 共享 共享同一个 mm_struct,能访问相同的全局变量、堆和映射
任务身份 属于不同线程组;普通单线程父子进程各自 pid = tgid 各有不同的内核任务 ID(TID),共享同一 TGID
线程组组长 普通 fork 子任务是新线程组的组长 group_leader 指向同一个线程组组长
文件与目录上下文 fd 表、目录上下文独立;继承的 fd 仍可共享打开文件对象 通常共享 fd 表及工作目录等上下文
执行现场与栈 各有寄存器上下文和内核栈;用户栈位于各自地址空间 各有寄存器上下文和内核栈;用户栈区域各自使用,但位于共享地址空间
修改普通变量 修改私有内存中的变量通常不影响另一方 对共享变量的修改可被其他线程观察,并发访问需要适当同步

这里对比普通 fork 与 glibc NPTL 的 POSIX 线程。clone 可以组合不同共享标志,资源共享方式不能仅凭"进程"或"线程"的名称判断。

PID、TID、TGID 怎么对应?

/* 同一个进程的三个线程,ID 为示意值 */
主线程:task_struct.pid = 1000,tgid = 1000
线程 A:task_struct.pid = 1001,tgid = 1000
线程 B:task_struct.pid = 1002,tgid = 1000

各线程 getpid() → 1000(用户态进程 ID)
各线程 gettid() → 自己的任务 ID

内核 task_struct.pid 标识具体任务;用户态通常说的进程 PID 则对应线程组 ID。理解这个区别,就不会把"每个线程都有自己的任务 ID"误认为"每个线程都是一个独立的用户态进程"。示意值按同一 PID 命名空间理解。

pthread_create 如何建立这些共享关系?

pthread_create() 是线程库接口。glibc 负责准备用户栈、线程本地存储等,再通过 clone 系列系统调用创建内核任务;具体使用 clone 还是 clone3 取决于实现和运行环境。

/* glibc NPTL 常见标志组合的讲解示意 */
CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM |
CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID

CLONE_VM 等标志让新任务共享资源;CLONE_THREAD 让它加入调用者的线程组。因此,同一进程中的线程有各自的内核任务 ID,通常共享 TGID,用户态 getpid() 返回共同的进程 ID。

线程有各自的执行上下文、内核栈,并使用各自的用户栈区域。用户栈仍位于共享地址空间中,不是对其他线程不可见的独立内存。

信号处理动作与信号掩码要分开: CLONE_SIGHAND 共享信号处理动作表;阻塞信号掩码仍是每个线程独立维护的状态。

标志组合参照 glibc 2.36 · pthread_create.c;线程共享与独立属性见 pthreads(7),任务 ID 见 gettid(2)。

09 · 动手验证:子进程改变量,父进程会变吗?

把下面这个单线程示例保存为 fork_demo.c。子进程修改局部变量,父进程用 waitpid() 等待它结束,再打印自己的变量。

#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    int value = 10;
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        value = 20;
        printf("child: value=%d, pid=%ld, ppid=%ld\n",
               value, (long)getpid(), (long)getppid());
        fflush(stdout);
        _exit(0);
    }
    if (waitpid(pid, NULL, 0) == -1) {
        perror("waitpid");
        return 1;
    }
    printf("parent: value=%d, child_pid=%ld\n",
           value, (long)pid);
    return 0;
}
gcc -Wall -Wextra -std=c11 fork_demo.c -o fork_demo
./fork_demo

输出类似下面这样,PID 以实际运行结果为准:

child: value=20, pid=1235, ppid=1234
parent: value=10, child_pid=1235

两份变量一开始都是 10,子进程改成 20 后,父进程仍然读到 10。这验证了私有内存的独立修改语义;它不能单独证明物理页何时发生复制。示例中的打印顺序由等待操作保证。

多线程程序调用 fork 后,子进程只保留调用 fork 的那一个线程;在 exec 之前,可安全调用的函数受到异步信号安全规则限制。上面的示例限定为单线程程序。

10 · 创建过程观察速查表

想观察什么 命令
进程创建与退出系统调用 strace -f -e trace=process ./fork_demo
PID、父 PID 与状态 ps -eo pid,ppid,stat,comm
进程树 pstree -p
线程身份 ps -eLo pid,tid,comm
地址空间与 fd cat /proc/PID/maps、ls -l /proc/PID/fd

strace 和 pstree 需已安装。/proc 命令中的 PID 要替换为仍在运行且有权限观察的进程;这个示例退出很快。strace 能观察系统调用,不能直接展示 copy_process 等内核函数。

写在最后

沿着这条创建链路看,进程的诞生可以归纳为三件事:准备任务、组织资源、参与调度。

kernel_clone 组织创建;copy_process 准备任务及资源;wake_up_new_task 让任务进入运行队列。普通 fork 建立独立地址空间,并对私有可写页采用写时复制;线程同样各有一个 task_struct,通过共享标志复用资源,再加入同一个线程组。

再读内核里的 copy_*,可以带着一个问题往下看:这里创建了新对象,还是增加了对原对象的引用?理解这个选择,就能把进程、线程和资源继承串起来。

LINUX KERNEL · 从一张图开始理解内核
源码参照 Linux 6.1 / glibc 2.36
图与调用链为讲解示意,实际以目标版本源码为准。




上一篇:Linux底层逻辑:进程如何使用虚拟内存?mmap、栈与ptmalloc详解
下一篇:一图看懂 Linux 进程加载:从 execve 到 main 完整链路
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-23 12:37 , Processed in 0.084207 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表