# 操作系统面试题


操作系统这块内容,在面试里的存在感有时候不如 MySQL 和 Redis 那么强,但遇到了往往能拉开差距。尤其是校招,进程、内存管理、死锁这些题目是标配,社招的话也会在系统设计或底层原理上被追问到。

这篇文章整理了操作系统面试中最常被问到的知识点,包括用户态与内核态、进程与线程、锁与死锁、内存管理、文件系统、中断,以及服务器 IO 模型,整体覆盖比较全面。内容偏向理解原理,不是让你背概念,而是帮你搞清楚"为什么是这样"。

有几块内容在面试里出现频率最高,建议重点花时间:

  • 进程与线程:两者的区别、协程是什么、进程间通信的几种方式(管道/共享内存/信号量/socket),还有进程调度算法,这块问得非常频繁。
  • 锁与死锁:死锁的四个必要条件、如何避免死锁、自旋锁和互斥锁的区别,以及银行家算法,这些都是经典考点。
  • 内存管理:虚拟内存和物理内存的关系、页表的工作原理、缺页中断、写时复制(Copy On Write),还有 malloc 底层的 brk 和 mmap 机制。
  • 文件系统:文件描述符和 inode 的区别、硬链接与软链接、Page Cache,以及为什么 write 成功不代表数据已经落盘。
  • IO 模型:select/poll/epoll 的区别、边缘触发和水平触发,以及零拷贝的原理,这是理解高性能服务器(Redis、Nginx、Netty)的基础。

如果你是第一次系统复习操作系统,建议先把进程线程和内存管理这两块搞透,它们是整个操作系统知识体系的核心,其他内容理解起来也会顺很多。


# 用户态和内核态

# 用户态和内核态的区别?

内核态和用户态是 CPU 的两种运行模式,核心区别是执行权限不同。以常见的 x86 架构为例,内核通常运行在 Ring 0,用户程序通常运行在 Ring 3。

  • 内核态(Kernel Mode):可以执行特权指令,管理页表、中断和设备等资源,主要用于操作系统内核的运行。
  • 用户态(User Mode):不能执行特权指令,也不能随意访问内核内存和其他进程的内存,需要通过操作系统提供的接口申请服务,主要用于运行用户程序。

内核态的底层操作主要包括:内存管理、进程管理、设备驱动程序控制、系统调用等。这些操作涉及到操作系统的核心功能,需要较高的权限来执行。

分为内核态和用户态的原因主要有以下几点:

  • 安全性:通过对权限的划分,用户程序无法直接访问硬件资源,从而避免了恶意程序对系统资源的破坏。
  • 稳定性:用户态程序的普通错误通常被限制在进程内,减少了直接破坏内核、导致整个系统崩溃的风险。
  • 隔离性:内核态和用户态的划分使得操作系统内核与用户程序之间有了明确的边界,有利于系统的模块化和维护。

内核态和用户态的划分有助于保证操作系统的安全性、稳定性和易维护性。

这里还要注意,root 用户运行的普通程序也处于用户态。root 是操作系统管理的用户权限,内核态是 CPU 的执行权限,两者不是一回事。

# 用户态和内核态在什么情况下会发生切换?

用户态进入内核态,主要有三种情况:

  • 系统调用:程序主动请求内核服务,比如调用 read 读取文件、调用 socket 创建网络连接。
  • 异常:CPU 执行当前指令时遇到需要内核处理的情况,比如访问尚未建立物理映射的页面,触发缺页异常。
  • 外部中断:网卡、磁盘、时钟等硬件发出中断,CPU 转去执行内核里的中断处理程序。

处理完成后,如果继续执行原来的用户程序,就会恢复现场,回到用户态。

**用户态和内核态切换,不一定会发生线程切换。**比如一次 read 调用命中了文件缓存,内核复制完数据就直接返回,前后执行的仍然是同一个线程。只有线程阻塞、时间片用完等情况,才可能进一步触发调度。

# 系统调用和普通函数调用有什么区别?

普通函数调用是在当前执行环境里调用一段代码,系统调用则是请求内核提供服务。

比如调用一个计算两数之和的函数,只需要传递参数、跳转到函数入口,执行完再返回,整个过程可以一直在用户态完成。

而 read、write 这样的系统调用,需要通过专门的指令进入内核。内核检查参数和权限,完成操作,再把结果返回给用户程序。以 x86-64 Linux 为例,通常通过 syscall 指令进入内核。

所以,系统调用除了函数本身的工作,还会有特权级切换、现场保存恢复、参数检查等开销,有些操作还涉及用户空间和内核空间之间的数据拷贝。

不过,库函数不等于系统调用。比如 malloc 通常先从分配器管理的空闲内存中分配,空间不够时才向内核申请;printf 也可能先把数据放进用户态缓冲区,等刷新时才调用 write。

# 进程管理

# 线程和进程的区别是什么?

  • 本质区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
  • 在开销方面:每个进程都有独立的虚拟地址空间,进程切换通常需要切换地址空间;同一进程内的线程共享代码、堆和全局变量,每个线程有自己的运行栈、寄存器和程序计数器,同一进程内的线程切换通常开销更小
  • 稳定性方面:线程发生非法内存访问等错误时,可能导致整个进程退出,因为线程共享地址空间;另一个进程崩溃通常不会直接破坏当前进程的私有内存,但业务依赖和共享资源仍可能受到影响。
  • 内存分配方面:进程拥有独立的地址空间;线程也需要占用内存,比如用户栈、内核栈和线程管理结构,只是用户栈等资源位于所属进程的地址空间里
  • 包含关系:一个执行中的进程至少有一个线程,如果进程内有多个线程,就可以有多条执行流

这里的“线程有自己的栈”,指的是各自使用不同的栈区域,不代表这些栈在地址空间上相互隔离。同一进程里的线程如果拿到了另一个线程栈上变量的地址,仍然可能访问它,这也是多线程需要注意内存安全的原因。

# 进程,线程,协程的区别是什么?

  • 首先,我们来谈谈进程。进程是资源分配的基本单位,拥有独立的虚拟地址空间。不同进程默认不能直接访问对方的内存,需要通过管道、共享内存、Socket 等机制通信。进程隔离性比较好,但创建和切换的开销通常也比较大。
  • 接下来是线程。线程是进程内的执行单元,也是操作系统调度的基本单位。同一进程内的线程共享堆、全局变量和打开的文件等资源,但各自有独立的栈和寄存器上下文。共享数据很方便,同时也需要解决数据竞争的问题。
  • 最后是协程。协程是由语言运行时或协程库在用户态调度的执行单元。协程切换通常不需要进入内核,所以开销比较小。有栈协程会保存自己的栈和寄存器上下文,无栈协程则通常把暂停位置和局部状态保存在状态机里,不能一概说所有协程都有独立的栈。

举个例子,一个线程里可以运行很多协程。当某个协程等待网络数据时,运行时可以先去执行其他协程,等数据就绪后再恢复它。

不过,协程要依托线程执行,并不会凭空增加 CPU 的并行能力。只有一个工作线程时,即使创建很多协程,也只能在一个时刻执行其中一个;想利用多核,还需要多个线程配合。

另外,如果协程直接调用了没有被运行时接管的阻塞系统调用,可能把所在的线程一起阻塞。所以协程通常要配合非阻塞 I/O、事件循环,或者由运行时把阻塞任务交给其他线程处理。

# 为什么进程崩溃不会对其他进程产生很大影响

主要是因为:

  • 进程隔离性:每个进程都有自己独立的内存空间,当一个进程崩溃时,其内存空间会被操作系统回收,不会影响其他进程的内存空间。这种进程间的隔离性保证了一个进程崩溃不会直接影响其他进程的执行。
  • 资源分别管理:内核会分别管理各个进程持有的资源。一个进程退出时,通常只会释放它自己的资源引用,不会直接销毁其他进程仍在使用的资源。

但是,进程隔离不代表业务上完全没有影响。两个进程可能使用共享内存、访问同一个文件,也可能通过网络互相依赖。如果一个进程写坏了共享数据,或者它提供的服务突然不可用,其他进程仍然可能受到影响。

# 你说到进程是分配资源的基本单位,那么这个资源指的是什么?

主要指的是虚拟地址空间、打开的文件描述符、网络连接,以及权限和资源限制等运行环境。

比如一个进程打开了文件,同一进程里的线程通常都能使用这个文件描述符;进程申请了一块堆内存,其他线程拿到地址后也可以访问。

线程则主要拥有自己的执行状态,比如寄存器、程序计数器、栈和线程局部存储。所以我们说,进程负责组织资源,线程负责执行代码。

# 讲下为什么进程之下还要设计线程?

我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:

  • 从视频文件当中读取数据;
  • 对读取的数据进行解压缩;
  • 把解压缩后的视频数据播放出来;

如果采用单进程、单线程的顺序执行方式,流程可能是这样:

img

对于这种顺序执行方式,存在以下问题:

  • 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,Read 的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放;
  • 各个函数之间不是并发执行,影响资源的使用效率;

那改进成多进程的方式:

img

对于多进程的这种方式,依然会存在问题:

  • 进程之间如何通信,共享数据?
  • 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;

那到底如何解决呢?需要有一种新的实体,满足以下特性:

  • 实体之间可以并发运行;
  • 实体之间共享相同的地址空间;

这个新的实体,就是线程(Thread),线程之间可以并发运行且共享相同的地址空间。

# 多线程比单线程的优势,劣势?

  • 多线程比单线程的优势:可以让多个任务并发推进,利用等待 I/O 的时间执行其他工作;对于能拆分的计算任务,也可以通过多核并行提高执行效率。
  • 多线程比单线程的劣势:存在多线程数据竞争访问的问题,需要通过锁机制来保证线程安全,增加了加锁的开销,并且还会有死锁的风险。多线程会消耗更多系统资源,如CPU和内存,因为每个线程都需要占用一定的内存和处理时间。

# 多线程是不是越多越好,太多会有什么问题?

多线程不一定越多越好,过多的线程可能会导致一些问题。

  • 切换开销增加:可运行线程远多于 CPU 核数时,CPU 需要频繁切换线程,保存和恢复上下文,还可能影响缓存命中率。
  • 内存占用增加:每个线程都需要栈和管理结构,线程数量太多会占用大量地址空间和内存,甚至触发线程数量或资源限制。
  • 锁竞争增加:如果线程都在争抢同一把锁,线程越多不一定处理得越快,反而可能花更多时间等待和唤醒。

线程数要结合任务类型来确定:CPU 密集型任务通常从接近可用 CPU 核数开始调优,I/O 密集型任务因为存在等待,可以适当增加线程数。还要考虑数据库连接池、下游服务容量等限制,最后通过压测看吞吐量和响应时间。

# 并发和并行有什么区别?

  • 并发:多个任务在同一段时间内交替推进,不要求在同一个时刻执行。
  • 并行:多个任务在同一个时刻同时执行,通常需要多个 CPU 核心。

比如只有一个厨师时,他可以炒菜炒到一半,趁等待的时间去准备另一道菜,这就是并发。如果有两个厨师同时做两道菜,就是并行。

所以,单核 CPU 也可以通过任务切换实现并发,多核 CPU 才能让多个计算任务真正并行执行。多线程既可能并发,也可能并行,要看硬件和调度情况。

# 进程切换和线程切换的区别?

  1. 不同进程之间的切换:除了保存和恢复寄存器、程序计数器、栈指针等执行状态,还需要切换虚拟地址空间,比如切换页表根指针,这可能影响 TLB 和 CPU 缓存的命中率。
  2. 同一进程内的线程切换:同样需要保存和恢复执行状态、切换栈,但地址空间相同,通常不用切换页表,因此开销更小。

需要注意,切换地址空间不等于把整个内存复制一遍,全局变量、堆数据和文件描述符表也不会在每次切换时被复制。内核主要是在切换执行上下文,以及当前任务使用的地址空间等信息。

如果两个线程属于不同进程,那么线程切换也要切换地址空间,不能把所有线程切换都理解成低开销。

# 线程切换为什么比进程切换快,节省了什么资源?

同一进程内的线程切换,主要节省的是地址空间切换的开销,而不是完全不保存上下文。

线程之间共享同一套内存映射,所以不用切换页表根指针,也能减少地址转换缓存受到的影响。但寄存器、程序计数器、栈指针等仍然要保存和恢复,调度本身也有成本。

# 线程切换详细过程是怎么样的?上下文保存在哪里?

image-20240725233216165

线程切换的详细过程可以分为以下几个步骤:

  • 上下文保存:当操作系统决定切换到另一个线程时,它首先会保存当前线程的上下文信息。上下文信息包括寄存器状态、程序计数器、堆栈指针等,用于保存线程的执行状态。
  • 切换到调度器:操作系统将执行权切换到调度器(Scheduler)。调度器负责选择下一个要执行的线程,并根据调度算法做出决策。
  • 上下文恢复:调度器选择了下一个要执行的线程后,它会从该线程保存的上下文信息中恢复线程的执行状态。
  • 切换到新线程:调度器将执行权切换到新线程,使其开始执行。

上下文信息的保存通常由操作系统负责管理,具体保存在哪里取决于操作系统的实现方式。一般情况下,上下文信息会保存在线程的控制块(Thread Control Block,TCB)中。

TCB是操作系统用于管理线程的数据结构,包含了线程的状态、寄存器的值、堆栈信息等。当发生线程切换时,操作系统会通过这些信息保存和恢复执行现场。

在 Linux 中,每个线程都有自己的 task_struct 和内核栈,上下文信息会按架构和切换路径保存在相关结构及内核栈中,不是简单地把所有寄存器都塞进一个 TCB。

# 进程的状态(五种状态),如何切换?

一个完整的进程状态的变迁如下图:

img

进程五种状态的变迁

再来详细说明一下进程的状态变迁:

  • NULL -> 创建状态:一个新进程被创建时的第一个状态;
  • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
  • 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
  • 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
  • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
  • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
  • 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;

这里最容易混淆的是就绪和阻塞:就绪状态是“只差 CPU,拿到就能运行”,阻塞状态是“还在等数据、锁或其他事件,给它 CPU 也没法继续”。所以阻塞进程被唤醒后,通常先进入就绪状态,等调度器选中后才开始运行。

# 僵尸进程和孤儿进程有什么区别?

僵尸进程是子进程已经退出,但父进程还没有回收它的退出信息;孤儿进程是父进程先退出,子进程还在运行。

  • 僵尸进程:子进程退出后,大部分资源已经释放,但内核还会保留 PID、退出状态等信息,等父进程调用 wait 或 waitpid 获取。这个等待回收的状态就是僵尸状态,在 ps 里通常显示为 Z。
  • 孤儿进程:父进程先退出后,子进程会由 init 或合适的子进程收割者(subreaper)接管,它仍然可以继续运行,退出后再由新的父进程负责回收。

僵尸进程不会继续执行代码,也不会占用大量用户内存,但大量僵尸进程会占用进程表项和 PID 等资源。

怎么处理呢?根本办法是让父进程正确调用 wait/waitpid,比如在收到 SIGCHLD 后循环回收已退出的子进程。对已经退出的僵尸进程再发 kill 信号没有用,因为它已经没有可执行的代码了。

# 多线程进程调用 fork,有什么需要注意的?

子进程只会保留调用 fork 的那个线程,其他线程不会一起出现在子进程里。

但是父进程的地址空间会被继承,其中也包含互斥锁等同步对象的状态。这就可能出现一个问题:fork 时某把锁由另一个线程持有,子进程继承了“已加锁”的状态,却没有那个负责解锁的线程。

这样子进程再去获取这把锁,就可能一直等下去。即使代码里没有直接加锁,调用 malloc、printf 等库函数,也可能碰到库内部的锁。

因此,多线程程序 fork 后,子进程在 exec 前应只调用异步信号安全的函数,常见做法是尽快 exec 执行新程序,失败时通过 _exit 退出。需要协调特定锁的状态时,可以使用 pthread_atfork 注册处理函数,但仍然要遵守 fork 后的调用限制。

# 进程上下文有哪些?

各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。

在详细说进程上下文切换前,我们先来看看 CPU 上下文切换

大多数操作系统都是多任务,通常支持超过 CPU 核数的任务并发运行。每个 CPU 核心在某个时刻执行一条任务执行流,通过短时间内不断切换任务实现并发;多个核心之间则可以并行执行不同任务。

任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。

所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。

CPU 寄存器是 CPU 内部容量很小、访问很快的存储单元,用来保存运算数据和执行状态,它与 CPU Cache 不是同一种东西。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快得多。

再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。

所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。

既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。

CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行.

上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。

进程的上下文切换到底是切换什么呢?

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。

所以,**进程的上下文切换既要保存和恢复 CPU 的执行状态、切换内核栈,也要切换进程使用的虚拟地址空间。**用户栈、堆和全局变量仍然保留在原来的地址空间里,不会因为一次调度就被整体搬走。

通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:

img

大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。

# 进程间通讯有哪些方式?

Linux 内核提供了不少进程间通信的方式:

  • 管道
  • 消息队列
  • 共享内存
  • 信号
  • 信号量
  • socket

Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。

匿名管道顾名思义,它没有文件系统中的路径名,通常通过 pipe 创建,shell 命令中的「|」竖线就是匿名管道。通信的数据是没有消息边界的字节流,内核缓冲区的容量有限,通信方向是单向的,如果要双向通信,需要创建两个管道。匿名管道通常用于父子进程或兄弟进程之间,因为它们可以通过 fork 继承管道的文件描述符;本质上,通信双方需要持有同一个管道的读写端。所有引用它的文件描述符都关闭后,管道才会被释放。

命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。

共享内存可以减少进程间传递数据时的拷贝开销,它让多个进程的虚拟地址映射到同一块物理内存。创建和建立映射时需要内核参与,但映射建立后,普通的读写就像访问自己的内存一样,不需要每次通过系统调用传递数据。不过,共享内存只解决了数据共享,没有自动解决同步问题,多个进程同时修改同一份数据,仍然可能造成数据错乱。

那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。

与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号用于通知进程发生了某个事件,比如终端处理 Ctrl+C 时会向前台进程组发送 SIGINT,kill 命令可以请求发送指定信号,非法内存访问也可能由内核转成 SIGSEGV。信号不是前面说的硬件中断,而是内核提供给进程的通知机制。进程通常可以选择执行默认操作、捕捉信号或忽略信号,但 SIGKILL 和 SIGSTOP 不能捕捉、忽略或阻塞。

前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。

# 管道有几种方式?

管道在Linux中有两种方式:匿名管道和命名管道。

  • 匿名管道:通常通过 pipe 系统调用创建,父子进程或兄弟进程可以通过继承文件描述符来使用。本质要求是通信双方持有同一个管道的读写端,亲缘关系是最常见的获取方式。
  • 命名管道:是一种允许无关的进程间进行通信的机制,基于文件系统,可以在不相关的进程之间进行通信。

# 信号和信号量有什么区别?

  • 信号:一种处理异步事件的方式。信号是比较复杂的通信方式,用于通知接收进程有某种事件发生,除了用于进程外,还可以发送信号给进程本身。
  • 信号量:用于进程或线程之间的同步与互斥,可以理解为一个资源计数器。比如连接池里只有 10 个连接,就可以用初值为 10 的信号量限制同时使用连接的任务数量。

一个是通知发生了什么事件,一个是协调谁可以使用资源,用途不同。

# 共享内存怎么实现的?

共享内存的机制,就是让不同进程的一段虚拟地址空间,映射到相同的物理内存中。这样双方可以直接访问同一份数据,不必每次通过内核来回复制,减少了进程间传递数据的开销。但读写顺序和数据可见性,仍然要通过合适的同步方式来保证。

image-20240725233155798

在 Linux 中,可以通过 shmget/shmat 建立共享内存,也可以通过 shm_open 配合 mmap,或者对同一个文件建立 MAP_SHARED 映射。

**相同的是背后的物理内存,不要求两个进程映射到相同的虚拟地址。**因此共享内存里通常保存相对偏移,不能随便把某个进程里的指针交给另一个进程使用。

另外,看到同一份内存不代表数据已经按正确的顺序发布。比如生产者先写数据再修改“已就绪”标记,消费者要通过进程间共享的锁、信号量,或正确的原子操作和内存序来协调,才能安全地读取完整数据。

# 线程间通讯有什么方式?

线程在同一进程内共享地址空间,所以可以直接通过共享变量、队列等传递数据,不像进程那样通常需要额外建立通信通道。通信方便,但必须处理好同步。

常见的同步机制有:互斥锁、读写锁、条件变量、自旋锁和信号量。其中互斥锁、读写锁和自旋锁主要解决互斥问题,条件变量和信号量也常用于等待与通知。

  • 互斥锁(Mutex):访问共享数据前加锁,访问完成后解锁,保证同一时刻只有一个线程进入对应的临界区。竞争时可能先进行短暂自旋,再进入阻塞等待,具体取决于实现。释放锁时通常唤醒一个或适量的等待者,并不是一定唤醒所有线程,也不保证先等待的线程一定先拿到锁。
  • 条件变量(Condition Variables):条件变量(cond)是在多线程程序中用来实现"等待--》唤醒"逻辑常用的方法。条件变量利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使“条件成立”。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。线程在改变条件状态前必须首先锁住互斥量,函数pthread_cond_wait把自己放到等待条件的线程列表上,然后对互斥锁解锁(这两个操作是原子操作)。在函数返回时,互斥量再次被锁住。
  • 自旋锁(Spinlock):通过 CAS、原子交换等原子指令竞争锁,加锁失败的线程循环检查锁状态,也就是“忙等待”。它不会因为等待锁而主动睡眠,适合多核环境下持锁时间很短的临界区,但长时间自旋会消耗 CPU。
  • 信号量(Semaphores):通常表示可用资源的数量。P 操作申请资源,有资源就减少计数并继续,没有资源就等待;V 操作归还资源,并在需要时唤醒等待者。它既可以用于线程之间,也可以用于进程之间;无名信号量放在共享内存中并按进程共享方式初始化后,同样可以供多个进程使用。
  • 读写锁(Read-Write Locks):读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。所以,读写锁适用于能明确区分读操作和写操作的场景。读写锁的工作原理是:当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势。

# 除了互斥锁你还知道什么锁?分别应用于什么场景?

常见的锁还有读写锁、自旋锁,另外也常用条件变量和信号量进行同步,但后两者不能简单当作锁。

  1. 读写锁:读写锁允许多个线程同时读取共享资源,但只允许一个线程进行写操作。适用于读操作频繁、写操作较少的场景,可以提高并发性能。
  2. 自旋锁:自旋锁是一种忙等待锁,线程在获取锁时不会进入阻塞状态,而是循环忙等待直到获取到锁。适用于临界区很小且锁的持有时间很短的场景,避免线程频繁切换带来的开销。
  3. 条件变量:条件变量用于线程间的同步和通信。它通常与互斥锁一起使用,线程可以通过条件变量等待某个条件满足,当条件满足时,其他线程可以通过条件变量发送信号通知等待线程。
  4. 信号量:信号量是一种计数器,用于控制对共享资源的访问。它可以用来限制同时访问资源的线程数量,或者用于线程间的同步。

# 条件变量为什么要配合互斥锁,还要用 while 判断条件?

条件变量负责等待和唤醒,互斥锁负责保护真正的共享条件。

举个例子,消费者要等队列里有数据,真正的条件是“队列不为空”,不是“收到了一次通知”。常见写法如下:

pthread_mutex_lock(&mutex);
while (queue_empty()) {
    pthread_cond_wait(&cond, &mutex);
}
consume_one(); // 持锁取出一条数据
pthread_mutex_unlock(&mutex);

pthread_cond_wait 会把释放互斥锁和进入等待作为一个原子过程,避免消费者刚检查完队列,生产者就放入数据并发出通知,而消费者随后才睡下去,错过这次通知。被唤醒后,wait 会先重新获取互斥锁,再返回。

为什么用 while,而不是 if 呢?

  • 可能出现虚假唤醒:即使没有对应的通知,wait 也可能返回。
  • 条件可能又变了:多个消费者被唤醒后,其他线程可能先一步取走数据,轮到当前线程拿到锁时,队列又空了。

所以,唤醒只代表可以重新检查条件,不代表条件一定成立。生产者也要在同一把锁保护下修改队列,并发送通知。

# 互斥锁和信号量有什么区别?

  • 互斥锁保护临界区:同一时刻只允许一个线程进入,通常由加锁的线程负责解锁,强调所有权。
  • 信号量控制资源数量或执行顺序:初值为 N 时,可以允许 N 个任务同时申请资源,申请和释放也可以由不同线程完成。

比如保护一个共享链表,可以使用互斥锁;限制最多 10 个任务同时访问某个服务,可以使用初值为 10 的信号量;让线程 A 完成后通知线程 B,可以使用初值为 0 的信号量。

初值为 1 的信号量也能实现互斥,但它的所有权和误用检查等语义与互斥锁不同,不能认为两者完全一样。

# 进程调度算法有哪些?

先来先服务调度算法

最简单的一个调度算法,就是非抢占式的先来先服务(First Come First Served, FCFS)算法了。

顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。

这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。 FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。

最短作业优先调度算法

最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。

这显然对长作业不利,很容易造成一种极端现象。

比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。

高响应比优先调度算法

前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。

那么,高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。

每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:

从上面的公式,可以发现:

  • 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
  • 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;

时间片轮转调度算法

一种经典的、实现比较简单的算法是时间片轮转(Round Robin, RR)调度算法。

image.png 每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。

  • 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
  • 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;

另外,时间片的长度就是一个很关键的点:

  • 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
  • 如果设得太长又可能引起对短作业进程的响应时间变长,极端情况下就接近先来先服务;

时间片没有适用于所有系统的固定值,要结合上下文切换成本和响应时间要求来选择。

最高优先级调度算法

前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。

但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法。 进程的优先级可以分为,静态优先级或动态优先级:

  • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
  • 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。

该算法也有两种处理优先级高的方法,非抢占式和抢占式:

  • 非抢占式:就绪队列出现更高优先级的进程时,仍等当前进程结束、阻塞或主动让出 CPU,再进行选择。
  • 抢占式:就绪队列出现更高优先级的进程时,可以让当前进程回到就绪状态,优先调度更高优先级的进程。

但是依然有缺点,可能会导致低优先级的进程永远不会运行。

多级反馈队列调度算法

多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。

顾名思义:

  • 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
  • 「反馈」表示根据进程的运行表现调整它所在的队列,比如持续用完时间片的进程会逐渐降到低优先级队列,而经常等待 I/O 的交互任务通常能保留较高的优先级;

来看看,它是如何工作的:

  • 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
  • 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
  • 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;

可以发现,对于短作业可能可以在第一级队列很快被处理完。

对于长作业,如果在第一级队列处理不完,可以移入下一级队列,获得更长的时间片,减少频繁切换的开销。这种方式希望兼顾长短作业,并让交互任务有较好的响应时间。

为了避免低优先级任务长期得不到运行,还可以定期提升等待任务的优先级,或者把任务统一提升到高优先级队列。这些是调度算法的基本思想,具体操作系统的实现不一定照搬这一套队列规则。

# 锁

# 为什么并发执行线程要加锁?

并发执行线程需要加锁主要是为了保护共享数据,防止出现"竞态条件"。

"竞态条件"是指当多个线程同时访问和操作同一块数据时,最终结果依赖于线程的执行顺序,这可能导致数据的不一致性。

比如两个线程同时执行 count++,这个操作通常包含读取、加 1、写回三个步骤。如果两个线程都读到了 10,最后都写回 11,就丢失了一次更新。

通过互斥锁保护这段操作,可以确保同一时刻只有一个线程进入临界区。前提是所有访问这份共享数据的线程都遵守同一套同步规则,只在写入的一处加锁,而其他地方随意读写,仍然可能出问题。

不过,并发不意味着所有地方都要加锁。如果数据是只读的,或者每个线程操作各自的数据,就不需要为了“有多线程”而加锁。

# 自旋锁是什么?应用在哪些场景?

自旋锁加锁失败后,线程会忙等待,直到它拿到锁。

自旋锁可以通过 CPU 提供的 CAS(Compare And Swap)或原子交换等指令实现。它既可以用在用户态,也可以用在内核态,关键特征是竞争失败后忙等待,而不是进入睡眠。

一般加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;

CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

比如,设 lock 为原子变量,0 表示空闲,1 表示已加锁,通过 CAS 尝试把 0 改成 1,成功的线程获得锁。解锁时通常通过带有 release 语义的原子写把它设回 0,加锁成功则需要相应的 acquire 语义,保证临界区内数据的可见性。

使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。

自旋适合多核环境、持锁时间很短,而且持锁线程能继续运行的情况。如果只有一个核心,或者持锁线程已经被调度出去,等待线程一直自旋,就可能浪费大量 CPU 时间。

如果多个协程运行在同一个线程上,一个协程一直自旋还可能挡住持锁协程的执行,所以不能把自旋锁理解成天然适合协程。内核自旋锁还有抢占、中断等上下文约束,普通内核自旋锁保护的临界区不能随意睡眠。

自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。

短临界区可以考虑自旋锁,持锁时间较长或可能阻塞时通常更适合互斥锁。但最终还要看核心数量、竞争程度和具体实现,不能只凭代码行数决定哪一种更快。

# 死锁发生条件是什么?

死锁只有同时满足以下四个条件才会发生:

  • 互斥条件:互斥条件是指多个线程不能同时使用同一个资源。
  • 持有并等待条件:持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
  • 不可剥夺条件:不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
  • 环路等待条件:存在一个等待环,比如线程 A 等待 B 持有的资源,B 等待 C 持有的资源,C 又等待 A 持有的资源,环里可以有两个或更多线程。

这四个是必要条件。对于每类资源可能有多个实例的情况,存在等待环也不一定已经死锁,还要看剩余资源能否让某个任务继续推进。

# 如何避免死锁?

从日常编程的角度,防止死锁可以通过破坏四个必要条件中的某一个来实现。最常见的做法是使用资源有序分配法,破坏环路等待条件。

那什么是资源有序分配法呢?线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。

img

还可以一次性申请需要的全部资源,申请不到就先不持有任何资源,破坏“持有并等待”;或者使用 trylock,获取失败时释放已经拿到的锁,再重试。

不过,超时和重试只有配合释放资源,才可能解除等待。如果一直持有原来的锁重试,问题仍然存在;大家同时释放、同时重试,还可能出现活锁,需要退避等策略。

严格区分的话,破坏必要条件属于“死锁预防”;银行家算法这种每次分配前检查系统是否安全的做法,才属于“死锁避免”。已经发生死锁,则要通过检测和恢复处理,比如终止某个任务或回滚事务。

# 死锁、活锁和饥饿有什么区别?

  • 死锁:几个任务相互等待对方持有的资源,谁也无法继续。
  • 活锁:任务没有阻塞,一直在重试或调整,但始终没有实际进展。比如两个人迎面走来,同时往左让、又同时往右让,一直过不去。
  • 饥饿:系统整体还在运行,但某个任务长期拿不到 CPU 或锁。比如高优先级任务不断到来,低优先级任务一直得不到执行。

对应的处理思路也不同:死锁可以通过统一加锁顺序来预防,活锁可以通过随机退避减少同步重试,饥饿可以通过公平队列或提高等待任务的优先级来缓解。

# 讲一下银行家算法

银行家算法会在分配资源前检查,分配后是否仍然存在一个让所有进程都能顺利完成的安全序列。如果找不到,就暂缓这次分配。

银行家算法是最有代表性的避免死锁的算法。

为什么叫银行家算法呢?就是这个算法的逻辑很像银行放贷的逻辑,也就是尽可能避免坏账的出现。

银行家算法的业务逻辑如下。

  • 总量约束:一个进程的最大需求量不能超过系统拥有的总资源数,否则不会被接纳执行。
  • 可分期请求:一个进程可以分期请求资源,但已持有的数量加上本次请求,不能超过事先声明的最大需求量。
  • 推迟分配:如果资源不足,或者试分配后会进入不安全状态,就让进程等待,不能只看当前剩余资源够不够。

判断时主要使用三个量:Available 表示剩余可用资源,Allocation 表示已经分配给进程的资源,Need = Max - Allocation 表示进程还可能需要的资源。

检查新请求时,先判断请求有没有超过 Need,以及当前 Available 能否满足,再在副本上试分配并进行安全性检查。安全性检查是在模拟,不是真的要求进程按安全序列逐个执行。

听起来有点绕,我们还是举个例子来说明。

假如系统中有三类互斥资源 R1、R2、R3,总资源数分别是 9、8、5,在指定时刻有 P1、P2、P3、P4 和 P5 这五个进程,它们的最大需求量和已分配资源数如下表所示,那么能否找到安全序列?

进程 最大需求量(分别为R1 R2 R3) 已分配资源数(分别为R1 R2 R3)
P1 6 5 2 1 2 1
P2 2 2 1 2 1 1
P3 8 1 1 2 1 0
P4 1 2 1 1 2 0
P5 3 4 4 1 1 3

第一步:分析

先计算剩余需求,系统剩余可用资源数分别是 2、1、0,各进程还需要的资源数如下表所示。

资源 R1 的剩余可用资源数 = 9 - 1 - 2 - 2 - 1 - 1 = 2。

资源 R2 的剩余可用资源数 = 8 - 2 - 1 - 1 - 2 - 1 = 1。

资源 R3 的剩余可用资源数 = 5 - 1 - 1 - 0 - 0 - 3 = 0。

进程 最大需求量 已分配资源数 剩余需要的资源数
P1 6 5 2 1 2 1 5 3 1
P2 2 2 1 2 1 1 0 1 0
P3 8 1 1 2 1 0 6 0 1
P4 1 2 1 1 2 0 0 0 1
P5 3 4 4 1 1 3 2 3 1

此时 Available = (2, 1, 0),只有 P2 的剩余需求 (0, 1, 0) 不超过 Available,所以可以先在安全性检查中模拟 P2 完成。

第二步:执行 P2

模拟 P2 获得剩余所需的 (0, 1, 0) 并执行完后,会释放全部持有的资源。因此可用资源的净增加量,正好是它原本持有的 (2, 1, 1)。

资源 R1 的剩余可用资源数 = 原资源数 - 执行 P2 消耗数 + P2 执行完释放的资源数 = 2 - 0 +(2 + 0) = 4。

资源 R2 的剩余可用资源数 = 原资源数 - 执行 P2 消耗数 + P2 执行完释放的资源数 = 1 - 1 + (1 + 1) = 2。

资源 R3 的剩余可用资源数 = 原资源数 - 执行 P2 消耗数 + P2 执行完释放的资源数 = 0 - 0 +(0 + 1) = 1。

执行完成 P2 后,操作系统剩余可用资源数为 4 2 1。

进程 最大需求量 已分配资源数 剩余需要的资源数
P1 6 5 2 1 2 1 5 3 1
P2 完成 完成 完成
P3 8 1 1 2 1 0 6 0 1
P4 1 2 1 1 2 0 0 0 1
P5 3 4 4 1 1 3 2 3 1

第三步:执行 P4

此时操作系统剩余可用资源数为 4 2 1,只能执行进程 P4,因为其他进程资源不够。

模拟 P4 获得 (0, 0, 1) 并执行完后,可用资源的净增加量是它原本持有的 (1, 2, 0)。

资源 R1 的剩余可用资源数 = 原资源数 - 执行 P4 消耗数 + P4 执行完释放的资源数 = 4 - 0 +(1 + 0) = 5。

资源 R2 的剩余可用资源数 = 原资源数 - 执行 P4 消耗数 + P4 执行完释放的资源数 = 2 - 0 + (2 + 0) = 4。

资源 R3 的剩余可用资源数 = 原资源数 - 执行 P4 消耗数 + P4 执行完释放的资源数 = 1 - 1 +(0 + 1) = 1。

执行完成 P4 后,操作系统剩余可用资源数为 5 4 1。

进程 最大需求量 已分配资源数 剩余需要的资源数
P1 6 5 2 1 2 1 5 3 1
P2 完成 完成 完成
P3 8 1 1 2 1 0 6 0 1
P4 完成 完成 完成
P5 3 4 4 1 1 3 2 3 1

第四步:执行 P5

此时剩余可用资源数为 (5, 4, 1),P1 和 P5 的剩余需求都能满足。这里选择 P5 继续推演,选择 P1 也可以找到安全序列。

P5 执行之后,释放了刚刚放入的 2 3 1 资源,而且可以释放已分配的 1 1 3 资源,所以此时的资源剩余量。

资源 R1 的剩余可用资源数 = 原资源数 - 执行 P5 消耗数 + P5 执行完释放的资源数 = 5 - 2 +(1 + 2) = 6。

资源 R2 的剩余可用资源数 = 原资源数 - 执行 P5 消耗数 + P5 执行完释放的资源数 = 4 - 3 + (1 + 3) = 5。

资源 R3 的剩余可用资源数 = 原资源数 - 执行 P5 消耗数 + P5 执行完释放的资源数 = 1 - 1 +(3 + 1) = 4。

执行完成 P5 后,操作系统剩余可用资源数为 6 5 4。

进程 最大需求量 已分配资源数 剩余需要的资源数
P1 6 5 2 1 2 1 5 3 1
P2 完成 完成 完成
P3 8 1 1 2 1 0 6 0 1
P4 完成 完成 完成
P5 完成 完成 完成

第五步:执行 P1 或者 P3

此时操作系统剩余可用资源数为 6 5 4,可以执行 P1 或 P3。

所以可以找到安全序列 P2 => P4 => P5 => P1 => P3 或 P2 => P4 => P5 => P3 => P1,这些是可行的例子,不代表安全序列只有这两种。

img

或

img

银行家算法总结

银行家算法的核心思想,就是在分配资源前,判断试分配后的整个系统是否仍然安全。即使当前资源足够满足某个请求,如果分配后找不到安全序列,也不能直接批准。

安全性检查时,用 Work 记录模拟的可用资源,初值为 Available。反复寻找 Need 不超过 Work 的进程,假设它完成,然后把它原本持有的 Allocation 加回 Work,看最终能否让所有进程都完成。找不到这样的进程而又有进程未完成,就说明这个状态不安全。

需要注意:安全状态存在避免死锁的完成顺序,不安全状态也不一定已经死锁,只是不再能保证所有进程按最大需求申请资源时都能顺利完成。

银行家算法需要提前知道各个进程的最大资源需求,而且每次申请都要进行检查,所以理解它的安全性思想很有价值,但实际系统不一定直接采用它。

# 乐观锁和悲观锁有什么区别?

乐观锁:

  • 基本思想:乐观锁假设冲突比较少,先读取数据并计算新值,再通过版本号或 CAS 等方式检查数据有没有被其他任务修改。检查和更新必须是一个原子过程,不能先检查通过,再单独执行一次无保护的写入。
  • 使用场景:适合冲突较少、失败后可以重试的场景。比如更新数据库记录时,把旧版本号放进更新条件,成功后同时增加版本号,更新失败就重新读取或报告冲突。

悲观锁:

  • 基本思想:悲观锁认为访问共享数据时需要先获得锁,再执行受保护的操作,完成后释放。它不要求所有普通读取都加锁,而是对需要保证互斥或一致性的访问加锁。
  • 使用场景:适合竞争较强,或者重试成本较高的场景。比如使用互斥锁保护共享结构,或者在事务中用行锁保护需要连续修改的数据。

关键要看冲突概率和失败后的重试成本。乐观方式在高冲突下可能不断重试,浪费 CPU;悲观方式则可能有阻塞、唤醒和死锁等开销,不能只凭读写数量决定用哪一种。

# 内存管理

# 介绍一下操作系统内存管理

操作系统设计了虚拟内存,每个进程都有自己的独立虚拟地址空间,我们所写的程序通常不会直接与物理内存打交道。

img

有了虚拟内存之后,它带来了这些好处:

  • 第一,进程可以使用比物理内存更大的虚拟地址空间,不必把所有页面同时放进物理内存。程序运行符合局部性原理,可以只保留当前常用的页面,文件页按需重新读取,不常用的匿名页在启用 Swap 时可以换出。不过,这不代表实际可用内存没有限制,频繁换入换出还会明显拖慢程序。
  • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
  • 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。

Linux 主要通过分页来管理内存,分页是把虚拟和物理内存空间划分成固定大小的页和页框。常见 x86 Linux 的普通页大小是 4KB,但具体大小与架构和配置有关,也可以使用更大的页。

虚拟地址与物理地址之间通过页表来映射,如下图:

img

页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。

当进程访问的页面尚未驻留,或者访问违反页表记录的权限时,CPU 会产生缺页异常。内核会判断这次访问是否合法:合法的按需分配、换入或写时复制可以处理后重试;非法地址或真正的权限错误,则可能向进程发送 SIGSEGV。

# 什么是虚拟内存和物理内存?

  • 虚拟内存:是操作系统提供给进程的地址空间抽象,程序通常使用虚拟地址访问数据。通过页表等机制,一部分虚拟地址映射到物理内存,其他区域可以尚未分配,或者对应文件、Swap 等后备存储。它让进程获得独立的地址空间,并支持按需加载和访问保护,不能把它简单理解成“内存加硬盘”。
  • 物理内存:物理内存是计算机实际存在的内存,是计算机中的实际硬件部件。

# 讲一下页表?

页表记录了虚拟页到物理页框的映射,以及读写权限、是否驻留等属性。常见 x86 Linux 的普通页大小是 4KB,也支持大页,不能认为所有 Linux 系统的页大小都固定为 4KB。

虚拟地址与物理地址之间通过页表来映射,如下图:

img

页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。

如果页表项表明页面没有驻留,或者当前操作不满足访问权限,就会触发缺页异常,由内核判断是否可以通过分配页面、加载文件或写时复制等方式解决。并不是每次缺页都要读磁盘,也不是每次都能恢复执行。

基本分页按固定大小的页框分配,连续的虚拟页可以映射到不连续的物理页框,不需要为了一个连续的虚拟地址区间找到一整块连续物理内存,因此避免了分段连续分配带来的外部碎片问题。不过,内核申请大块连续物理内存时,仍然可能受到物理内存碎片的影响。

但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。

在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。

img

总结一下,对于一个内存地址转换,其实就是这样三个步骤:

  • 把虚拟内存地址,切分成页号和偏移量;
  • 根据页号,从页表里面,查询对应的物理页号;
  • 用物理页号乘以页大小,得到物理页框的基地址,再加上页内偏移量,就得到了物理地址。

下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:

img

# 讲一下段表?

分段机制会按照代码、数据、栈等逻辑区域划分地址空间,段的数量不是固定为 4 个。虚拟地址由段号和段内偏移组成,每个段在段表中有一个表项,记录段基地址、段长度和权限等信息。

先根据段号查段表,检查偏移是否越界、权限是否满足,再把段基地址加上段内偏移,就得到物理地址。下面这张图是一个分段的例子:

img

如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

分段按逻辑划分,段长可变,方便表达保护和共享关系,但要求段内连续,容易产生外部碎片;分页按固定大小划分,主要解决内存分配和按需加载的问题。

# 虚拟地址是怎么转化到物理地址的?

虚拟地址转化为物理地址是通过内存管理单元(Memory Management Unit,MMU)来完成的。MMU是计算机系统中的硬件组件,负责虚拟地址和物理地址之间的转换。

在虚拟地址转换的过程中,通常会使用页表(Page Table)来进行映射。页表是一种数据结构,它将虚拟地址空间划分为固定大小的页(Page),对应于物理内存中的页框(Page Frame)。每个页表项(Page Table Entry)记录了虚拟页和物理页的对应关系。

当程序访问一个虚拟地址时,MMU会将虚拟地址分解为页号和页内偏移量。然后,MMU会查找页表,根据页号找到对应的页表项。页表项中包含了物理页的地址或页框号。最后,MMU将物理页的地址与页内偏移量组合,得到对应的物理地址。

虚拟地址转化为物理地址的过程中,还可能涉及到多级页表、TLB(Translation Lookaside Buffer)缓存等机制,以提高地址转换的效率。

image-20240725233041537

# 为什么要用多级页表?

主要是为了减少稀疏地址空间下页表占用的内存。

举个例子,32 位虚拟地址空间有 4GB,如果页大小是 4KB,那么就有 2^20 个虚拟页。假设每个页表项占 4 字节,单级页表就要占用 4MB,即使进程只用了很小一部分地址空间,也要为整张表留出位置。

多级页表把这张大表拆开,只为实际使用的地址范围分配下一级页表。没有使用的一大片地址空间,就不需要建立对应的下级页表。

代价是地址转换时,可能需要沿着多级页表查询,增加访存次数。为了解决这个问题,CPU 会用 TLB 缓存近期使用的地址映射。

# TLB 是什么?TLB 未命中一定会发生缺页异常吗?

TLB 可以理解成地址转换的高速缓存,缓存最近使用过的虚拟页到物理页框的映射及权限等信息。

访问内存时,CPU 通常先查 TLB,命中后就不用再逐级查询页表;未命中时,再查页表,并把有效映射填入 TLB。

TLB 未命中不等于缺页。

  • TLB 未命中:只是这条映射没有缓存在 TLB 里,页表中的映射可能完全有效。
  • 缺页异常:页表检查发现页面未驻留,或者操作不符合权限,需要内核介入处理。

以常见 x86 系统为例,TLB 未命中后的页表遍历可以由硬件完成,不一定进入内核。进程切换时,地址空间标识等机制也能减少 TLB 失效的成本,不能简单说每次切换都会清空全部 TLB。

# 缺页异常是怎么处理的?一定会访问磁盘吗?

**缺页异常不一定是程序出错,也不一定需要读取磁盘。**它可以是按需分配内存、加载文件或写时复制的正常过程。

处理过程大致是:

  1. CPU 保存现场并进入内核,内核根据出错地址和访问类型检查对应的虚拟内存区域。
  2. 如果地址合法、权限允许,就按情况分配新页面、从文件或 Swap 读取页面,或者处理写时复制。
  3. 更新页表和相关地址转换缓存,恢复现场,重新执行触发异常的指令。
  4. 如果地址没有合法映射,或者确实不允许这次访问,通常向进程发送 SIGSEGV;某些文件映射错误可能产生 SIGBUS。

比如第一次写入新申请的匿名内存,可能只需要分配一个清零的物理页;文件页已经在 Page Cache 中时,也可以直接建立映射。这些通常属于次缺页(minor fault),不需要从磁盘加载页面。

如果所需页面必须从文件或 Swap 等后备存储读取,就属于主缺页(major fault),通常开销大得多。

# 程序的内存布局是怎么样的?

image-20240725233029022

通过这张图你可以看到,用户空间内存,从低到高分别是 6 种不同的内存段:

  • 代码段,包括二进制可执行代码;
  • 数据段,通常包括已初始化且非零的全局变量和静态变量,只读常量通常放在只读数据区域;
  • BSS 段,通常包括未显式初始化或初始化为零的全局变量和静态变量;
  • 堆段,包括动态分配的内存,从低地址开始向上增长;
  • 文件映射段,包括动态库、共享内存等;
  • 栈段,包括函数调用的上下文和需要放在栈上的局部变量等。栈有大小限制,部分 Linux 环境下主线程的默认限制是 8 MB,但不是所有线程、所有系统都固定为这个值;

上图中的内存布局可以看到,代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。

这张图是典型布局,实际地址还会受架构、加载方式和 ASLR(地址空间布局随机化)等影响。malloc 返回的内存可能来自堆,也可能来自匿名 mmap 映射,不能仅凭调用 malloc 就认定它一定在图里的堆段。

# 堆和栈的区别?

  • 分配方式:堆中的内存通常通过 malloc、new 等动态申请;栈空间则随着函数调用建立和回收,通常通过调整栈指针完成,不能把它简单理解成静态分配。
  • 内存管理:C/C++ 中堆内存通常由程序或智能指针等机制管理,带垃圾回收的语言则可以由运行时回收。栈上的函数调用信息通常随函数返回自动回收,所以不能在函数返回后继续使用指向其局部变量的指针。
  • 大小和速度:堆通常比栈大,内存空间较大,动态分配和释放内存需要时间开销。栈大小有限,通常比较小,内存分配和释放速度较快,因为是编译器自动管理。

# fork()会复制哪些东西?

fork 会创建一个新的子进程,继承父进程的大部分运行环境,但不是把所有资源都完整复制一遍。

  • 进程管理信息:创建子进程的内核管理结构,分配新的 PID,继承工作目录、权限等属性。
  • 虚拟地址空间:建立子进程的地址空间和页表。普通私有可写页通常通过写时复制共享物理页,等父进程或子进程实际修改时再按需分离。
  • 文件描述符:子进程继承文件描述符表中的引用,但对应的内核打开文件描述通常与父进程共享,所以文件偏移等状态也可能共享。
  • 执行状态:子进程从 fork 返回的位置继续执行,父进程得到子进程 PID,子进程得到 0;如果父进程有多个线程,子进程只保留调用 fork 的线程。

因此,fork 本身仍然有建立管理结构和复制页表等成本。父进程地址空间很大时,即使没有立即复制全部物理内存,fork 也可能带来延迟。

# 介绍copy on write(写时复制)

父进程执行 fork 的时候,操作系统会为子进程建立独立的地址空间,复制相关「页表」信息。对于普通私有页面,父子进程可以先通过各自的页表指向同一个物理页,而不立即复制页面里的数据。

img

这样一来,父子进程暂时共享物理页,能够节约物理内存资源。对于原本可写的私有页,父子进程对应的页表项都会被写保护,内核同时保留这块虚拟区域允许写入的信息。

当父进程或子进程尝试写入这些页面时,CPU 会触发写保护导致的缺页异常。内核发现这是合法的写时复制访问后,通常会分配新物理页,复制原页的数据,再把发生写入的那个进程的页表项改为指向新页,并恢复它的写权限,最后重新执行写操作。

另一方仍然指向原来的物理页,不能理解成一次写入就把父子进程的所有页都改成可写。若原物理页已经没有其他共享者,内核也可能直接恢复写权限,不必再复制。

写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

这里讨论的是普通私有映射。显式建立的 MAP_SHARED 共享映射,会保持共享语义,不会因为 fork 后写入就自动变成两份独立数据。

# copy on write节省了什么资源?

主要节省了物理内存,以及立即复制大量数据的 CPU 时间。父子进程暂时共享普通私有页面的物理内存,不必在 fork 时就把全部数据复制一份,但仍然需要创建子进程结构和建立页表。

之后发生写入时,才按需分离涉及的页面。如果子进程很快就调用 exec 执行新程序,旧地址空间里大量没有修改过的页面,可能从头到尾都不需要复制。

# malloc 1KB和1MB 有什么区别?

首先,malloc 会优先考虑分配器已经管理的空闲内存,不是每调用一次就执行一次系统调用。

以 glibc 的常见分配策略为例,需要向内核申请新空间时:

  • 1KB 这样的小块内存:通常从已有的缓存或 arena 中分配,主 arena 空间不足时可能通过 brk 扩展堆。
  • 1MB 这样的大块内存:在常见的初始阈值下,可能直接使用匿名 mmap 建立独立映射,释放时也更容易单独归还给操作系统。

经常提到的 128KB 是 glibc mmap 阈值的常见初始值,但阈值可能动态调整,也能通过配置修改,还会受空闲块和 arena 等因素影响。不能只看申请大小,就断言 1KB 一定走 brk、1MB 一定走 mmap。

# 介绍一下brk,mmap

实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。

以 glibc 为例,分配器需要新地址空间时,常见的方式有两种。

  • 方式一:通过 brk() 系统调用从堆分配内存
  • 方式二:通过 mmap() 系统调用在文件映射区域分配内存;

方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:

img

方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:

img

两者的区别是:brk 调整连续堆区域的边界,mmap 可以建立独立的映射区域,并通过 munmap 解除映射。mmap 还可以映射文件,不只是 malloc 申请匿名内存的工具。

# malloc 申请成功,就一定分配了物理内存吗?

不一定。malloc 返回成功,首先说明得到了一块可使用的虚拟地址范围。

如果这块内存来自分配器已经使用过的空间,对应物理页可能早已存在;如果来自新扩展的匿名区域,Linux 通常采用按需分配,在程序真正访问时,通过缺页异常建立物理页映射。

比如申请一大块内存后只写其中一页,通常只需要为访问到的页面提供物理内存,并不一定立即为整块区域分配全部物理页。首次读取匿名页还可能先映射到共享的只读零页,写入时再分配独立页面。

Linux 的 overcommit 策略还会影响申请是否被接受。所以,malloc 成功不保证后续访问所有页面时都不会遇到内存不足,它也可能在申请阶段就返回 NULL。

# free 之后,内存一定会还给操作系统吗?

不一定,free 首先是把这块内存交还给分配器,让后续 malloc 可以复用。

  • 小块内存通常会留在分配器的缓存或空闲链表里,并不会每次都向内核归还。
  • brk 管理的堆区域要缩小边界,通常要求堆顶部有可释放的连续空闲空间,堆中间的一小块不能直接通过降低堆顶来归还。
  • 对于单独通过 mmap 分配的大块内存,释放时通常可以通过 munmap 解除映射,归还资源。

所以看到 free 后进程的 RSS 没有马上下降,不能直接认定有内存泄漏,还要看分配器是否保留了可复用空间。反过来,free 后原指针就不能再使用,不管内存有没有归还内核,继续访问都属于错误用法。

# 内存泄漏和内存溢出有什么区别?

  • 内存泄漏:已经不再需要的内存没有被正确释放,或者仍然被无用的引用保留,导致无法回收。
  • 内存溢出:在这里指 OOM,也就是申请或使用内存时,超过了当前可提供的内存或资源限制。

比如每次处理请求都申请一块内存,却忘记释放,运行越久占用越大,这是泄漏;一次加载一个特别大的文件,直接超过进程可用内存,即使没有泄漏,也可能 OOM。

所以,泄漏可能积累成 OOM,但 OOM 不一定由泄漏导致。还要区分数组越界、缓冲区溢出这类内存安全问题,它们与“内存不够用”不是同一种问题。

# 操作系统内存不足的时候会发生什么?

应用程序通过 malloc 申请的是可用的虚拟地址范围。对于新申请且尚未建立物理映射的匿名页面,物理内存通常在实际访问时按需提供;如果复用了已有页面,则不一定再走分配流程。

当应用程序访问尚未建立物理映射的合法页面时,CPU 会产生缺页异常(也常被称为缺页中断),进入内核的 Page Fault Handler 处理。对于需要新物理页的情况,内核会尝试分配页面。

如果有合适的空闲物理页,就可以分配并建立映射,再恢复程序执行。

如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。

  • 后台内存回收(kswapd):在空闲内存低于一定水位时,唤醒 kswapd 内核线程回收内存。回收不由申请内存的线程同步完成,但仍会消耗 CPU 和 I/O 资源,也可能间接影响应用延迟。
  • 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。

如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。

OOM Killer 会在允许选择的任务范围里,根据内存占用、oom_score_adj 等因素选择进程终止,以释放资源,不一定直接杀死占用内存最多的进程。有些分配也可能直接失败,具体取决于分配方式和系统配置。

容器还可能因为达到 cgroup 的内存限制触发 OOM,即使宿主机仍有剩余内存,也不代表容器可以继续申请。

申请物理内存的过程如下图:

img

系统内存紧张的时候,就会进行回收内存的工作,那具体哪些内存是可以被回收的呢?

主要有两类内存可以被回收,而且它们的回收方式也不同。

  • 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。
  • 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

Linux 回收内存时,会参考页面的访问活跃程度,优先考虑较冷的页面。传统实现采用近似 LRU 思路,并不是每次访问内存都精确维护一次全局排序。可以用 active 和 inactive 两类链表来理解,其中:

  • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
  • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

回收时通常先扫描不活跃的页面,再结合访问标记等信息判断是否回收。较新的内核也支持多代 LRU(MGLRU),按访问的新旧程度组织页面,具体使用哪种实现取决于内核版本和配置。

另外,Swap 没有启用或空间不足时,普通匿名页不能靠换出释放物理内存,内存压力会更难缓解。

# 页面置换有哪些算法?

页面置换算法的功能是,需要新页面但可用物理内存不足时,选择可以回收或换出的页面。并不是每次淘汰都要写磁盘,比如干净的文件缓存页就可以直接丢弃。

那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:

  • 最佳页面置换算法(OPT)
  • 先进先出置换算法(FIFO)
  • 最近最久未使用的置换算法(LRU)
  • 时钟页面置换算法(Clock)
  • 最不常用置换算法(LFU)

最佳页面置换算法

最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。

所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。

我们举个例子,假设一开始有 3 个空闲的物理页,然后有请求的页面序列,那它的置换过程如下图:

最佳页面置换算法

在这个请求的页面序列中,缺页共发生了 7 次(空闲页换入 3 次 + 最优页面置换 4 次),页面置换共发生了 4 次。

这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。

所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。

先进先出置换算法

既然我们无法预知页面下一次会在什么时候被访问,那可以选择最早进入内存的页面进行置换,这个就是「先进先出置换」算法的思想。

还是以前面的请求的页面序列作为例子,假设使用先进先出置换算法,则过程如下图:

先进先出置换算法

在这个请求的页面序列中,缺页共发生了 10 次,页面置换共发生了 7 次,跟最佳页面置换算法比较起来,性能明显差了很多。

最近最久未使用的置换算法

最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。

这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。

还是以前面的请求的页面序列作为例子,假设使用最近最久未使用的置换算法,则过程如下图:

最近最久未使用的置换算法

在这个请求的页面序列中,缺页共发生了 9 次,页面置换共发生了 6 次,跟先进先出置换算法比较起来,性能提高了一些。

虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。

困难的是,严格 LRU 需要跟踪每一次内存访问并更新页面顺序。即使移动一个已知链表节点可以做到 O(1),在每次内存访问时维护这些额外信息,成本仍然很高。

所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。

时钟页面置换算法

那有没有一种既能减少置换次数,也方便实现的算法呢?

时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进。

该算法的思路是,把候选页面组织成一个类似钟面的环,一个表针指向下一个要检查的页面。它不是严格按“最老”排序,而是通过访问位给近期访问过的页面一次机会。

当发生缺页中断时,算法首先检查表针指向的页面:

  • 如果它的访问位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
  • 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;

我画了一幅时钟页面置换算法的工作流程图,你可以在下方看到:

时钟页面置换算法

了解了这个算法的工作方式,就明白为什么它被称为时钟(Clock)算法了。

最不常用算法

最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。

它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。

看起来很简单,每个页面加一个计数器就可以实现了,但是在操作系统中实现的时候,我们需要考虑效率和硬件成本的。

要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。

但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。

这个问题可以通过定期衰减访问计数来缓解,比如周期性地把历史计数除以 2。也就是说,随着时间推移,过去的高访问频率会逐渐失去优势,避免长期保留已经不常用的页面。

# 文件系统

# 文件描述符和 inode 有什么区别?

文件描述符是进程访问已打开文件的入口,inode 则记录文件本身的元数据。

  • 文件描述符(fd):是一个非负整数,用来索引进程的文件描述符表。通常 0、1、2 分别表示标准输入、标准输出和标准错误。不同进程里的同一个 fd 数字,不一定指向同一个文件。
  • 打开文件描述:是内核管理的一次打开状态,记录文件偏移、打开状态标志等。文件描述符通过引用指向它,dup 或 fork 得到的描述符可能共享同一个打开文件描述。
  • inode:记录文件类型、权限、大小、链接数,以及数据块的定位信息等。文件名通常保存在目录项里,不在 inode 里。

可以理解成:fd 找到打开状态,打开状态再关联到文件的 inode。

比如同一个进程对同一文件调用两次 open,通常会得到两个独立的打开文件描述,读写偏移分别维护;而用 dup 复制 fd,两个 fd 共享偏移,操作其中一个就可能影响另一个。

# 硬链接和软链接有什么区别?

  • 硬链接:给同一个 inode 增加一个目录项,也就是一个文件可以有多个名字。这几个名字地位相同,不存在某个必然是“原文件”。删掉其中一个,只是减少链接数,其他名字仍然可以访问数据。
  • 软链接:创建一个独立的文件,内容是目标路径。访问软链接时,系统再根据这个路径找目标,目标被删除或路径改变后,软链接就可能失效。

硬链接通常不能跨文件系统,也通常不允许给目录创建硬链接;软链接可以指向其他文件系统里的路径,也可以指向目录,甚至可以在目标还不存在时创建。

举个例子,文件 A 和 B 是硬链接,那么修改 A,通过 B 也能看到,因为它们指向同一份数据;如果 B 是指向 A 的软链接,删除 A 后,B 就成了无法找到目标的链接。

# 文件已经删除了,为什么磁盘空间还没有释放?

因为删除文件名,不一定意味着文件数据马上被释放。

Linux 中的 unlink 主要是删除目录项,并减少 inode 的硬链接数。只有没有其他硬链接,也没有进程等继续引用这个文件时,相应数据空间才能被回收。

举个例子,服务进程一直打开某个日志文件,你用 rm 删除它,文件路径虽然消失了,但进程仍然可以通过已有 fd 继续写入,磁盘空间也就还占着。

这时可以用 lsof +L1 排查已经删除但仍被进程打开的文件,再让服务按自身的日志轮转机制关闭旧 fd、重新打开日志文件。

另外,如果还有其他硬链接、文件映射或文件系统快照等引用,也可能让空间不能马上释放。所以排查时要看是谁还在引用数据,不能只看目录里有没有文件名。

# Page Cache 是什么?有什么作用?

Page Cache 是内核用来缓存文件数据的内存,可以减少重复访问磁盘的开销。

普通文件的缓冲读取,通常先查 Page Cache。命中就把缓存里的数据复制到用户缓冲区;没有命中,再从存储设备读取文件内容,放进 Page Cache 后供程序使用。

缓冲写入则通常先修改 Page Cache,把对应页面标记成脏页,后续由内核回写,或者由程序主动同步。

所以,Page Cache 主要带来两个好处:

  • 读缓存:重复读取相同内容时,不必每次访问磁盘。
  • 写缓冲:可以合并、批量提交写入,提高吞吐量,但也意味着要考虑持久化时机。

内存紧张时,干净的缓存页通常可以回收,需要时再读回来;脏页一般要先写回。看到 Linux 的 free 内存很少,不能马上认定内存不够,还要结合可回收缓存和 available 内存来看。

# write 返回成功,就代表数据已经落盘了吗?

不一定。普通缓冲 I/O 下,write 成功通常表示内核接受了对应数量的数据,数据可能还在 Page Cache 里。

如果这时突然断电,还没同步到持久存储的数据可能丢失。需要持久化保证时,要使用合适的同步接口,并检查返回结果:

  • fsync:同步文件数据和相关元数据。
  • fdatasync:同步文件数据,以及读取这些数据所必需的元数据,减少部分不必要的元数据同步。
  • O_SYNC/O_DSYNC:在打开文件时指定相应的同步写语义。

还有两个容易漏掉的点:write 可能只写入部分数据,需要按返回的字节数继续写;如果是新建文件或 rename 后需要保证目录项也能在掉电后保留,通常还需要同步对应目录。

printf、fwrite 等库函数还可能先写用户态缓冲区,fflush 只是把这层缓冲提交给内核,不能代替 fsync 的持久化作用。同步保证也依赖文件系统和存储设备正确执行请求。

# mmap 读文件和 read 读文件有什么区别?

read 把文件数据读到程序提供的缓冲区,mmap 则把文件映射到进程的虚拟地址空间,让程序像访问内存一样访问文件。

普通缓冲 read 通常需要把 Page Cache 中的数据复制到用户缓冲区;文件 mmap 则可以让进程的虚拟页直接映射到对应的文件缓存页,避免这一次复制。

不过,mmap 并不是建立映射时就读完全部文件。首次访问尚未映射的页面时,可能触发缺页异常,由内核建立映射或加载内容;它也有缺页处理、页表和映射管理的开销,所以不能认为一定比 read 快。

写入行为还要看映射方式:

  • MAP_SHARED:修改可以反映到文件并被其他共享映射观察到,但可见性不等于已经落盘,仍要根据需要使用 msync 等接口同步。
  • MAP_PRIVATE:修改采用写时复制,不会通过这份映射写回原文件。

因此,mmap 适合某些随机访问、重复访问或共享数据场景,read 则有更直接的显式读写和错误处理方式,具体性能要结合访问模式判断。

# 中断

# 什么是中断?

**中断可以理解成 CPU 收到一个事件通知,暂时放下当前任务,先去处理这个事件。**处理完成后,通常可以恢复原来的执行过程。

比如网卡收到数据包后通知 CPU,CPU 就进入内核处理接收事件;时钟中断则可以让内核统计运行时间,并在需要时触发调度。

严格来说,还要区分外部中断和异常:

  • 外部中断:来自 CPU 外部的设备或中断控制器,与当前正在执行的指令通常没有直接关系,是异步事件。
  • 异常:由当前指令的执行触发,是同步事件,比如除零、缺页或非法指令。

一些教材把异常也称为“内部中断”。按 x86 的处理方式,异常还可以分为故障(fault)、陷阱(trap)和终止(abort):故障修复后通常重试当前指令,陷阱处理后通常从下一条指令继续,终止则通常无法可靠恢复。

不过,进入内核不一定都是通过中断指令。比如 x86-64 Linux 的系统调用通常走 syscall,它有专门的进入和返回机制。

# 讲讲中断的流程

以设备发出的外部中断为例,处理流程大致是:

  1. 设备发出请求:设备通过中断控制器等机制通知 CPU,CPU 在允许响应时处理中断,通常先完成当前指令,而不是把指令执行到一半随意丢下。
  2. 保存现场并找到入口:硬件保存必要的返回信息,根据中断向量找到处理入口;内核入口代码再保存需要的其他寄存器。若中断发生在用户态,就进入内核态。
  3. 执行中断处理程序:确认事件来源,完成必要的设备操作。耗时较长的工作可以交给后续的软中断或工作队列处理,避免长时间占用中断处理路径。
  4. 结束处理并返回:完成相应的确认和收尾,恢复现场。如果需要且当前上下文允许调度,可能先切换到其他任务;否则返回被打断的任务。

所以,中断处理并不等于一定切换到另一个进程。很多中断只是暂时打断当前线程,处理完后仍然继续执行它。

# 中断的类型有哪些?

按事件来源,可以分为外部中断和异常;外部中断又常分为可屏蔽中断和不可屏蔽中断。

  • 可屏蔽中断:CPU 可以在某些临界阶段暂时屏蔽响应,常见于网卡、磁盘、时钟等事件。可屏蔽不代表“不重要”或“可以一直不处理”,长期不响应同样会影响系统运行。
  • 不可屏蔽中断(NMI):不受普通中断屏蔽标志控制,常用于硬件错误报告、看门狗等场景。它不代表每次发生都必然导致系统崩溃,能否恢复要看具体事件。
  • 异常:与当前执行的指令有关,比如缺页、除零、调试断点。缺页可以是正常的按需加载,也可能是非法访问,不能把所有异常都当成不可修复的错误。

传统 x86 可以用 INTR、NMI 信号来理解中断输入,现代系统还会通过 APIC、消息信号中断等机制传递请求,并不是所有设备都只靠两根引脚通知 CPU。

另外,软件执行 int 指令产生的中断,与 Linux 内核里的 softirq(软中断)是两个概念,不要因为名字相似就混在一起。

# 中断的作用是什么?

中断让 CPU 不必一直轮询设备,而是在事件发生时及时处理,提高了 CPU 的利用率和系统的响应能力。

比如磁盘正在读取数据时,CPU 可以先执行其他任务,等设备完成后再通过中断通知内核;时钟中断也为时间统计和抢占式调度提供了基础。

不过,中断也有保存现场、进入处理程序等开销。事件非常密集时,频繁中断可能占用大量 CPU,所以高性能网络处理还会结合中断合并、批量处理或轮询等方式。

# 硬中断、软中断和工作队列有什么区别?

硬中断先处理紧急且必要的工作,软中断和工作队列再承担可以推后的工作。

  • 硬中断:响应设备事件,处理时间应尽量短,不能调用可能睡眠的操作。
  • 软中断(softirq):内核的一种延后处理机制,常用于网络收发等高频工作。可以在中断返回路径等位置处理,工作较多时也可能由 ksoftirqd 内核线程承接,但处理函数仍然不能随意睡眠。
  • 工作队列(workqueue):把任务交给工作线程,在进程上下文执行,允许使用可以睡眠的操作,适合需要等待或执行较重工作的场景。

举个例子,网卡中断先确认设备事件,后续网络数据处理再交给软中断等机制完成。这样能减少硬中断处理时间,让 CPU 更快恢复其他工作。

# 网络 i/o

# 你了解过哪些io模型?

理解网络 I/O 模型,可以先把一次接收操作分成两个阶段:等待数据就绪,以及把数据从内核交给用户程序。

  • 阻塞 I/O:调用 recv 后,如果没有数据,线程会等待;有数据后,内核把数据复制到用户缓冲区,再返回。
  • 非阻塞 I/O:没有数据时立即返回 EAGAIN 或 EWOULDBLOCK,程序可以先做其他事情。但当有数据可读时,仍然需要通过 recv 等操作获取数据,非阻塞不代表数据已经由后台自动读好了。
  • I/O 多路复用:通过 select、poll、epoll 等接口,同时等待多个 fd 的就绪事件,得到通知后再调用 read/recv 处理。等待就绪的调用本身可以阻塞,只是一个线程能够同时等多个连接。
  • 信号驱动 I/O:注册信号通知,数据就绪时内核发出信号,程序随后再调用读取接口。通知的是就绪,通常不是数据已经复制完成。
  • 异步 I/O:程序提交请求后继续做其他事情,内核或对应的 I/O 执行机制完成读取并把数据放到指定缓冲区后,再通知程序获取结果。

前四种通常归为同步 I/O,因为实际的数据读取仍由程序主动发起。多路复用解决的是“同时等哪些连接”,异步 I/O 解决的是“把整个操作提交后,等完成结果”。

# 阻塞、非阻塞和同步、异步有什么区别?

这两组概念关注的角度不同。

  • 阻塞和非阻塞:关注调用时,如果暂时不能完成,线程是等在那里,还是先返回。
  • 同步和异步:在前面介绍的 I/O 模型里,关注实际的数据传输由程序主动调用并等待结果,还是提交请求后由系统完成,再通知结果。

比如非阻塞 recv 没数据时马上返回 EAGAIN,但有数据时,程序仍然要调用 recv 把数据读进用户缓冲区,所以它属于同步 I/O。

而 epoll_wait 虽然可以阻塞等待事件,但一个线程可以同时等待很多连接。等到“可读”通知后仍要自己读取数据,所以 epoll 是 I/O 多路复用接口,不是异步读写接口。

异步这个词在不同语言或框架里还有自己的含义。看到 async/await,也不能直接断定底层一定使用了内核异步 I/O,它可能封装的是非阻塞 I/O 加事件循环。

# 服务器处理并发请求有哪几种方式?

常见方式有以下几种:

  • 单线程串行处理:一次处理一个请求,遇到慢请求就会拖住后面的请求,实现简单,但并发能力有限。
  • 多进程或多线程处理:让多个执行单元分别处理连接或请求,也可以用池复用执行单元。容易理解,但线程或进程过多时,内存和调度开销会增加。
  • 单线程事件循环配合 I/O 多路复用:一个线程监听多个连接,只处理就绪事件,避免为每个连接创建线程。不过,一旦执行耗时业务,其他连接的处理也会被延迟。
  • 多个事件循环,或事件循环配合工作线程池:用多个进程或线程承担网络事件,利用多核;较重的计算或阻塞操作可以交给工作线程,再把结果交回事件循环发送。

**连接数、请求数和线程数不是一一对应的。**一个连接可以发送多个请求,一个事件循环也可以管理很多连接。设计时要结合任务耗时、是否阻塞、CPU 核数和下游容量来选择。

# 讲一下io多路复用

I/O 多路复用,就是让一个线程通过一个接口,同时等待多个文件描述符的就绪事件,比如哪些连接有数据可读、哪些连接可以继续写入。

img

如果不用多路复用,一个线程阻塞在某个连接的 recv 上,就顾不上其他连接;或者只能为每个连接创建一个线程,连接多时开销很大。

有了多路复用,线程可以先等一批连接中的任意一个就绪,拿到结果后逐个处理,处理完再继续等待。因此可以用较少的线程管理大量连接,特别适合连接很多、但同一时刻只有少量连接活跃的场景。

不过,多路复用并没有代替 read/write,也不会自动执行所有业务代码。事件循环里的读写和业务处理仍然需要合理控制耗时,否则一个慢操作就可能拖住很多连接。

# select、poll、epoll 的区别是什么?

我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。

select/poll/epoll 都是先告诉内核关心哪些文件描述符及事件,再等待就绪结果,最后在用户态处理。区别是 select/poll 每次等待都要传入关注的集合,epoll 则把注册和等待拆开,在内核中持续维护关注列表。

select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。

select/poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 常见的用户态 fd_set 是固定大小的位图。Linux 上 glibc 的 FD_SETSIZE 通常是 1024,因此这套接口只能安全表示 0~1023 的 fd,限制的是 fd 的数值范围,不只是监听的连接数量。另外,select 返回时会修改这些集合,下次调用前需要重新准备。

poll 使用 pollfd 数组描述关注的 fd 和事件,没有 fd_set 的固定数值范围限制,但仍受到进程和系统资源限制。返回后,程序检查数组中每项的 revents,找到发生的事件。

但是 poll 和 select 的基本思路相近,每次调用都要处理整个关注集合,查找就绪事件通常需要线性扫描,也有用户态和内核态之间传递集合的开销。连接很多而活跃连接很少时,扫描成本会比较明显,但不能说复杂度呈指数级增长。

epoll

先复习下 epoll 的用法。下面是流程示意:先用 epoll_create1 创建 epoll 实例,再通过 epoll_ctl 添加需要监听的 Socket,最后调用 epoll_wait 等待就绪事件。

int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event); // 注册关注的 fd 和事件

while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        // 根据 events[i] 处理就绪事件,实际程序还需处理错误等情况
    }
}

epoll 通过两个方面,很好解决了 select/poll 的问题。

  • 第一点,epoll 在内核里持续保存关注列表,Linux 的典型实现使用红黑树管理注册项,查找和维护的成本通常是 O(log n)。通过 epoll_ctl 增删改监听项后,不需要在每次 epoll_wait 时重新传入全部 fd。它减少的是重复传递整个关注集合的开销,不代表整个机制完全没有数据拷贝。

  • 第二点,epoll 使用事件驱动的机制,通过回调把就绪的监听项加入就绪列表。epoll_wait 不需要每次扫描全部连接,而是处理就绪项,把就绪事件记录复制到用户提供的 events 数组中,并返回记录数量。

从下图你可以看到 epoll 相关的接口作用:

img

epoll 特别适合连接很多、但活跃连接相对较少的场景,因此被称为解决 C10K 问题的利器。它没有 select 那样的 fd_set 限制,但监听数量仍受文件描述符、内核内存和监听项限制等影响。

另外,epoll_wait 仍要处理和复制实际返回的事件,事件越多,工作量也越大,不能把整个 epoll 的开销理解成恒定 O(1)。当连接很少,或几乎所有连接都持续活跃时,它也不保证一定比 poll 更快。

# epoll 的 边缘触发和水平触发有什么区别?

epoll 支持两种事件触发模式,分别是水平触发(level-triggered,LT)和边缘触发(edge-triggered,ET),默认是 LT。

  • 水平触发:只要 fd 仍然满足就绪条件,再调用 epoll_wait 时就仍可能返回这个事件。比如接收缓冲区里还有数据,就可以继续收到可读通知。
  • 边缘触发:侧重通知新的就绪变化。不能指望尚未处理完的旧数据,会像 LT 一样反复提醒你,但也不能理解成一个连接一生只通知一次,后续新事件仍然可能产生通知。

举个例子,你的快递被放到了快递箱里,水平触发像是“你没取完,我就继续提醒”;边缘触发像是“有新快递到达时提醒你,你收到提醒后要把目前能取的都取出来”。

假设接收缓冲区有 2KB 数据,你只读了 1KB:LT 下,下一次等待还可以得到可读通知;ET 下,如果没有新事件,剩余的 1KB 可能一直留在那里。

所以,ET 通常要配合非阻塞 I/O,循环读到 EAGAIN/EWOULDBLOCK,表示当前已经没有数据可读。如果 read 返回 0,通常表示对端已经有序关闭发送方向;返回其他错误时也要按错误类型处理,不能一律继续重试。

写入也是一样,要处理部分写入,保存尚未发送的数据,等可写时继续发送。LT 下,如果没有数据要发,却一直监听通常处于可写状态的 Socket,就可能让 epoll_wait 反复返回,造成空转。

ET 可以减少重复通知,但实现要求更高,并不是所有场景都一定比 LT 快。如果一次处理过多数据,还可能让其他连接长时间得不到处理,需要兼顾公平性。

# epoll 返回可读后,read 就一定能读到数据吗?

不一定,事件通知和实际读取之间,状态仍然可能变化。

比如多个线程操作同一个 Socket,其他线程先把数据读走了,当前线程再读就可能得到 EAGAIN;对端关闭发送方向时,也可能出现可读事件,此时读完已有数据后,read 返回 0。

所以,事件就绪可以理解成“现在值得尝试这个操作”,不能把它当成一定有业务数据的保证。服务器通常配合非阻塞 Socket,并分别处理:

  • 返回正数:实际读到了对应数量的字节。
  • 返回 0:对于这里讨论的流式 Socket,通常表示对端已关闭发送方向。
  • 返回 -1:检查 errno,EAGAIN/EWOULDBLOCK 表示暂时没有数据,EINTR 等其他情况则按对应规则处理。

另外,TCP 提供的是字节流,一次可读通知或一次 read 不等于一个完整请求。程序还需要缓冲、拆包和协议解析,不能根据事件次数判断消息边界。

# Reactor 和 Proactor 有什么区别?

Reactor 处理就绪事件,Proactor 处理完成事件。

Reactor 的流程是:注册关注的事件,等内核通知某个连接可读或可写,再由事件处理程序调用 read/write。前面介绍的 epoll 配合事件循环,就是典型的 Reactor 思路。

Proactor 的流程是:先提交异步读写请求,等系统完成数据传输后,再调用处理程序消费结果。比如读请求完成时,数据已经放到了指定的用户缓冲区,处理程序可以直接进行业务解析。

两者都可以通过事件分发组织程序,但 Reactor 的通知表示“可以尝试 I/O”,Proactor 的通知表示“这次 I/O 已完成”。不能因为某个框架用了回调,就判断它一定是 Proactor,还要看底层通知的含义。

# redis,nginx,netty 是依赖什么做的这么高性能?

它们都可以用事件循环和 Reactor 模式来理解网络处理:通过 I/O 多路复用监听事件,事件就绪后,再执行对应的接收、读写和业务逻辑。

Reactor 翻译过来是“反应堆”,可以理解为来了一个事件,就调用对应的处理程序。事件监听和业务处理可以放在同一个执行单元里,也可以拆成多个事件循环、工作线程或工作进程。

Redis

理解 Redis 传统的网络和命令处理路径,可以从“单 Reactor、主线程处理命令”的模型入手:通过事件循环管理很多连接,命令主要在内存中执行,减少多个命令执行线程之间的锁竞争。

img

这里的单线程主要描述命令执行路径,不代表整个 Redis 进程只有一个线程。它还有后台任务,Redis 6.0 起也引入了可配置的多线程 I/O,具体分工要结合版本和配置来看。

单个命令执行线程也可能成为 CPU 瓶颈,复杂命令、长时间脚本或大数据操作会拖慢其他请求,所以不能因为数据在内存里,就认定 CPU 一定不是瓶颈。

Netty

Netty 服务端常见的是多 Reactor、多线程的组织方式:boss EventLoopGroup 接收新连接,worker EventLoopGroup 处理已建立连接的 I/O 和相关事件。

一个 EventLoop 可以管理多个连接,普通 I/O 事件和绑定其上的处理逻辑通常按顺序执行。阻塞数据库调用、长时间计算等任务不适合直接放在 EventLoop 中,可以通过单独的执行器或线程池处理,避免拖住同一 EventLoop 管理的其他连接。

nginx

Nginx 常见的是 master 配合多个 worker 的进程模型。master 负责管理工作进程、配置和生命周期等,worker 各自运行事件循环,接收连接并处理请求。

img

worker 通常自行 accept 连接,再放到自己的事件循环里处理。减少惊群的方式与版本、平台和配置有关,可以使用 accept_mutex、EPOLLEXCLUSIVE 或 SO_REUSEPORT 等机制,不能认为所有 Nginx 都必然通过同一把锁串行 accept。

这几个系统的共同点是:用较少的执行单元管理大量连接,并尽量避免在事件循环里做长时间阻塞的工作。实际性能还取决于数据结构、业务复杂度、内存管理和系统配置,Reactor 只是其中的重要一环。

# 零拷贝是什么?

**零拷贝的核心,是在传输数据时,减少 CPU 参与的数据复制,尤其是避免让数据在内核空间和用户空间之间来回搬运。**它不意味着数据在整个过程中完全没有移动。

以文件发送到网络为例,传统 read 加 write 的路径,可以简化成四次数据搬运:

  1. 存储设备把数据读到内核的文件缓存,通常由 DMA 搬运。
  2. CPU 把文件缓存的数据复制到用户缓冲区。
  3. CPU 把用户缓冲区的数据复制到内核 Socket 缓冲区。
  4. 网卡从内核缓冲区取走数据,通常由 DMA 搬运。

img

两次系统调用,还会带来四次用户态与内核态之间的切换。这里统计的是简化路径里的特权级切换,不代表一定发生四次线程调度。

sendfile 可以直接在内核里完成文件到 Socket 的传输,避免数据先进入用户缓冲区、再被写回内核,并减少系统调用次数。

img

如果内核和网卡支持相应的 scatter-gather 路径,还可以通过引用文件缓存页等方式,避免文件缓存到 Socket 缓冲区的完整数据复制。两次 DMA 搬运、没有完整 CPU 数据拷贝,是满足条件时的典型模型,不是所有 sendfile 调用的固定结果,缓存是否命中也会影响实际路径。

另外,mmap 加 write 通常能避免文件缓存复制到用户缓冲区的那一次,但 write 仍可能把映射页的数据复制到 Socket 缓冲区,不能直接认为它也消除了所有 CPU 拷贝。

零拷贝适合程序不需要修改内容的文件传输,比如静态文件服务。如果还要在用户态压缩、加密或转换数据,能否采用零拷贝要看具体实现。它能减少开销,但性能提升幅度要看数据大小、硬件和实际瓶颈,不能保证固定翻倍。


最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。

img

上次更新: 10/4/2026