《深入理解Linux进程与内存》第 3 章 · 进程与线程的对比 · 重点总结

《深入理解Linux进程与内存》第 3 章 · 进程与线程的对比 · 重点总结

一句话结论: 在 Linux 内核里,进程和线程都由 task_struct 表示、都由调度器独立调度,也都经过 kernel_clone() → copy_process() 创建;真正的分界不是"名字",而是新任务是否通过 clone flags 与创建者共享地址空间、文件表、目录信息、信号处理和线程组等资源。

本总结覆盖《深入理解Linux进程与内存》第 3 章(书页 58~95),并与《操作系统真象还原》中的 OS 项目实现对照:第 9 章内核线程、第 11 章用户进程、第 15 章 fork。

1. 先建立正确的心智模型

1.1 Linux 调度的是 task,不是抽象名词

经典教材常说:进程是资源分配单位,线程是 CPU 调度单位。这适合作为入门,但不能直接当成 Linux 内核的数据结构模型。Linux 把进程和线程都抽象为 task ,每个可调度实体都有独立的 task_struct、内核栈、运行状态、调度字段和任务 ID。调度器看到的是一个个 task,而不是两个完全不同的 PCB/TCB 类型。

用户态所说的"一个多线程进程",从内核视角看是一组 task_struct:它们各自可被调度,但通过指针共同引用若干资源对象。

1.2 真正的分界:资源复制还是资源共享

判断一个 task 更像"进程"还是"线程",最有用的问题是:

  1. 是否拥有独立的 mm_struct(虚拟地址空间)?
  2. 是否共享 files_struct(打开文件描述符表)?
  3. 是否共享 fs_struct(根目录、当前工作目录等)?
  4. 是否处于同一线程组,拥有相同 TGID?
  5. 是否共享信号处理相关结构?

如果新 task 拥有独立地址空间和独立资源表,它表现为进程;如果它与同组 task 共享地址空间、文件表、目录和信号处理等核心资源,它表现为线程。

1.3 内核线程为什么应该叫"线程"

内核线程同样是可调度 task,但其 mm 通常为 NULL,不拥有用户态虚拟地址空间,只在共享的内核地址区域中运行。因此,内核任务 ksoftirqd 等应称为内核线程,而不是内核进程。

这里要区分两个"共享":所有普通用户进程也都映射内核地址区域,但每个进程仍有自己的用户地址空间;内核线程没有自己的用户地址空间,调度运行时只使用内核映射。

2. task_struct 封装了什么

2.1 状态:从可运行到阻塞再到唤醒

任务创建后进入可运行状态;调度器选中它后开始执行;当它等待 socket 数据、锁、定时器或其他事件时,会进入可中断或不可中断睡眠;事件到达后被唤醒,再次回到可运行队列。

复制代码
新建 task → TASK_RUNNING(可运行)→ 被调度执行
                         ↓ 等待事件
              TASK_INTERRUPTIBLE / TASK_UNINTERRUPTIBLE
                         ↓ 事件到达,wake_up
                   TASK_RUNNING(可运行)

TASK_RUNNING 同时覆盖"正在 CPU 上执行"和"已就绪、等待 CPU"两类状态;真正区分它们的是 task 是否当前占有处理器、是否位于运行队列。

2.2 PID、TID 与 TGID

Linux 为每个 task 分配唯一的内核任务 ID。多线程程序中:每个线程都有不同的 TID;同一线程组共享 TGID;主线程的 TID 等于 TGID;用户态 getpid() 返回 TGID,所以同一进程内各线程看到相同"进程号"。

task 内核 TID TGID 用户态含义
主线程 888 888 进程 ID 为 888
工作线程 1 889 888 属于进程 888
工作线程 2 890 888 属于进程 888

2.3 亲缘关系、线程组与调度

task_struct 通过父任务、子任务链表、兄弟节点、线程组 leader 等字段把所有 task 组织起来。进程树回答"谁创建了谁",线程组回答"哪些 task 共同构成用户看到的一个进程"。每个线程都是独立调度实体,一个线程阻塞不会直接让同进程其他线程一起阻塞。

2.4 资源对象不是都内嵌在 task 中

task_struct 更像总索引,许多重量级资源由指针指向独立对象:

资源 典型对象 说明
虚拟地址空间 mm_struct 代码段、数据段、堆、栈、mmap 区及页表关系
根目录与当前目录 fs_struct root、pwd 等路径信息
打开文件 files_struct 文件描述符表,0/1/2 通常为标准输入/输出/错误
命名空间 nsproxy PID、mount、network、UTS、IPC、user 等命名空间引用
调度信息 sched_entity 等 运行时间、优先级、调度类状态

这种"task 壳 + 可共享资源对象"的设计,正是进程与线程能够共用同一创建主干的基础。

3. 进程是怎样创建的

3.1 从 Nginx worker 看 fork

Nginx master 创建 worker 时调用 fork()。返回值把父子路径分开:

arduino 复制代码
pid_t pid = fork();
switch (pid) {
case -1:
    /* 创建失败 */
    break;
case 0:
    worker_cycle();   // 子进程从这里进入自己的工作循环
    break;
default:
    /* 父进程记录子进程 pid */
    break;
}

fork() 的关键效果不是"从磁盘再加载一次程序",而是创建一个新的 task,使其从系统调用返回点继续执行;父进程得到子 PID,子进程得到 0。

3.2 统一创建主干

不同内核版本的外层函数名略有变化,但核心路径相同:

scss 复制代码
fork() / clone() / clone3()
        ↓
kernel_clone(args)
        ↓
copy_process(...)
        ├─ dup_task_struct()   创建 task_struct 与内核栈
        ├─ copy_files()       复制或共享文件表
        ├─ copy_fs()          复制或共享目录信息
        ├─ copy_mm()          复制或共享地址空间
        ├─ copy_namespaces()  继承或创建命名空间
        ├─ 分配 PID/TID/TGID
        └─ 初始化父子、线程组与调度关系
        ↓
wake_up_new_task()
        ↓
进入就绪队列,等待调度器选中

最先发生的是创建新的 task_struct 和内核栈。结构体的指针字段起初只是浅复制;随后 copy_files、copy_fs、copy_mm 等函数根据 flags 决定是创建新对象还是仅增加旧对象的引用计数。

3.3 进程资源的复制语义

普通 fork() 不传 CLONE_VM、CLONE_FS、CLONE_FILES 等共享标志,因此新进程在逻辑上拥有独立资源视图:

  • files_struct:创建独立文件描述符表,但表项仍可能引用相同的内核 file 对象;
  • fs_struct:创建独立目录信息,初值继承父进程;
  • mm_struct:创建独立地址空间描述;
  • nsproxy:未要求新命名空间时,通常继续引用父进程所在命名空间;
  • PID:在所属 PID 命名空间中分配新 ID;
  • 父子关系:新 task 进入进程树,退出时通过 SIGCHLD 等机制通知父进程。

3.4 地址空间不是立刻完整物理复制

从语义上看,父子进程各自拥有独立地址空间;从性能上看,现代 Linux 通常用写时复制(Copy-on-Write):创建时让父子页表暂时指向同一批只读物理页,任一方写入时触发缺页异常,再复制被写的页。

因此要区分:地址空间对象独立;物理页可暂时共享;fork() 后紧接 execve() 的场景不会白白复制所有用户页。

3.5 PID 分配为何不断演进

早期内核曾用 bitmap 记录 PID 是否占用:1 bit 表示 1 个 PID,空间紧凑、缓存友好。进程规模增大后,单纯扫描 bitmap 的分配成本上升,新内核改用 IDR/基数树一类层次化结构降低查找成本。小规模时 bitmap 简洁高效;规模扩大后,更低的查找复杂度可能比极致节省几 KB 内存更重要。

4. 线程是怎样创建的

4.1 pthread_create 最终仍进入内核 clone 路径

用户态 pthread_create() 由 glibc/NPTL 封装。它先准备线程栈、线程控制块、TLS 和启动函数,再通过 clone/clone3 进入内核。内核最终仍走 kernel_clone() → copy_process()。

所以"创建线程"和"创建进程"并不是两套内核机制,而是同一套机制的不同参数组合。

4.2 三个最关键的共享标志

标志 内核动作 线程语义
CLONE_VM copy_mm() 只增加 mm_struct 引用 共享同一虚拟地址空间
CLONE_FILES copy_files() 只增加 files_struct 引用 共享文件描述符表
CLONE_FS copy_fs() 只增加 fs_struct 引用 共享当前目录、根目录等

实际 POSIX 线程还需要 CLONE_THREAD、CLONE_SIGHAND、CLONE_SETTLS、CLONE_PARENT_SETTID、CLONE_CHILD_CLEARTID 等标志,用于线程组、信号处理、TLS 以及线程退出/回收协作。

4.3 每个线程仍然独立拥有的内容

共享地址空间不等于共享一切。每个线程仍有独立 task_struct、内核栈、用户栈区、寄存器上下文、程序计数器、调度状态、TID 和 TLS 视图。正因为寄存器和栈独立,多个线程才能在同一地址空间内同时执行不同函数。

5. 进程与线程对比

维度 进程 线程
内核表示 独立 task_struct 同样是独立 task_struct
调度 独立调度 独立调度
地址空间 独立 mm_struct;物理页可暂时 COW 共享 mm_struct
文件描述符表 通常复制一份表 通常共享同一 files_struct
当前目录/根目录 通常复制 fs_struct 通常共享 fs_struct
PID 视角 新 PID/TGID 新 TID,与同组线程共享 TGID
通信方式 管道、socket、共享内存等 IPC 直接读写共同地址空间
同步需求 共享较少,边界清晰 必须保护共享数据,易出现竞态、死锁
故障隔离 地址空间隔离较强 非法内存访问可能终止整个进程
创建成本 较高,但 COW 显著降低 fork 初始成本 通常更低,主要复用资源对象
上下文切换 可能切换地址空间和页表上下文 同进程线程间通常无需切换 mm

选择原则: 需要隔离、权限边界或独立生命周期时优先进程;需要低成本并发且数据高度共享时选择线程,但必须把同步、取消、错误传播和资源回收设计在前面。

6. 与 OS 项目相比,究竟有什么不同

这里的 OS 项目指《操作系统真象还原》中从零搭建的 32 位 x86 教学内核。它与真实 Linux 的最大差别不是概念相反,而是目标不同:Linux 解决生产环境的规模、兼容性、隔离和性能;OS 项目刻意把关键机制拆开,让读者能亲手实现并看见每一步。

6.1 OS 项目第 9 章:先实现内核线程

OS 项目先定义精简版 task_struct:

arduino 复制代码
struct task_struct {
    uint32_t *self_kstack;       // 独立内核栈顶
    enum task_status status;     // RUNNING / READY / BLOCKED ...
    uint8_t priority;            // 同时影响时间片
    char name[16];
    uint32_t stack_magic;        // 检测内核栈是否侵入 PCB
};

thread_start() 为 PCB 申请一个 4 KiB 内核页,init_thread() 初始化状态与优先级,thread_create() 在页顶构造中断栈和线程栈。之后 task 被加入 thread_ready_list 和 thread_all_list,时钟中断触发 schedule(),再由汇编 switch_to() 保存/恢复寄存器与栈指针。

与 Linux 相同的是:线程和以后出现的用户进程共用一个 PCB 类型,每个任务都有独立内核栈,就绪队列最终选择 task。不同的是:OS 项目使用单核、简单时间片模型,PCB 字段很少,且没有统一 clone flags API。

6.2 OS 项目第 11 章:在线程骨架上补出用户进程

process_execute() 复用线程创建框架,但为用户进程新增用户虚拟地址位图、独立页目录、用户栈、3 级特权执行现场和 TSS 的 esp0。process_activate() 切换页目录(CR3)并更新 TSS。

这直接演示了本章的核心判据:有独立用户地址空间的 task 是进程;pgdir == NULL、只在内核地址空间运行的 task 是内核线程。

6.3 OS 项目第 15 章:fork 是逐页完整复制

scss 复制代码
get_kernel_pages(1)                  为子进程申请 PCB + 内核栈
copy_pcb_vaddrbitmap_stack0()        复制 PCB、虚拟地址位图、内核栈并分配 PID
create_page_dir()                    创建子进程页表
copy_body_stack3()                   遍历父进程有效用户页
    父页 → 一页内核缓冲区 → 子页    逐页完整复制
build_child_stack()                  令子进程 fork 返回 0,并从 intr_exit 返回
update_inode_open_cnts()             修正打开文件 inode 引用计数
加入 ready/all 队列                 等待 schedule() 调度

这里没有实现 Linux 的写时复制。父进程每个有效用户页都被真正复制到子进程物理页中,期间反复切换父子页表并借助共享内核页作中转。效率较低,但非常适合教学:页目录、地址空间隔离、内核共享区和 fork 双返回语义都清清楚楚。

6.4 一张表看懂两套实现

维度 真实 Linux OS 项目
核心抽象 task_struct + 可共享资源对象 精简 task_struct/PCB,逐章添加字段
线程创建 pthread_create → clone → kernel_clone → copy_process thread_start → init_thread → thread_create
进程创建 fork 与线程共享内核主干,flags 不同 process_execute 先创建初始进程,sys_fork 后续加入
进程/线程分界 flags 决定复制或共享 mm/files/fs/... 主要看 pgdir;没有通用 flags 组合
地址空间 fork 常用 COW 每进程独立页目录;fork 逐页完整复制
PID 管理 命名空间 + 分层 ID 数据结构 全局 PID 分配器,模型简单
调度 多类调度器、SMP、负载均衡 单核 ready list + 时间片 + switch_to
文件表 files_struct 引用计数,可按 flags 共享 PCB 的 fd_table 被复制,手工增加 inode 打开计数
命名空间/cgroup 原生支持,是容器基础 没有实现
工程目标 高并发、兼容、隔离、性能和扩展性 暴露底层原理,代码短、路径可跟踪

7. 性能与正确性推论

7.1 为什么线程创建和切换通常更轻

线程复用 mm_struct、files_struct、fs_struct 等对象,创建时多是增加引用计数。同进程线程切换时通常不必切换地址空间上下文,因此页表和 TLB 相关成本也更低。但"更轻"不是"免费":线程仍需要 task、内核栈、用户栈、TLS、调度和同步开销。

7.2 为什么线程更容易出并发错误

共享 mm_struct 避免了 IPC,却把正确性责任交给程序:复合操作不是天然原子的;一个线程关闭 fd 会改变其他线程看到的表;改变当前目录会影响共享 fs_struct 的其他线程;越界写可能破坏整个进程;锁顺序不一致会产生死锁。

7.3 为什么多进程仍然很常见

进程的价值主要在隔离:不同地址空间减少意外共享,崩溃边界更清晰,也便于使用不同用户、权限、命名空间和资源限制。Nginx 多 worker、浏览器多进程和服务分片都利用了这一点。

8. 实践观察命令

8.1 查看线程组与 TID

bash 复制代码
# LWP 列就是线程 ID;同一 PID 可对应多行
ps -eLf
​
# 查看指定进程下的所有线程
ls -1 /proc/<PID>/task
​
# 观察 Name、State、Tgid、Pid、PPid、Threads
cat /proc/<PID>/status

8.2 查看命名空间

bash 复制代码
ls -l /proc/<PID>/ns
​
# 比较两个进程是否处于同一网络命名空间
readlink /proc/<PID1>/ns/net
readlink /proc/<PID2>/ns/net

如果两个符号链接显示相同的 net:[inode],它们引用同一个网络命名空间对象。

8.3 跟踪创建系统调用

bash 复制代码
# 观察程序创建进程/线程时实际使用的系统调用
strace -f -e trace=clone,clone3,fork,vfork,execve ./your_program

不同 glibc 与内核版本可能选择不同入口,因此观察到 clone 或 clone3 都是正常的。

9. 常见误区

  1. "Linux 内核里进程和线程是两种完全不同的结构。" 错。二者都是 task_struct;区别主要在资源共享关系和线程组关系。
  2. "线程没有自己的栈。" 错。线程共享地址空间,但每个线程必须有独立用户栈和内核栈。
  3. "fork 会立刻复制父进程所有物理内存。" 对现代 Linux 通常不成立;它主要依靠写时复制。对本 OS 项目的 copy_body_stack3() 却基本成立:有效用户页被逐页复制。
  4. "共享文件意味着两个线程各有一份相同的文件表。" 不准确。CLONE_FILES 让线程直接引用同一 files_struct,修改会互相可见。
  5. "内核线程有自己的用户地址空间。" 错。内核线程 mm == NULL,只使用内核地址区域。
  6. "线程一定比进程快,所以越多越好。" 错。线程过多会增加调度、栈内存、同步和缓存争用。

10. 自测问题

  1. 为什么同一进程内的线程拥有不同 TID,却从 getpid() 获得相同值?
  2. CLONE_VM、CLONE_FILES、CLONE_FS 分别改变哪个资源对象的复制策略?
  3. 为什么内核线程的 mm 可以为 NULL?
  4. fork() 的"地址空间独立"和"物理页写时共享"为什么不矛盾?
  5. OS 项目的 process_activate() 为什么既要处理 CR3,又要更新 TSS 的 esp0?
  6. OS 项目 build_child_stack() 如何让子进程从 fork() 得到返回值 0?
  7. 为什么 OS 项目逐页复制适合教学,却不适合作为现代生产内核的默认实现?

11. 一页速记

  • 统一抽象 :进程、线程都是 task,都有独立 task_struct、内核栈、调度状态。
  • 核心边界 :独立 mm_struct 更像进程;共享 mm_struct 更像线程。
  • 统一创建 :kernel_clone → copy_process → wake_up_new_task。
  • 线程关键 flags :CLONE_VM + CLONE_FILES + CLONE_FS + CLONE_THREAD + CLONE_SIGHAND 等。
  • ID 关系:每线程不同 TID,同线程组共用 TGID,主线程 TID = TGID。
  • fork 语义:地址空间逻辑独立,现代 Linux 的物理页通常先 COW。
  • 内核线程 :mm == NULL,只运行在内核地址区域。
  • OS 项目线程 :thread_start → ready list → schedule → switch_to。
  • OS 项目进程:在线程 PCB 上增加页表、用户地址位图、用户栈和 TSS 支持。
  • OS 项目 fork:逐页完整复制用户空间,无 COW,路径直观但成本高。

12. 来源与适用范围

  • 《深入理解Linux进程与内存》第 3 章"进程、线程的对比",书页 58~95(PDF 第 86~123 页)。
  • 《操作系统真象还原》第 9 章"线程"、第 11 章"用户进程"、第 15 章"系统交互"中的 fork 实现。
  • 内核函数名和具体调用层级会随版本变化;本文以书中 Linux 5.4/6.1 对照和稳定机制为主,理解重点是统一 task 模型与 clone flags 的资源语义,而不是死记某一版本的函数名。
相关推荐
别动我齐刘海2 小时前
实时控制学习2
linux·c++·人工智能·学习·算法
行者-全栈开发2 小时前
【容器/虚拟化】CVE-2026-53359:Linux KVM Januscape 虚拟机逃逸漏洞修复指南(潜伏16年的云安全噩梦)
linux·运维·服务器·多租户隔离·cve-2026-53359·kvm虚拟机逃逸·shadow mmu
杂家2 小时前
ubuntu使用指南
linux
GIFT1682 小时前
进程优先级
linux
虎头金猫2 小时前
从上传PPT到观众远程打开互动页面:Claper在CentOS 7上的Docker部署、互动组件设置与固定访问地址实战
linux·运维·网络·人工智能·docker·centos·powerpoint
用户766743934542 小时前
云服务器上线前,我必做的 5 项安全检查
linux
slimer3 小时前
11.C 语言 printf 打印函数详解:格式说明符、宽度精度与安全输出
linux·c语言
不会就选b3 小时前
Linux之ip地址<3>
linux·运维·tcp/ip
Victor Zhong3 小时前
llama.cpp 部署大模型到 Ubuntu 台式机 SOP
linux·ubuntu·llama