在操作系统中,“进程”是资源分配的基本单位,“线程”是 CPU 调度的基本单位。Linux 内核用同一个数据结构——task_struct——来实现这两者,并将其统称为“任务(task)”。理解这一点,就能抓住 Linux 进程与线程模型的本质。
1. 进程控制块(PCB)是什么
进程控制块(Process Control Block,PCB)是操作系统内核为每个进程维护的一个数据结构,用于保存该进程的所有管理信息。在 Linux 中,PCB 的具体实现就是 task_struct 结构体(定义在 <linux/sched.h> 中)。每当通过 fork() 创建新进程或通过 pthread_create() 创建新线程,内核都会分配并初始化一个对应的 task_struct 实例。一个正在运行的系统中,可能存在数百甚至数千个这样的结构体,它们通过链表和树形结构(父子关系、进程组等)组织在一起。
task_struct 包含的信息极其丰富,可以大致分为以下几类:
| 信息类别 | 具体内容示例 |
|-----------|--------------|
| 进程标识 | PID(进程 ID)、TGID(线程组 ID)、PPID(父进程 ID) |
| 状态信息 | 运行态、就绪态、阻塞(可中断/不可中断)、僵尸等 |
| 调度信息 | 优先级(nice 值)、调度策略(CFS、实时等)、时间片 |
| 内存描述 | 指向 mm_struct 的指针,描述地址空间(代码段、数据段、堆、栈、页表等) |
| 文件系统信息 | 指向 files_struct 的指针,描述打开的文件描述符表;以及当前工作目录、根目录等 |
| 信号信息 | 阻塞信号集、挂起信号队列、信号处理函数指针 |
| 上下文信息 | CPU 寄存器现场(在进程被调度出去时保存,恢复运行时还原) |
| 资源统计 | CPU 使用时间、内存占用、I/O 计数等 |
你可以通过 /proc 伪文件系统直接查看某个进程的 task_struct 中的许多字段。例如:
cat /proc/1234/status # 查看进程状态、内存占用、线程数等
cat /proc/1234/maps # 查看进程的内存映射(地址空间布局)
ls -l /proc/1234/fd # 查看该进程打开的所有文件描述符
2. 进程与线程在 Linux 中的统一
早期的 Unix 系统中,进程和线程是泾渭分明的两套概念。但 Linux 选择了一种更加轻量且统一的实现:内核不区分进程和线程,它只调度“任务”。区别只是在创建任务时,决定它要和父任务共享哪些资源。
- 调用
fork()创建一个传统意义的“进程”:内核会复制当前任务的task_struct,并分配新的 PID,同时复制一份父进程的内存映像(写时复制优化)、新的文件描述符表(但指向相同的已打开文件对象)、新的信号挂载结构等。所以父子进程拥有独立的地址空间和文件描述符表。 - 调用
clone()并指定需要的共享标志,可以创建“线程”。POSIX 线程库(如 NPTL)最终是通过clone()系统调用来创建线程的,典型标志为:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, ...)
这将告诉内核:新创建的任务要与调用者共享虚拟地址空间(CLONE_VM)、共享文件系统信息、共享文件描述符表、共享信号处理结构,并加入同一个线程组(CLONE_THREAD)。这样一来,虽然我们仍为每个线程得到一个独立的 task_struct(有自己的 PID、栈、CPU 上下文等),但它们通过指针引用相同的资源结构。
3. 线程之间到底共享什么,独享什么?
当一个进程包含多个线程时,实际上内核里只有一个 mm_struct(内存描述)、一个 files_struct(文件描述符表)、一套信号处理表等,所有线程的 task_struct 都指向这些共同的资源。这种设计带来了线程间通信的极低开销,但也意味着需要小心同步。
线程之间共享的资源:
- 虚拟地址空间:代码段、全局数据、堆、内存映射区域对所有线程可见。一个线程在堆上
malloc()分配的内存,另一个线程可以立即通过指针访问。 - 文件描述符表:线程 A 打开的一个文件,线程 B 可以使用同一个描述符数字直接读写。一个线程调用
close(fd),会导致该文件在所有线程中关闭。 - 信号处理设置:对一个线程设置 SIGINT 信号处理函数,会应用到整个进程的线程组。
- 进程 ID(TGID)和进程凭证:所有线程共享同一个进程 ID(即 getpid() 返回的值)、同一套用户和组 ID。
- 资源统计:CPU 时间、内存占用等通常以进程为单位累积。
线程独享的资源:
- 自己的线程 ID:每个线程有独有的 PID(即 gettid() 返回的值),用于内核调度和信号标识。
- 独立的栈空间:每个线程在进程的虚拟地址空间中拥有一块独立的栈(通常是
mmap出来的区域),存放局部变量和函数调用帧。 - CPU 上下文:包括程序计数器、寄存器组、处理器状态等。线程切换时,这些内容被保存在各自的
task_struct中。 - 线程局部存储(TLS):通过
__thread或pthread_key_create定义的变量只有本线程能看到,实现上通常利用特定的寄存器偏移来寻址。 - 信号掩码与挂起信号:每个线程可以独立阻塞特定信号,不影响其他线程。
4. 实用视角:如何观察进程与线程
在日常运维和调试中,常用以下方式确认进程和线程的关系:
ps -eLf # 显示所有线程(L 列显示线程 ID)
pstree -p 1234 # 查看进程 1234 及其子进程/线程的树状关系
htop # 交互式界面中配置显示线程(默认按进程显示)
在 /proc 中,线程被表现为带有子目录的数字,例如 /proc/1234/task/ 下会列出进程 1234 中所有线程的 tid,每个子目录下又有各自的 /proc/1234/task/5678/status 等文件。
5. 内核 PCB 带来的实际影响
理解 PCB 和线程共享机制,有助于解释实际工作中常见的现象和问题:
- 线程崩溃能导致整个进程退出:因为线程共享地址空间,如果某个线程触发段错误(segfault)访问非法内存,内核会向整个进程发送 SIGSEGV,默认行为是终止整个进程。这不像独立进程那样可以“一个进程死掉,另一个存活”。
fork()之后全局变量的脏数据:fork()虽然使用了写时复制,但父子进程的地址空间是分离的,后来的修改互不可见。而线程中对全局变量的修改,对所有线程立即可见,必须用锁保护。- 文件描述符泄漏可能来自任何线程:如果一个线程打开文件后忘记关闭且提前退出,文件描述符会随着整个进程的存活而一直占用,直到进程结束或显式关闭。所以多线程程序需要格外关注 fd 的生命周期。
- 系统资源的限制依进程而起:大多数资源限制(如
ulimit -n打开文件数上限)是对整个进程生效的,所有线程加起来不能超过这个限制。
总结:Linux 通过 task_struct 将进程和线程统一为“任务”,用复制或共享关键数据结构的方式灵活实现了两种抽象。进程拥有独立的资源环境,而线程则共享了地址空间、文件表等核心资源,仅保持自己执行上下文独立。这种设计在灵活性和性能间取得了很好的平衡,也是 Linux 系统编程和性能调优的重要基础。