线程
REVIEW:进程
首先,我们为什么需要“进程”
这是个老生常谈的问题,古早的计算机,一次只跑一个程序。程序从头跑到尾,独占所有内存和 CPU。这会带来两个问题:
问题 1:资源浪费。程序在等磁盘、等网络的时候,CPU 完全空闲。
问题 2:不安全。两个程序同时跑,一个写坏了内存,另一个也跟着崩。
所以这就引到了我们最熟悉的那句话:给每个程序一个“幻觉”——让它以为自己独占整台机器。这个幻觉的载体,就是进程。
所以进程并不仅仅是“运行中的程序”这么简单。它是操作系统构建的一个隔离容器,里面装着程序运行所需的全部资源视图。
注意我们的措辞:进程是资源的所有者,但是进程本身不跑!真正跑起来的是进程里的线程!
线程
进程解决了隔离问题,但是我们去思考一个问题:假设有一个下载器,如果主界面需要相应用户点击,同时后台在下载文件,那下载的时候,界面就会卡死!
朴素解法:fork()
但是太朴素了,因为:fork() 会复制父进程的整个地址空间(虚拟内存映射、文件描述符表……),这个操作很重。更糟的是,两个进程想共享数据很难——它们的地址空间是隔离的,通信必须走 IPC(管道、共享内存、socket 等),又慢又复杂。
线程的关键思想:在同一个进程内,开多条执行流,让它们共享地址空间和资源,但各自有独立的栈和寄存器状态。
task_struct
Linux 没有在内核里分别实现进程管理和线程管理两套系统。它问了一个更深的问题:
进程和线程本质上有什么共同点?
答:它们都是可被调度的执行流,只是共享资源的程度不同。
于是 Linux 的设计是:用同一个数据结构 task_struct 描述一切,用 clone() 的参数控制”共享多少”。
task_struct 定义在 include/linux/sched.h
- 我是谁? → 身份字段
- 我现在在干什么? → 状态与调度字段
- 我能看到什么内存? → 内存管理字段
- 我打开了什么文件? → 文件系统字段
- 我和谁有关系? → 进程关系字段
- 我被切换出去时,CPU 长什么样? → 上下文字段
- 我怎么响应信号? → 信号字段
第一组:身份字段 —— “我是谁”
1
2
3
pid_t pid; // 内核调度单元的 ID(每个 task 唯一)
pid_t tgid; // Thread Group ID(等于线程所属进程的 PID)
char comm[TASK_COMM_LEN]; // 可执行文件名,最长 16 字节
pid vs tgid 的区别——最常见的混淆点
这里有一个命名陷阱,Linux 的历史包袱导致了反直觉的命名:
1
2
3
4
5
你以为的"进程 PID" → 实际是 tgid
你以为的"线程 ID" → 实际是 pid(内核层面)
用户态 getpid() 返回的是 tgid
用户态 gettid() 返回的是 pid
用一个具体例子说明:
1
2
3
4
5
6
7
8
一个进程启动了 3 个线程:
task_struct(主线程): pid=1000, tgid=1000
task_struct(线程2): pid=1001, tgid=1000
task_struct(线程3): pid=1002, tgid=1000
ps 命令显示的 PID = tgid = 1000(你只看到一行)
ps -L 显示的 LWP = pid = 1000/1001/1002(你看到三行)
设计原因:POSIX 规定同一进程的所有线程共享同一个 PID,但 Linux 内核的调度单元需要独立 ID。tgid 就是对 POSIX 的妥协层。
comm 的限制
TASK_COMM_LEN = 16,包含 \0,所以实际只能存 15 个字符。超长的命令名会被截断。这就是为什么你在 dmesg 或 ps 里有时看到进程名被截断。
第二组:状态与调度字段 —— “我现在在干什么,优先级怎么算”
1
2
3
4
5
6
7
volatile long state; // 当前状态(TASK_RUNNING=0 等)
int prio; // 动态优先级(调度器实际使用)
int static_prio; // 静态优先级(由 nice 值决定)
int normal_prio; // 归一化优先级(实时/普通统一尺度)
unsigned int rt_priority; // 实时优先级(RT 调度器用)
const struct sched_class *sched_class; // 该 task 用哪套调度算法
struct sched_entity se; // CFS 调度实体(含 vruntime)
优先级的三层结构
sched_class:策略模式的绝佳范例
sched_class 是一个函数指针结构体,里面全是虚函数:enqueue_task、dequeue_task、pick_next_task……
不同的调度策略(CFS、RT、Deadline)各自实现这套接口。调度器主循环调用 curr->sched_class->pick_next_task(),完全不关心背后是哪套算法。这是教科书级别的策略模式(Strategy Pattern)在系统软件里的应用。
se.vruntime:CFS 的核心
sched_entity 里最重要的字段是 vruntime(虚拟运行时间)。CFS 的调度规则极简:
谁的
vruntime最小,谁下一个上 CPU。
vruntime 的增长速度和进程优先级成反比——低优先级(nice 值高)的进程 vruntime 增长更快,自然更快”轮到别人”。这一个字段实现了公平调度的全部语义。
第三组:内存管理字段 —— “我能看到什么内存”
1
2
struct mm_struct *mm; // 用户态地址空间(用户进程有,内核线程为 NULL)
struct mm_struct *active_mm; // 当前使用的地址空间(内核线程借用上一个进程的)
mm 为 NULL 意味着什么
内核线程(如 kworker、ksoftirqd)没有用户态地址空间,它们的 mm == NULL。
但内核线程仍然需要访问内核内存(内核地址空间是所有进程共享的)。调度器切换到内核线程时,不切换地址空间,而是让它”借用” active_mm 继续使用上一个用户进程的页表——因为内核地址空间的映射在所有页表里都一样,借用是安全的。
这是一个工程妥协:避免切换页表(省掉 TLB 刷新的代价),同时内核线程根本不访问用户地址空间,所以借用的那部分从不被实际使用。
mm_struct 内部关键字段
1
2
3
4
5
6
7
8
9
10
struct mm_struct {
struct vm_area_struct *mmap; // 所有 VMA 的链表(每段虚拟内存区域)
pgd_t *pgd; // 页全局目录(页表的根,cr3 寄存器的内容)
atomic_t mm_users; // 有几个线程在用这个地址空间
atomic_t mm_count; // 引用计数(包含 active_mm 的借用)
unsigned long start_code, end_code; // 代码段范围
unsigned long start_data, end_data; // 数据段范围
unsigned long start_brk, brk; // 堆的起止(brk() 系统调用改的就是这里)
unsigned long start_stack; // 栈起始地址
};
pgd 是进程切换时被装入 cr3 寄存器的值——这一行代码完成地址空间切换:mov cr3, new_pgd。
第四组:文件系统字段 —— “我打开了什么”
1
2
struct fs_struct *fs; // 当前工作目录、根目录
struct files_struct *files; // 文件描述符表
files_struct 展开
1
2
3
4
5
struct files_struct {
atomic_t count; // 引用计数(线程共享同一个 files_struct)
struct fdtable *fdt; // 指向当前文件描述符表
struct file *fd_array[]; // fd 数组:fd[0]=stdin, fd[1]=stdout, fd[2]=stderr
};
fd_array[n] 就是 open() 返回的文件描述符 n 对应的内核 struct file。
线程共享 files 的含义:同一进程的所有线程共享同一个 files_struct。一个线程 open() 一个文件,另一个线程立刻能用同一个 fd 访问它。这也意味着一个线程 close(fd),其他线程的这个 fd 也随之失效——这是多线程编程里一个真实的坑。
fork() 之后的行为:子进程获得父进程文件描述符表的副本(浅拷贝,指向同一批 struct file),文件偏移量共享。这是 Unix shell 实现重定向的基础——fork() 后、exec() 前,子进程可以随意修改自己的 fd 表(比如把 fd[1] 替换成一个文件),然后 exec() 启动新程序,新程序的 stdout 就指向了那个文件。
第五组:进程关系字段 —— “我和谁有关系”
1
2
3
4
5
struct task_struct __rcu *real_parent; // 真正创建我的父进程
struct task_struct __rcu *parent; // 当前父进程(ptrace 时可能不同)
struct list_head children; // 我的子进程链表头
struct list_head sibling; // 我在父进程 children 链表里的节点
struct list_head tasks; // 全局进程链表的节点(for_each_process 用这个)
两个 parent 字段的区别
正常情况下 real_parent == parent。但当 gdb 用 ptrace 附加到进程时,parent 会被改成 gdb 的 task——信号和 wait() 通知会发给 gdb 而不是原来的父进程。real_parent 始终记录真正的亲生父进程,不受 ptrace 影响。
tasks 字段:for_each_process 的秘密
tasks 是 struct list_head 类型,它是一个侵入式双向链表节点——链表节点嵌入在 task_struct 内部,而不是 task_struct 被节点包含。
1
2
3
4
5
6
// 内核遍历所有进程的方式:
list_for_each_entry(p, &init_task.tasks, tasks) { ... }
// for_each_process 宏本质就是:
#define for_each_process(p) \
for (p = &init_task; (p = next_task(p)) != &init_task;)
init_task 是 PID=0 的 idle 进程,是这个循环链表的起始节点。实验 1 里你写的 for_each_process(p) 遍历的就是这条链。
第六组:上下文字段 —— “被切换出去时 CPU 长什么样”
1
struct thread_struct thread; // 平台相关的寄存器快照
thread_struct 的内容是体系结构相关的。x86-64 下大致包含:
1
2
3
4
5
6
struct thread_struct {
unsigned long sp; // 内核栈指针
unsigned long ip; // 指令指针(上次切换时的 PC)
struct fpu fpu; // 浮点/SSE/AVX 寄存器状态(延迟保存)
// ... 其他段寄存器、调试寄存器等
};
浮点寄存器的延迟保存是一个经典的性能优化:x86 有数百字节的 FPU/SSE 状态,每次上下文切换都保存/恢复代价很高。内核用”用到时才保存”(lazy FPU switching)——切换时先不保存 FPU 状态,当新 task 真正执行浮点指令时,触发一个 Device Not Available 异常,内核再去保存上一个 task 的 FPU 状态。对于不做浮点运算的 task,完全省掉了这笔开销。
第七组:信号字段 —— “收到信号怎么办”
1
2
3
4
struct signal_struct *signal; // 线程组共享的信号信息
struct sighand_struct *sighand; // 信号处理函数表(共享于线程间)
sigset_t blocked; // 当前 task 屏蔽的信号集
struct sigpending pending; // 待处理的信号队列(私有)
信号的两级结构
1
2
3
4
5
6
7
8
sighand_struct(共享)
└── 存储 signal handlers:收到 SIGINT 调用哪个函数?
signal_struct(线程组共享)
└── 存储发给整个进程的 pending 信号
task_struct->pending(私有)
└── 发给具体这个线程的 pending 信号
pthread_kill(tid, sig) 把信号放进指定线程的 pending。kill(pid, sig) 把信号放进 signal->shared_pending,内核选一个没有屏蔽该信号的线程来处理。blocked 字段控制当前线程屏蔽哪些信号——pthread_sigmask() 修改的就是这个字段。
总结
用 fork() 创建子进程时,内核做的事情就是按照字段分组决定:复制还是共享:
| 字段组 | fork() 后 | clone(CLONE_VM|...) 后(线程) |
|---|---|---|
pid / tgid | 新 pid,新 tgid | 新 pid,共享 tgid |
state | 新的(TASK_RUNNING) | 新的 |
mm_struct | 深拷贝(独立地址空间) | 共享指针(同一 mm) |
files_struct | 浅拷贝(独立 fd 表,但指向同一文件) | 共享指针 |
sighand_struct | 拷贝(独立 handler 表) | 共享指针 |
thread_struct | 新的(从父进程 fork 点起跑) | 新的(从指定入口函数起跑) |
tasks 链表节点 | 新节点插入全局链表 | 新节点插入全局链表 |
task_struct 的字段设计,本质上就是一张资源所有权的清单。fork 和 clone 的区别,就是这张清单里哪些列选择”复制”,哪些选择”共享”。
1
2
3
4
5
6
7
8
9
// 创建"进程":几乎不共享任何东西
fork()
// 等价于:
clone(SIGCHLD, ...)
// 创建"线程":共享地址空间、文件、信号等
pthread_create()
// 等价于:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, ...)
CLONE_VM 这个 flag 的含义就是:新 task 和父 task 共用同一个 mm_struct(虚拟地址空间),而不是复制一份新的。
这个设计的深层含义:进程和线程的区别,只存在于用户的概念里。在内核调度器眼中,它只看到一个个 task_struct,平等地放进 run queue,平等地被调度。
线程私有什么,共享什么?
为什么寄存器必须是私有的?
寄存器保存的是“当前执行到哪里了”——程序计数器 PC 指向下一条要执行的指令,栈指针 SP 指向当前栈顶。如果两个线程共享寄存器,它们根本没法同时存在于不同的执行位置。寄存器的私有性,是并发执行成为可能的前提。
上下文切换的本质
1
2
3
4
5
6
7
8
Thread A 正在跑
↓ 时间片到 / 发生中断
1. 把 A 的所有寄存器状态保存到 A 的 task_struct->thread_struct
2. 从 B 的 task_struct->thread_struct 恢复 B 的寄存器
3. 如果 A 和 B 属于不同进程:还要切换 mm_struct(刷 TLB,这很贵!)
4. 如果 A 和 B 属于同一进程(即线程切换):跳过步骤 3
↓
Thread B 开始跑,从它上次停下的地方继续
这就是线程切换比进程切换快的根本原因:同进程内的线程切换不需要切换地址空间,省掉了 TLB 刷新(TLB 是 CPU 缓存页表映射的硬件,刷掉意味着之后的内存访问都要重新查页表,代价很大)。
共享带来便利,也带来危险。
线程的代价:
共享地址空间意味着一个线程可以踩坏另一个线程的数据,而两个进程之间做不到这一点(地址空间隔离保护了它们)。所以多线程编程需要锁、原子操作、内存屏障……这是一整套复杂的并发控制体系。
一个常被忽视的点:
Linux 的线程在内核里没有”线程 ID”这个概念——它有的是
pid(内核调度用)和tgid(thread group ID,等于进程的 PID)。ps命令默认显示的 PID 对应的是tgid,这就是为什么你用ps看一个多线程进程,只看到一行,但用ps -L才能看到所有线程。
| 维度 | 进程 | 线程 |
|---|---|---|
| 内核数据结构 | task_struct | task_struct(完全相同) |
| 创建方式 | fork() / clone(少量flags) | clone(CLONE_VM, ...) |
| 地址空间 | 独立(mm_struct 复制) | 共享(同一个 mm_struct) |
| 切换代价 | 高(需刷 TLB) | 低(无需切换地址空间) |
| 通信方式 | IPC(管道、共享内存等) | 直接读写共享内存 |
| 隔离性 | 强(崩溃不互相影响) | 弱(一个崩全进程崩) |
| 调度器视角 | 一个 task | 一个 task(无区别) |
进程状态机
❌ “状态是进程的感受,进程‘决定’进入某个状态”
✅ 状态是
task_struct->state字段的当前值,由内核在特定事件发生时修改你永远不会看到用户代码写
my_state = SLEEPING。状态转换是内核行为,用户代码只能通过系统调用间接触发它。
运行态 R:有资格使用 CPU
调度器需要知道:现在哪些 task 可以被放上 CPU? R 状态就是这个问题的答案——所有 R 状态的 task 构成“候选池”。
R 状态实际上包含两种情况,内核用同一个值表示:
1
2
3
4
R = TASK_RUNNING = 0
情况1:正在某个 CPU 核上执行 → "running"(运行态)
情况2:在 run queue 里等待被调度 → "runnable"(就绪态)
为什么合并成一个状态? 因为从调度器的角度,这两种情况没有本质区别——task 随时可以被放上 CPU,不需要等待任何外部事件。区分它们需要额外的每核数据结构,收益很低。
R 状态 task 住在哪里?
每个 CPU 核有一个独立的 run queue(struct rq)。普通进程用 CFS 调度器,内部是一棵以 vruntime(虚拟运行时间)为键的红黑树,vruntime 最小的 task 最先被调度。
1
2
3
4
5
CPU 0 的 run queue
├── 红黑树(CFS,普通进程)
│ 左子树 vruntime 小 → 优先调度
└── 优先级数组(RT,实时进程)
100个优先级链表,严格按优先级
关键设计:每个 CPU 独立维护 run queue,避免多核竞争同一把锁。这是性能优先的工程选择。
睡眠态 S/D:等待某个事件
它解决什么问题?
进程经常需要等待——等磁盘读完、等网络包到达、等用户输入。如果等待时仍然占着 R 状态,调度器就会不停地把它放上 CPU,然后发现没数据,再换下来——这叫忙等待(busy waiting),纯粹浪费 CPU。
睡眠态的设计是:把 task 从 run queue 里摘出去,等事件发生时再放回来。
S 和 D 的区别:能不能被信号打断
这是一个有意的设计选择,不是技术限制。
1
2
S = TASK_INTERRUPTIBLE 可中断睡眠
D = TASK_UNINTERRUPTIBLE 不可中断睡眠
D 状态最容易被误解的地方:SIGKILL 也杀不掉 D 状态的进程,这让很多人以为是 bug。实际上这是内核的一个正确性保证。想象进程正在向磁盘写一个数据库事务的中间状态,如果这时强制中断,文件系统可能损坏。D 状态告诉你:“等我把这件事做完,再来处理信号。”
D 状态持续太久怎么办? 通常意味着硬件问题(磁盘挂了、NFS 服务器无响应)。这时 ps 里会出现大量 D 状态进程,系统负载飙高,但 CPU 使用率接近 0——这是经典的 I/O 卡死现象。
睡眠态 task 住在哪里?
这是一个常见误区:
❌ “内核有一个全局的睡眠队列”
✅ 睡眠 task 挂在具体资源/事件的等待队列上
1
2
3
等待 socket 数据 → 挂在该 socket 的 wait_queue 上
等待某个锁 → 挂在该锁的 wait_queue 上
等待磁盘块 → 挂在该块设备的 wait_queue 上
设计动机:如果所有睡眠进程都在一个全局队列里,每次任何事件发生都要线性扫描整个队列——O(n) 的唤醒代价。把等待队列绑定在资源上,唤醒时只通知等待这个资源的那些 task,精准高效。
假设 Linux 取消 D 状态,所有睡眠都用 S 替代,会发生什么?
答:内核 I/O 中途被信号打断,文件系统元数据写到一半,数据损坏。
SIGKILL能杀掉一个正在写磁盘的进程,内核无法安全回滚。这就是为什么 D 状态必须存在——它是原子性的操作系统级别保证,不是线程安全那么简单。
停止态 T:执行被冻结
它解决什么问题?
两个场景需要“暂停”一个进程但不杀掉它:
场景1:用户主动暂停。在终端里 Ctrl+Z,把前台进程挂到后台,之后用 fg 恢复。
场景2:调试器跟踪。gdb 在断点处暂停被调试的进程,检查其内存和寄存器,然后单步执行。
机制
1
2
SIGSTOP → task 进入 T 状态,从 run queue 摘出
SIGCONT → task 回到 R 状态,重新进入 run queue
SIGSTOP 的特殊性:它是 Linux 里少数几个不能被捕获、不能被忽略的信号之一(另一个是 SIGKILL)。原因是:如果允许进程忽略 SIGSTOP,调试器就无法可靠地暂停它,Ctrl+Z 就会失效。这是操作系统保留的“最终控制权”。
T 状态 task 住在哪里? 没有专属队列。task 仍然在全局进程双向链表里(for_each_process 能遍历到),但从 run queue 里摘出来了。它就像一本书被从书架上抽出来放到桌上——还存在,但不参与借阅流程。
僵尸态 Z:死了但没走
这是四种状态里设计最精妙也最反直觉的一种。
为什么需要僵尸状态?
先从 Unix 的设计哲学说起:父进程需要知道子进程怎么死的。
子进程退出时,有两件事要传递给父进程:
- 退出码(exit code):正常退出是 0,异常退出是非 0
- 资源使用统计:用了多少 CPU 时间、内存峰值等
问题是:子进程已经死了,谁来保存这些信息?
答案是内核:子进程调用 exit() 时,内核不会立即销毁它的 task_struct,而是:
1
2
3
4
5
6
1. 释放大部分资源:地址空间、文件描述符、内存……
2. 保留最小信息:PID、退出码、资源统计
3. 把 state 设为 EXIT_ZOMBIE
4. 向父进程发送 SIGCHLD,通知"我死了"
5. 等待父进程调用 wait() 来读取信息
6. 父进程 wait() 之后,task_struct 才彻底释放
僵尸进程的危害:它占用 PID,PID 是有限资源(默认上限约 32768)。如果父进程是一个长期运行的服务(如 web server),不断 fork 子进程但从不 wait(),僵尸会积累,最终 PID 耗尽,系统无法再创建新进程。
自然消亡:如果父进程先于子进程死,子进程会被 init(PID 1)收养,init 会周期性调用 wait() 回收它们。所以孤儿进程(orphan)不会成为永久僵尸,僵尸的问题只出现在父进程活着但不 wait()。
内在联系:三个维度把它们串起来
维度 1:谁决定状态转换?
| 转换 | 触发者 |
|---|---|
| R → S/D | 进程自己调用系统调用(sleep、read……),内核执行转换 |
| S/D → R | 内核(事件到达时,驱动/定时器中断唤醒 task) |
| R → T | 外部信号(SIGSTOP),可以是另一个进程发的 |
| T → R | 外部信号(SIGCONT) |
| R → Z | 进程自己调用 exit(),但内核保留残影 |
| Z → 消失 | 父进程调用 wait(),内核清除 |
规律:进入睡眠是主动的(系统调用),唤醒是被动的(内核通知)。停止和恢复是外部强制的。只有 Z 状态的消失依赖父进程的配合。
维度 2:它们对 CPU 的“态度”
1
2
3
4
5
R ─── 想要 CPU,随时准备运行
S ─── 不需要 CPU,但很快会需要(等待的事件通常会发生)
D ─── 不需要 CPU,且无法被打断(内核强制)
T ─── 不需要 CPU,直到外部命令恢复
Z ─── 永远不需要 CPU,只是占着一个表项
维度 3:它们存在的“目的”
- R 是正常执行,是系统存在的意义
- S/D 是为了不浪费 CPU——睡眠是效率的体现,不是失败
- T 是为了给人类(调试器/用户)控制进程的手段
- Z 是为了让父子进程间的信息传递有一个短暂的缓冲期
最深的联系:这四种状态,本质上是操作系统在资源效率、正确性保证和可控性三个目标之间做出的设计选择的具体体现。没有一种状态是”多余”的。







