进程与线程 —— 从"菜谱"到"厨房"的完整故事

一、先搞清楚:程序 vs 进程

你写了一个 Java 文件 HelloWorld.java,编译后得到 HelloWorld.class

这个 .class 文件安静地躺在硬盘上,它就是一个程序(Program)——一段静态的代码。

当你运行它:java HelloWorld,操作系统把这个程序加载到内存中,给它分配 CPU 时间、内存空间、文件描述符等各种资源。

这时候,它就变成了一个进程(Process)——一个正在运行的程序实例。

生活比喻

  • 程序 = 菜谱(一张纸上的做菜步骤)
  • 进程 = 按照菜谱正在做的那道菜(需要锅、食材、厨师、时间)

同一个菜谱,可以同时做两道菜。同一个程序,也可以同时运行两个进程。

那同一个程序开两个进程,它们的数据会互相影响吗?

解答: 不会。每个进程有自己独立的内存空间,就像两个厨师各自用各自的锅和食材,互不干扰。进程 A 修改变量 x,进程 B 的 x 完全不受影响。


二、进程到底是什么?—— 从内核角度看

从操作系统的角度看,进程不仅仅是一段代码。它是一个完整的运行环境

2.1 进程的组成

一个进程包含以下内容:

组成部分说明比喻
代码段(Text)程序的机器指令菜谱上的步骤
数据段(Data)全局变量、静态变量公共食材仓库
堆(Heap)动态分配的内存(new 出来的对象)临时取用的食材
栈(Stack)函数调用栈、局部变量做菜时的临时操作台
PCB(进程控制块)操作系统管理进程的"档案"厨师的工牌和排班表

其中最重要的是 PCB(Process Control Block,进程控制块)。这是操作系统为每个进程建立的"身份证",里面记录了进程的所有关键信息:

  • PID:进程的唯一编号
  • 状态:就绪、运行、阻塞等
  • 程序计数器(PC):下一条要执行的指令地址
  • 寄存器值:CPU 寄存器的当前值
  • 内存管理信息:页表、内存边界等
  • IO 状态:打开的文件、使用的设备
  • 调度信息:优先级、调度队列指针

PCB 为什么这么重要?

解答: 因为操作系统需要随时暂停和恢复一个进程。当进程被暂停时,操作系统把它的所有"现场"(寄存器值、程序计数器等)保存到 PCB 中。等轮到它再次运行时,再从 PCB 中恢复"现场"。就像你做菜做到一半被叫走,你需要在纸上记下"做到第几步、火候多大",回来才能继续。

2.2 深入:PCB 在内核中长什么样?

在 Linux 内核中,PCB 对应 task_struct 结构体(定义在 include/linux/sched.h),它是内核中最大的结构体之一,包含 100 多个字段:

struct task_struct {
    // 1. 进程状态
    volatile long state;          // 运行态、就绪态、阻塞态等
    
    // 2. 进程标识
    pid_t pid;                    // 进程 ID
    pid_t tgid;                   // 线程组 ID(主线程的 PID)
    struct task_struct *parent;   // 父进程
    struct list_head children;    // 子进程列表
    
    // 3. 调度信息
    int prio, static_prio;        // 动态优先级和静态优先级
    struct sched_entity se;       // CFS 调度器的调度实体
    
    // 4. 内存管理
    struct mm_struct *mm;         // 用户态内存描述符(页表等)
    struct mm_struct *active_mm;  // 活跃内存描述符
    
    // 5. 文件系统
    struct fs_struct *fs;         // 文件系统信息(当前工作目录等)
    struct files_struct *files;   // 打开的文件描述符表
    
    // 6. 信号处理
    struct signal_struct *signal; // 信号处理函数
    struct sighand_struct *sighand;
    sigset_t blocked, real_blocked;
    
    // 7. 时间统计
    cputime_t utime, stime;       // 用户态/内核态 CPU 时间
    
    // 8. 命名空间(用于容器)
    struct nsproxy *nsproxy;
    
    // ... 还有很多字段
};

mm_struct** —— 进程的内存描述符**:

struct mm_struct {
    pgd_t *pgd;                   // 页全局目录(页表的根)
    unsigned long start_code, end_code;   // 代码段起止地址
    unsigned long start_data, end_data;   // 数据段起止地址
    unsigned long start_brk, brk;         // 堆的起止地址
    unsigned long start_stack;            // 栈的起始地址
    
    // 虚拟内存区域链表
    struct vm_area_struct *mmap;
    struct rb_root mm_rb;
    
    // 引用计数
    atomic_t mm_users;            // 使用这个 mm_struct 的线程数
    atomic_t mm_count;            // 引用计数(包括内核引用)
};

为什么 mm_structmm_usersmm_count 两个引用计数?

解答:

  • mm_users:有多少个线程在使用这个内存空间(进程内的线程共享 mm)
  • mm_count:有多少个内核引用(比如内核态访问用户态内存时临时引用)

mm_users 减到 0 时,说明没有线程使用这个内存空间了,可以释放大部分资源。
mm_count 减到 0 时,说明内核也没有引用了,可以彻底释放 mm_struct

就像一本书:mm_users 是"有多少人正在读这本书",mm_count 是"图书馆的借阅记录数"。所有人读完了(mm_users=0),书可以放回书架;但图书馆的借阅记录还在(mm_count>0),直到记录也清除才算彻底归还。

2.3 追问:进程的内存空间是怎么隔离的?

每个进程有独立的虚拟地址空间,范围通常是 04GB(32 位)或 02^64(64 位)。

虚拟地址 → 物理地址的转换

进程 A 的虚拟地址空间          物理内存
+---------------+             +---------------+
|  0x00000000   | ──页表A──→  |  物理页 1     |
|     ...       |             |  物理页 2     |
|  0x08048000   | ──页表A──→  |  物理页 5     |  ← 代码段
|     ...       |             |     ...       |
|  0xC0000000   | ──页表A──→  |  物理页 8     |  ← 内核空间(映射到同一区域)
+---------------+             +---------------+

进程 B 的虚拟地址空间
+---------------+             +---------------+
|  0x00000000   | ──页表B──→  |  物理页 3     |
|     ...       |             |  物理页 4     |
|  0x08048000   | ──页表B──→  |  物理页 6     |  ← 代码段(不同的物理页!)
|     ...       |             |     ...       |
|  0xC0000000   | ──页表B──→  |  物理页 8     |  ← 内核空间(和进程 A 一样)
+---------------+             +---------------+

关键点

  • 每个进程有自己的页表,同样的虚拟地址可以映射到不同的物理页
  • 用户空间(低地址)是独立的,互相隔离
  • 内核空间(高地址)是所有进程共享的(映射到相同的物理内存)

为什么内核空间是所有进程共享的?

解答: 因为内核代码只有一份,所有进程进入内核态时执行的是同一份内核代码。如果每个进程都有独立内核空间,那内核代码要复制很多份,浪费内存。

另外,共享内核空间方便内核在不同进程之间传递数据(比如系统调用参数、IPC)。

就像所有餐厅共用同一个"食品安全监管部门",不需要每个餐厅都建一个独立的监管部门。


三、进程的状态转换

一个进程从创建到结束,会经历多种状态。最基本的有三种

  • 就绪态(Ready):厨师已经穿好工服、准备就绪,等着接单做菜。万事俱备,只差一个"开始"信号。
  • 运行态(Running):厨师正在炒菜,占用了灶台(CPU)。
  • 阻塞态(Blocked):厨师在等一个食材送来,暂时没法做菜。灶台让给别人用了。

3.1 五状态模型(更完整)

实际操作系统中,还有两个额外的状态:

状态说明触发条件
新建态(New)进程正在被创建,分配资源执行 fork() 或创建进程
终止态(Terminated)进程结束,回收资源程序执行完或被杀死
就绪态(Ready)已准备好,等 CPU时间片用完、IO 完成
运行态(Running)正在使用 CPU被调度器选中
阻塞态(Blocked)等待某个事件等待 IO、等待锁、等待信号

状态转换图

新建态 ──fork()──→ 就绪态 ←──────┐
                      ↓          │
                   调度器选中     │ 时间片用完 / 被抢占
                      ↓          │
                   运行态 ───────┘
                      ↓
                  等待 IO / 等待事件
                      ↓
                   阻塞态
                      ↓
                  IO 完成 / 事件到达
                      ↓
                   就绪态
                      ↓
                  程序结束 / 被杀死
                      ↓
                   终止态

进程从运行态变成就绪态,和从运行态变成阻塞态,有什么区别?

解答:

  • 运行 → 就绪:进程还想继续运行,但 CPU 时间片用完了(或者被更高优先级的进程抢占了)。进程本身没有问题,只是"轮到别人了"。
  • 运行 → 阻塞:进程主动放弃 CPU,因为它需要等待某个事件(比如读取文件、等待网络数据)。即使把 CPU 给它,它也没法继续执行。

就像你在餐厅等位:

  • 运行→就绪:你正在吃饭,但限时 1 小时,时间到了你得让位(但你还想吃)
  • 运行→阻塞:你正在吃饭,但发现没筷子了,你主动离开座位去要筷子(这时候给你座位也没用)

3.2 Linux 中的进程状态

在 Linux 中,用 ps 命令可以看到进程状态:

$ ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.1  18532  3200 ?        Ss   09:00   0:01 /sbin/init
root       500  0.0  0.2  72340  4500 ?        S    09:01   0:00 sshd
root       600  0.0  0.1  21500  2800 pts/0    R+   09:05   0:00 ps aux

STAT 列的含义

状态码含义说明
RRunning / Runnable运行中或就绪态
SSleeping (Interruptible)可中断睡眠(等待某个事件)
DSleeping (Uninterruptible)不可中断睡眠(通常在进行 IO)
TStopped被信号停止(如 Ctrl+Z)
ZZombie僵尸进程(已终止但未被回收)
sSession leader会话首领
+Foreground process前台进程

什么是"不可中断睡眠(D 状态)"?

解答: 不可中断睡眠是进程正在执行某些不能被中断的操作,通常是磁盘 IO。

为什么不能被中断?因为中断可能导致数据不一致。比如进程正在往磁盘写数据,如果这时候被中断,数据可能只写了一半,文件就损坏了。

D 状态的进程无法被 kill -9 杀死,只能等 IO 完成。如果 D 状态持续很长时间,通常是硬件故障(比如磁盘坏了)。


四、进程太"重"了 —— 引入线程

进程有一个问题:创建和切换的开销太大

为什么?因为每次创建进程,操作系统都要:

  1. 分配独立的内存空间
  2. 复制代码段、数据段
  3. 创建新的 PCB
  4. 设置独立的文件描述符表

每次切换进程,操作系统都要保存和恢复整个进程的上下文(寄存器、内存映射、文件状态等)。

生活比喻:进程就像开一家新餐厅——要租场地、雇人、办执照,成本很高。如果我只是想多做一道菜,没必要开新餐厅,只需要在现有餐厅多雇一个厨师就行。

这个"多雇的厨师"就是线程(Thread)

4.1 线程是什么

线程是进程内的执行单元。一个进程可以包含多个线程,它们共享进程的资源(内存、文件等),但每个线程有自己独立的执行流。

特性进程线程
资源独立的内存空间共享进程的内存空间
创建开销大(分配内存、复制资源)小(只需创建栈和 TCB)
切换开销大(切换整个地址空间)小(只切换寄存器和栈)
通信需要 IPC 机制(管道、消息队列等)直接共享内存,通信方便
隔离性互相隔离,一个崩不影响另一个一个线程崩溃可能导致整个进程崩溃
比喻一家独立的餐厅餐厅里的一个厨师

线程共享了什么?不共享什么?

解答:

  • 共享的:代码段、数据段、堆、打开的文件描述符、信号处理器
  • 不共享的:栈(每个线程有自己的调用栈)、寄存器、程序计数器(PC)、线程 ID

这就是为什么多个线程可以访问同一个全局变量——因为它们共享数据段。但也正因为如此,多线程编程需要特别小心同步问题

4.2 深入:线程控制块(TCB)

线程也有自己的控制块,但比 PCB 小得多:

struct thread_struct {
    // 1. 寄存器状态(上下文切换时保存/恢复)
    unsigned long sp;             // 栈指针(RSP)
    unsigned long ip;             // 指令指针(RIP)
    unsigned long ax, bx, cx, dx; // 通用寄存器
    // ... 其他寄存器
    
    // 2. 栈信息
    void *stack;                  // 栈的起始地址
    unsigned long stack_size;     // 栈大小(通常 8MB)
    
    // 3. 线程状态
    int state;                    // 运行、就绪、阻塞等
    
    // 4. 调度信息
    int priority;
    
    // 5. 指向所属进程的指针
    struct task_struct *process;
};

TCB 比 PCB 小得多,因为线程不需要维护自己的内存映射、文件描述符表等——这些都在进程级别共享。


五、进程和线程的对比(从内核角度)

维度进程线程
定义资源分配的基本单位CPU 调度的基本单位
内存独立地址空间(独立的页表)共享进程地址空间(共享页表)
内核数据结构task_struct + mm_structtask_struct(共享 mm_struct)
创建fork() / `clone(CLONE_VMCLONE_FS
销毁回收所有资源(内存、文件、信号等)只回收线程私有资源(栈、寄存器状态)
切换开销切换页表、刷新 TLB、切换内核栈只切换寄存器和栈指针
通信管道、消息队列、共享内存、Socket直接读写共享变量(需加锁)
安全性一个进程崩溃不影响其他一个线程崩溃可能导致整个进程崩溃
适用场景需要高隔离性的任务需要高并发、共享数据的任务

为什么线程切换比进程切换快?具体快在哪里?

解答: 线程切换不需要切换地址空间,也就是不需要切换页表。

进程切换时:

  1. 保存当前进程的寄存器状态到 PCB
  2. 切换页表(CR3 寄存器)
  3. 刷新 TLB(转换检测缓冲区,页表的缓存)
  4. 加载新进程的寄存器状态
  5. 跳转到新进程的程序计数器

线程切换时:

  1. 保存当前线程的寄存器状态到 TCB
  2. 切换栈指针(RSP)
  3. 加载新线程的寄存器状态
  4. 跳转到新线程的程序计数器

关键区别:线程切换不需要切换页表,所以不需要刷新 TLB。TLB 刷新是非常昂贵的操作,因为接下来所有的内存访问都要重新查页表。

就像换教室上课:

  • 进程切换 = 换到另一栋楼的教室(需要重新熟悉环境、找座位)
  • 线程切换 = 在同一栋楼换教室(环境熟悉,只需要换个座位)

六、Linux 中线程本质上是轻量级进程

6.1 clone 系统调用

在 Linux 中,线程和进程本质上都是 task_struct,区别只在于创建时传入的标志位不同。

创建进程

// fork() 内部调用 clone()
pid_t fork(void) {
    return clone(SIGCHLD, 0);
}

创建线程(POSIX 线程库 pthread 内部):

// pthread_create() 内部调用 clone()
int pthread_create(...) {
    return clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
                 CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
                 CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID,
                 stack_top);
}

clone 标志位的含义

标志位含义进程(fork)线程(pthread)
CLONE_VM共享内存空间不设置(独立 mm)设置(共享 mm)
CLONE_FS共享文件系统信息不设置设置
CLONE_FILES共享文件描述符表不设置设置
CLONE_SIGHAND共享信号处理函数不设置设置
CLONE_THREAD放入同一个线程组不设置设置
CLONE_PARENT共享父进程不设置设置

既然线程和进程都是 task_struct,那 Linux 的线程和 Windows 的线程有什么区别?

解答: Windows 有明确的"进程"和"线程"概念,进程是资源容器,线程是执行单元,两者是不同的内核对象。

Linux 没有严格的"线程"概念,线程只是共享了更多资源的进程。Linux 用 task_struct 统一表示,通过 clone 标志位控制共享哪些资源。

所以 Linux 中的线程有时被称为 LWP(Light Weight Process,轻量级进程)

就像:

  • Windows:公司和员工是两个不同的概念,员工必须属于某个公司
  • Linux:所有人都是"人",只是有些人共享了房子、车子(线程),有些人完全独立(进程)

6.2 查看 Linux 中的线程

# 查看进程的线程
ps -T -p <pid>

# 或者使用 /proc 文件系统
ls /proc/<pid>/task/

# 使用 top 查看线程
top -H -p <pid>

# 使用 htop(更直观)
htop

/proc/<pid>/task/ 目录下,每个子目录对应一个线程(包括主线程),目录名就是线程的 TID。


七、进程的上下文切换到底切换了什么?

7.1 上下文切换的完整过程

进程上下文切换时,操作系统需要保存和恢复以下内容:

1. 用户态上下文(保存在 PCB 中)

  • 通用寄存器:RAX, RBX, RCX, RDX, RSI, RDI, RBP 等
  • 程序计数器(RIP):下一条要执行的指令地址
  • 栈指针(RSP):用户态栈顶地址
  • 标志寄存器(RFLAGS):条件码、中断使能等

2. 内核态上下文

  • 内核栈指针:切换到新进程的内核栈
  • 页表基址(CR3):切换到新进程的页表

3. 其他状态

  • 浮点寄存器(如果使用了浮点运算)
  • SIMD 寄存器(XMM/YMM/ZMM)
  • 调试寄存器
  • 性能监控寄存器

7.2 为什么上下文切换这么贵?

1. 直接开销(CPU 周期)

  • 保存/恢复寄存器:几十到几百个 CPU 周期
  • 切换页表、刷新 TLB:几百到几千个 CPU 周期
  • 切换内核栈:几十个 CPU 周期

2. 间接开销(缓存失效)

  • TLB 刷新:页表切换后,TLB 中所有条目都失效了。接下来每次内存访问都要查页表,直到 TLB 重新预热
  • CPU 缓存失效:新进程的代码和数据不在当前 CPU 缓存中,需要从内存加载(缓存未命中)
  • 分支预测失效:CPU 的分支预测器是基于历史记录的,新进程的执行模式不同,预测准确率下降

TLB 是什么?为什么刷新 TLB 这么贵?

解答: TLB(Translation Lookaside Buffer)是 CPU 内部的页表缓存

每次内存访问都需要把虚拟地址转换成物理地址,这个转换需要查页表。页表在内存中,查一次需要好几次内存访问,非常慢。

TLB 就是缓存最近用过的"虚拟地址 → 物理地址"映射。如果 TLB 命中,地址转换只需要几个 CPU 周期;如果 TLB 未命中,需要查页表,需要几十到几百个周期。

进程切换时,页表变了,TLB 里的映射全部失效(因为不同进程的同一个虚拟地址映射到不同的物理地址)。接下来所有的内存访问都要重新查页表,直到 TLB 重新填满。

就像你搬家了,手机里的导航地址全都要重新输入一遍。

7.3 线程的上下文切换和进程的区别

切换内容进程切换线程切换
寄存器状态保存/恢复保存/恢复
程序计数器保存/恢复保存/恢复
栈指针保存/恢复保存/恢复
页表(CR3)切换不切换
TLB刷新不刷新
内核栈切换切换(但开销小)
地址空间改变不变

线程切换的开销大约是进程切换的 1/5 ~ 1/10


八、进程间通信的 6 种方式

进程之间是隔离的,不能直接访问对方的内存。但它们需要协作,所以操作系统提供了多种进程间通信(IPC,Inter-Process Communication)机制。

8.1 管道(Pipe)

原理:管道是内核中的一块环形缓冲区,一个进程往里面写,另一个进程从里面读。

#include <unistd.h>

int pipe(int pipefd[2]);
// pipefd[0] 用于读
// pipefd[1] 用于写

特点

  • 半双工:数据只能单向流动
  • 亲缘关系:只能用于父子进程或兄弟进程之间
  • 字节流:没有消息边界,需要自行约定格式

使用示例

int pipefd[2];
pipe(pipefd);

pid_t pid = fork();
if (pid == 0) {
    // 子进程:关闭写端,从管道读
    close(pipefd[1]);
    char buf[100];
    read(pipefd[0], buf, sizeof(buf));
    printf("子进程收到: %s\n", buf);
    close(pipefd[0]);
} else {
    // 父进程:关闭读端,往管道写
    close(pipefd[0]);
    write(pipefd[1], "Hello", 5);
    close(pipefd[1]);
}

为什么管道只能用于亲缘进程?

解答: 因为管道是通过文件描述符访问的,而文件描述符是进程私有的。子进程会继承父进程的文件描述符,所以父子进程可以通过继承来的 fd 访问同一个管道。

没有亲缘关系的进程,它们的文件描述符表是独立的,不知道对方的管道 fd 是多少。

就像家里的对讲机:只有住在这个家里的人(亲缘进程)才知道对讲机的频道号(fd),外面的人不知道。

8.2 命名管道(FIFO)

命名管道是管道的升级版,它有一个文件系统路径,没有亲缘关系的进程也可以通过路径访问它。

# 创建命名管道
mkfifo /tmp/my_fifo

# 进程 A 写入
echo "Hello" > /tmp/my_fifo

# 进程 B 读取
cat /tmp/my_fifo
// 代码中创建和使用 FIFO
mkfifo("/tmp/my_fifo", 0666);
int fd = open("/tmp/my_fifo", O_WRONLY);
write(fd, "Hello", 5);

8.3 消息队列(Message Queue)

原理:消息队列是内核维护的消息链表,进程可以往队列里发送有类型的消息,其他进程可以按类型接收。

#include <sys/msg.h>

// 创建消息队列
int msgid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);

// 发送消息
struct msgbuf {
    long mtype;     // 消息类型
    char mtext[100]; // 消息内容
};
struct msgbuf msg = {1, "Hello"};
msgsnd(msgid, &msg, sizeof(msg.mtext), 0);

// 接收消息(只接收类型为 1 的消息)
struct msgbuf recv;
msgrcv(msgid, &recv, sizeof(recv.mtext), 1, 0);

特点

  • 消息有类型,可以 selective receive
  • 可以非亲缘进程之间通信
  • 消息有边界,不像管道是字节流

消息队列和管道有什么区别?

解答:

特性管道消息队列
消息边界无(字节流)有(按消息为单位)
消息类型有(可以按类型接收)
亲缘关系需要不需要
持久化随进程结束消失随内核重启消失
性能较快(内核拷贝少)较慢(需要维护消息结构)

8.4 共享内存(Shared Memory)

原理:多个进程映射同一块物理内存到自己的虚拟地址空间,直接读写这块内存来通信。

#include <sys/shm.h>

// 创建共享内存段
int shmid = shmget(IPC_PRIVATE, 4096, 0666 | IPC_CREAT);

// 映射到进程地址空间
void *addr = shmat(shmid, NULL, 0);

// 直接读写这块内存
strcpy((char*)addr, "Hello from shared memory!");

// 另一个进程也映射这块内存,就能读到同样的内容

为什么共享内存是最快的 IPC 方式?

IPC 方式数据拷贝次数说明
管道2 次用户态 → 内核缓冲区 → 用户态
消息队列2 次用户态 → 内核消息结构 → 用户态
共享内存0 次直接读写同一块物理内存

共享内存的通信过程

进程 A 的虚拟地址空间          物理内存          进程 B 的虚拟地址空间
+------------------+         +--------+         +------------------+
|  0x7f0000000000  | ──────→ | 共享页  | ←────── |  0x7f0000000000  |
|  (共享内存映射)   |         |        |         |  (共享内存映射)   |
+------------------+         +--------+         +------------------+

进程 A 写入:*(char*)0x7f0000000000 = 'X'
进程 B 立即看到:*(char*)0x7f0000000000 == 'X'

既然共享内存最快,为什么还需要其他 IPC 方式?

解答: 共享内存虽然快,但有一个致命问题:没有同步机制

如果进程 A 正在写数据,进程 B 同时来读,可能读到一半新一半旧的数据(数据不一致)。

所以使用共享内存时,通常需要配合信号量互斥锁来实现同步:

  • 进程 A 写之前:获取锁
  • 进程 A 写完后:释放锁
  • 进程 B 读之前:获取锁
  • 进程 B 读完后:释放锁

就像多人共用一块白板:如果不约定"谁写的时候别人不能看",就可能看到乱七八糟的内容。

8.5 信号量(Semaphore)

原理:信号量是一个计数器,用来控制多个进程对共享资源的访问。

#include <sys/sem.h>

// 创建信号量
int semid = semget(IPC_PRIVATE, 1, 0666 | IPC_CREAT);

// 初始化信号量值为 1(二元信号量 = 互斥锁)
union semun arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);

// P 操作(等待/获取)
struct sembuf sb = {0, -1, 0};  // 信号量 0,减 1
semop(semid, &sb, 1);

// 访问共享资源...

// V 操作(信号/释放)
sb.sem_op = 1;  // 加 1
semop(semid, &sb, 1);

信号量的两种类型

类型初始值用途
二元信号量1互斥锁,保证同一时刻只有一个进程访问资源
计数信号量N控制最多 N 个进程同时访问资源(如连接池)

8.6 Socket

原理:Socket 是网络通信的端点,不仅可以用于不同机器之间的通信,也可以用于同一台机器上的进程间通信(Unix Domain Socket)。

// Unix Domain Socket 示例
#include <sys/socket.h>
#include <sys/un.h>

int sock = socket(AF_UNIX, SOCK_STREAM, 0);

struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/mysocket");

bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 5);

Socket 的优势

  • 可以跨机器通信(TCP/UDP Socket)
  • 也可以本机通信(Unix Domain Socket)
  • 是最通用的 IPC 机制

Unix Domain Socket vs TCP Socket(本机通信)

特性Unix Domain SocketTCP Socket(127.0.0.1)
数据拷贝直接通过内核缓冲区传递经过完整的 TCP/IP 协议栈
性能更快稍慢
寻址方式文件路径IP + 端口
权限控制文件权限防火墙规则

8.7 信号(Signal)

原理:信号是一种异步通知机制,操作系统或进程可以发送信号给另一个进程,通知它发生了某个事件。

# 常用信号
kill -9 <pid>    # SIGKILL:强制终止
kill -15 <pid>   # SIGTERM:请求终止(可以捕获)
kill -1 <pid>    # SIGHUP:终端断开
kill -2 <pid>    # SIGINT:Ctrl+C
#include <signal.h>

// 注册信号处理函数
void handler(int sig) {
    printf("收到信号 %d\n", sig);
}

signal(SIGTERM, handler);

信号的特点

  • 异步:发送方不等待接收方处理
  • 简单:只能传递信号编号,不能传递复杂数据
  • 有限:标准信号只有 64 种

信号和前面几种 IPC 有什么区别?

解答: 信号是通知机制,不是数据传输机制

  • 管道/消息队列/共享内存/Socket:用来传递数据
  • 信号:用来通知事件(比如"你该退出了"、“你的子进程死了”)

就像:

  • 数据传输 = 寄快递(有具体内容)
  • 信号 = 门铃响(只告诉你"有人来了",但不告诉你是谁、来干什么)

8.8 六种 IPC 方式对比总结

IPC 方式数据方向亲缘要求数据拷贝同步机制适用场景
管道单向需要2 次父子进程简单通信
命名管道单向不需要2 次无关进程简单通信
消息队列双向不需要2 次按类型接收消息
共享内存双向不需要0 次需配合信号量大量数据高速通信
信号量无(同步用)不需要自身就是同步配合共享内存使用
Socket双向不需要2 次跨网络、最通用
信号无(通知用)不需要异步事件通知

九、协程是什么?和线程的区别?

9.1 协程的定义

协程(Coroutine) 是一种用户态的轻量级线程,由用户程序自己调度,而不是由操作系统内核调度。

协程的特点

  • 用户态调度:切换不需要进入内核,开销极小
  • 非抢占式:协程主动让出 CPU(yield),不会被强制中断
  • 共享线程:多个协程可以运行在同一个线程内
  • 栈空间小:协程栈通常只有几 KB

9.2 协程 vs 线程 vs 进程

特性进程线程协程
调度者操作系统内核操作系统内核用户程序/运行时
切换开销大(~1-10us)中(~0.1-1us)极小(~纳秒级)
栈大小MB 级MB 级(通常 8MB)KB 级(通常 2-8KB)
创建数量几百个几千个几十万个
切换方式抢占式抢占式协作式(主动让出)
数据共享不共享共享进程内存共享线程内存
多核利用可以可以需要多线程 + 多协程
阻塞影响只影响当前进程只影响当前线程阻塞整个线程

9.3 协程的工作原理

协程切换的本质

// 协程切换(简化版)
void coroutine_switch(Coroutine *from, Coroutine *to) {
    // 1. 保存当前协程的上下文(寄存器、栈指针)
    save_context(&from->context);
    
    // 2. 恢复目标协程的上下文
    restore_context(&to->context);
    
    // 3. 跳转到目标协程的执行位置
}

协程切换不需要进入内核,只需要保存/恢复几个寄存器(比线程切换少得多),所以速度极快。

协程的调度

线程(内核调度)
├── 协程 A(运行中)──yield()──→ 协程 B
├── 协程 B(运行中)──yield()──→ 协程 C
├── 协程 C(运行中)──yield()──→ 协程 A
└── ...

所有协程都在同一个线程内协作式调度,没有抢占。

协程的"阻塞整个线程"是什么意思?

解答: 如果一个协程执行了一个阻塞操作(比如读取文件、网络请求),它会阻塞整个线程,线程里的其他协程也无法执行。

所以使用协程时,必须用非阻塞 IO + 事件循环

  • 协程 A 发起网络请求(非阻塞)
  • 协程 A yield,让出 CPU
  • 事件循环检查哪个协程的 IO 完成了
  • 协程 A 的 IO 完成,事件循环恢复协程 A 的执行

这就像:

  • 线程 = 一个厨房,厨师(线程)可以同时做几道菜(协程),但如果一道菜需要等烤箱(阻塞),厨师只能干等着,其他菜也做不了
  • 解决办法:厨师不等烤箱,先去做别的菜,烤箱响了再回来(事件循环)

9.4 协程的实际应用

Java 虚拟线程示例(JDK 21+)

// 创建虚拟线程
Thread.startVirtualThread(() -> {
    System.out.println("Hello from virtual thread!");
});

// 或者用 ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        // 这个任务在虚拟线程上执行
        return fetchDataFromNetwork();
    });
}

Java 的虚拟线程:

  • 底层是协程,但暴露为 Thread API
  • JVM 负责把虚拟线程调度到平台线程(内核线程)上
  • 阻塞操作会自动让出,不会阻塞平台线程

十、实际项目中的多进程 vs 多线程选择

10.1 选择依据

场景推荐方案原因
CPU 密集型计算多进程绕过 GIL(Python)、利用多核
IO 密集型(网络/磁盘)多线程 / 协程线程切换开销相对 IO 可忽略
需要高隔离性多进程一个进程崩溃不影响其他
需要共享大量数据多线程共享内存,通信简单
高并发连接(C10K+)协程 / IO 多路复用上下文切换开销极小
多核充分利用多进程 + 多线程进程利用多核,线程处理并发

10.2 经典案例分析

案例 1:Nginx(多进程 + IO 多路复用)

Master 进程
├── Worker 进程 1(单线程 + epoll)
├── Worker 进程 2(单线程 + epoll)
├── Worker 进程 3(单线程 + epoll)
└── Worker 进程 4(单线程 + epoll)
  • 多进程:利用多核,一个 Worker 崩溃不影响其他
  • 单线程 + epoll:避免线程切换开销,一个线程处理万级连接

案例 2:MySQL(多线程)

MySQL Server 进程
├── 连接线程 1(处理客户端请求)
├── 连接线程 2
├── 连接线程 3
└── 后台线程(刷盘、日志、锁监控等)
  • 多线程:连接之间需要共享数据库缓存(Buffer Pool)
  • 线程池:避免创建过多线程

案例 3:Chrome 浏览器(多进程)

Browser 进程(主进程)
├── GPU 进程
├── 渲染进程 1(标签页 A)
├── 渲染进程 2(标签页 B)
├── 渲染进程 3(标签页 C)
├── 插件进程
└── 扩展进程
  • 多进程:一个标签页崩溃不会影响其他标签页
  • 沙箱隔离:渲染进程在沙箱中运行,防止恶意网页攻击系统

案例 4:Java Web 应用(多线程)

Tomcat 进程
├── 线程池(200 个线程)
│   ├── 线程 1:处理 HTTP 请求 A
│   ├── 线程 2:处理 HTTP 请求 B
│   └── ...
  • 多线程:请求之间需要共享数据库连接池、缓存等
  • 线程池:控制并发数,避免资源耗尽

10.3 Java 中的选择

// 多线程(Java 最常用的方式)
ExecutorService threadPool = Executors.newFixedThreadPool(10);
threadPool.submit(() -> {
    // 处理任务
});

// Java 21+ 虚拟线程(协程)
ExecutorService virtualPool = Executors.newVirtualThreadPerTaskExecutor();
virtualPool.submit(() -> {
    // 处理任务(底层是协程)
});

// 多进程(Java 中较少用,通常用多 JVM 实例)
ProcessBuilder pb = new ProcessBuilder("java", "-jar", "worker.jar");
Process process = pb.start();

我的项目应该用多进程还是多线程?

解答: 大部分 Java Web 项目用多线程就够了,因为:

  1. 请求之间需要共享数据库连接池、Redis 连接、业务缓存等
  2. Java 的线程模型成熟,Spring 等框架都基于多线程
  3. 现代 JVM 的线程性能已经很好了

考虑多进程的场景:

  1. 需要执行不可信代码(比如用户提交的脚本),用子进程隔离
  2. 需要利用多核做 CPU 密集型计算,且不想被 GIL 限制(Python 场景)
  3. 需要高可用,一个进程崩溃不能影响整个服务(比如用 Supervisor 管理多个进程)

考虑协程/虚拟线程的场景:

  1. 超高并发(C10K 以上),线程数量成为瓶颈
  2. 大量 IO 操作,协程的轻量级切换更有优势
  3. 使用 Java 21+,可以直接用虚拟线程替换传统线程

十一、进程间关系

11.1 父进程与子进程

在 Linux/Unix 中,进程有"家族关系":

  • init 进程(PID=1)是所有进程的"祖先"
  • 每个进程都有父进程(除了 init
  • A 创建 B,则 AB 的父进程,BA 的子进程
init (PID=1)
  ├── sshd (PID=100)
  │     ├── bash (PID=200)
  │     │     └── java (PID=300)  ← 你的Java程序
  │     └── bash (PID=201)
  └── nginx (PID=101)

为什么需要父子关系?

  • 父进程可以等待子进程结束wait()
  • 父进程结束时,子进程通常也会被终止
  • 方便资源回收——父进程负责回收子进程的资源

什么是"僵尸进程"?

解答: 子进程结束了,但父进程没有调用 wait() 来回收它的资源。这时候子进程的 PCB 还留在系统中,变成了"僵尸"。就像一个人去世了,但户口还没注销。如果父进程也结束了,init 进程会接管并回收这些僵尸进程。

11.2 前台进程与后台进程

类型特点示例
前台进程占用终端,用户可以直接交互你在终端输入命令运行的程序
后台进程不占用终端,在后台默默运行Linux 的 servicedaemon

在 Linux 中,用 & 可以把程序放到后台运行:

java MyApp &    # 后台运行
nohup java MyApp &  # 后台运行,且关闭终端也不会停止

十二、Java 中的进程和线程

12.1 Java 程序的进程

当你运行 java HelloWorld,JVM 本身就是一个进程。你的 Java 程序运行在这个 JVM 进程内部。

12.2 Java 中创建线程

// 方式一:继承 Thread 类
public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("线程 " + Thread.currentThread().getName() + " 正在运行");
    }
}

// 使用
MyThread t1 = new MyThread();  // 创建线程
t1.start();                      // 启动线程(注意是 start(),不是 run())
// 方式二:实现 Runnable 接口(推荐)
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("线程 " + Thread.currentThread().getName() + " 正在运行");
    }
}

// 使用
Thread t2 = new Thread(new MyRunnable());  // 创建线程
t2.start();                                  // 启动线程
// 方式三:线程池(实际开发最常用)
ExecutorService pool = Executors.newFixedThreadPool(3);  // 创建3个线程的线程池
pool.submit(() -> {
    System.out.println("线程池中的线程正在执行任务");
});
pool.shutdown();  // 关闭线程池

start()run()` 有什么区别?

解答: start()创建一个新线程,然后在新线程中调用 run()。而直接调用 run() 只是在当前线程中执行方法,不会创建新线程。就像你让厨师去做菜(start())和自己去做菜(run())的区别。

12.3 Java 线程的底层实现

Java 的 Thread 类最终调用的是 JVM 的本地方法,JVM 再调用操作系统的线程创建 API:

Java: new Thread().start()
  ↓
JVM: JVM_StartThread()
  ↓
JVM: create_vm_thread()
  ↓
Linux: clone() 或 pthread_create()
  ↓
内核: 创建 task_struct,加入调度队列

在 Linux 上,HotSpot JVM 使用 pthread_create() 创建线程,而 pthread_create() 内部调用 clone()


十三、本章要点回顾

概念一句话解释类比
程序静态的代码文件菜谱
进程正在运行的程序实例正在做的那道菜
PCB操作系统管理进程的"档案"厨师的工牌
线程进程内的执行单元餐厅里的一个厨师
就绪态准备好了,等 CPU厨师穿好工服等接单
运行态正在使用 CPU厨师正在炒菜
阻塞态等待某个事件厨师等食材送来
上下文切换保存/恢复进程/线程的执行状态换班时交接工作
管道单向字节流传输一根水管
消息队列有类型的消息链表带分类的收件箱
共享内存多个进程映射同一块物理内存多人共用一块白板
信号量控制资源访问的计数器停车场的车位计数器
Socket网络通信端点电话
信号异步事件通知门铃
协程用户态轻量级线程厨师同时做几道菜
僵尸进程子进程结束但未被回收人去世了但户口没注销

本文档基于 Linux 内核和通用操作系统原理编写。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐