操作系统中最核心的抽象莫过于进程(Process)。它不仅是资源分配的基本单位,也是程序运行的载体。而线程(Thread)作为更轻量的执行单元,让并发程序设计更加高效。本文从内核视角出发,深入剖析进程控制块、线程模型、Linux 实现机制以及用户态协程的本质。
一、进程控制块(PCB)
每个进程在内核中都有一个对应的数据结构,用于保存其全部运行状态,这个结构就是 进程控制块(Process Control Block, PCB)。操作系统通过 PCB 实现对进程的创建、调度、终止和通信管理。
1.1 PCB 中存储的关键信息
一个完整的 PCB 通常包含以下几类信息:
- 标识信息:PID、PPID、进程名、用户 ID(UID)、组 ID(GID)
- 处理器状态:程序计数器(PC)、寄存器值、程序状态字(PSW)
- 内存信息:代码段、数据段、堆栈的地址空间映射
- 调度信息:进程状态(就绪/运行/阻塞)、优先级、时间片、调度策略
- 资源信息:打开的文件描述符表、信号处理表、IPC 资源
- 记账信息:CPU 使用时间、启动时间、上下文切换次数
1.2 Linux 的 task_struct
在 Linux 中,PCB 对应的是 struct task_struct,定义于 include/linux/sched.h。它是内核中最大的数据结构之一,关键字段如下:
struct task_struct {
pid_t pid; // 进程 ID
pid_t tgid; // 线程组 ID
volatile long state; // 进程状态: TASK_RUNNING, TASK_INTERRUPTIBLE 等
struct mm_struct *mm; // 内存描述符(地址空间)
struct files_struct *files; // 打开文件表
struct signal_struct *signal; // 信号处理信息
struct sched_entity se; // CFS 调度实体
struct list_head tasks; // 任务链表
cputime_t utime, stime; // 用户态/内核态 CPU 时间
// ... 数百个字段
};
其中 mm 指针指向内存描述符,如果多个线程共享地址空间,它们会指向同一个 mm_struct。se 是 Completely Fair Scheduler(CFS)的调度实体,用于红黑树排序和 vruntime 计算。
1.3 通过 /proc 文件系统观察
Linux 将每个进程的信息暴露到 /proc 伪文件系统中:
# 查看进程基本信息
cat /proc/self/status
# 关键字段示例
Name: bash
State: S (sleeping)
Tgid: 1234
Pid: 1234
PPid: 1233
Threads: 1
VmRSS: 4096 kB
voluntary_ctxt_switches: 42
nonvoluntary_ctxt_switches: 3
Tgid 是线程组 ID,对单线程进程而言等于 Pid。多线程进程中,所有线程有相同的 Tgid(即主线程 PID),但每个线程的 Pid 不同。
线程信息可通过 /proc/[pid]/task/ 目录查看:
ls /proc/$$/task/
# 输出该进程下所有线程的 TID
二、线程实现模型
线程可以在用户态或内核态实现,也可混合。三种经典模型各有优劣。
2.1 One-to-One(1:1)模型
每个用户线程对应一个内核调度实体。Linux 的 POSIX 线程(pthreads)采用此模型。
优点:真正的并行执行;一个线程阻塞不影响同进程其他线程;内核直接调度。
缺点:线程创建/销毁开销较大;内核资源消耗随线程数线性增长。
2.2 Many-to-One(N:1)模型
多个用户线程映射到一个内核线程,线程调度完全在用户态完成。早期 Java Green Threads、某些用户态协程库采用此模型。
优点:线程切换无需进入内核,开销极低;可创建数万甚至数十万个用户线程。
缺点:无法利用多处理器;一个线程执行阻塞系统调用会导致整个进程阻塞。
2.3 Many-to-Many(M:N)模型
M 个用户线程映射到 N 个内核线程(通常 M > N)。Solaris 的 LWP(Lightweight Process)机制是典型代表。
优点:兼顾了 1:1 的并行性和 N:1 的轻量性。
缺点:实现极其复杂,需要用户态调度器和内核调度器协同,容易产生优先级倒置和调度不公平问题。
2.4 为什么 Linux 选择 1:1
Linux 的线程实现历史上有过讨论,但最终选择了纯 1:1 模型,原因如下:
- 简洁性:无需维护复杂的 M:N 调度层,每个线程对内核都是
task_struct - CFS 调度器的成熟:线程数增多时,CFS 红黑树调度仍能保证公平性
- 多核普及:现代服务器 CPU 核心数远超早期,1:1 模型能充分利用硬件并行
- futex 等优化:用户态同步原语的优化降低了线程间竞争的开销,缓解了 1:1 模型的部分劣势
三、Linux 的任务与线程模型
Linux 有一个著名的设计哲学:没有独立的线程概念,线程只是共享资源的进程。
3.1 clone() 系统调用
进程和线程在 Linux 中都通过 clone() 创建,区别在于传入的 flags:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int thread_fn(void *arg) {
printf("Thread: PID=%d, TID=%d\n", getpid(), gettid());
return 0;
}
int main() {
char stack[4096];
// 创建线程:共享地址空间、文件描述符、信号处理
int flags = CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD;
pid_t tid = clone(thread_fn, stack + 4096, flags, NULL);
if (tid == -1) {
perror("clone");
return 1;
}
printf("Main: PID=%d, TID=%d, child TID=%d\n", getpid(), gettid(), tid);
sleep(1);
return 0;
}
当 clone() 带上 CLONE_THREAD 等标志后,新创建的 “进程” 与调用者共享大部分资源,表现上就是一个线程。
3.2 gettid() 与 getpid() 的区别
| 调用 | 返回值含义 |
|---|---|
getpid() | 返回线程组 ID(Tgid),即主线程 PID |
gettid() | 返回内核线程 ID(TID),每个线程唯一 |
在单线程程序中两者相同;多线程程序中,getpid() 所有线程一致,而 gettid() 各不相同。
3.3 轻量级进程(LWP)
早期 Linux 文献中常提到 LWP,它是内核可调度的最小实体。在 Linux 2.6 之后,LWP 的概念逐渐与内核线程(Kthread)合并。今日语境下,Linux 的线程就是 LWP,每个都有独立的 task_struct 和内核栈。
四、上下文切换开销
上下文切换是并发的代价。理解它做了什么,就明白为什么线程比进程更轻。
4.1 进程切换需要保存和恢复的内容
- 通用寄存器:rax, rbx, rcx, rsp, rbp, rip 等(约 16 个 64 位寄存器)
- 段寄存器:fs, gs(用于 TLS)
- 浮点/SIMD 状态:XMM/YMM 寄存器,FPU 控制字
- 页表基址:cr3 寄存器,指向新进程的页表
- 内核栈指针:
task_struct->thread.sp0 - 调度信息:更新 vruntime、移动红黑树节点
4.2 为什么线程更轻
| 操作 | 进程 | 线程(同进程) |
|---|---|---|
| 地址空间切换 | 需要(写 cr3) | 不需要 |
| TLB 刷新 | 是(全刷新或 ASID/PCID) | 否 |
| 文件描述符表 | 不共享(需复制) | 共享 |
| 内存描述符 | 独立 mm_struct | 共享 mm_struct |
线程切换只需保存/恢复寄存器和内核栈,无需改动地址空间,因此 TLB(转译后备缓冲器)中的页表缓存仍然有效。而进程切换需要刷新 TLB(或依赖 PCID 做选择性保留),这是进程切换开销远超线程的主要原因。
4.3 fork() vs pthread_create 实测
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
void* thread_func(void* arg) { return NULL; }
int main() {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < 1000; i++) {
pid_t pid = fork();
if (pid == 0) _exit(0);
}
clock_gettime(CLOCK_MONOTONIC, &end);
long fork_us = (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_nsec - start.tv_nsec) / 1000;
printf("1000 forks: %ld us\n", fork_us);
clock_gettime(CLOCK_MONOTONIC, &start);
pthread_t tid;
for (int i = 0; i < 1000; i++) {
pthread_create(&tid, NULL, thread_func, NULL);
pthread_join(tid, NULL);
}
clock_gettime(CLOCK_MONOTONIC, &end);
long thread_us = (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_nsec - start.tv_nsec) / 1000;
printf("1000 threads: %ld us\n", thread_us);
return 0;
}
在典型 Linux x86_64 系统上,pthread_create 的开销约为 fork() 的 1/5 到 1/10。
五、futex:快速用户态互斥锁
线程间同步若每次都陷入内核,开销极大。Linux 的 futex(Fast Userspace muTEX) 机制实现了 “非竞争时用户态完成,竞争时内核仲裁” 的混合策略。
5.1 基本思想
用户态维护一个 32 位整数锁变量:
- 锁空闲时:变量值为 0
- 加锁成功(CAS 将 0 改为 1):完全在用户态完成,无需系统调用
- 锁已被占用:CAS 失败,调用
futex_wait(&lock, 1)进入内核阻塞 - 解锁时:若无人竞争,CAS 将 1 改为 0;若有等待者,调用
futex_wake(&lock, 1)唤醒
5.2 glibc 中的实现
pthread mutex 在 Linux 上底层使用 futex:
#include <pthread.h>
#include <stdio.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* worker(void* arg) {
pthread_mutex_lock(&mutex); // 无竞争: CAS; 有竞争: futex_wait
// 临界区
pthread_mutex_unlock(&mutex); // 有等待者: futex_wake
return NULL;
}
在绝大多数生产环境中,锁的持有时间很短、竞争概率很低,futex 使得同步操作几乎零开销。
六、协程:操作系统的盲区
协程(Coroutine)近年来随着 Go、C++20、Rust async/await 的普及而备受瞩目。从操作系统视角看,协程有一个特殊属性:内核根本不知道它的存在。
6.1 用户态上下文切换
协程的调度不经过内核,完全发生在用户态。实现方式主要有:
栈式协程(Stackful):每个协程有独立栈,切换时保存/恢复栈指针和寄存器。Go 的 goroutine 属于此类,但 Go 运行时做了大量优化(动态栈扩容、与内核线程的 M:N 映射)。
无栈协程(Stackless):状态机实现,局部变量保存在堆上分配的状态对象中。C++20 co_await、Rust async、JavaScript Promise 均为无栈协程。它们更轻量,但无法在中断点之外任意挂起。
6.2 OS 如何看待协程
操作系统只看到承载协程的底层线程(通常是 1:1 或 M:N 的内核线程)。协程的 yield、resume、调度队列完全由用户态运行时管理。这意味着:
- 优势:创建销毁开销极低(通常只需要几十字节到几 KB);切换不需要内核态/用户态转换
- 限制:若协程执行阻塞系统调用(如 read 磁盘文件),整个底层线程会被内核阻塞,该线程上的其他协程全部停滞
6.3 三类实现对比
| 类型 | 代表 | 栈 | 切换方式 | 与内核关系 |
|---|---|---|---|---|
| N:1 Green Threads | 早期 Java Green Threads | 有栈 | 用户态调度 | 单内核线程 |
| Goroutine | Go runtime | 有栈 | Go 调度器(M:N) | 多内核线程 |
| C++20 Coroutine | C++20 co_await | 无栈 | 编译器生成状态机 | 依赖宿主线程 |
C++20 的无栈协程本质上是一个编译器辅助的状态机,每个 co_await 点对应一个状态。它们比 goroutine 更轻,但编程模型受限(不能在任意函数调用中挂起)。
七、总结
进程和线程的边界在 Linux 中已经模糊——一切都是 task_struct,差异仅在于资源共享的程度。1:1 线程模型凭借简洁性和硬件利用效率成为主流;futex 让线程同步在用户态完成快速路径;而协程则在用户态之上再次抽象出更轻的执行单元,让高并发程序以更低的资源代价运行。
理解这些机制,才能在面临高并发系统设计时做出正确选择:I/O 密集型服务优先选协程/异步模型,计算密集型任务仍需充分利用内核线程的并行能力,而进程隔离则是安全沙箱和故障边界的首选。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。