Skip to content

Process

基本概念

程序 vs 进程 vs 线程

  • 程序(Program):静态的代码和数据,存储在磁盘。
  • 进程(Process):程序的一次执行过程,是资源分配的基本单位。
  • 线程(Thread):进程中的执行单元,是CPU调度的基本单位。

线程与进程的关系

  1. 对比点
  2. 地址空间:
    • 多进程:各自独立。
    • 多线程:共享进程地址空间。
  3. 资源开销:
    • 进程创建/切换开销大。
    • 线程创建/切换开销小。
  4. 通信方式:
    • 进程间通信需要IPC。
    • 线程间通过共享内存+同步原语即可。
  5. 常见问题
  6. “多进程 vs 多线程,性能和安全如何权衡?”
  7. “在什么场景你更倾向于用多进程?什么场景用多线程?”
  8. “线程共享和私有的资源有哪些?”

进程的组成(进程映像)

当一个程序被操作系统加载运行后,操作系统会给它分配一块“进程专属的虚拟地址空间”,里面一般会按区域划分成:

  • 代码段(Text)
  • 数据段(Data/BSS)
  • 堆(Heap)
  • 栈(Stack)
  • PCB(Process Control Block,进程控制块)PCB 是操作系统在内核中为每个进程维护的一块数据结构,记录了该进程的所有“元信息”
  • 面试常问:PCB里都有哪些内容?
  • 进程标识信息(PID、父进程ID、用户ID…)
  • 处理机状态(寄存器、程序计数器PC、程序状态字PSW等)这个是上下文切换的时候必须保存/恢复的那部分。
  • 进程调度信息(优先级、状态、调度队列指针、时间片信息…)
  • 资源分配信息(打开的文件、使用的内存区域、I/O设备…)

进程状态与状态转换

  • 常见状态:
  • 就绪(Ready)
  • 运行(Running)
  • 阻塞/等待(Blocked/Waiting)
  • 创建(New)
  • 结束(Terminated)
  • 状态转换例子:
  • New → Ready(创建完成,等待调度)
  • Ready → Running(被调度)
  • Running → Blocked(等待I/O或事件)
  • Blocked → Ready(事件完成)
  • Running → Ready(时间片耗尽,被抢占)

Linux进程描述符

Linux用task_struct (进程描述符)来管理线程和进程。有一个tasklist是双向链表。 taskstruct里有

  • pid:线程的pid就是所属进程的pid
  • tid:线程号,进程的主线程=pid
  • state: 描述进程的状态

创建 task_struct 的时候用slab分配器分配。再它的内核栈里有个 thread_info 指向taskstruct.

内核线程

只在内核态运行、没有独立用户态地址空间(不跑用户程序代码)的线程。用来干什么:

  • 异步写回/回收:比如写回脏页、回收缓存
  • 块设备/IO 处理:某些存储、RAID、dm 等后台工作
  • 网络相关后台任务:软中断/工作队列配合处理
  • workqueue 的 worker:很多内核子系统把“要延后做的事”丢给 workqueue

fork的实现

里面是 clone() ,然后调用 do_fork() ,然后copy_process

  • 创建内核栈,thread_info, task_struct
  • 设置pid
  • 复制/共享“进程资源”
  • 复制地址空间(mm)并启用 COW

线程创建

clone() 创建一个新的 task,并让它和创建者共享一组资源。在传参的时候,让它不复制地址空间。

  • 共享同一地址空间
  • 文件描述符
  • 信号处理
  • 当前目录
  • 线程组与 PID

你写 pthread_create() 时:

  • 分配线程栈, 通常用 mmap()
  • 设置 TLS(线程本地存储)和线程控制块 TCB
  • 调用内核:clone

进程调度

调度的层次

  • 作业调度(高级调度):选择哪些作业进入内存成为进程。
  • 进程调度(中级/低级调度):决定哪个就绪进程获得CPU。
  • 内存调度(中级调度):进程在内存和外存之间的换入换出(挂起/激活)。

调度算法

常见面试一定会问几种调度策略的特点和适用场景:

  • 先来先服务 FCFS
  • 简单,可能导致短作业等待时间长(“队首效应”)。
  • 短作业优先 SJF / 短进程优先 SPF
  • 平均等待时间短,但需要预知CPU运行时间,可能导致“长作业饥饿”。
  • 高响应比优先 HRRN
  • 兼顾短作业和长作业,普通操作系统不一定真用,但面试爱考公式。
  • 时间片轮转 RR
  • 每个进程给一个时间片,适合分时系统。
  • 重点:时间片大小影响响应时间和上下文切换开销。
  • 优先级调度
  • 静态/动态优先级。
  • 可能导致低优先级进程饥饿,可用“优先级老化”解决。
  • 多级队列、多级反馈队列
  • 多级反馈队列:综合考虑交互性、优先级和运行时间,是面试常问的一个综合型算法

调度时机与抢占

  • 抢占式 vs 非抢占式调度。
  • 抢占时机:
  • 时间片用完
  • 有更高优先级进程就绪
  • 进程从阻塞状态恢复(有的系统也会重新选择)

上下文切换(Context Switch)

  1. 什么是上下文?

  2. 硬件层面的上下文

    这些东西都在 CPU 里,切换时必须保存/恢复:

    • 通用寄存器(保存中间计算结果、局部变量)
    • 程序计数器 PC(下一条要执行的指令地址)
    • 栈指针 SP(当前栈顶位置)
    • 程序状态字/标志寄存器(条件码、中断开关位、特权级等)
    • 有些架构还有浮点寄存器、向量寄存器等
  3. 操作系统层面的上下文

    OS 为每个执行单元(进程/线程)维护的内核数据结构,比如:

    • 进程/线程控制块中的各种信息(PCB / TCB):
    • 进程/线程 ID
    • 运行状态、优先级、时间片
    • 指向内存映像的数据结构(地址空间描述、页表等)
    • 打开文件列表、信号处理状态
    • 内核栈位置等
  4. 加上操作系统保存的进程相关信息(PCB中的内容)。

  5. 上下文切换过程

  6. 保存当前进程上下文到其PCB。

  7. 根据调度算法选择下一个进程。
  8. 从下一个进程的PCB中恢复上下文。
  9. 上下文切换的开销:时间+CPU缓存/TLB失效。

  10. 常见口头题:

  11. “什么是上下文切换?开销主要体现在哪?”

  12. “进程上下文切换和线程上下文切换有什么区别?”

内核栈

内核栈就是一块在内核虚拟地址空间里的内存(在内存 RAM 里),每个线程有一份,只在内核态时用,用户态看不到也不能访问。

在一个典型的 32 位/64 位进程地址空间中,可以大致想象这一张图(只是示意,不是精确):

用户空间(User Space)
--------------------------------  高地址
|         用户栈(每线程一份)   |
|                                |
|               ...              |
|          堆 / 全局数据         |
|               ...              |
|         代码段 / 只读数据      |
--------------------------------  用户空间结束
|                                |
|         内核空间(Kernel)     |
|    内核代码 / 内核数据结构     |
|    各种缓存 / 页表 / slab      |
| ->  每线程的内核栈 就在这儿     |
--------------------------------  低地址
  • 用户栈:在用户空间这一部分虚拟地址里,进程自己“看得见”的地方。
  • 内核栈:在内核空间这一部分虚拟地址里,只有内核态能访问,用户态根本看不到这段地址。

每个线程有一个 task_struct(线程描述符);

通常会为这个线程分配一小块连续内核内存(比如 8KB / 16KB):

  • 这块内存的一部分当做内核栈
  • 另一部分存放 task_struct 等元数据:

进程间通信(IPC)

管道(Pipe)

  • 特点:
  • 半双工:单向通信。
  • 只能用于具有亲缘关系的进程(匿名管道)。
  • 以字节流为单位,对应用层透明。
  • 变体:命名管道(FIFO) → 可用于无亲缘关系进程。

适用场景

  • 一个进程 fork 出子进程,父子之间做简单的数据传递
  • Linux 命令行里的 ls | grep xxx 本质就是多个进程通过管道连接。

原理

内核做了几件事:

  1. 在内核里分配一个环形缓冲区(通常几十 KB,比如 64KB 左右,不同系统略有差异)。
  2. 创建一个「管道对象」,里面维护:
  3. 缓冲区指针、读写偏移
  4. 引用计数(有多少个 fd 指向这根管道)
  5. 为当前进程的文件描述符表分配两个描述符:
  6. fd[0]:只读端(read end)
  7. fd[1]:只写端(write end)

对管道或 FIFO 的写操作,如果长度小于 PIPE_BUF(系统保证的最小值至少 512 字节,常见是 4096),在多写端并发写入时,每次写入会被认为是原子操作

如果 n > PIPE_BUF

  • 内核可以在多次写中交叉完成,不再保证原子性。

匿名管道的生命周期由引用计数控制:

  • 所有写端 fd 被关闭,但还有读端:
  • 缓冲区的数据读完后,再读就得到 0(EOF)。
  • 一般来说这就是“写方已经退出”的信号。
  • 所有读端 fd 被关闭,但还有写端:
  • 下次 write() 会收到 SIGPIPE 信号(默认导致进程退出),且 write() 返回 -1 / EPIPE
  • 意味着“没人读了,你别写了”。

这也是为什么一般写端进程要么忽略 SIGPIPE,要么处理好错误。

消息队列(Message Queue)

  • 以“消息”为单位,结构化数据。
  • 有内核缓冲区,发送方/接收方异步。
  • 适合多生产者多消费者模型。

多个进程之间的可靠消息传递,希望消息不要丢,而且有明确的消息边界。

业务上天然是“消息驱动”的:任务队列、事件队列等。

内核维护的消息链表

  • 每条是「消息」而不是纯字节流,有自己的边界

队列满时发送方要么阻塞等待,要么失败返回。

队列空时接收方要么阻塞等待,要么失败返回。

内存映射文件(mmap)

需要在多个进程之间共享大文件/数据,且希望“加载即用”,不会一次性读全文件。

日志查看/修改、大文件解析,数据库实现等。

共享内存(Shared Memory)

  • 通信速度最快,因为数据不需要在内核与用户空间来回拷贝。
  • 问题:需要额外的同步机制(信号量、互斥锁等)避免并发冲突。

适用场景

  • 需要在进程间传输大数据、高频数据,对性能极度敏感:
  • 图像/视频缓冲,共享缓存池,大型数据结构等。
  • 多进程服务器共享缓存、配置、统计信息。

信号量(Semaphore)

  • 是一种计数器,用于进程/线程之间的同步与互斥
  • P操作(wait):申请资源。
  • V操作(signal):释放资源。
  • 重点:信号量本身不传输数据,只控制访问顺序。

和共享内存配合:保证同一时刻只有一个进程写,或控制读写顺序。

生产者-消费者模型,多个进程之间的同步与互斥。

S 的数值:现在还剩多少张票(资源/名额)

P(S)(也叫 wait / down)含义:申请一个资源/名额S = S - 1

V(S)(也叫 signal / up)含义:释放一个资源/名额S = S + 1如果 S ≤ 0:说明之前有人在等,唤醒一个等待进程

// g++ -std=c++17 sem_named_a.cpp -pthread -o a
#include <semaphore.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <iostream>

int main() {
    sem_t* s = sem_open("/mysem", O_CREAT | O_EXCL, 0666, 0); // 初值0
    if (s == SEM_FAILED) { perror("sem_open"); return 1; }

    std::cout << "A: do something...\n";
    sleep(2);
    std::cout << "A: signal\n";
    sem_post(s);

    sem_close(s);
    // 用完后清理(一般由创建者做一次)
    sem_unlink("/mysem");
}

程序B

// g++ -std=c++17 sem_named_b.cpp -pthread -o b
#include <semaphore.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <iostream>
int main() {
    sem_t* s = sem_open("/mysem", 0);
    if (s == SEM_FAILED) { perror("sem_open"); return 1; }

    std::cout << "B: waiting...\n";
    sem_wait(s);
    std::cout << "B: got signal!\n";

    sem_close(s);
}

有进程之前因为“资源不够/条件不成立”去申请资源(或等事件)时,会被 OS 挂起:阻塞态(在该资源的等待队列里)。

现在某进程释放了资源(或发出信号),资源变得可用,OS 就把等待队列里满足条件的进程 唤醒,放回就绪队列:就绪态

信号(Signal)

  • 软中断,用于通知进程发生异步事件(如 Ctrl+C)。
  • 对于Linux程序员岗可能会问得更细。

套接字(Socket)

  • 支持跨主机通信,也支持本机进程间通信。
  • 面试偏网络/分布式岗位会更关注。

适用场景

UNIX Domain Socket

  • 同机不同进程之间的“类网络协议”通信,比如:

网络 Socket

可以跨主机通信。

TCP:可靠、面向连接,适合长连接、大量数据。

UDP:不可靠、无连接,适合实时或广播场景。

进程同步与互斥

为什么要同步/互斥?

  • 并发导致:
  • 竞态条件
  • 临界区问题
  • 面试很爱问的经典例子:生产者-消费者问题、读者-写者问题、哲学家就餐问题。

临界区 & 同步互斥的基本要求

  • 互斥(同一时间只允许一个进入临界区)
  • 有界等待(不能让某个进程永远等)
  • 空闲让进(临界区空闲时应该能尽快让进程进入)
  • 让权等待(等待的时候尽量不占用CPU)

实现手段

  • 锁:
  • 互斥锁(Mutex)
  • 自旋锁(Spinlock)
  • 信号量(Semaphore)
  • 条件变量(Condition Variable)
  • 高级同步原语:读写锁、屏障等

死锁(Deadlock)

  1. 死锁的定义
  2. 一组进程互相等待对方持有的资源,导致都无法继续执行。
  3. 死锁产生的必要条件(四个)
  4. 互斥(Mutual Exclusion)
  5. 占有并等待(Hold and Wait)
  6. 不可剥夺(No Preemption)
  7. 循环等待(Circular Wait)
  8. 面试几乎必考:“死锁产生的必要条件?如何破坏这些条件?
  9. 处理死锁的策略
  10. 死锁预防:破坏四个必要条件之一。
  11. 死锁避免:银行家算法,动态判断资源分配是否安全。
  12. 死锁检测与恢复:
    • 定期检测资源分配图是否有环。
    • 杀进程或回滚来恢复。
  13. 银行家算法(了解/熟悉)
  14. 主要思想:系统只在安全状态下分配资源。
  15. 面试可能会给一个小表,让你判断是否安全、执行安全序列。

进程创建与终止(生命周期细节)

  1. 创建
  2. fork / vfork / clone(Linux)
  3. 父子进程关系:资源继承情况(文件描述符等)。
  4. 终止
  5. 正常终止:执行完、exit。
  6. 异常终止:信号、错误。
  7. 僵尸进程 & 孤儿进程:
    • 僵尸进程:子进程结束后,父进程未wait,导致PCB残留。
    • 孤儿进程:父进程退出,子进程被init接管。

僵尸进程

已经结束执行 的子进程,但它的父进程还没调用 wait / waitpid 去回收它的退出状态,于是内核保留了一条仅包含退出信息的“进程表项”,状态标记为 Z(Zombie)。

如何避免 / 处理僵尸进程

  • 父进程要记得 wait 子进程
  • SIGCHLD + wait 回收
  • 让子进程“直接变孤儿”,交给 init/systemd 回收(双重 fork 技巧)
  • ps aux | grep Z 看僵尸进程;通过 PPID 找到父进程。

孤儿进程

父进程先退出,但子进程还在运行的进程。

在类 Unix 系统中:

  • 孤儿进程会被 PID 为 1 的 init / systemd 接管:
  • 它成为 init/systemd 的子进程;
  • 当 B 将来退出时,init/systemd 会自动对它调用 wait 完成回收。

子进程

fork() 之后父子进程的执行流程

当某个进程调用 fork() 时,内核会:

  1. 创建一个新的进程控制块(PCB),分配一个新的 PID;
  2. 把父进程的大部分进程状态复制给子进程:
  3. 寄存器上下文(程序计数器、栈指针等)
  4. 虚拟地址空间结构(代码段、数据段、堆、栈的布局)
  5. 打开的文件描述符表(指向同一组“打开的文件”内核对象)
  6. 返回到用户态后:
  7. 父子进程都从 fork() 调用返回的下一条语句继续执行

不能保证顺序。

  • fork() 返回以后,父子进程都变成“普通就绪进程”,由调度器决定谁先跑;

fork 之后,子进程会继承父进程的很多东西(但不完全是“共享”):

  • 虚拟地址空间结构:代码段、数据段、堆、栈 → 起始内容一样;
  • 打开的文件描述符:子进程也打开同样的文件,指向同一个“打开文件对象”(共享文件偏移、状态);
  • 信号处理方式、工作目录、环境变量等。

写时复制Copy-On-Write

如果 fork 时真的把父进程的整个内存空间(堆、栈、数据段所有物理页面)逐页拷贝一份给子进程,会有几个问题:

  • 时间开销大:大进程 fork 会非常慢;
  • 内存浪费:很多情况下,子进程 很快就 exec() 一个新程序,根本用不到这份拷贝。

fork 之后:

  1. 父子进程的页表被设置成:
  2. 都指向同一批物理页面(父原来的那些);
  3. 这些页被标记为:
    • 只读(read-only)
    • 或带有“写时复制”标志。

exec

不创建新进程,只把“当前这个进程”的程序映像整个换掉

  • PID 不变
  • 旧的代码段/数据段/堆/栈都被新的可执行文件替换
  • 保留了一部分“进程属性”:打开的文件、工作目录、UID/GID、环境变量等

大部分时候,我们想要的是“再启动一个程序,同时自己还活着”

你在 shell 里敲:

ls | grep xxx > out &

shell 想要做的事情是:

  1. 自己这个 shell 进程要一直活着(还得等你敲下一条命令)。
  2. 每个命令都是一个新的子进程:
  3. fork 出来;
  4. 在子进程里设置好 I/O 重定向(dup2 管道、重定向到文件);
  5. exec 具体的程序,比如 lsgrep
  6. 父进程(shell)要:
  7. 维护前台/后台作业;
  8. 等子进程退出(wait);
  9. 显示结束状态等。

如果 shell 直接 exec("ls")

  • 它自己就变成 ls 了;
  • 原来的 shell 进程就没了;
  • 命令跑完之后已经没人能再等你输入下一条命令。

协程

通俗一点:

  • 一个线程里,你可以主动把控制权“让出来”,再切换到另一个协程;
  • 每个协程有自己的执行栈/上下文,可以“挂起 → 以后再从原地继续”。

协程

  • 完全由用户态的协程库 / 语言运行时调度;
  • OS 只看见底层那几个线程,不知道你上面有多少协程;
  • 调度通常是协作式:协程只能在显式 yield/await 等地方被切走。

协程切换:

  • 完全在用户态:保存/恢复少量寄存器 + 栈指针;
  • 不用系统调用、不动内核栈;
  • 协程栈可以很小、甚至是“按需增长”/拆分(stackful/stackless 各实现不同);
  • 可以轻松创建成千上万、甚至几十万协程。

协程:

  • 协程跑在某个线程里,一个线程在同一时间只执行一个协程
  • 如果所有协程都跑在同一个线程上 → 只能获得“并发”,没有 CPU 级并行;

题目

进程和线程的区别?从资源、上下文切换、通信方式等方面说。

为什么说线程是“轻量级进程”?

进程有哪几种状态?状态之间如何转换?

说一下进程上下文切换的大致过程,会保存 / 恢复哪些信息?

线程之间共享哪些资源?不共享哪些?

用户级线程和内核级线程有什么区别?各自优缺点?

什么是协程?它和线程相比有什么优点和限制?

多进程和多线程分别适合什么场景?如何选择?

什么是僵尸进程和孤儿进程?如何避免 / 处理僵尸进程?

说一下 fork() 之后父子进程的执行流程;写时复制(COW)是怎么回事?

进程间通信(IPC)有哪些方式?分别适合什么场景?

管道(pipe / named pipe)的工作机制?

消息队列、共享内存、信号量的特点和区别?

Socket 通信属于哪一种 IPC?为什么?

在 Linux 下如何用命令查看当前进程、线程信息?