Process
基本概念
程序 vs 进程 vs 线程
- 程序(Program):静态的代码和数据,存储在磁盘。
- 进程(Process):程序的一次执行过程,是资源分配的基本单位。
- 线程(Thread):进程中的执行单元,是CPU调度的基本单位。
线程与进程的关系
- 对比点
- 地址空间:
- 多进程:各自独立。
- 多线程:共享进程地址空间。
- 资源开销:
- 进程创建/切换开销大。
- 线程创建/切换开销小。
- 通信方式:
- 进程间通信需要IPC。
- 线程间通过共享内存+同步原语即可。
- 常见问题
- “多进程 vs 多线程,性能和安全如何权衡?”
- “在什么场景你更倾向于用多进程?什么场景用多线程?”
- “线程共享和私有的资源有哪些?”
进程的组成(进程映像)
当一个程序被操作系统加载运行后,操作系统会给它分配一块“进程专属的虚拟地址空间”,里面一般会按区域划分成:
- 代码段(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)
-
什么是上下文?
-
硬件层面的上下文
这些东西都在 CPU 里,切换时必须保存/恢复:
- 通用寄存器(保存中间计算结果、局部变量)
- 程序计数器 PC(下一条要执行的指令地址)
- 栈指针 SP(当前栈顶位置)
- 程序状态字/标志寄存器(条件码、中断开关位、特权级等)
- 有些架构还有浮点寄存器、向量寄存器等
-
操作系统层面的上下文
OS 为每个执行单元(进程/线程)维护的内核数据结构,比如:
- 进程/线程控制块中的各种信息(
PCB/TCB): - 进程/线程 ID
- 运行状态、优先级、时间片
- 指向内存映像的数据结构(地址空间描述、页表等)
- 打开文件列表、信号处理状态
- 内核栈位置等
- 进程/线程控制块中的各种信息(
-
加上操作系统保存的进程相关信息(PCB中的内容)。
-
上下文切换过程
-
保存当前进程上下文到其PCB。
- 根据调度算法选择下一个进程。
- 从下一个进程的PCB中恢复上下文。
-
上下文切换的开销:时间+CPU缓存/TLB失效。
-
常见口头题:
-
“什么是上下文切换?开销主要体现在哪?”
- “进程上下文切换和线程上下文切换有什么区别?”
内核栈
内核栈就是一块在内核虚拟地址空间里的内存(在内存 RAM 里),每个线程有一份,只在内核态时用,用户态看不到也不能访问。
在一个典型的 32 位/64 位进程地址空间中,可以大致想象这一张图(只是示意,不是精确):
用户空间(User Space)
-------------------------------- 高地址
| 用户栈(每线程一份) |
| |
| ... |
| 堆 / 全局数据 |
| ... |
| 代码段 / 只读数据 |
-------------------------------- 用户空间结束
| |
| 内核空间(Kernel) |
| 内核代码 / 内核数据结构 |
| 各种缓存 / 页表 / slab |
| -> 每线程的内核栈 就在这儿 |
-------------------------------- 低地址
- 用户栈:在用户空间这一部分虚拟地址里,进程自己“看得见”的地方。
- 内核栈:在内核空间这一部分虚拟地址里,只有内核态能访问,用户态根本看不到这段地址。
每个线程有一个 task_struct(线程描述符);
通常会为这个线程分配一小块连续内核内存(比如 8KB / 16KB):
- 这块内存的一部分当做内核栈;
- 另一部分存放
task_struct等元数据:
进程间通信(IPC)
管道(Pipe)
- 特点:
- 半双工:单向通信。
- 只能用于具有亲缘关系的进程(匿名管道)。
- 以字节流为单位,对应用层透明。
- 变体:命名管道(FIFO) → 可用于无亲缘关系进程。
适用场景
- 一个进程
fork出子进程,父子之间做简单的数据传递。 - Linux 命令行里的
ls | grep xxx本质就是多个进程通过管道连接。
原理
内核做了几件事:
- 在内核里分配一个环形缓冲区(通常几十 KB,比如 64KB 左右,不同系统略有差异)。
- 创建一个「管道对象」,里面维护:
- 缓冲区指针、读写偏移
- 引用计数(有多少个 fd 指向这根管道)
- 为当前进程的文件描述符表分配两个描述符:
fd[0]:只读端(read end)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)
- 死锁的定义
- 一组进程互相等待对方持有的资源,导致都无法继续执行。
- 死锁产生的必要条件(四个)
- 互斥(Mutual Exclusion)
- 占有并等待(Hold and Wait)
- 不可剥夺(No Preemption)
- 循环等待(Circular Wait)
- 面试几乎必考:“死锁产生的必要条件?如何破坏这些条件?”
- 处理死锁的策略
- 死锁预防:破坏四个必要条件之一。
- 死锁避免:银行家算法,动态判断资源分配是否安全。
- 死锁检测与恢复:
- 定期检测资源分配图是否有环。
- 杀进程或回滚来恢复。
- 银行家算法(了解/熟悉)
- 主要思想:系统只在安全状态下分配资源。
- 面试可能会给一个小表,让你判断是否安全、执行安全序列。
进程创建与终止(生命周期细节)
- 创建
- fork / vfork / clone(Linux)
- 父子进程关系:资源继承情况(文件描述符等)。
- 终止
- 正常终止:执行完、exit。
- 异常终止:信号、错误。
- 僵尸进程 & 孤儿进程:
- 僵尸进程:子进程结束后,父进程未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() 时,内核会:
- 创建一个新的进程控制块(PCB),分配一个新的 PID;
- 把父进程的大部分进程状态复制给子进程:
- 寄存器上下文(程序计数器、栈指针等)
- 虚拟地址空间结构(代码段、数据段、堆、栈的布局)
- 打开的文件描述符表(指向同一组“打开的文件”内核对象)
- 返回到用户态后:
- 父子进程都从
fork()调用返回的下一条语句继续执行
不能保证顺序。
fork()返回以后,父子进程都变成“普通就绪进程”,由调度器决定谁先跑;
fork 之后,子进程会继承父进程的很多东西(但不完全是“共享”):
- 虚拟地址空间结构:代码段、数据段、堆、栈 → 起始内容一样;
- 打开的文件描述符:子进程也打开同样的文件,指向同一个“打开文件对象”(共享文件偏移、状态);
- 信号处理方式、工作目录、环境变量等。
写时复制Copy-On-Write
如果 fork 时真的把父进程的整个内存空间(堆、栈、数据段所有物理页面)逐页拷贝一份给子进程,会有几个问题:
- 时间开销大:大进程 fork 会非常慢;
- 内存浪费:很多情况下,子进程 很快就
exec()了 一个新程序,根本用不到这份拷贝。
fork 之后:
- 父子进程的页表被设置成:
- 都指向同一批物理页面(父原来的那些);
- 这些页被标记为:
- 只读(read-only)
- 或带有“写时复制”标志。
exec
不创建新进程,只把“当前这个进程”的程序映像整个换掉
- PID 不变
- 旧的代码段/数据段/堆/栈都被新的可执行文件替换
- 保留了一部分“进程属性”:打开的文件、工作目录、UID/GID、环境变量等
大部分时候,我们想要的是“再启动一个程序,同时自己还活着”
你在 shell 里敲:
ls | grep xxx > out &
shell 想要做的事情是:
- 自己这个 shell 进程要一直活着(还得等你敲下一条命令)。
- 每个命令都是一个新的子进程:
fork出来;- 在子进程里设置好 I/O 重定向(
dup2管道、重定向到文件); - 再
exec具体的程序,比如ls、grep。 - 父进程(shell)要:
- 维护前台/后台作业;
- 等子进程退出(
wait); - 显示结束状态等。
如果 shell 直接 exec("ls"):
- 它自己就变成
ls了; - 原来的 shell 进程就没了;
- 命令跑完之后已经没人能再等你输入下一条命令。
协程
通俗一点:
- 一个线程里,你可以主动把控制权“让出来”,再切换到另一个协程;
- 每个协程有自己的执行栈/上下文,可以“挂起 → 以后再从原地继续”。
协程
- 完全由用户态的协程库 / 语言运行时调度;
- OS 只看见底层那几个线程,不知道你上面有多少协程;
- 调度通常是协作式:协程只能在显式
yield/await等地方被切走。
协程切换:
- 完全在用户态:保存/恢复少量寄存器 + 栈指针;
- 不用系统调用、不动内核栈;
- 协程栈可以很小、甚至是“按需增长”/拆分(stackful/stackless 各实现不同);
- 可以轻松创建成千上万、甚至几十万协程。
协程:
- 协程跑在某个线程里,一个线程在同一时间只执行一个协程;
- 如果所有协程都跑在同一个线程上 → 只能获得“并发”,没有 CPU 级并行;
题目
进程和线程的区别?从资源、上下文切换、通信方式等方面说。
为什么说线程是“轻量级进程”?
进程有哪几种状态?状态之间如何转换?
说一下进程上下文切换的大致过程,会保存 / 恢复哪些信息?
线程之间共享哪些资源?不共享哪些?
用户级线程和内核级线程有什么区别?各自优缺点?
什么是协程?它和线程相比有什么优点和限制?
多进程和多线程分别适合什么场景?如何选择?
什么是僵尸进程和孤儿进程?如何避免 / 处理僵尸进程?
说一下 fork() 之后父子进程的执行流程;写时复制(COW)是怎么回事?
进程间通信(IPC)有哪些方式?分别适合什么场景?
管道(pipe / named pipe)的工作机制?
消息队列、共享内存、信号量的特点和区别?
Socket 通信属于哪一种 IPC?为什么?
在 Linux 下如何用命令查看当前进程、线程信息?