Linux 进程与线程:结构体 & 创建/执行原理

从内核源码角度看——进程和线程在内核里是同一个东西:struct task_struct。区别只在于创建时共享了哪些资源。本页用图拆开结构体、fork / execve / clone 的真实调用路径。

① 核心洞察
② task_struct 解剖
③ mm_struct 与地址空间
④ 进程创建 fork()
⑤ 执行原理 execve()
⑥ 线程创建 clone()
⑦ 调度 & 内核线程 & 总结

① 一句话点破:Linux 里没有「线程」这种独立概念

进程 = 一个 task_struct 拥有独立的地址空间(mm_struct);线程 = 另一个 task_struct 与它共享同一个 mm_struct(以及文件表、信号等)。

Windows 把进程和线程做成两种不同内核对象;Linux 不这么干。内核只有一种调度实体 task_struct,所谓「线程」只是创建时带上了 CLONE_VM 等共享标志的 task_struct。所以「轻量级进程 LWP」就是这个意思——它本质上就是个 task_struct。
struct task_struct(每个进程/线程一个)
进程(Process)
自己的 mm_struct · 独立地址空间 · 独立 pid/tgid
线程(Thread)
共享 mm_struct · 同 tgid · 各自 pid · 同线程组
点上方两个框看「进程」与「线程」在内核里的真正区别:就是 task_struct 是否共享 mm_struct,以及 tgid 是否相同。

贯穿全篇的三个关键字段

  • task_struct.pid —— 该任务自身的唯一编号。每个 task_struct(无论进程还是线程)都有独立 pid。
  • task_struct.tgid —— 线程组 ID。等于该组「组长」(group_leader) 的 pid。getpid() 返回的就是 tgid,所以同一进程里的所有线程 getpid() 都一样。
  • task_struct.mm —— 指向地址空间 mm_struct。进程独有;线程共享。这就是「进程/线程」在内核里的唯一本质差别
验证:你在用户态调 getpid() 拿到的是 tgid;调 gettid() 拿到的才是真正的 pid。同进程多线程,pid 不同、tgid 相同——这正是 task_struct 模型的直接体现。

② task_struct 解剖(源码:include/linux/sched.h)

这是内核里最核心的结构体,几百个字段。按功能分组点击查看(节选与进程/线程最相关的字段):

身份 / 线程组pid · tgid · group_leader
pid_t pid;
pid_t tgid;
struct task_struct *group_leader;
struct pid *pids[PIDTYPE_MAX];
char comm[TASK_COMM_LEN];
内存mm · active_mm
struct mm_struct *mm;
struct mm_struct *active_mm;
文件 / 文件系统files · fs
struct files_struct *files;
struct fs_struct *fs;
信号sighand · signal
struct sighand_struct *sighand;
struct signal_struct *signal;
状态 / 调度__state · se · rt
unsigned int __state;
int exit_state;
struct sched_entity se;
struct sched_rt_entity rt;
亲属 / 凭据 / 寄存器parent · children · cred · thread
struct task_struct *parent;
struct list_head children, sibling;
struct cred *cred;
struct thread_struct thread;
身份与线程组:pid(本任务唯一id)、tgid(线程组id,=组长pid)、group_leader(指向组长 task_struct)、pids[](多级 pid 结构)、comm[16](命令名)。★ 进程 vs 线程的区别就藏在这里:线程的 tgid 与组长相同,pid 各不相同。

从源码看「进程」到底是什么粒度

task_struct 把「一个能独立跑的东西」需要的全部上下文都装下了:它是谁(pid/tgid)、住哪(mm)、开了哪些文件(files)、收到信号怎么办(sighand)、现在啥状态(__state)、该被怎么调度(se)、它爹是谁(parent)。

所以内核调度器只认 task_struct根本不区分「这是进程还是线程」——它只关心这个 task_struct 该不该上 CPU。进程/线程的差异完全由「创建时共享了哪些字段」决定,下一节会看到。

③ mm_struct 与虚拟地址空间(mm_types.h)

task_struct 的 mm 指向它。这是「进程」和「线程」在内核层面的唯一本质分水岭:进程有自己独立的 mm,线程与同组共享同一个 mm。

区域列表mmap · mm_rb
struct vm_area_struct *mmap;
struct rb_root mm_rb;
页表pgd
pgd_t *pgd; /* 顶层页表 */
引用计数mm_users · mm_count
atomic_t mm_users;
atomic_t mm_count;
区域边界brk · stack · code
unsigned long start_code, end_code;
unsigned long start_brk, brk;
unsigned long start_stack;
mmap:vm_area_struct 链表(进程所有内存区域的头);mm_rb:同一批 VMA 的红黑树。每个 VMA 描述一段『[起,止]地址+权限+背后文件』。这就是上一页讲的『VMA 地图』的内核实现。

vm_area_struct:一段连续虚拟区间

struct vm_area_struct
unsigned long vm_start, vm_end;
pgprot_t vm_page_prot;
unsigned long vm_flags;
struct file *vm_file;
const struct vm_operations_struct *vm_ops;
回顾:execve 装载时,内核就是为 ELF 的每个 PT_LOAD 段 new 出一个 vm_area_struct 挂进 mm->mmap。页表项此时多为空——真正访问才靠缺页异常从磁盘读进来(按需分页)。

④ 进程创建:fork() 内核路径(kernel/fork.c)

用户态 fork() → glibc 包成 clone() 系统调用 → 内核 kernel_clone()copy_process()wake_up_new_task()

用户态:fork() / clone(SIGCHLD)
↓ 系统调用陷入内核
kernel_clone()
kernel/fork.c
copy_process()
① dup_task_struct 复制一个全新 task_struct+内核栈
↓ 逐个 copy_*
copy_creds → copy_files → copy_fs → copy_sighand → copy_mm → copy_signal → copy_namespaces → copy_io → copy_thread
分配 pid / tgid,挂入任务链表与父进程 children
↓ 返回 kernel_clone
wake_up_new_task()
放入运行队列,子进程通常先被调度(利于 COW)
点上方任一节点无单独交互,这里补充关键点:copy_process 把父 task_struct 几乎原样复制成子 task_struct,父子此时除了 pid/tgid 几乎一模一样(包括寄存器——所以 fork 后靠返回值区分父子)。

fork 的核心:copy_mm 做了什么

fork(无 CLONE_VM)

mm_struct复制(dup_mm)
页表复制,页标 COW
VMA 列表逐份拷贝
物理内存暂不拷,写时复制
结果父子独立地址空间

关键点:为何 fork 快

fork 并不立刻复制物理内存。copy_mm 只是复制页表并把所有页标记为写时复制(COW)。父子共享同一份物理页,直到某一方写入才真正分裂。

这就是 fork 极快的原因——配合「子进程先被调度」,常见 fork+exec 场景下子进程往往还没写内存就 execve 换镜像了,COW 几乎从不会发生。

一句话: fork = 复制出一个新的 task_struct,mm 被 dup 但页表 COW;execve 往往紧随其后把旧镜像整个换掉,所以 fork 的「复制」开销在实际链路里被巧妙规避。

⑤ 执行新程序:execve() 内核路径(fs/exec.c + fs/binfmt_elf.c)

execve() 不新建 task_struct,而是把当前进程「换脑子」:丢弃旧地址空间,按新 ELF 建新地址空间,跳到新入口。pid 不变。

用户态:execve(path, argv, envp)
do_execveat_common()
打开文件、读脚本#!或ELF头(fs/exec.c)
exec_binprm() → search_binary_handler()
↓ 匹配 ELF 格式
load_elf_binary()
fs/binfmt_elf.c —— 真正装载逻辑
↓ 关键动作
begin_new_exec() 拆旧 mm → setup_new_exec() 换 mm → setup_arg_pages() 铺栈(argv/envp/auxv) → elf_map() 映射各 PT_LOAD 段 → 映射 ld.so
start_thread()
arch/x86/.../process_64.c:设 regs->ip=入口, regs->sp=新栈,返回用户态
execve 的本质:同一个 task_struct,把 mm 整个替换。旧 mm 的 mm_users 减一(归零则释放),新 mm 由 ELF 段重建。PC 被设为新程序入口,栈被换成新的 argv/envp 栈——于是「进程」瞬间变成了另一个程序,但 pid、父进程关系全都不变。

load_elf_binary 里发生了什么(精华步骤)

  • 解析 ELF 头:校验魔数,找到 PT_INTERP(动态链接器路径)。
  • begin_new_exec():拆除旧地址空间(刷新 mm_users,旧的按需释放),开始新生命。
  • setup_new_exec():把 current->mm 指向新建的 mm,设置 mm->exe_file、地址空间大小。
  • setup_arg_pages():在计算好的 mm->start_stack 处铺好初始栈——argc/argv/envp/auxv(呼应上册「栈初始化」)。
  • elf_map():对每个 PT_LOAD 段调用 mmap 映射进 mm,生成对应 vm_area_struct(text r-x、data rw-)。
  • 映射解释器:若有 PT_INTERP,把 ld.so 也 mmap 进来(下一步由它做动态链接)。
  • start_thread():把寄存器 ip 设为入口(ELF e_entry 或 ld.so 入口)、sp 设为新栈顶,返回用户态那一刻,新程序第一条指令开始执行。
对照上一页「静态程序→动态进程」:这里的 load_elf_binary 就是那页里「内核校验&读 ELF / 建VMA / 装ld.so / 铺栈」在内核源码里的真实函数名与调用顺序。两页是同一个故事的「原理篇」与「源码篇」。

⑥ 线程创建:clone() 与 pthread_create(kernel/fork.c)

用户态 pthread_create()(glibc/NPTL)→ 调用 clone() 系统调用,带上一组共享标志。内核走的还是 kernel_clone → copy_process,区别全在这些标志改变了 copy_* 的行为。

6.1 pthread_create 实际传的标志

CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID
CLONE_VMshare mm 共享内存描述符(mm_struct)
CLONE_FILESshare fd table 共享打开文件表
CLONE_SIGHANDshare sighand 共享信号处理方式
CLONE_THREADsame thread group 同一线程组(同 tgid)
点上方按钮或任一行查看该标志的作用。线程与进程在内核里唯一的结构性差别,正是这些 CLONE_* 标志的有无。

6.2 fork 进程 vs pthread 线程:资源对照

资源fork 出的进程pthread 线程(CLONE_*)
mm_struct(地址空间)独立副本(COW)共享同一份
页表 / 物理内存复制,写时分裂完全共用
文件描述符表默认复制共享
信号处理表复制共享
pid各自独立各自独立(gettid 不同)
tgid(getpid 看到)各自不同相同(同组)
内核对象类型都是 task_struct,调度器一视同仁
所以「多线程比多进程轻」轻在哪?创建时不用复制 mm/页表/文件表(共享即可),通信也不用 IPC(直接读写共享内存)。但代价是任一线程崩(段错误)会拖垮整个组——因为它们本就是共享地址空间的同一个 task_struct 家族。

⑦ 调度、内核线程与总结

7.1 调度:每个 task_struct 都是独立单位

CFS 调度器操作的是 task_struct 里的 sched_entity se。无论进程还是线程,只要是一个 task_struct,就是一个可被调度上 CPU 的实体。所以 N 个线程是 N 个并行调度单位(受限于 CPU 核数),这正是多线程能真正并发的原因。

7.2 内核线程(kthread)

内核线程(如 ksoftirqd、kworker)也是 task_struct,但mm = NULL——它们从不跑用户代码、没有用户地址空间。调度切换时借上一个任务的 mm 作为 active_mm,避免无谓刷 CR3。它们由 kthread_create() → kernel_thread() → clone 创建,标志里通常带 CLONE_VM 但 mm 仍为空。

记住 mm vs active_mm:用户任务的 mm 非空;内核线程 mm 为空、靠 active_mm 借页表。这是 task_struct 模型优雅之处——统一调度,按需借用。

7.3 全篇一张速查表

问题内核里的真相(结构体 / 函数)
进程和线程在内核是两种对象吗?不是。 都是 struct task_struct(include/linux/sched.h)。
那区别到底是什么?线程创建带 CLONE_VM 等标志 → 共享 mm_struct 且同 tgid;进程则不共享 mm。
getpid 为何多线程一样?它返回 tgid,而同组线程 tgid 相同。
fork 干了啥?kernel_clone→copy_process:dup_task_struct + 各 copy_*(copy_mm 复制 mm、页表 COW)。
execve 干了啥?不新建 task_struct,load_elf_binary 拆旧 mm、建新 mm/VMA、铺栈、start_thread 跳入口。
线程创建干了啥?还是 copy_process,但 CLONE_VM 让它共享 mm、CLONE_THREAD 让 tgid 相同。
地址空间在哪描述?struct mm_struct(mm_types.h):mmap(VMA链表)+pgd(页表)+各区域边界。
内核线程特殊在哪?mm=NULL,调度时用 active_mm 借用页表。

收尾:把四页串起来

这套内存系列现在是一条完整链路:
原理全过程(物理内存→OS→C/Go 分配)
分配器角色(malloc 不是 OS 给的)
空间模型(C 布局 ⊂ OS 虚拟空间)
静态程序→动态进程(fork+execve 变身)
本页:task_struct 源码视角(进程/线程同体,差异在 CLONE_* 与 mm 共享)

到这一层,你看到的不再是「黑盒进程」,而是 task_struct 里那几百个字段和 kernel/fork.cfs/exec.c 里真实的代码路径。