操作系统面经,分为基础、进程管理、内存管理三个部分讲解。
[1] 参考《现代操作系统》
[2] 参考了JavaGuide的操作系统文章]
[3] 参考了cyc2018的操作系统文章
[4] 参考了小牛的面经网站
之前看《现代操作系统》的时候,看了前四章分别是引论、进程线程、内存管理、文件系统。似乎针对面试而言,进程线程、内存管理比较重要。Guide的总结恰好也是如此,面向面试学习主要就是弄懂Guide的文章、总结好自己能够理解的回答然后背下来,所以文章的主要结果分别是基础、进程和线程、内存管理、虚拟内存。
1 操作系统基础
1.1 什么是操作系统
操作系统是管理计算机硬件和软件资源的计算机程序。本质上讲,操作系统也是一个软件,向上对用户程序提供接口,向下接管硬件资源。
作为最接近硬件的系统软件,操作系统存在屏蔽了硬件层的复杂性,操作系统内核(Kernel)负责的基本功能有进程管理、内存管理、设备管理、文件管理。

1.2 操作系统的基本特征
并发
并发(concurrency):指宏观上看起来两个程序在同时运行,比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的,在单个周期内只运行了一个指令。
并行(parallelism):指严格物理意义上的同时运行,比如多核cpu,两个程序分别运行在两个核上,两者之间互不影响,单个周期内每个程序都运行了自己的指令,也就是运行了两条指令。
共享
- 共享是指系统中的资源可以被多个并发进程共同使用。有两种共享方式:互斥共享和同时共享。
- 互斥共享的资源称为临界资源,例如打印机等,在同一时刻只允许一个进程访问,需要用同步机制来实现互斥访问。
虚拟
- 虚拟技术把一个物理实体转换为多个逻辑实体。主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术。
- 多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。
- 虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,==地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。==
异步
异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进。
1.3 什么是内核态和用户态?
为了避免操作系统和关键数据被用户程序破坏,将处理器的执行状态分为内核态和用户态。
内核态是操作系统管理程序执行时所处的状态,能够执行包含特权指令在内的一切指令,能够访问系统内所有的存储空间。
用户态是用户程序执行时处理器所处的状态,不能执行特权指令,只能访问用户地址空间。
操作系统内核运行在内核态,用户程序运行在用户态。
1.4 系统调用
如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。系统调用就是为了使用操作系统内核的功能,如进程管理、内存管理、文件管理、设备管理等 (操作系统内核现在也分为宏内核和微内核,微内核将部分功能模块化需要频繁的进行用户态和内核态的切换)
- 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
- 进程通信。完成进程之间的消息传递或信号传递等功能。
- 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。
- 文件管理。完成文件的读、写、创建及删除等功能。
- 设备管理。完成设备的请求或释放,以及设备启动等功能。
Linux 的系统调用主要有以下这些:
Task | Commands |
---|---|
进程控制 | fork(); exit(); wait(); |
进程通信 | pipe(); shmget(); mmap(); |
文件操作 | open(); read(); write(); |
设备操作 | ioctl(); read(); write(); |
信息维护 | getpid(); alarm(); sleep(); |
安全 | chmod(); umask(); chown(); |
1.5 用户态向内核态切换的三种方式(也叫中断分类)
处理器从用户态切换到内核态的方法有三种:系统调用、异常和外部中断。
- 用户程序使用系统调用,陷入内核态。系统调用本身是一种软中断。
- 异常,也叫做内中断,是由错误引起的,如文件损坏、缺页故障等。
- 外部中断,由 CPU 执行指令以外的事件引起,如 I/O 完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。
2 进程和线程
2.1 线程/进程的区别
进程是资源分配的基本单位,而线程是任务调度和执行的基本单位。简单来说,进程就是一个正在运行的程序,并且一个进程中包含多个并发执行的线程(CPU调度,分配时间片给不同的线程)。
多个线程共享进程的资源(如堆和方法区),同时线程也拥有自己的程序计数器、虚拟机栈和本地方法栈。
总结来说 进程之间的执行和调度需要分配内存空间、开销较大。而线程之间的切换,开销较小

2.2 进程的5种状态
进程一共有5种状态,分别是创建、就绪、运行(执行)、阻塞、终止。 (线程也是类似的5种状态)
- 创建状态(new) :进程正在被创建,尚未到就绪状态。
- 就绪状态(ready) :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
- 运行状态(running) :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
- 阻塞状态(waiting) :又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
- 结束状态(terminated) :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。
2.3 进程间通信的方式
我还不懂,后面原理还是要看书
Guide推荐:《进程间通信 IPC (InterProcess Communication)》 推荐阅读,总结的非常不错。
每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信
进程间通信主要包括 管道、命名管道FIFO、消息队列、信号量、信号、共享内存、以及套接字socket。
- 管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。调用pipe系统函数即可创建一个管道。
管道本质是一个伪文件(实为内核缓冲区),使用环形队列机制实现
管道的局限性:
- 数据一旦被读走,便不在管道中存在,不可反复读取。
- 由于管道采用半双工通信方式。因此,数据只能在一个方向上流动。
- 只能公共祖先的进程间可以使用管道。
- 有名管道(Names Pipes) : 管道只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
有名管道不同于匿名管道之处在于它提供了一个路径名与之关联,以有名管道的文件形式存在于文件系统中,这样,即使与有名管道的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过有名管道相互通信
有名管道的名字存在于文件系统中,内容存放在内存中。
- 消息队列(Message Queuing) :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。
消息队列允许一个或多个进程向它写入与读取消息.
消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。
- 信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。
- 共享内存(Shared memory) :允许多个进程共享访问同一块内存空间,因为数据不需要在进程之间复制,所以这是最快的一种 IPC。这种方式需要依靠同步操作,如信号量来同步对共享内存的访问。
多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 XSI 共享内存不是使用文件,而是使用内存的匿名段。(这里没懂)
- 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
2.3* 进程同步
进程通信是一种手段,而进程同步是一种目的。也可以说,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。
- 进程同步:控制多个进程按一定顺序执行;
- 进程通信:进程间传输信息。
操作系统中,进程是具有不同的地址空间。有时候,需要多个进程来协同完成一些任务。 进程的互斥是解决进程间竞争关系的方法,即同一个时刻只有一个进程可以进入临界区。同步可以认为是一中更高级的互斥,指多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。
进程的同步主要有个两种方式:信号量和管程
信号量:用于进程间传递信号的一个整数值。在信号量上只有三种操作可以进行:初始化,P操作(减操作)和V操作(加操作)。基本原理是两个或多个进程可以通过简单的信号进行合作,一个进程可以被迫在某一位置停止,直到它接收到一个特定的信号。特别的当信号量只能是0和1时就是一个互斥量
管程:是由一个或多个过程、一个初始化序列和局部数据组成的软件模块
- 局部数据变量只能被管程的过程访问,任何外部过程都不能访问。
- 一个进程通过调用管程的一个过程进入管程。
- 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。
2.4 进程的调度算法
进程调度的原因
在操作系统中,由于进程总数多于CPU的数量,它们必然会竞争CPU资源。
进程调度是指按照某种调度算法(或原则)从就绪队列中选取进程分配CPU。
通常有以下两种进程调度方式:非抢占方式、抢占方式
进程调度的常见算法
1 先来先服务调度算法(FCFS,First Come First Server)
非抢占式的调度算法,按照请求的顺序进行调度。有利于长作业,但不利于短作业
2 短作业优先调度算法(SJF,Short Job First)
非抢占式的调度算法,按估计运行时间最短的顺序进行调度。长作业有可能会饿死
3 最短剩余时间优先 shortest remaining time next(SRTN)
最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。新作业少就挂起当前线程
4 优先级调度算法
为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。
5 时间片轮转法(RR,Round Robin)
将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。时间片轮转算法的效率和时间片的大小有很大关系
6 多级反馈队列调度算法(MLFQ,Multi-Level Feedback Queue)
一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。
多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。
每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
2.5 死锁的四个必要条件
死锁:两个或两个以上进程在执行过程中,陷入一种循环等待资源的阻塞状态的现象。死锁的四个必要条件如下
- 互斥条件: 一个资源每次只能被一个进程使用
- 不可剥夺条件:进程已获得的资源,在末使用完之前,不能被强行剥夺
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源仍然保持不放
- 循环等待条件:多个线程之间存在一种首尾相接、循环等待资源的关系.
2.6 出现死锁如何处理
- 预防——及时破环四个必要条件
- 破坏互斥条件、让资源共享。(比如假脱机打印技术允许若干个进程同时输出,但实际只有一个真正请求物理打印机的进程)
- 破坏不可剥夺条件,可能造成前段工作失效——效率很低
- 破坏请求和保持条件,采用预先分配资源的方法,一次性分配一个进程所需的所有资源——过于消耗资源
- 破坏循环等待条件,采用顺序资源分配法,就是给资源编号、顺序取用——资源数量很多时、找不到这样的一个完美的顺序的
- 避免——维持一个安全的系统状态
- 安全的系统状态一定不会导致死锁,不安全的系统状态可能导致死锁。如果资源分配不会进入不安全的系统状态就给进程分配资源
- 银行家算法:动态的进行资源分配,使之不进入不安全状态
- 检测和解除
- 画出资源分配图,圆圈表示进程,框表示一类资源。如果出现环路,说明死锁了。(资源重复采用矩阵的方法)
- ①资源剥夺法:挂起某些死锁进程并剥夺其资源。②撤销进程法:撤销一个甚至全部死锁进程并剥夺其 资源。③进程回退法:让一个或多个进程回到不至于造成死锁的状态。
3 操作系统的内存管理基础
3.1 什么是虚拟内存?为什么要引入虚拟内存【自用】
虚拟内存是计算机系统内存管理的一种技术。每个程序拥有自己的虚拟地址空间,这些虚拟地址一般以分页的方式被映射到物理内存,但不需要映射到连续的物理内存,当程序引用到不在物理内存中的页时,就发生缺页中断修改映射关系,操作系统将再需要访问的页面映射到物理内存中以便程序顺利进行。这样造成的效果将物理内存扩充成了更大的虚拟内存,进程看起来像是拥有了更多的可用内存。
说到虚拟内存,其实也可以提一下当不使用抽象储存的情景。比如上世纪60年代的IBM360机器。直接简单粗暴的使用物理内存地址,从0到某个上限值。这样会带来两个问题:1当在一个物理内存中运行多道程序时,会出现“保护和重定位”的问题,就是一个进程的指令可能跳转到另一个进程的地址中去了(之前是使用保护键、基址寄存器和界限寄存器的解决方案来区分不同的进程)。2 就是内存的大小问题,一个现代操作系统动辄就是几十个甚至几百个进程,而内存的大小受价格等现实因素的约束一般是无法满足一些软件对内存的需求的。
所以说白了虚拟内存就是解决了这两个问题,1首先每个进程拥有私有的地址空间,进程的虚拟地址空间是独立于其他进程了,解决了保护和重定位的问题。 2其次,虚拟内存技术使用了空分复用技术,使物理内存抽象为地址空间,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。解决了内存大小不够用的问题
3.2 虚拟地址和物理地址及其转换原理?
虚拟地址:页面号+偏移量;物理地址:页框号+偏移量
程序或进程自己的地址称为虚拟地址,它们构成了虚拟地址空间。虚拟地址通过内存管理单元(Memory Management Unit, MMU) 映射到物理内存地址上,从而被执行。
MMU是CPU里面专门用来完成地址转换的单元。MMU通过查找页表来完成虚拟地址到物理地址的转换。有两种情况:1一种是该虚拟地址存在于物理内存中时直接查出物理地址;2该虚拟地址没有映射到物理内存中,那就需要缺页中断,利用页面置换算法,将该页面置换到物理内存中,然后重新查页表得到物理地址。
通过页表查询物理地址具体的过程:1 通过虚拟地址的页号作为索引找到对应的“页框号”和“在不在”位,2 如果在不在位为1,就将页框号拼接上偏移量得到物理地址。如果在不在为0,就先缺页中断进行页面的调度,最后也是获得页框号后拼接上偏移量的到物理地址。

3.3 快表和多级页表
普通的分页和MMU查询页表进行地址的转换还存在着两个问题:
- 1)虚拟地址到物理地址的映射必须非常快。 ——使用TLB
- 2)如果虚拟地址空间很大,页表也会很大——使用多级页表、倒排页表
- 每个进程都需要有自己的页表,,
- 32位的电脑、每页4k个地址的话,,就是100万个页,,太多了
- 页表太大 查询的就会慢
快表TLB
快表其实就是使用转换检测缓冲区(TLB),为计算机设置一个小型的硬件设备,将虚拟地址直接映射到物理地址,而不必再访问页表。TLB一般位于MMU中,TLB中包换少量频繁使用的表项,当要转换的虚拟地址位于TLB中时、直接获取对应的物理地址,而无需再查询页表了!
TLB的实现其实是基于局部性原理:大多数程序总是对少量的页面进行多次的访问,而不是相反的。因此,只有很少的页表项会 被反复读取,而其他的页表项很少被访问。。所以我们将这些常用的访问页面的映射存在TLB中,将极大提高转换效率。
多级页表
引入多级页表的原因是避免把全部页表一直保存在内存中。特别是那些从不需要的页表就不应该保留。
- 引入多级页表的原因是避免把全部页表一直保存在内存中。特别是那些从不需要的页表就不应该保留。
- 比如一个程序只需要12M的内存,,但虚拟内存的空间有4G。这4GB被分为1024个4MB的块,,真正是需要将3个虚拟内存块映射的物理内存加入到内存中间就好了!!

3.4 常用的页面置换算法
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
包括以下算法:
- 最佳算法:所选择的被换出的页面将是最长时间内不再被访问,通常可以保证获得最低的缺页率。这是一种理论上的算法,因为无法知道一个页面多长时间不再被访问。
- 先进先出:选择换出的页面是最先进入的页面。该算法将那些经常被访问的页面也被换出,从而使缺页率升高。
- LRU:虽然无法知道将来要使用的页面情况,但是可以知道过去使用页面的情况。
LRU
将最近最久未使用的页面换出。为了实现 LRU,需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面是最近最久未访问的。因为每次访问都需要更新链表,因此这种方式实现的LRU
代价很高。 - 时钟算法:时钟算法使用环形链表将页面连接起来,再使用一个指针指向最老的页面。它将整个环形链表的每一个页面做一个标记,如果标记是
0
,那么暂时就不会被替换,然后时钟算法遍历整个环,遇到标记为1
的就替换,否则将标记为0
的标记为1
。
3.5 分段和段页式管理机制
分段:
分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)。
分段内存管理当中,地址是二维的,一维是段号,二维是段内地址;其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。由于分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制。
段页式:
段页式管理机制结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制 中段与段之间以及段的内部的都是离散的。
4 操作系统磁盘IO
[1] 完全参考小林coding的文章原来 8 张图,就可以搞懂「零拷贝」了 建议阅读文章理解
磁盘由核心态管理,用户程序想要访问磁盘数据,一般要经过系统调用,陷入核心态,进而获取数据。磁盘IO相比内存存储和cpu寄存器,是相当耗时的操作,所以要尽可能地提高磁盘IO操作的速度。
4.1 三种IO方式
忙等待是阻塞的。一般来说普通异步IO和DMA模式的IO都是非阻塞的。它们的区别在于:
- 普通异步IO在磁盘IO处理完之后就发起中断,交由CPU拷贝数据。
- DMA模式的IO在DMA将磁盘数据拷贝到内核内存上之后,再发起中断,占用CPU
《现代操作系统》中介绍了实现输入输出(IO)的三种方式:忙等待、异步IO 中断、DMA中断。
- 忙等待:用户程序发出一个系统调用,然后就执行I/O过程,CPU一直等待I/O的数据,直到得到数据后处理,处理完以后返回结果,CPU才继续处理其他事情。这种方式称为忙等待。
- 异步IO:第二种是通过异步IO和中断机制,需要I/O时,先让I/O设备执行对应操作,这个时候CPU不需要等待,继续做其他事情,如果I/O执行完,拿到数据了,这个时候由中断控制器对CPU发起一个中断,处理这个I/O得到的数据。大白话就是先让CPU处理其他事情,当得到I/O数据后,告诉CPU,你先停一下现在手头上的事儿,你刚刚要的数据准备好了,现在给你,你处理下。
- DMA的异步IO:使用直接存储器访问芯片(DMA,Direct Memory Access),直接控制位流,DMA得到数据时,也会对CPU发起中断。(DMA模式的IO在DMA将磁盘数据拷贝到内核内存上之后,再发起中断,占用CPU)
1 传统的忙等待IO过程
- CPU 发出对应的指令给磁盘控制器,然后返回;
- 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;
- CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器(内核缓冲区PageCache),然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。

2 异步IO过程
DMA和异步IO一般都是非阻塞的IO
异步IO用于处理cpu阻塞的问题。 当不使用PageCache 内核缓存时也称为直接IO
过程如下:
- 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
- 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;

3 DMA中断IO过程
DMA直接内存访问(Direct Memory Access)在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
简单来讲就是,传统IO由CPU负责将数据的拷贝搬运:从磁盘缓存器—>到内核缓存区—>再到用户缓冲区。。而使用DMA之后,磁盘缓存器—>到内核缓存区过程由DMA负责,此时cpu可以处理其他的事情也可以闲置等待….
过程如下:
- 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
- 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务(这其实就是一种的异步IO);
- DMA 进一步将 I/O 请求发送给磁盘;
- 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
- DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;
- 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
- CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

4.2 零拷贝技术(传输小文件)
传统的文件传输模式:read+write:4次上下文切换和4次数据拷贝过程。要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。零拷贝有两种方式:
- mmap + write:4 次上下文切换,和 3 次数据拷贝过程
- sendfile: 2 次上下文切换,和 3/2 次数据拷贝过程
1 传统的文件传输模式(4次切换、4次拷贝)
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。如下两个系统调用
1 | read(file, tmp_buf, len); |

期间共发生了 4 次用户态与内核态的上下文切换分别对应着两次系统调用:一次是 read()
,一次是 write()
,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的。
2 mmap + write(4 次切换,和 3 次拷贝)
read()
系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap()
替换 read()
系统调用函数。
mmap()
系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
1 | buf = mmap(file, len); |

数据不用来回从内核态拷贝到用户态(只进行一个映射),减少了一次拷贝的过程
3 sendfile(2 次切换,和 3 次拷贝/2次拷贝)
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
,函数形式如下:
1 | #include <sys/socket.h> |
sendfile()
可以替代前面的 read()
和 write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

对于支持网卡支持 SG-DMA 技术的情况下,还可以直接通过SG-DMA拷贝,将内核缓存区的数据直接拷贝到网卡上,可以再省一次的拷贝过程:

这就是所谓的零拷贝(*Zero-copy*)技术,因为我们没有在内存层面(内核内存和用户内存)去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
4.3 传输大文件(异步直接IO)
零拷贝使用了内核缓存区PageCache(一种高速缓存)来缓存磁盘数据,提高了效率,但显然不适用于大文件的传输(高速缓存区放不下大文件)
针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。
传输大文件应当使用:异步IO+直接IO(即不使用PageCache内核高速缓存)
直接 I/O 应用场景常见的两种:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:
1 | location /video/ { |