进程与线程:从 PCB 到内核调度实体

操作系统中最核心的抽象莫过于进程(Process)。它不仅是资源分配的基本单位,也是程序运行的载体。而线程(Thread)作为更轻量的执行单元,让并发程序设计更加高效。本文从内核视角出发,深入剖析进程控制块、线程模型、Linux 实现机制以及用户态协程的本质。

操作系统中最核心的抽象莫过于进程(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_structse 是 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 模型,原因如下:

  1. 简洁性:无需维护复杂的 M:N 调度层,每个线程对内核都是 task_struct
  2. CFS 调度器的成熟:线程数增多时,CFS 红黑树调度仍能保证公平性
  3. 多核普及:现代服务器 CPU 核心数远超早期,1:1 模型能充分利用硬件并行
  4. 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有栈用户态调度单内核线程
GoroutineGo runtime有栈Go 调度器(M:N)多内核线程
C++20 CoroutineC++20 co_await无栈编译器生成状态机依赖宿主线程

C++20 的无栈协程本质上是一个编译器辅助的状态机,每个 co_await 点对应一个状态。它们比 goroutine 更轻,但编程模型受限(不能在任意函数调用中挂起)。

七、总结

进程和线程的边界在 Linux 中已经模糊——一切都是 task_struct,差异仅在于资源共享的程度。1:1 线程模型凭借简洁性和硬件利用效率成为主流;futex 让线程同步在用户态完成快速路径;而协程则在用户态之上再次抽象出更轻的执行单元,让高并发程序以更低的资源代价运行。

理解这些机制,才能在面临高并发系统设计时做出正确选择:I/O 密集型服务优先选协程/异步模型,计算密集型任务仍需充分利用内核线程的并行能力,而进程隔离则是安全沙箱和故障边界的首选。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 虚拟内存与分页机制:从 MMU 到 TLB
  2. 系统性能诊断与调优:strace、perf、bpftrace
  3. 现代高性能 IO:epoll、io_uring 与异步 IO