一、进程基础:定义、生命周期与状态模型

核心概念

什么是进程?

进程是操作系统进行资源分配和调度的基本单位,是程序在某个数据集合上的一次运行活动。它由程序段、数据段、进程控制块(PCB)三部分组成。

简单理解:程序是静态的指令集合,进程是动态的执行过程。例如,你双击打开 Chrome 浏览器,系统会创建一个或多个进程来运行它。

关键特征

进程的五大特性

  • 动态性:进程是程序的一次执行过程,有创建、运行、撤销的生命期
  • 并发性:多个进程可同存于内存并交替执行(宏观并行,微观串行)
  • 独立性:进程是独立的调度单位和资源拥有者
  • 异步性:进程按各自独立、不可预知的速度向前推进
  • 结构特征:由程序、数据和PCB组成
经典案例

进程 vs 程序对比

以 gcc hello.c 编译命令为例:

$ ps aux | grep gcc
root 12345 0.1 0.2 ... gcc hello.c

此时系统中存在一个名为 gcc 的进程,其 PCB 中记录了 PID=12345、内存映射、打开文件列表等信息。程序文件 gcc 是磁盘上的静态二进制文件,而进程是其在内存中的动态实例。

进程的生命周期与状态转换

创建(Create)→ 就绪(Ready)

操作系统调用 fork() 或 clone() 创建新进程时:

pid_t pid = fork();
if (pid == 0) {
// 子进程:PCB已创建,但尚未分配CPU时间片
// 状态:就绪(Ready)
}

此时进程已具备运行条件,但尚未获得CPU,处于就绪队列中等待调度。

就绪(Ready)→ 运行(Running)

调度器(Scheduler)从就绪队列中选取进程,分配CPU时间片后进入运行状态。

面试陷阱:在单核CPU上,任意时刻仅有一个进程处于运行态;多核下可有多个进程并行运行。

运行(Running)→ 阻塞(Blocked)

进程主动调用 sleep()、wait() 或等待I/O(如读文件)时进入阻塞态。

int fd = open("data.txt", O_RDONLY);
char buf[1024];
read(fd, buf, sizeof(buf)); // 此处可能阻塞

当I/O完成(如磁盘数据就绪),操作系统通过中断机制唤醒该进程,重新进入就绪队列。

运行(Running)→ 就绪(Ready)

当时间片耗尽(Time Slice Expired),调度器剥夺CPU,进程回到就绪态继续排队。

时间片长度影响:过短 → 上下文切换频繁;过长 → 响应延迟。Linux CFS调度器采用“公平调度”,动态调整时间片。

⚠️ 常见误区

❌ “进程切换比线程切换快” —— 实际相反!进程切换需保存/恢复整个地址空间(包括页表),而线程共享地址空间,仅需切换寄存器和栈指针。

✅ 正确理解:线程是CPU调度的基本单位;进程是资源分配的基本单位。

二、进程与线程:区别、对比与典型应用场景

高频考点

核心区别对比表

维度 进程(Process) 线程(Thread)
资源拥有 独占内存空间、文件描述符、信号处理 共享所属进程的资源
调度单位 否(资源载体) 是(CPU调度基本单位)
切换开销 高(需切换页表、TLB等) 低(仅保存寄存器)
通信方式 管道、消息队列、共享内存等 直接读写共享变量(需同步机制)
独立性 相互独立,一个崩溃不影响其他进程 共享地址空间,一个线程崩溃可能导致进程退出
实战案例

浏览器多进程架构

现代浏览器(如Chrome)采用多进程模型:

  • Browser进程:主进程,管理UI、网络、存储
  • Renderer进程:每个标签页独立进程,渲染HTML/CSS/JS
  • GPU进程:独立GPU调度
  • Plugin进程:插件隔离

优势:隔离性好(一个标签页崩溃不影响其他页面)、安全(沙箱机制)、可利用多核。

性能对比

创建1000个进程 vs 线程

# 创建1000个进程(Linux)
for (int i = 0; i < 1000; i++) {
if (fork() == 0) break;
}
# 创建1000个线程(POSIX)
pthread_t threads[1000];
for (int i = 0; i < 1000; i++)
pthread_create(&threads[i], NULL, worker, NULL);

实测结果(Ubuntu 22.04, 16GB RAM):

  • 1000进程:内存占用约 2.8GB(含页表开销),创建耗时 ~1.2秒
  • 1000线程:内存占用约 120MB(共享地址空间),创建耗时 ~0.08秒

网友关注:何时该用进程?何时该用线程?

三、进程通信(IPC):6大方式详解与性能对比

无名管道(Pipe)

适用于具有亲缘关系的进程(父子进程),半双工通信。

int pipefd[2];
pipe(pipefd); // pipefd[0]: 读端, pipefd[1]: 写端
if (fork() == 0) { // 子进程
close(pipefd[0]);
write(pipefd[1], "Hello", 5);
} else { // 父进程
close(pipefd[1]);
char buf[10];
read(pipefd[0], buf, sizeof(buf));
}

限制:仅限父子/兄弟进程;单向通信;内核缓冲区大小有限(Linux默认64KB)。

命名管道(FIFO)

通过文件系统路径名标识,可用于无亲缘关系进程通信。

# 创建FIFO
mkfifo("/tmp/my_fifo", 0666);
# 进程A写入
int fd = open("/tmp/my_fifo", O_WRONLY);
write(fd, "data", 4);
# 进程B读取
int fd = open("/tmp/my_fifo", O_RDONLY);
read(fd, buf, sizeof(buf));

优势:跨进程通信;劣势:仍为单向、无长度限制,但需手动管理同步。

消息队列(Message Queue)

内核维护的链表,支持多进程读写,可带类型标识。

int msgid = msgget(ftok("/tmp", 'A'), IPC_CREAT | 0666);
# 发送消息(结构体需以long类型msg_type开头)
struct msgbuf { long mtype; char mtext[100]; };
msgsnd(msgid, &msg, 100, 0);
# 接收消息
msgrcv(msgid, &msg, 100, 1, 0);

特点:异步通信;支持多消费者;消息可带类型过滤;但需内核系统调用,开销较大。

共享内存(Shared Memory)

最快IPC方式,多个进程映射同一物理内存区域。

int shmid = shmget(ftok("/tmp", 'B'), 4096, IPC_CREAT | 0666);
char *shm = shmat(shmid, NULL, 0);
strcpy(shm, "Hello from shared memory!");
# 其他进程 shmat() 后可直接读取

关键点:需配合信号量/互斥锁保证同步;否则出现竞态条件(Race Condition)。

信号量(Semaphore)

用于进程/线程同步,控制共享资源访问权限。

int semid = semget(ftok("/tmp", 'C'), 1, IPC_CREAT | 0666);
union semun { int val; struct semid_ds *buf; ... };
semctl(semid, 0, SETVAL, (union semun){.val=1}); // 初始值=1(互斥锁)
# P操作(减1,阻塞)
struct sembuf p_op = {0, -1, SEM_UNDO};
semop(semid, &p_op, 1);
# V操作(加1,唤醒)
struct sembuf v_op = {0, 1, SEM_UNDO};
semop(semid, &v_op, 1);

注意:信号量不传输数据,仅用于同步;常与共享内存配合使用。

套接字(Socket)

最通用IPC方式,支持网络通信(同一台主机可用Unix Domain Socket)。

int sock = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {AF_UNIX, "/tmp/app.sock"};
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 5);
accept(sock, ...); // 接收连接

优势:跨主机;支持TCP/UDP;劣势:开销最大(需协议栈处理)。

IPC方式性能与适用场景对比

方式 速度 适用范围 是否需同步
共享内存 ★★★★★ 本地高效通信 必须
消息队列 ★★★☆ 任意进程 可选
无名管道 ★★★ 父子进程 自动
套接字 ★☆ 任意(含跨机) 视协议而定

四、进程调度算法:从FCFS到CFS的演进

经典调度算法详解

1960s

先来先服务(FCFS)

非抢占式,按请求顺序排队。简单但“护短”,短进程可能被长进程阻塞(平均等待时间长)。

// 伪代码
while (ready_queue not empty) {
process = ready_queue.dequeue();
run(process);
}
1970s

短作业优先(SJF)

抢占式(SRTF)更优:剩余执行时间最短者优先。减少平均等待时间,但需预估运行时间,且可能导致长进程“饿死”。

面试案例:进程A(10ms)、B(2ms)、C(1ms)同时到达。SJF顺序:C→B→A,平均等待时间 = (0+1+3)/3 = 1.33ms;FCFS顺序:A→B→C,平均等待时间 = (0+10+12)/3 = 7.33ms。

1980s

时间片轮转(RR)

每个进程分配固定时间片(如100ms),时间片用完则轮转。保证公平性,适合分时系统。

关键参数:时间片大小影响性能——太小→切换开销大;太大→交互性差。

2000s

完全公平调度(CFS)

Linux 2.6.23+采用。基于“红黑树”管理就绪队列,按虚拟运行时间(vruntime)选择进程。核心思想:让所有进程获得公平的CPU时间。

// CFS简化逻辑
select_next_entity() {
// vruntime最小者优先
return rb_entry(rb_first(&cfs_rq->tasks_timeline), struct task_struct, se.run_node);
}

优势:支持可配置权重(nice值);自动处理SMP负载均衡。

⚠️ 面试陷阱提醒

❌ “CFS是抢占式调度” —— 实际CFS本身非严格抢占,但配合内核抢占(CONFIG_PREEMPT)可实现。

✅ 正确理解:调度策略(Policy)决定选择哪个进程;调度类(Scheduler Class)决定策略实现。Linux有SCHED_FIFO、SCHED_RR、SCHED_DEADLINE、SCHED_NORMAL(CFS)等策略。

五、死锁:原理、检测与解决方案

核心条件

死锁的四个必要条件

  • 互斥:资源不能被共享(如打印机)
  • 占有且等待:进程持有资源又申请新资源
  • 不可剥夺:已分配资源不能被强制回收
  • 循环等待:存在进程-资源的循环等待链

只要破坏任一条件,即可避免死锁。

经典案例

哲学家进餐问题

5个哲学家围桌而坐,每人左右各有一根筷子。需同时拿到两根才能吃饭。若所有哲学家同时拿起左手筷子,则无法拿到右手筷子,陷入死锁。

// 错误实现(易死锁)
while (true) {
think();
take_fork(left);
take_fork(right);
eat();
put_fork(right);
put_fork(left);
}
解决方案

4种主流策略

  • 预防:破坏四个条件之一(如资源预分配)
  • 避免:银行家算法(动态检查资源分配安全性)
  • 检测与恢复:定期检测死锁,强制终止进程/回滚
  • 忽略:Unix/Linux默认策略(因发生概率低)

Linux中的死锁处理

常用检测命令

# 查看系统阻塞的进程
ps aux | grep 'D' # 状态D = 不可中断的睡眠态(常因I/O阻塞)
# 检查死锁线程(Java)
jstack -l PID | grep -A 20 "Found one Java-level deadlock"
# 监控系统级死锁(Linux内核锁)
cat /proc/lockdep_stats

调试技巧

  • 使用 strace -p PID 查看系统调用阻塞点
  • 用 lsof -p PID 检查打开的文件/网络连接
  • Java应用:通过 jstack 分析线程堆栈
  • 内核锁(spinlock):启用 CONFIG_DEBUG_SPINLOCK 编译内核

六、Linux进程管理:命令实战与原理剖析

基础命令

进程查看与监控

# 实时监控进程
top -d 1 # 每秒刷新
# 按CPU使用率排序
ps aux --sort=-%cpu | head
# 查看进程树
pstree -p

关键字段:PID(进程ID)、PPID(父进程ID)、STAT(状态:R运行、S睡眠、D不可中断、Z僵尸)。

实战技巧

僵尸进程处理

子进程退出后,若父进程未调用 wait() 回收PCB,子进程变为僵尸(Z状态)。

# 创建僵尸进程(仅演示)
fork(); if (pid == 0) exit(0); else sleep(60);
# 查找僵尸进程
ps aux | awk '$8 ~ /Z/ {print $2}'
# 清理(需杀父进程)
kill -9 PPID

根本解决:父进程应调用 waitpid() 或设置 signal(SIGCHLD, SIG_IGN)。

内核机制

进程创建流程(fork)

  1. 分配新的task_struct和PID
  2. 复制父进程页表(COW写时复制)
  3. 设置子进程状态为TASK_RUNNING
  4. 加入调度队列等待执行

COW原理:fork后父子进程共享物理页,仅当写入时才复制(减少内存开销)。

程序员常忽略的细节

fork后全局变量状态

int counter = 0;
if (fork() == 0) { // 子进程
counter++;
printf("Child: %d\n", counter); // 输出1
} else { // 父进程
printf("Parent: %d\n", counter); // 输出0
}

因COW机制,子进程修改counter时会复制新页,父进程值不变。

fork与多线程

fork() 在多线程程序中仅复制调用线程的上下文,其他线程消失!

pthread_create(...); // 创建线程B
pid_t pid = fork(); // 仅父线程继续
if (pid == 0) {
// 子进程只有主线程,线程B已消失
pthread_create(...); // 可重新创建线程
}

解决方案:使用 pthread_atfork() 注册清理回调;或改用 vfork()(不安全,慎用)。