第一章:进程与线程的千年纠葛
1.1 从冯·诺依曼体系说起
说实话,每次我给学生讲多线程,总得先聊聊冯·诺依曼。为什么?因为现代操作系统的并发革命,根子就在这儿。
冯·诺依曼体系的核心是什么?存储程序、顺序执行。说白了,CPU 一条一条地取指令、执行指令。早期计算机就是这么干的——一个程序跑完,下一个才能上。你想想看,那时候的计算机多「傻」啊,CPU 大部分时间都在等 I/O,利用率低得可怜。
我刚开始做嵌入式开发时,遇到过一台老式工控机。它跑的是单任务系统,一个数据采集程序没跑完,你就别想干别的。有一次采集卡卡住了,整个系统就僵在那,我盯着屏幕干瞪眼。嗯,从那时起我就明白了:并发不是锦上添花,是刚需。
1.2 进程的诞生:PCB 的底层数据结构
进程是什么?教科书上说:进程是程序的一次执行。但我觉得,更准确的说法是:进程是操作系统管理资源的最小单位。
每个进程都有一个「身份证」——进程控制块(PCB)。我习惯把它想象成一张档案卡,操作系统靠它来管理所有进程。
PCB 里到底存了什么?
- 进程标识符(PID):每个进程的唯一编号。就像身份证号。
- 程序计数器(PC):下一条指令的地址。进程切换时,CPU 靠它恢复现场。
- CPU 寄存器:通用寄存器、栈指针、状态寄存器等。说白了,就是 CPU 的「快照」。
- 内存管理信息:页表、段表、内存限制等。进程的「地盘」有多大,全看它。
- 文件描述符表:进程打开的文件列表。Linux 下一切皆文件,这个表很重要。
- 调度信息:优先级、状态(就绪/运行/阻塞)、时间片等。调度器靠它决定谁上 CPU。
我曾在一次性能调优中,发现某个进程的 PCB 里文件描述符表爆了。进程打开了几千个 socket 没关,系统直接卡死。查了半天,原来是个低级 bug。从那以后,我写代码都会定期检查文件描述符的用量。
PCB 在 Linux 内核里是用 task_struct 结构体实现的。这个结构体很大,几百行代码。我挑几个关键字段给你看看:
// Linux 内核 task_struct 简化版
struct task_struct {
volatile long state; // 进程状态:-1不可运行,0可运行,>0停止
void *stack; // 内核栈指针
unsigned int flags; // 进程标志
int prio, static_prio; // 优先级
struct list_head tasks; // 进程链表
struct mm_struct *mm; // 内存描述符
struct files_struct *files; // 文件描述符表
pid_t pid; // 进程ID
struct task_struct *parent; // 父进程
struct list_head children; // 子进程链表
// ... 还有几百行
};
你看,光是这个结构体就包含了进程的所有「家当」。每次进程切换,内核都要保存当前进程的 PCB,再加载下一个进程的 PCB。这个过程叫上下文切换,开销不小。
1.3 线程:轻量级进程的本质
进程的缺点很明显:创建慢、切换慢、通信慢。为什么?因为每个进程都有自己的独立地址空间,切换时要换页表、刷 TLB,开销很大。
那怎么办?人们想:能不能让多个「执行流」共享同一个地址空间?这样切换不就快了吗?
嗯,这就是线程的由来。
线程的本质是什么?说白了,线程就是「轻量级进程」。它共享进程的地址空间、文件描述符、信号处理等资源,但有自己的栈、寄存器和程序计数器。
我打个比方:进程就像一栋楼,线程就是楼里的住户。楼是固定的(地址空间),但每个住户有自己的房间(栈)和钥匙(寄存器)。住户之间串门很方便(共享内存),但要是有人把楼拆了(进程退出),所有住户都得搬家。
线程 vs 进程:核心区别
| 对比项 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 创建开销 | 大(复制页表等) | 小(仅分配栈) |
| 切换开销 | 大(换页表) | 小(不换页表) |
| 通信方式 | IPC(管道、消息队列等) | 直接读写共享内存 |
| 安全性 | 高(隔离性好) | 低(一个线程崩溃可能影响整个进程) |
在 Linux 内核里,线程其实是用 clone() 系统调用创建的。它和进程的 fork() 本质上是同一套机制,只是参数不同:
// 创建进程:复制所有资源
pid_t pid = fork();
// 创建线程:共享地址空间、文件描述符等
// CLONE_VM 表示共享内存空间
// CLONE_FS 表示共享文件系统信息
// CLONE_FILES 表示共享文件描述符表
pid_t tid = clone(CLONE_VM | CLONE_FS | CLONE_FILES, ...);
你看,线程和进程在底层其实是一家人。Linux 内核里没有专门的「线程结构体」,线程就是用 task_struct 表示的,只不过它和父进程共享了某些资源。
我曾经在调试一个多线程服务器时,发现线程间共享了一个全局变量,结果两个线程同时写,数据全乱了。嗯,这就是典型的竞态条件。从那以后,我写多线程代码必加互斥锁,或者用原子操作。
1.4 并发革命的本质
为什么说进程和线程的「纠葛」推动了并发革命?
你想想看,早期的操作系统只有进程,一个进程阻塞了,整个系统就闲着。后来有了线程,一个线程阻塞了,其他线程还能跑。再后来,多核 CPU 出现了,线程可以真正并行执行——一个核跑一个线程。
但问题也来了:线程多了,同步就成了噩梦。死锁、活锁、饥饿、数据竞争……我见过太多项目因为多线程 bug 而延期。有一次,一个同事写的线程池代码,在压力测试下直接死锁,所有线程都在等一个锁,CPU 占用率 100% 但吞吐量为 0。查了两天才发现是锁的获取顺序不一致。
避坑指南:我曾经踩过的坑
- 不要滥用线程:线程不是越多越好。上下文切换的开销可能抵消并行带来的收益。
- 注意线程安全:全局变量、静态变量、共享资源都要加锁。别偷懒。
- 避免死锁:锁的获取顺序要一致。我习惯用「锁排序」策略,所有线程按相同顺序获取锁。
- 小心信号处理:在多线程程序里,信号的处理方式很复杂。我建议尽量用专门的信号线程。
1.5 知识体系总览
下面这张图是我自己画的,把本章的核心知识点串起来了。你看一眼,应该能对进程和线程的关系有个整体认识。
这张图把本章的核心逻辑串起来了:从冯·诺依曼体系出发,引出进程和 PCB,再引出线程这个「轻量级进程」,最后落到并发革命和同步问题。你多看几遍,应该能建立起一个清晰的知识框架。
好了,第一章就讲到这里。进程和线程的「纠葛」远不止这些,后面我们会深入线程的创建、同步、调度等细节。嗯,慢慢来,先把基础打牢。
可可精品资料库