操作系统面试题
操作系统题库
入门 / 中等 / 高难各 20 题以上,★ 是高频题。10 文档第七节是浓缩版。 答题技巧:操作系统题很容易答成背书,每题尽量落到「这对我写的程序意味着什么」(比如 gevent、gunicorn、Redis、PG 的行为)。
入门(22 题)
1. 操作系统是干什么的?
管理硬件资源(CPU、内存、磁盘、网络),为应用程序提供统一的接口(系统调用),并在多个程序之间做隔离和调度。核心模块:进程管理、内存管理、文件系统、设备管理、网络协议栈。
2. ★ 什么是内核?用户态和内核态?
内核是操作系统的核心,运行在最高权限级别,可以直接操作硬件。CPU 有权限级别,应用程序运行在用户态(不能直接访问硬件和其他进程的内存),需要做特权操作时通过系统调用陷入内核态,由内核完成后返回。切换有开销(保存 / 恢复寄存器、切换栈、可能刷缓存)。
3. ★ 什么是系统调用?举几个例子。
应用程序请求内核服务的接口。文件:open、read、write、close;进程:fork、exec、wait、exit;网络:socket、bind、listen、accept、connect;内存:mmap、brk;IO 多路复用:epoll_wait。Python 的 open()、socket 模块最终都调用这些。strace 可以看一个进程调用了哪些系统调用。
4. ★ 进程是什么?
运行中的程序,是操作系统分配资源的基本单位。每个进程有独立的虚拟地址空间、打开的文件、信号处理等资源。内核用 PCB(进程控制块,Linux 里是 task_struct) 记录进程的 ID、状态、寄存器、内存映射、打开的文件等信息。
5. ★ 线程是什么?
CPU 调度的基本单位。一个进程可以有多个线程,它们共享进程的内存和文件等资源,但各自有独立的栈、寄存器和程序计数器。
6. ★ 进程和线程的区别?
| 进程 | 线程 | |
|---|---|---|
| 定位 | 资源分配单位 | 调度单位 |
| 内存 | 独立地址空间 | 共享进程的地址空间 |
| 创建 / 切换开销 | 大 | 小 |
| 通信 | 需要 IPC | 直接读写共享内存(需要同步) |
| 健壮性 | 一个进程崩溃不影响其他进程 | 一个线程崩溃(比如段错误)会让整个进程退出 |
| 例子 | gunicorn 的多个 worker 进程、Chrome 每个标签页一个进程 | Java 的线程池、Python 的 ThreadPoolExecutor |
7. ★ 协程是什么?
用户态的轻量级「线程」,由程序自己调度(协作式:在 IO 等待处主动让出),切换不需要进内核,开销极小,一个线程里可以跑成千上万个协程。代表:Python 的 asyncio / gevent、Go 的 goroutine、Java 21 的虚拟线程。缺点:一个协程做 CPU 密集计算或调用了阻塞的系统调用,会卡住同一线程上的所有协程。
8. ★ 并发和并行的区别?
- 并发:一段时间内多个任务交替执行(单核也能做到,靠快速切换);
- 并行:同一时刻多个任务同时执行(需要多核)。
- Python 多线程因为 GIL,CPU 计算只能并发不能并行;多进程可以并行。
9. ★ 进程有哪几种状态?
创建 → 就绪(万事俱备,等 CPU)→ 运行 → 阻塞(等 IO、等锁、sleep)→ 事件完成回到就绪 → …… → 终止。运行中时间片用完回到就绪。Linux 里 ps 看到的状态:R(运行 / 就绪)、S(可中断睡眠)、D(不可中断睡眠,通常在等磁盘 IO)、T(暂停)、Z(僵尸)。
10. ★ 僵尸进程和孤儿进程?
- 僵尸进程:子进程已经退出,但父进程没有调用
wait()读取它的退出状态,它的 PCB 还留在进程表里(状态Z)。大量僵尸会占满进程号。解决:父进程正确wait,或处理SIGCHLD信号;杀掉父进程(僵尸会被 init 收养并回收)。 - 孤儿进程:父进程先退出,子进程还在运行,会被 init 进程(PID 1)收养,由它负责回收,无害。
- Docker 里的坑:容器的 PID 1 如果是你的应用(而不是 init),它不会回收孤儿子进程 → 僵尸堆积。用
docker run --init或 tini 作为 PID 1。
11. ★ 进程间通信(IPC)有哪些方式?
| 方式 | 特点 |
|---|---|
| 管道(pipe) | 单向、只能在父子进程间;ls | grep 就是管道 |
| 命名管道(FIFO) | 有文件名,无亲缘关系的进程也能用 |
| 消息队列 | 内核里的消息链表,有格式 |
| 共享内存 | 最快(直接读写同一块内存,不用拷贝),要配合信号量做同步 |
| 信号量 | 主要用于同步,不传数据 |
| 信号(signal) | 异步通知,比如 SIGTERM、SIGKILL |
| 套接字(socket) | 可以跨机器;Unix Domain Socket 用于本机,比 TCP 快 |
| 实际工程中更多用 Redis、消息队列、数据库这些外部系统来通信。我们沙箱里的 CLI 调后端用的是 Unix Domain Socket 代理。 |
12. ★ 线程之间怎么同步?
互斥锁(Mutex)、读写锁、自旋锁、条件变量、信号量、屏障(Barrier)、原子操作(CAS)。Python 里是 threading.Lock / RLock / Condition / Semaphore / Event 和线程安全的 queue.Queue。
13. ★ 什么是死锁?必要条件是什么?
两个或多个线程 / 进程互相等待对方持有的资源,谁都无法继续。四个必要条件(缺一不可):
- 互斥:资源同一时刻只能被一个占用;
- 持有并等待:拿着已有的资源,再去等别的;
- 不可剥夺:资源不能被强行抢走;
- 循环等待:A 等 B、B 等 A,形成环。
14. 什么是中断?
CPU 在执行程序时被外部事件打断,转去执行中断处理程序,处理完再回来。硬件中断:键盘、网卡收到数据、磁盘 IO 完成、时钟中断(调度的基础);软件中断 / 异常:系统调用、除零、缺页。Linux 把中断处理分为上半部(快速响应,关中断)和下半部(软中断,处理耗时的部分,比如网络收包)。
15. ★ 物理内存和虚拟内存?
- 物理内存:真实的内存条地址;
- 虚拟内存:操作系统给每个进程提供的独立、连续的地址空间,通过页表映射到物理内存。进程只看得到虚拟地址。
- 好处:进程隔离(看不到别人的内存);可以使用比物理内存更大的空间(不常用的页换出到磁盘);方便共享(共享库、共享内存);实现写时复制。
16. ★ 文件描述符是什么?
进程打开文件、socket、管道时,内核返回一个非负整数作为句柄,后续读写都用它。0 / 1 / 2 分别是标准输入、输出、错误。每个进程能打开的数量有上限(ulimit -n,默认常是 1024),高并发服务器要调大,否则报 Too many open files。每个 TCP 连接都占一个 fd。
17. 页缓存(Page Cache)是什么?
内核把读写过的文件数据缓存在空闲内存里。读:命中页缓存就不用读磁盘;写:先写到页缓存(标记为脏页)就返回,后台线程定期刷盘。所以 free 命令看到 buff/cache 很大是正常的,内存紧张时内核会自动回收。数据库要保证落盘就得调用 fsync。
18. ★ 常用的 Linux 排查命令?
| 看什么 | 命令 |
|---|---|
| CPU、负载、进程 | top / htop、uptime(load average)、ps aux --sort=-%cpu |
| 内存 | free -h、ps aux --sort=-rss、cat /proc/meminfo |
| 磁盘空间 | df -h、du -sh *、ncdu |
| 磁盘 IO | iostat -x 1、iotop |
| 网络连接 | ss -tanp、ss -lntp(谁在监听哪个端口)、netstat |
| 打开的文件 | lsof -p <pid>、lsof -i :8001 |
| 系统调用 | strace -p <pid> |
| 日志 | tail -f、grep、journalctl -u 服务名、dmesg(内核日志,看 OOM) |
| 综合 | vmstat 1、sar |
19. 环境变量和 PATH 是什么?
环境变量是进程继承的一组键值对配置(export KEY=value),子进程会继承父进程的环境变量。PATH 是用冒号分隔的目录列表,执行命令时按顺序在这些目录里找可执行文件。我们的服务全部用环境变量配置(APP_SCOPE、MODE、数据库地址),符合十二要素应用的做法。
20. 硬链接和软链接的区别?
- 硬链接:多个文件名指向同一个 inode(同一份数据),删除其中一个名字不影响其他的;不能跨文件系统、不能链接目录;
- 软链接(符号链接):一个特殊文件,内容是目标路径;目标删了链接就失效;可以跨文件系统、可以链接目录。
ln -s 目标 链接名。
21. ★ Linux 文件权限怎么看?chmod 755 是什么意思?
ls -l 显示 -rwxr-xr-x:第一位是类型(- 文件、d 目录、l 链接),后面三组分别是所有者、所属组、其他人的读(r=4)、写(w=2)、执行(x=1)权限。755 = 所有者 rwx(7)、组 r-x(5)、其他人 r-x(5)。目录的 x 权限表示能否进入。chown 改所有者。
22. ★ kill -15 和 kill -9 的区别?
SIGTERM(15,kill默认):请求进程退出,进程可以捕获它做清理(优雅停机:停止接收新请求、处理完存量请求、关闭连接);SIGKILL(9):强制杀死,进程无法捕获、无法清理;SIGINT(2):Ctrl+C;SIGHUP(1):常用于让服务重新加载配置。- Docker
docker stop先发 SIGTERM,等stop_grace_period(默认 10 秒)后再发 SIGKILL。我们的 gunicorn 收到 SIGTERM 后先摘流量再优雅退出,所以 grace period 要设够(我们设了 120 秒)。
中等(23 题)
1. ★ 进程调度算法有哪些?
| 算法 | 思路 | 问题 |
|---|---|---|
| 先来先服务(FCFS) | 按到达顺序 | 短作业等长作业(护航效应) |
| 短作业优先(SJF) | 先执行预计最短的 | 长作业可能饿死;运行时间难以预知 |
| 时间片轮转(RR) | 每个进程轮流执行一个时间片 | 时间片太小切换开销大,太大退化成 FCFS |
| 优先级调度 | 按优先级 | 低优先级饿死(用「老化」逐步提升等待久的优先级) |
| 多级反馈队列 | 多个优先级队列,新进程进最高级,用完时间片降级 | 兼顾响应时间和吞吐,是很多系统的基础 |
2. Linux 的 CFS 调度器是怎么工作的?
完全公平调度器:每个进程记录一个虚拟运行时间(vruntime,实际运行时间按优先级 / nice 值加权),总是选 vruntime 最小的进程运行,所有可运行进程按 vruntime 存在红黑树里,取最左节点 O(1)、插入 O(log n)。效果是每个进程按权重公平地分到 CPU 时间。Linux 6.6 起被 EEVDF 调度器取代,思路类似但更注重延迟。
3. ★ 什么是上下文切换?开销在哪?
CPU 从一个进程 / 线程切换到另一个时,要保存当前的寄存器、程序计数器、栈指针,加载新的。进程切换还要切换页表(虚拟地址空间),导致 TLB 失效、CPU 缓存变冷 —— 这是主要开销。线程切换不用切页表,便宜一些;协程切换在用户态,最便宜。vmstat 的 cs 列是每秒上下文切换次数,太高说明线程太多或锁竞争严重。
4. 用户级线程、内核级线程、线程模型?
- 1:1(一个用户线程对应一个内核线程):Linux 的 pthread、Java 平台线程、Python 线程。内核能调度到多核,但创建和切换要进内核;
- N:1(多个用户线程对应一个内核线程):纯用户态协程(gevent、asyncio),切换极快,但无法利用多核,一个阻塞全部阻塞;
- M:N:Go 的 goroutine(GMP 模型)、Java 21 虚拟线程,用少量内核线程调度大量用户态线程,兼顾两者。
5. ★ 怎么处理死锁?
- 预防(破坏四个条件之一):
- 破坏「持有并等待」:一次性申请所有资源;
- 破坏「不可剥夺」:拿不到就释放已有的(
tryLock+ 超时); - 破坏「循环等待」:所有线程按相同的顺序加锁(最常用);
- 避免:银行家算法,分配资源前检查分配后系统是否仍处于安全状态(实际工程很少用);
- 检测与恢复:允许死锁发生,定期检测资源等待图里有没有环,有就杀掉一个(数据库就是这么做的:PG 等锁超过
deadlock_timeout后检测,回滚其中一个事务)。
6. ★ 互斥锁、自旋锁、读写锁的区别?
- 互斥锁:拿不到锁就睡眠,让出 CPU,等被唤醒(有上下文切换开销);适合临界区较长的情况;
- 自旋锁:拿不到锁就一直循环检查(忙等),不让出 CPU;适合临界区极短、多核的情况(比睡眠再唤醒更快);单核上自旋没有意义;
- 读写锁:读读不互斥、读写和写写互斥;适合读多写少。要注意写线程饥饿问题。
7. ★ 乐观锁和悲观锁?CAS 是什么?ABA 问题?
- 悲观锁:认为一定会冲突,先加锁再操作(
synchronized、SELECT FOR UPDATE); - 乐观锁:认为很少冲突,操作时不加锁,提交时检查有没有被别人改过(版本号、CAS);冲突多时重试开销大;
- CAS(Compare And Swap):一条 CPU 原子指令:「如果内存里的值等于预期值,就把它改成新值」,返回是否成功。无锁数据结构和原子类的基础;
- ABA 问题:值从 A 被改成 B 又改回 A,CAS 认为没变过。解决:加版本号(每次修改版本号加 1,Java 的
AtomicStampedReference)。数据库乐观锁用递增的 version 列天然避免了 ABA。
8. ★ 条件变量是什么?生产者-消费者怎么实现?什么是虚假唤醒?
条件变量让线程在某个条件不满足时睡眠,条件满足时被其他线程唤醒,必须配合互斥锁使用。
import threading
cond = threading.Condition()
buf, CAP = [], 10
def produce(x):
with cond:
while len(buf) >= CAP: # 用 while 不用 if
cond.wait()
buf.append(x)
cond.notify_all()
def consume():
with cond:
while not buf:
cond.wait()
x = buf.pop(0)
cond.notify_all()
return x虚假唤醒:线程可能在没有被 notify 的情况下醒来,或者醒来时条件已经被别的线程抢先改变了,所以醒来后必须用 while 重新检查条件。实际工程里直接用 queue.Queue。
9. 信号量和互斥锁的区别?
互斥锁是「0 / 1」,同一时刻只允许一个线程进入,而且通常要求谁加锁谁解锁;信号量是一个计数器,允许最多 N 个线程同时进入(限制并发数),任何线程都可以释放。互斥锁可以看作 N = 1 的信号量。我们限制模型并发数用的就是(分布式的)信号量思想。
10. ★ 分页、分段、段页式?
- 分段:按逻辑划分(代码段、数据段、栈段),段长不固定;会产生外部碎片(内存中零散的小空闲块用不上);
- 分页:把内存划分成固定大小的页(通常 4KB),虚拟页映射到物理页框;没有外部碎片,但有少量内部碎片(最后一页用不满);
- 段页式:先分段,段内再分页,结合两者。现代 x86-64 Linux 基本只用分页(段机制形同虚设)。
11. ★ 页表、多级页表、TLB?
- 页表:记录虚拟页号 → 物理页号的映射,每个进程一张;
- 多级页表:64 位地址空间如果用一张平铺的页表会非常大,Linux 用 4 级(或 5 级)页表,没用到的部分不分配,节省内存;代价是一次地址转换要查多次内存;
- TLB(快表):CPU 里缓存最近用过的页表项,命中就不用查页表。进程切换时 TLB 要刷新(或用 ASID 区分),这是进程切换比线程切换贵的原因之一。
12. ★ 缺页中断和页面置换算法?
访问的虚拟页不在物理内存里 → 触发缺页中断 → 内核把页从磁盘(swap 或文件)读进来,必要时先换出一个页。置换算法:
- OPT(最佳):换出将来最长时间不用的,理论最优但无法实现;
- FIFO:换出最早进来的,可能出现 Belady 异常(给更多内存反而缺页更多);
- LRU:换出最久没用的,效果好但精确实现开销大;
- Clock(时钟 / 二次机会):LRU 的近似,环形扫描访问位。Linux 用的是双链表(活跃 / 非活跃)的近似 LRU。 和 Redis 的近似 LRU、我们手写的 LRU 缓存是同一个思想。
13. swap 是什么?OOM Killer 是什么?
- swap:磁盘上的交换空间,内存不够时把不常用的页换出去。用了 swap 程序会变得非常慢(磁盘比内存慢几个数量级),Redis、数据库这类服务器通常要避免使用 swap;
- OOM Killer:内存实在不够时,内核根据评分(
oom_score,主要看内存占用)选一个进程杀掉。dmesg | grep -i oom能看到记录。容器超过内存限制也会被 OOM 杀掉(docker inspect显示OOMKilled: true)。
14. ★ fork 和写时复制(COW)?
fork() 创建子进程,子进程是父进程的副本。如果真的复制全部内存会很慢,所以 Linux 用写时复制:fork 后父子进程共享所有物理页(都标记为只读),任何一方写某页时触发异常,内核才复制那一页。应用:
- Redis 的
BGSAVE:fork 子进程写快照,主进程继续服务; - gunicorn
--preload:master 先加载应用再 fork worker,共享只读的代码和数据,省内存; - 副作用:fork 前打开的连接、锁也被复制过去(我们遇到的数据库连接串台问题)。
15. ★ 进程的内存布局?
从低地址到高地址:代码段(程序指令,只读)→ 数据段(已初始化的全局 / 静态变量)→ BSS 段(未初始化的全局变量)→ 堆(malloc 动态分配,向上增长)→ 内存映射区(共享库、mmap)→ 栈(函数调用的局部变量、返回地址,向下增长)→ 内核空间(用户态不可访问)。
16. ★ 栈和堆的区别?
| 栈 | 堆 | |
|---|---|---|
| 管理 | 编译器 / 运行时自动分配和释放 | 程序员手动(C 的 malloc / free)或 GC |
| 大小 | 小(Linux 默认 8MB),超出就栈溢出 | 大,受虚拟内存限制 |
| 速度 | 快(移动栈指针) | 慢(要找合适的空闲块,可能有碎片) |
| 存什么 | 局部变量、函数参数、返回地址 | 动态分配的对象 |
普通 CPython 对象由运行时在私有堆上管理,局部名字关联对象引用;Python 调用帧不是原生线程栈的简单同义词,生成器/协程还可保留暂停状态。递归深度限制可触发 RecursionError,常见默认值为 1000,但应查询实际配置;这与操作系统栈耗尽不是同一件事,调高限制不保证安全。详见 Python 运行时专题。 |
17. mmap 是什么?
把文件(或匿名内存)映射到进程的虚拟地址空间,之后像访问内存一样读写文件,由缺页中断按需加载、由内核负责刷回磁盘。好处:减少一次内核到用户空间的拷贝、多个进程可以共享同一个映射。应用:数据库(早期 MongoDB、LMDB)、Qdrant 的 on_disk 向量就是 mmap、Kafka 的索引文件。
18. ★ 五种 IO 模型?
阻塞 IO、非阻塞 IO、IO 多路复用、信号驱动 IO、异步 IO(见 10 文档第七节第 8 题)。区分「同步 / 异步」看的是数据从内核拷贝到用户空间这一步是否需要用户进程自己等:前四种都是同步的,只有真正的异步 IO(io_uring、Windows IOCP)是内核拷贝完再通知。
19. ★ epoll 的原理?为什么比 select 快?
epoll_create:在内核里建一个 epoll 实例,内部用红黑树存所有被监听的 fd,用一个就绪链表存已经就绪的 fd;epoll_ctl:增删改监听的 fd(只在注册时拷贝一次);- 当某个 fd 有数据到来时,内核通过回调把它加入就绪链表;
epoll_wait:只返回就绪链表里的 fd,复杂度和就绪的数量有关,和总连接数无关。- select 每次调用都要把全部 fd 从用户态拷到内核、内核遍历全部 fd、返回后用户还要再遍历一遍;还有 1024 的上限。
- 连接多、活跃少(典型的 Web 长连接场景)时,epoll 的优势巨大。Redis、Nginx、Node.js、gevent(libev / libuv)、Python asyncio 在 Linux 上都基于 epoll。
20. Reactor 和 Proactor 模式?
- Reactor:基于同步 IO 多路复用,「来了一个事件,通知你,你自己去读写」。单 Reactor 单线程(Redis 6 之前)、单 Reactor 多线程、主从 Reactor(Netty、Nginx 多 worker);
- Proactor:基于异步 IO,「你告诉内核要读什么,内核读完了通知你」。Windows IOCP、Linux io_uring。
- Python asyncio、Node.js 的事件循环本质上都是 Reactor。
21. ★ 什么是零拷贝?
传统的「读文件 + 发到网络」:磁盘 → 内核缓冲区(DMA)→ 用户缓冲区(CPU 拷贝)→ socket 缓冲区(CPU 拷贝)→ 网卡(DMA),4 次拷贝、4 次用户态 / 内核态切换。
mmap + write:省掉一次 CPU 拷贝;sendfile:数据不经过用户空间,直接从内核文件缓冲区到 socket(配合网卡的 SG-DMA 可以做到 0 次 CPU 拷贝),2 次切换。- 应用:Kafka 消费者读消息、Nginx 发静态文件(
sendfile on)。 - 限制:数据在内核里直接发出,应用不能修改内容(比如要加密或压缩就用不了)。
22. ★ 顺序 IO 为什么比随机 IO 快那么多?
机械硬盘随机 IO 要寻道 + 旋转等待(毫秒级),顺序 IO 不用;SSD 没有寻道,但顺序写仍然更快(能整块写、减少写放大、预读命中)。所以很多系统都把随机写变成顺序写:数据库的 WAL / redo log(先顺序写日志,数据页之后再刷)、Kafka 的追加写日志、LSM 树(RocksDB)。
23. inode 是什么?
文件系统中描述文件元数据的结构:大小、权限、所有者、时间戳、数据块的位置(不包括文件名,文件名存在目录项里)。df -i 看 inode 使用情况:磁盘空间还有但 inode 用完了(海量小文件,比如没清理的临时文件、session 文件)也会报「No space left on device」。
高难(22 题)
1. ★ epoll 的 LT 和 ET 模式?什么是惊群问题?
- 水平触发(LT,默认):只要 fd 上还有没读完的数据,每次
epoll_wait都会返回它。编程简单,不容易丢事件; - 边缘触发(ET):只在状态变化时(新数据到达)通知一次,必须配合非阻塞 fd,一次循环读到
EAGAIN为止,否则剩下的数据不会再通知。系统调用更少,效率更高(Nginx 用 ET); EPOLLONESHOT:触发一次后自动取消,防止多个线程同时处理同一个 fd;- 惊群:多个进程 / 线程同时等待同一个事件(比如同一个监听 socket 的新连接),事件到来时全部被唤醒,但只有一个能处理成功,其他白醒。解决:
EPOLLEXCLUSIVE(只唤醒一个)、SO_REUSEPORT(每个进程有自己的监听 socket,内核在它们之间分配新连接,Nginx 的reuseport)、加 accept 锁。
2. C10K 问题是什么?是怎么解决的?
如何让单机同时处理 1 万个连接。早期「一个连接一个进程 / 线程」的模型在几千个连接时就被内存和上下文切换拖垮。解决:非阻塞 IO + IO 多路复用(epoll / kqueue)+ 事件驱动(Nginx、Node.js),或者轻量级协程(Go、gevent)。后来的 C10M 问题(千万连接)则要绕过内核协议栈(DPDK)。
3. io_uring 是什么?
Linux 5.1 引入的真正的异步 IO 接口:用户态和内核共享两个环形队列(提交队列 SQ、完成队列 CQ),应用把 IO 请求放进 SQ,内核完成后把结果放进 CQ,批量提交、批量收割,大幅减少系统调用次数,文件 IO 和网络 IO 都支持。PostgreSQL 18 的异步 IO 就支持 io_uring。
4. CPU 缓存一致性和伪共享(false sharing)?
- 多核 CPU 每个核有自己的缓存,同一份数据可能在多个核的缓存里。MESI 协议(Modified / Exclusive / Shared / Invalid)保证一致:一个核修改了某个缓存行,其他核的副本被标记为失效;
- CPU 缓存以缓存行(通常 64 字节)为单位。两个线程分别频繁修改的两个变量恰好在同一个缓存行里,虽然逻辑上无关,却会互相让对方的缓存失效,性能急剧下降 —— 这就是伪共享。解决:填充(padding),让它们落在不同的缓存行(Java 的
@Contended); - 内存屏障:防止编译器和 CPU 对指令重排序,Java 的
volatile就是靠内存屏障实现可见性和有序性的。
5. ★ load average 是什么意思?CPU 100% 怎么排查?
- load average(
uptime显示 1 / 5 / 15 分钟的平均值):处于可运行状态和**不可中断睡眠(D 状态,通常是等磁盘 IO)**的进程平均数。和 CPU 核数比较:4 核机器 load 持续超过 4 说明有排队。注意:load 高不一定是 CPU 忙,可能是大量进程在等 IO; - CPU 100% 排查:
top看是哪个进程,以及是用户态(us)高还是内核态(sy)高,wa(等 IO)高不高;top -H -p <pid>看是进程里的哪个线程;- 看这个线程在干什么:Java 用
jstack(线程 ID 转十六进制);Python 用py-spy top/py-spy dump;通用的用perf top; - 常见原因:死循环、正则回溯、大 JSON 序列化、频繁 GC、锁自旋;
sy高往往是系统调用过多或上下文切换过多。
6. ★ 进程内存为什么只涨不降?RSS 和 VSZ 有什么区别?
- VSZ:虚拟内存大小(申请了但不一定在用);RSS:实际占用的物理内存(包含共享库的部分);看内存占用主要看 RSS,多进程共享时看 PSS 更准;
- 只涨不降的常见原因:
- 内存分配器不归还:CPython 3.12 的 pymalloc 按 arena/pool/block 管理小分配,空闲块可能先被复用,整区仍有活块时不易整体归还;系统分配器还有自己的缓存、碎片与归还策略。不能断言所有空闲块只能从堆顶归还,独立映射等情况不同;
- 内存碎片:总空闲够,但都是零散的小块,大对象放不下只能再申请;
- 真正的泄漏:引用一直没释放(全局缓存、循环引用里有资源)。
- 区分方法:在稳态下压测,看清理后活分配、引用链和 RSS 的多轮变化。收敛或持续增长只是线索,不足以单独证明没有/存在泄漏;原生内存还需额外追踪。
7. ★ 服务被 OOM 杀掉了,怎么排查?
- 确认:
dmesg -T | grep -i -E 'killed process|out of memory';容器docker inspect <id> | grep OOMKilled; - 看被杀时谁占内存最多、是容器内存限制触发还是整机内存不足;
- 复现并定位:Python 用
tracemalloc/memray,Java 用 heap dump + MAT; - 常见原因:一次性加载大文件 / 大结果集进内存、无上限的缓存、并发太高(每个请求都占一块内存,worker × 并发 × 单请求内存超过上限)、fork 后写时复制让内存翻倍(Redis bgsave);
- 我们的真实案例:发版构建前端时,本地还跑着测试用的服务,构建进程被 OOM 杀掉 —— 所以现在的约定是测完立刻停掉测试服务。
8. ★ 容器的隔离是怎么实现的?namespace 和 cgroup 分别负责什么?
- namespace(隔离:看得见什么):PID(容器里看到自己是 1 号进程)、Mount(独立的文件系统挂载)、Network(独立的网卡、IP、端口)、UTS(主机名)、IPC、User(容器里的 root 映射成宿主机上的普通用户)、Cgroup;
- cgroup(限制:能用多少):CPU(份额、配额)、内存(上限,超过就 OOM)、IO、进程数。cgroup v2 是统一的层级结构,现在主流发行版默认用 v2;
- 联合文件系统(overlayfs):镜像的只读层 + 容器的可写层;
- 容器和虚拟机的本质区别:容器共享宿主机内核,只是被 namespace 隔离、被 cgroup 限制的普通进程。
- 我们的沙箱隔离(
unshare --mount --pid+chroot)就是手动用 namespace 实现进程级隔离。
9. ★ Too many open files 怎么排查?
- 看上限:
ulimit -n(当前 shell)、cat /proc/<pid>/limits(进程实际生效的); - 看用了多少:
ls /proc/<pid>/fd | wc -l、lsof -p <pid> | wc -l; - 看都是什么:
lsof -p <pid>按类型统计 —— 大量 socket 处于 CLOSE_WAIT 说明连接泄漏;大量文件说明文件没关; - 解决:先调大上限(
/etc/security/limits.conf、systemd 的LimitNOFILE、Docker 的--ulimit nofile=)止血;根本上修代码(用with保证关闭、用连接池)。
10. 进程处于 D 状态杀不掉,是怎么回事?
D 状态是不可中断睡眠,进程在等待内核里的某个操作完成(通常是磁盘 IO、NFS 网络文件系统无响应),这时连 kill -9 都无效(信号要等它醒来才能处理)。大量 D 状态进程会推高 load average。排查:cat /proc/<pid>/stack 看卡在内核哪里;检查磁盘健康、NFS 挂载、存储是否满了或出故障。
11. 透明大页(THP)对数据库和 Redis 有什么影响?
大页(2MB 而不是 4KB)能减少 TLB 未命中,但透明大页由内核自动合并和拆分:
- 合并时有后台整理开销,可能造成延迟抖动;
- Redis fork 后写时复制以 2MB 为单位复制,一次小写入就要复制 2MB,内存暴涨、fork 后延迟升高。
- 所以 Redis、MongoDB 官方都建议关闭 THP(
echo never > /sys/kernel/mm/transparent_hugepage/enabled)。PG 可以使用显式配置的大页(huge_pages)获得好处而没有这个问题。
12. NUMA 是什么?对服务有什么影响?
多路服务器上每个 CPU 有自己「近」的内存,访问别的 CPU 的内存要跨互联总线,延迟更高(非一致内存访问)。影响:进程在一个 NUMA 节点上运行却分配到另一个节点的内存,性能下降;内核的 NUMA 内存回收策略可能导致明明总内存够、却在某个节点上频繁回收甚至 swap。数据库服务器常用 numactl --interleave=all 启动或关闭 zone_reclaim_mode。
13. ★ Docker 容器里的进程收不到 SIGTERM,docker stop 总要等 10 秒才停,为什么?
- 原因 1:Dockerfile 用 shell 形式
CMD python app.py,实际 PID 1 是/bin/sh -c,sh 不会把信号转发给子进程 → 用 exec 形式CMD ["python", "app.py"],或者在入口脚本里用exec启动主进程; - 原因 2:PID 1 进程对没有显式注册处理函数的信号,内核默认忽略(普通进程收到 SIGTERM 默认会退出,PID 1 不会)→ 程序里显式处理 SIGTERM,或用 tini /
--init作为 PID 1 来转发信号和回收僵尸进程; - 结果:10 秒后被 SIGKILL 强杀,存量请求直接中断,没有优雅停机。
14. 常用的高级排查工具?
strace -f -p <pid>:看进程在执行哪些系统调用、卡在哪个调用上(比如卡在connect说明连不上下游、卡在futex说明在等锁);perf top/perf record:CPU 采样,看热点函数,配合火焰图;py-spy:Python 专用的采样分析器,不用改代码、不用重启(py-spy dump打印所有线程栈、py-spy record生成火焰图);- eBPF(bcc、bpftrace):在内核里安全地挂探针,几乎零开销地观测系统调用、网络、磁盘 IO 延迟分布;
pidstat、vmstat、iostat、sar:系统级的历史和实时指标。
15. ★ write 返回成功了,数据就一定写到磁盘了吗?
不一定。write 只是把数据写进了内核的页缓存就返回了,内核之后(默认最多 30 秒左右)才把脏页刷到磁盘。如果这期间断电,数据就丢了。
- 要确保落盘:
fsync(fd)(数据 + 元数据)或fdatasync; - 数据库在事务提交时会 fsync WAL,这是持久性的保证,也是提交的主要开销(所以有组提交:多个事务的 WAL 一次 fsync);
- PG 的
synchronous_commit = off、Redis 的appendfsync everysec就是「不每次 fsync」来换性能; O_DIRECT:绕过页缓存直接写磁盘(数据库自己管理缓存时使用)。- 更进一步:磁盘自己也有写缓存,需要磁盘支持并启用 write barrier / FUA,否则 fsync 返回后断电依然可能丢。
16. ★ 协程为什么快?它有什么缺点?
- 快在:切换在用户态完成,只需保存少量寄存器和栈指针,不进内核、不切页表,开销是线程切换的几十分之一;内存占用小(几 KB 的栈),可以开几十万个;
- 缺点:
- 协作式调度,一个协程不让出就会饿死其他协程:CPU 密集计算、阻塞的系统调用(没有被 hook 的)会卡住整个线程上的所有协程;
- 单个线程跑不满多核,要配合多进程;
- 调试和排查更难(调用栈不连续);
- asyncio 式的协程有「函数染色」问题(async 会传染整条调用链)。
17. ★ gevent 的 hub 被阻塞是怎么回事?
gevent 所有的 greenlet 都运行在一个操作系统线程上,由一个叫 hub 的特殊 greenlet 运行事件循环(libev / libuv)。只有当前 greenlet 主动让出(遇到被 monkey-patch 过的 IO)时,hub 才能调度下一个。如果某个 greenlet:
- 调用了没有被 patch 的阻塞函数(C 扩展里的同步网络调用、
subprocess的某些用法、没 patch 时的 socket),线程真的在内核里睡眠,hub 无法运行; - 或者做长时间 CPU 计算, 那么这个进程里所有的请求都会停住。gevent 可以开启阻塞检测(
gevent.config.monitor_thread = True,设置max_blocking_time),超过阈值就打印当时阻塞的调用栈。我们 Celery worker 因为 monkey-patch 没生效、第一个网络 IO 就把 hub 钉死,任务永久挂起,就是这个原理。
18. 一个程序从输入命令到开始运行,经历了什么?
shell 解析命令 → 在 PATH 里找到可执行文件 → fork 出子进程 → 子进程 execve 加载程序:内核解析 ELF 文件头,建立新的虚拟地址空间,映射代码段和数据段 → 如果是动态链接的,先加载动态链接器(ld-linux.so),由它加载依赖的共享库(.so)并做符号重定位 → 设置栈(参数、环境变量)→ 跳到入口 _start → 调用 main。Python 脚本则是执行 python 解释器,解释器再去编译和执行 .py 文件。
19. ★ Redis bgsave 时内存会翻倍吗?
fork 之后父子进程共享物理内存,只有被修改的页才会被复制。所以:
- 写少读多:几乎不增加内存;
- 写很多:被改过的页都要复制一份,最坏接近翻倍;
- 开了透明大页:每次复制 2MB 而不是 4KB,放大严重; 所以 Redis 机器要预留内存(
maxmemory不要设成机器内存的 100%),并关闭 THP;INFO persistence里的latest_fork_usec可以看 fork 本身耗时(复制页表,内存越大越久)。
20. ★ 容器里 os.cpu_count() 返回的是什么?会带来什么问题?
- 返回的是宿主机的 CPU 核数,不感知容器的 CPU 限制(cgroup 的 quota)。宿主机 64 核、容器只给了 2 核,程序按 64 开了 129 个 gunicorn worker 或 64 个线程,结果大量上下文切换、CPU 被限流(throttling),还多占一大堆内存。
- Python 3.13 起有
os.process_cpu_count()(按 CPU 亲和性计算,但依然不读 cgroup 的 quota);Java 从 8u191 起默认感知容器限制。 - 正确做法:worker 数通过环境变量显式配置,不要依赖自动探测(我们的 worker 数就是通过入口脚本的参数和环境变量传入的)。
21. 写时复制、引用计数和 Python 多进程的内存问题?
gunicorn --preload 后 fork 可共享物理页,但对部分普通对象的访问会产生引用计数写入,GC 也可能修改管理信息,触发写时复制;不是每次读取、每个对象都必定复制一页,不朽对象等也有区别。gc.freeze() 可让冻结对象避开后续循环收集扫描,减少这一类元数据写入,不能消除引用计数写入或业务写入的 COW。有意使用时还要设计 freeze/unfreeze 生命周期,冻结的新不可达环可能暂不被循环 GC 处理。用 PSS/USS 与私有脏页测收益,大型只读数据可考虑 mmap/共享内存,详见 20。
22. 综合题:一台机器上的服务突然变慢,你怎么用「USE 方法」排查?
对每种资源(CPU、内存、磁盘、网络、文件描述符、连接池)检查三件事:
- Utilization(使用率):
top的 CPU、free的内存、iostat的%util、网卡流量; - Saturation(饱和度 / 排队):load average、run queue、swap 使用、
iostat的await和aqu-sz、TCP 全连接队列溢出(netstat -s | grep overflow)、连接池等待; - Errors(错误):
dmesg里的硬件 / OOM 错误、网卡丢包、TCP 重传、应用日志里的错误。 先找到饱和或报错的资源,再用perf/py-spy/strace深入到具体进程和代码。这比凭感觉猜要高效得多。
七、操作系统基础
1. ★ 进程、线程、协程的区别?
| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 是什么 | 资源分配的单位 | CPU 调度的单位 | 用户态的「轻量线程」 |
| 内存 | 独立地址空间 | 共享所在进程的内存 | 共享线程的内存 |
| 切换开销 | 大(切页表、刷缓存) | 中(内核态切换) | 小(用户态切换,不进内核) |
| 调度 | 操作系统 | 操作系统(抢占式) | 程序自己(协作式,在 IO 处让出) |
| 崩溃影响 | 不影响其他进程 | 一个线程崩溃可能拖垮整个进程 | 同线程 |
| 例子 | gunicorn 的多个 worker | Java 线程池 | Python 的 asyncio / gevent、Go 的 goroutine、Java 21 的虚拟线程 |
2. ★ 进程间通信(IPC)有哪些方式?
管道(匿名管道、命名管道)、消息队列、共享内存(最快,要自己同步)、信号量(同步用)、信号(kill -9、SIGTERM)、套接字(Socket,可以跨机器)。实际工程里更多用 Redis / MQ / 数据库这类外部系统。
3. 线程间同步方式?
互斥锁、读写锁、条件变量、信号量、原子操作(CAS)。
4. ★ 死锁的四个必要条件?怎么预防?
- 互斥(资源一次只能被一个占用);2. 持有并等待(拿着一个等另一个);3. 不可剥夺;4. 循环等待。
预防:破坏其中一个,最常用的是破坏循环等待:所有人按相同顺序加锁;或者加锁设超时(tryLock)。数据库死锁也是同样的道理。
5. 进程的几种状态?
新建 → 就绪 → 运行 → 阻塞(等 IO)→ 就绪 …… → 终止。还有僵尸进程(子进程退出了,父进程没调用 wait 回收)和孤儿进程(父进程先退出,被 init 收养)。
6. ★ 用户态和内核态?
CPU 的两种权限级别。用户程序在用户态运行,访问硬件、文件、网络时要通过系统调用切到内核态,由内核执行后再返回。切换有开销,所以「减少系统调用次数」是常见的优化方向(批量读写、零拷贝)。
7. ★ 虚拟内存是什么?为什么需要?
每个进程都以为自己独占一块完整连续的内存(虚拟地址),由操作系统和 CPU 的 MMU 通过页表映射到物理内存。好处:进程之间隔离;可以使用比物理内存更大的空间(不常用的页换到磁盘 swap);共享内存和写时复制(fork)都基于它。内存按页(通常 4KB)管理,访问的页不在物理内存里就触发缺页中断。
8. ★ 五种 IO 模型?
- 阻塞 IO:调用后一直等到数据就绪并拷贝完成;
- 非阻塞 IO:数据没就绪立即返回,要不断轮询;
- IO 多路复用:一个线程用
select/poll/epoll同时监听多个连接,哪个就绪处理哪个(Redis、Nginx、Node.js、gevent、asyncio 都基于它); - 信号驱动 IO;
- 异步 IO:发起后直接返回,内核把数据拷贝完再通知(Linux 的 io_uring)。
前四种都属于「同步 IO」(数据从内核拷到用户空间这一步要自己等)。
9. ★ select、poll、epoll 的区别?
| select | poll | epoll | |
|---|---|---|---|
| 连接数上限 | 1024(FD_SETSIZE) | 无 | 无 |
| 每次调用 | 把所有 fd 从用户态拷到内核 | 同 | fd 只在注册时拷一次 |
| 找就绪的 fd | 遍历全部,O(n) | 遍历全部,O(n) | 内核回调把就绪的放进就绪列表,O(就绪数) |
| 适合 | 少量连接 | 少量连接 | 大量连接、少量活跃(C10K) |
| epoll 两种触发模式:水平触发 LT(只要还有数据就一直通知,默认,编程简单)、边缘触发 ET(只在状态变化时通知一次,要一次读完,效率更高)。macOS 上对应的是 kqueue。 |
10. ★ 零拷贝是什么?
传统「读文件发网络」要 4 次拷贝(磁盘 → 内核缓冲 → 用户缓冲 → socket 缓冲 → 网卡)和 4 次用户态 / 内核态切换。零拷贝(sendfile、mmap)让数据不经过用户空间,直接在内核里从文件缓冲送到网卡。Kafka、Nginx 发静态文件都用它。
11. 页缓存(Page Cache)?
操作系统把读过的文件内容缓存在内存里,下次读直接命中;写入先写页缓存,后台再刷盘(所以需要 fsync 保证落盘)。PG 依赖它做第二层缓存,Kafka 依赖它实现高吞吐。
12. 常见调度算法?
先来先服务、短作业优先、时间片轮转、优先级调度、多级反馈队列。Linux 的 CFS(完全公平调度器)用红黑树按虚拟运行时间排序。
