← 返回操作系统总览

🧵 进程与线程模型面试题

进程线程概念、状态转换与调度

📚 进程与线程核心知识

1. 进程和线程的区别是什么?为什么需要线程?简单

进程 vs 线程核心区别

维度进程(Process)线程(Thread)
定义资源分配的基本单位CPU 调度的基本单位
地址空间独立的地址空间共享进程的地址空间
资源开销创建/切换开销大(需要分配内存、文件描述符等)创建/切换开销小(只需分配栈)
通信方式IPC(管道、消息队列、共享内存、Socket)直接读写共享变量(需要同步)
独立性高度独立,一个进程崩溃不影响其他进程一个线程崩溃可能导致整个进程崩溃
内存结构独立的代码段、数据段、堆、栈共享代码段、数据段、堆,独立栈

为什么需要线程?

  • 更轻量:创建和销毁开销小,上下文切换快
  • 资源共享:线程间通信方便,无需复杂的 IPC
  • 并发性:充分利用多核 CPU,提高程序响应速度
  • 模块化:将复杂任务分解为多个并发执行的子任务

实际应用场景

  • 多进程:Chrome 浏览器(每个标签页一个进程,隔离性好)、Nginx(多进程 Worker 模型)
  • 多线程:Tomcat(线程池处理请求)、数据库连接池、GUI 应用(UI 线程 + 工作线程)
2. 进程有哪些状态?状态之间如何转换?简单

进程的五种基本状态

状态说明Linux 标识
新建(New)进程正在被创建,PCB 已分配但未完成初始化-
就绪(Ready)等待 CPU 分配,具备运行条件R
运行(Running)正在 CPU 上执行指令R
阻塞(Blocked/Waiting)等待某个事件(I/O 完成、信号量、锁)S/D
终止(Terminated)进程执行完毕或被终止,等待回收资源Z

进程状态转换图

        fork()
          ↓
       ┌─────┐
       │ 新建 │
       └──┬──┘
          ↓ 就绪
       ┌─────┐      调度      ┌─────┐
    ┌→ │ 就绪 │ ──────────→  │ 运行 │
    │  └─────┘               └──┬──┘
    │     ↑                     │
    │     │ 时间片用完/被抢占     │
    │     └─────────────────────┘
    │     ↑
    │     │ I/O 完成/事件发生
    │  ┌─────┐
    └─ │ 阻塞 │ ←─── I/O 请求/等待事件
       └─────┘
          ↑
          │
       ┌─────┐
       │ 运行 │ ──→ exit() ──→ 终止
       └─────┘

状态转换详解

  • 新建 → 就绪:进程创建完成,加入就绪队列
  • 就绪 → 运行:调度器选中该进程,分配 CPU
  • 运行 → 就绪:时间片用完、被更高优先级进程抢占
  • 运行 → 阻塞:等待 I/O、等待锁、sleep()、wait()
  • 阻塞 → 就绪:I/O 完成、获得锁、定时器到期
  • 运行 → 终止:正常退出、异常终止、被 kill

重要注意事项

  • 阻塞状态不能直接转换到运行状态,必须先进入就绪状态
  • 就绪和阻塞的本质区别:就绪是等待 CPU,阻塞是等待事件
3. 进程间通信(IPC)有哪些方式?各有什么特点和适用场景?中等

IPC 方式对比

方式特点性能适用场景
管道(Pipe)半双工,父子进程简单的单向数据流
命名管道(FIFO)半双工,无亲缘关系不相关进程的单向通信
消息队列异步,消息有类型解耦的异步消息传递
共享内存最快,需要同步大量数据交换
信号量用于同步进程/线程同步
信号(Signal)异步通知事件通知、进程控制
套接字(Socket)支持网络通信跨主机通信、C/S 架构

1. 管道(Pipe)

特点:半双工(单向通信),只能用于有亲缘关系的进程

int pipefd[2];
pipe(pipefd);  // pipefd[0] 读端,pipefd[1] 写端

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

2. 共享内存(最快)

特点:直接映射到进程地址空间,无需数据拷贝,但需要同步机制

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

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

// 写入数据
strcpy(shmaddr, "Hello");

// 读取数据(另一个进程)
printf("%s\n", shmaddr);

// 解除映射
shmdt(shmaddr);

3. 消息队列

特点:消息有类型,可以选择性接收,异步通信

// 创建消息队列
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);

// 接收消息
msgrcv(msgid, &msg, sizeof(msg.mtext), 1, 0);

选择建议

  • 简单通信:管道(如 shell 中的 ls | grep
  • 大量数据:共享内存 + 信号量(如数据库、缓存系统)
  • 异步消息:消息队列(如任务队列)
  • 跨主机:Socket(如微服务通信)
4. 什么是线程同步?常见的线程同步机制有哪些?中等

为什么需要线程同步?

多个线程访问共享资源时,如果不加控制,会导致竞态条件(Race Condition),产生不可预期的结果。

常见同步机制

机制特点适用场景
互斥锁(Mutex)保证同一时刻只有一个线程访问临界区保护共享资源
读写锁(RWLock)允许多个读者或一个写者读多写少场景
信号量(Semaphore)控制同时访问资源的线程数量资源池、限流
条件变量(Condition)线程间通知机制生产者-消费者模型
自旋锁(Spinlock)忙等待,不释放 CPU临界区很短的场景

1. 互斥锁(Mutex)

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* thread_func(void* arg) {
    pthread_mutex_lock(&mutex);
    // 临界区:访问共享资源
    shared_counter++;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

2. 信号量(Semaphore)

sem_t sem;
sem_init(&sem, 0, 3);  // 初始值为 3,最多 3 个线程同时访问

void* thread_func(void* arg) {
    sem_wait(&sem);  // P 操作,信号量 -1
    // 访问资源
    sem_post(&sem);  // V 操作,信号量 +1
    return NULL;
}

3. 条件变量(生产者-消费者)

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int buffer[10];
int count = 0;

// 生产者
void* producer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (count == 10) {
        pthread_cond_wait(&cond, &mutex);  // 缓冲区满,等待
    }
    buffer[count++] = produce_item();
    pthread_cond_signal(&cond);  // 通知消费者
    pthread_mutex_unlock(&mutex);
}

// 消费者
void* consumer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (count == 0) {
        pthread_cond_wait(&cond, &mutex);  // 缓冲区空,等待
    }
    int item = buffer[--count];
    pthread_cond_signal(&cond);  // 通知生产者
    pthread_mutex_unlock(&mutex);
}

死锁的四个必要条件

  • 互斥:资源不能被多个线程同时使用
  • 占有并等待:持有资源的同时等待其他资源
  • 不可抢占:资源不能被强制剥夺
  • 循环等待:存在线程等待环路

避免死锁的方法

  • 按固定顺序获取锁
  • 使用超时机制(trylock)
  • 使用死锁检测算法
5. 什么是上下文切换?如何减少上下文切换的开销?中等

上下文切换(Context Switch)

CPU 从一个进程/线程切换到另一个进程/线程时,需要保存当前执行状态并恢复目标状态的过程。

需要保存/恢复的内容

  • CPU 寄存器:程序计数器(PC)、栈指针(SP)、通用寄存器
  • 进程状态:运行、就绪、阻塞
  • 内存管理信息:页表、段表
  • I/O 状态:打开的文件、网络连接

上下文切换的开销

  • 直接开销:保存/恢复寄存器、切换页表(几微秒)
  • 间接开销:TLB 失效、CPU 缓存失效(可能几十微秒)

进程切换 vs 线程切换

类型开销原因
进程切换需要切换虚拟内存空间、TLB 全部失效
线程切换(同进程)共享地址空间,只需切换栈和寄存器
协程切换极小用户态切换,只需保存栈指针

减少上下文切换的方法

  • 减少线程数量:使用线程池,避免频繁创建/销毁线程
  • 使用协程:用户态调度,开销极小
  • 减少锁竞争:使用无锁数据结构、CAS 操作
  • 合理设置时间片:时间片过小导致频繁切换
  • CPU 亲和性:绑定线程到特定 CPU,减少缓存失效

查看上下文切换次数

# Linux 查看系统级上下文切换
vmstat 1
# cs 列:每秒上下文切换次数

# 查看进程级上下文切换
pidstat -w -p  1
# cswch/s:自愿上下文切换(I/O 等待)
# nvcswch/s:非自愿上下文切换(时间片用完)

实战优化案例

某 Java 服务 QPS 从 5000 提升到 8000 的优化:

  • 线程池大小从 200 降到 50(减少竞争)
  • 使用 ConcurrentHashMap 替代 synchronized HashMap
  • 使用 Disruptor 无锁队列替代 BlockingQueue
6. 什么是僵尸进程和孤儿进程?如何避免?中等

僵尸进程(Zombie Process)

子进程已经终止,但父进程还没有调用 wait()waitpid() 回收其资源,导致子进程的 PCB 仍然保留在系统中。

僵尸进程的特征

  • 进程状态为 Z(Zombie)
  • 不占用 CPU 和内存,但占用进程号(PID)
  • 大量僵尸进程会耗尽 PID 资源

如何避免僵尸进程?

// 方法 1:父进程调用 wait() 回收子进程
pid_t pid = fork();
if (pid == 0) {
    // 子进程
    exit(0);
} else {
    // 父进程
    wait(NULL);  // 阻塞等待子进程结束
}

// 方法 2:使用 SIGCHLD 信号处理
void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0);  // 非阻塞回收
}
signal(SIGCHLD, sigchld_handler);

// 方法 3:两次 fork(),让孙子进程成为孤儿进程
if (fork() == 0) {
    if (fork() == 0) {
        // 孙子进程,父进程立即退出,由 init 回收
        sleep(10);
        exit(0);
    }
    exit(0);  // 子进程立即退出
}
wait(NULL);  // 回收子进程

孤儿进程(Orphan Process)

父进程先于子进程终止,子进程成为孤儿进程,会被 init 进程(PID 1)收养并自动回收。

孤儿进程的特征

  • 父进程 PID 变为 1(init 进程)
  • 不会造成资源泄漏(init 会自动回收)
  • 通常不是问题,除非有意外的孤儿进程

查看僵尸进程

# 查看僵尸进程
ps aux | grep Z
# 或
ps -eo pid,ppid,stat,cmd | grep Z

# 查看僵尸进程数量
ps aux | grep Z | wc -l

# 杀死僵尸进程的父进程(僵尸进程无法直接 kill)
kill -9 <父进程PID>

实际问题案例

某服务器出现大量僵尸进程,导致无法创建新进程:

  • 原因:父进程没有正确处理 SIGCHLD 信号
  • 解决:重启父进程,并修改代码添加信号处理
7. 多线程编程中常见的问题有哪些?如何避免?中等

1. 竞态条件(Race Condition)

多个线程同时访问共享资源,结果依赖于线程执行的时序。

// 问题代码
class Counter {
    private int count = 0;
    public void increment() {
        count++;  // 非原子操作:读取 -> 加 1 -> 写回
    }
}

// 解决方案 1:synchronized
public synchronized void increment() {
    count++;
}

// 解决方案 2:AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet();
}

2. 死锁(Deadlock)

两个或多个线程互相等待对方持有的资源,导致永久阻塞。

// 问题代码
synchronized(lockA) {
    synchronized(lockB) {
        // 线程 1
    }
}

synchronized(lockB) {
    synchronized(lockA) {
        // 线程 2:可能死锁
    }
}

// 解决方案:按固定顺序获取锁
synchronized(lockA) {
    synchronized(lockB) {
        // 所有线程都按 A -> B 顺序
    }
}

3. 活锁(Livelock)

线程不断重试但无法继续执行,类似于两个人在走廊相遇,都试图让路但始终挡住对方。

解决方案:引入随机延迟

4. 饥饿(Starvation)

某些线程长期无法获得资源,一直处于等待状态。

解决方案:使用公平锁(ReentrantLock 的公平模式)

5. 内存可见性问题

// 问题代码
class Task {
    private boolean stop = false;
    
    public void run() {
        while (!stop) {
            // 可能永远看不到 stop 的变化
        }
    }
    
    public void stop() {
        stop = true;
    }
}

// 解决方案:使用 volatile
private volatile boolean stop = false;

6. 伪共享(False Sharing)

多个线程修改同一缓存行中的不同变量,导致缓存失效。

// 问题代码
class Counter {
    long count1;  // 可能在同一缓存行
    long count2;
}

// 解决方案:缓存行填充
class Counter {
    long count1;
    long p1, p2, p3, p4, p5, p6, p7;  // 填充到不同缓存行
    long count2;
}

多线程编程最佳实践

  • 尽量减少共享状态
  • 使用不可变对象
  • 使用线程安全的数据结构(ConcurrentHashMap)
  • 使用线程池而非手动创建线程
  • 使用高级并发工具(CountDownLatch、CyclicBarrier)
8. 用户态线程和内核态线程有什么区别?什么是混合线程模型?困难

用户态线程(User-Level Thread)

由用户空间的线程库管理,内核不感知其存在。

  • 优点:创建/切换快,无需系统调用
  • 缺点:一个线程阻塞会导致整个进程阻塞,无法利用多核

内核态线程(Kernel-Level Thread)

由操作系统内核管理和调度。

  • 优点:可以利用多核,一个线程阻塞不影响其他线程
  • 缺点:创建/切换开销大,需要系统调用

三种线程模型

模型映射关系特点代表
多对一(N:1)多个用户线程映射到一个内核线程无法利用多核早期 Java Green Threads
一对一(1:1)每个用户线程对应一个内核线程开销大,但可利用多核Linux NPTL、Windows
多对多(M:N)多个用户线程映射到多个内核线程兼顾性能和并发Go goroutine、Erlang

Go 的 GMP 模型(M:N 混合模型)

G(Goroutine):用户态线程
M(Machine):内核线程
P(Processor):调度器

         P1              P2
         ↓               ↓
    G1 G2 G3        G4 G5 G6
         ↓               ↓
         M1              M2
         ↓               ↓
      内核线程         内核线程

Java 虚拟线程(Project Loom)

Java 19 引入的虚拟线程,采用 M:N 模型,可以创建百万级线程。

// 传统线程
Thread thread = new Thread(() -> {
    // 任务
});
thread.start();

// 虚拟线程(Java 19+)
Thread.startVirtualThread(() -> {
    // 任务
});

为什么需要混合模型?

  • 用户态线程:快速创建和切换
  • 内核态线程:利用多核、处理阻塞
  • 混合模型:兼顾两者优点
9. 什么是线程池?如何合理设置线程池大小?困难

线程池的优势

  • 降低资源消耗:重用线程,避免频繁创建/销毁
  • 提高响应速度:任务到达时无需等待线程创建
  • 提高可管理性:统一管理、监控、调优
  • 防止资源耗尽:限制线程数量

Java 线程池核心参数

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,      // 核心线程数
    maximumPoolSize,   // 最大线程数
    keepAliveTime,     // 空闲线程存活时间
    TimeUnit.SECONDS,
    workQueue,         // 任务队列
    threadFactory,     // 线程工厂
    rejectedHandler    // 拒绝策略
);

线程池执行流程

1. 提交任务
   ↓
2. 核心线程数未满?
   是 → 创建核心线程执行
   否 → 继续
   ↓
3. 队列未满?
   是 → 加入队列等待
   否 → 继续
   ↓
4. 最大线程数未满?
   是 → 创建非核心线程执行
   否 → 执行拒绝策略

如何设置线程池大小?

CPU 密集型任务:

  • 线程数 = CPU 核心数 + 1
  • 原因:CPU 密集型任务主要消耗 CPU,过多线程会增加上下文切换

I/O 密集型任务:

  • 线程数 = CPU 核心数 × (1 + I/O 时间 / CPU 时间)
  • 或:线程数 = CPU 核心数 × 2
  • 原因:I/O 操作会阻塞线程,需要更多线程保持 CPU 利用率

实际案例

// CPU 密集型:图像处理
int cpuCount = Runtime.getRuntime().availableProcessors();
ExecutorService cpuPool = Executors.newFixedThreadPool(cpuCount + 1);

// I/O 密集型:HTTP 请求
ExecutorService ioPool = Executors.newFixedThreadPool(cpuCount * 2);

// 混合型:根据实际测试调整
ThreadPoolExecutor mixedPool = new ThreadPoolExecutor(
    10,                           // 核心线程数
    50,                           // 最大线程数
    60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

拒绝策略

  • AbortPolicy:抛出异常(默认)
  • CallerRunsPolicy:由调用线程执行
  • DiscardPolicy:直接丢弃
  • DiscardOldestPolicy:丢弃队列中最旧的任务

线程池监控

ThreadPoolExecutor executor = ...;

// 监控指标
executor.getActiveCount();      // 活跃线程数
executor.getPoolSize();         // 当前线程数
executor.getQueue().size();     // 队列中任务数
executor.getCompletedTaskCount(); // 已完成任务数
10. 什么是协程?协程与线程有什么区别?适用于哪些场景?困难

协程(Coroutine)

协程是用户态的轻量级线程,由程序自己调度,不需要内核参与,可以在单个线程内实现并发。

协程 vs 线程核心区别

特性线程协程
调度方式内核抢占式调度用户态协作式调度
切换开销较大(需要内核态切换)极小(用户态切换)
并发数量数千(受限于内存)数万甚至百万
栈大小固定(MB 级)动态(KB 级)
同步问题需要锁不需要(单线程内)
利用多核可以需要配合多线程

协程工作原理

# 协程执行流程
协程 A                协程 B
  ↓                     ↓
执行代码              等待
  ↓                     ↓
遇到 I/O,主动让出      ↓
  ↓ ─────────────→   获得执行权
等待                 执行代码
  ↓                     ↓
  ↓                  遇到 I/O,主动让出
  ↓ ←─────────────   等待
获得执行权              ↓
  ↓                     ↓
继续执行              等待

Python 协程示例

import asyncio

async def fetch_data(url):
    print(f"开始请求 {url}")
    await asyncio.sleep(1)  # 模拟 I/O,主动让出
    print(f"完成请求 {url}")
    return f"Data from {url}"

async def main():
    # 并发执行多个协程
    tasks = [
        fetch_data("url1"),
        fetch_data("url2"),
        fetch_data("url3")
    ]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())

# 输出:
# 开始请求 url1
# 开始请求 url2
# 开始请求 url3
# (1 秒后)
# 完成请求 url1
# 完成请求 url2
# 完成请求 url3

Go 协程示例

package main

import (
    "fmt"
    "time"
)

func fetchData(url string, ch chan string) {
    fmt.Printf("开始请求 %s\n", url)
    time.Sleep(1 * time.Second)
    fmt.Printf("完成请求 %s\n", url)
    ch <- fmt.Sprintf("Data from %s", url)
}

func main() {
    ch := make(chan string, 3)
    
    // 启动 3 个协程
    go fetchData("url1", ch)
    go fetchData("url2", ch)
    go fetchData("url3", ch)
    
    // 接收结果
    for i := 0; i < 3; i++ {
        fmt.Println(<-ch)
    }
}

协程的优势

  • 高并发:可以创建百万级协程
  • 低开销:切换成本极低(纳秒级)
  • 简化异步编程:用同步的方式写异步代码
  • 无需锁:单线程内执行,避免竞态条件

协程适用场景

  • I/O 密集型:网络请求、文件读写、数据库查询
  • 高并发服务:Web 服务器、API 网关、WebSocket 服务
  • 爬虫:大量并发 HTTP 请求
  • 实时通信:聊天服务器、游戏服务器

协程不适用场景

  • CPU 密集型:无法利用多核(需配合多进程/多线程)
  • 需要真正并行:协程是并发而非并行

各语言协程实现

  • Python:asyncio、gevent
  • Go:goroutine(语言内置,GMP 调度模型)
  • Kotlin:Coroutines
  • C++:C++20 Coroutines
  • Java:Project Loom 虚拟线程(Java 19+)
  • JavaScript:async/await

实战案例

某爬虫系统使用 Python asyncio 协程:

  • 从 100 个线程降到 10 个线程 + 10000 个协程
  • QPS 从 500 提升到 5000
  • 内存占用从 2GB 降到 500MB