乐趣区

关于i-o:Linux-IO-原理和-Zerocopy-技术全面揭秘

博客原文

https://strikefreedom.top/lin…

导言

现在的网络应用早已从 CPU 密集型转向了 I/O 密集型,网络服务器大多是基于 C-S 模型,也即 客户端 - 服务端 模型,客户端须要和服务端进行大量的网络通信,这也决定了古代网络应用的性能瓶颈:I/O。

传统的 Linux 操作系统的规范 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和用户过程地址空间定义的缓冲区之间进行传输。设置缓冲区最大的益处是能够缩小磁盘 I/O 的操作,如果所申请的数据曾经寄存在操作系统的高速缓冲存储器中,那么就不须要再进行理论的物理磁盘 I/O 操作;然而传统的 Linux I/O 在数据传输过程中的数据拷贝操作深度依赖 CPU,也就是说 I/O 过程须要 CPU 去执行数据拷贝的操作,因而导致了极大的零碎开销,限度了操作系统无效进行数据传输操作的能力。

I/O 是决定网络服务器性能瓶颈的要害,而传统的 Linux I/O 机制又会导致大量的数据拷贝操作,损耗性能,所以咱们亟需一种新的技术来解决数据大量拷贝的问题,这个答案就是零拷贝(Zero-copy)。

计算机存储器

既然要剖析 Linux I/O,就不能不理解计算机的各类存储器。

存储器是计算机的核心部件之一,在齐全现实的状态下,存储器应该要同时具备以下三种个性:

  1. 速度足够快:存储器的存取速度该当快于 CPU 执行一条指令,这样 CPU 的效率才不会受限于存储器
  2. 容量足够大:容量可能存储计算机所需的全副数据
  3. 价格足够便宜:价格低廉,所有类型的计算机都能装备

然而事实往往是残暴的,咱们目前的计算机技术无奈同时满足上述的三个条件,于是古代计算机的存储器设计采纳了一种分档次的构造:

从顶至底,古代计算机里的存储器类型别离有:寄存器、高速缓存、主存和磁盘,这些存储器的速度逐级递加而容量逐级递增。存取速度最快的是寄存器,因为寄存器的制作资料和 CPU 是雷同的,所以速度和 CPU 一样快,CPU 拜访寄存器是没有时延的,然而因为价格昂贵,因而容量也极小,个别 32 位的 CPU 装备的寄存器容量是 32✖️32 Bit,64 位的 CPU 则是 64✖️64 Bit,不论是 32 位还是 64 位,寄存器容量都小于 1 KB,且寄存器也必须通过软件自行治理。

第二层是高速缓存,也即咱们平时理解的 CPU 高速缓存 L1、L2、L3,个别 L1 是每个 CPU 独享,L3 是全副 CPU 共享,而 L2 则依据不同的架构设计会被设计成独享或者共享两种模式之一,比方 Intel 的多核芯片采纳的是共享 L2 模式而 AMD 的多核芯片则采纳的是独享 L2 模式。

第三层则是主存,也即主内存,通常称作随机拜访存储器(Random Access Memory, RAM)。是与 CPU 间接替换数据的外部存储器。它能够随时读写(刷新时除外),而且速度很快,通常作为操作系统或其余正在运行中的程序的长期材料存储介质。

最初则是磁盘,磁盘和主存相比,每个二进制位的成本低了两个数量级,因而容量比之会大得多,动辄上 GB、TB,而问题是访问速度则比主存慢了大略三个数量级。机械硬盘速度慢次要是因为机械臂须要一直在金属盘片之间挪动,期待磁盘扇区旋转至磁头之下,而后能力进行读写操作,因而效率很低。

主内存是操作系统进行 I/O 操作的重中之重,绝大部分的工作都是在用户过程和内核的内存缓冲区里实现的,因而咱们接下来须要提前学习一些主存的相干原理。

物理内存

咱们平时始终提及的物理内存就是上文中对应的第三种计算机存储器,RAM 主存,它在计算机中以内存条的模式存在,嵌在主板的内存槽上,用来加载各式各样的程序与数据以供 CPU 间接运行和应用。

虚拟内存

在计算机领域有一句如同摩西十诫般神圣的哲言:”计算机科学畛域的任何问题都能够通过减少一个间接的中间层来解决“,从内存治理、网络模型、并发调度甚至是硬件架构,都能看到这句哲言在闪烁着光辉,而虚拟内存则是这一哲言的完满实际之一。

虚拟内存是古代计算机中的一个十分重要的存储器形象,次要是用来解决应用程序日益增长的内存应用需要:古代物理内存的容量增长曾经十分疾速了,然而还是跟不上应用程序对主存需要的增长速度,对于应用程序来说内存还是不够用,因而便须要一种办法来解决这两者之间的容量差矛盾。

计算机对多程序内存拜访的治理经验了 动态重定位 –> 动静重定位 –> 替换 (swapping) 技术 –> 虚拟内存,最原始的多程序内存拜访是间接拜访相对内存地址,这种形式简直是齐全不可用的计划,因为如果每一个程序都间接拜访物理内存地址的话,比方两个程序并发执行以下指令的时候:

mov cx, 2
mov bx, 1000H
mov ds, bx
mov [0], cx

...

mov ax, [0]
add ax, ax

这一段汇编示意在地址 1000:0 处存入数值 2,而后在前面的逻辑中把该地址的值取出来乘以 2,最终存入 ax 寄存器的值就是 4,如果第二个程序存入 cx 寄存器里的值是 3,那么并发执行的时候,第一个程序最终从 ax 寄存器里失去的值就可能是 6,这就齐全谬误了,失去脏数据还顶多算程序后果谬误,要是其余程序往特定的地址里写入一些危险的指令而被另一个程序取出来执行,还可能会导致整个零碎的解体。所以,为了确保过程间互不烦扰,每一个用户过程都须要实时通晓以后其余过程在应用哪些内存地址,这对于写程序的人来说无疑是一场噩梦。

因而,操作相对内存地址是齐全不可行的计划,那就只能用操作绝对内存地址,咱们晓得每个过程都会有本人的过程地址,从 0 开始,能够通过绝对地址来拜访内存,然而这同样有问题,还是后面相似的问题,比方有两个大小为 16KB 的程序 A 和 B,当初它们都被加载进了内存,内存地址段别离是 0 ~ 16384,16384 ~ 32768。A 的第一条指令是 jmp 1024,而在地址 1024 处是一条 mov 指令,下一条指令是 add,基于后面的 mov 指令做加法运算,与此同时,B 的第一条指令是 jmp 1028,原本在 B 的绝对地址 1028 处应该也是一条 mov 去操作本人的内存地址上的值,然而因为这两个程序共享了段寄存器,因而尽管他们应用了各自的绝对地址,然而仍然操作的还是相对内存地址,于是 B 就会跳去执行 add 指令,这时候就会因为非法的内存操作而 crash。

有一种 动态重定位 的技术能够解决这个问题,它的工作原理非常简单粗犷:当 B 程序被加载到地址 16384 处之后,把 B 的所有绝对内存地址都加上 16384,这样的话当 B 执行 jmp 1028 之时,其实执行的是 jmp 1028+16384,就能够跳转到正确的内存地址处去执行正确的指令了,然而这种技术并不通用,而且还会对程序装载进内存的性能有影响。

再往后,就倒退进去了存储器形象:地址空间,就如同过程是 CPU 的形象,地址空间则是存储器的形象,每个过程都会调配独享的地址空间,然而独享的地址空间又带来了新的问题:如何实现不同过程的雷同绝对地址指向不同的物理地址?最开始是应用 动静重定位 技术来实现,这是用一种绝对简略的地址空间到物理内存的映射办法。基本原理就是为每一个 CPU 装备两个非凡的硬件寄存器:基址寄存器和界线寄存器,用来动静保留每一个程序的起始物理内存地址和长度,比方前文中的 A,B 两个程序,当 A 运行时基址寄存器和界线寄存器就会别离存入 0 和 16384,而当 B 运行时则两个寄存器又会别离存入 16384 和 32768。而后每次拜访指定的内存地址时,CPU 会在把地址发往内存总线之前主动把基址寄存器里的值加到该内存地址上,失去一个真正的物理内存地址,同时还会依据界线寄存器里的值查看该地址是否溢出,若是,则产生谬误停止程序,动静重定位 技术解决了 动态重定位 技术造成的程序装载速度慢的问题,然而也有新问题:每次拜访内存都须要进行加法和比拟运算,比拟运算自身能够很快,然而加法运算因为进位传递工夫的问题,除非应用非凡的电路,否则会比较慢。

而后就是 替换(swapping)技术,这种技术简略来说就是动静地把程序在内存和磁盘之间进行替换保留,要运行一个过程的时候就把程序的代码段和数据段调入内存,而后再把程序封存,存入磁盘,如此重复。为什么要这么麻烦?因为后面那两种重定位技术的前提条件是计算机内存足够大,可能把所有要运行的过程地址空间都加载进主存,才可能并发运行这些过程,然而事实往往不是如此,内存的大小总是无限的,所有就须要另一类办法来解决内存超载的状况,第一种便是简略的替换技术:

先把过程 A 换入内存,而后启动过程 B 和 C,也换入内存,接着 A 被从内存替换到磁盘,而后又有新的过程 D 调入内存,用了 A 退出之后空进去的内存空间,最初 A 又被从新换入内存,因为内存布局曾经产生了变动,所以 A 在换入内存之时会通过软件或者在运行期间通过硬件(基址寄存器和界线寄存器)对其内存地址进行重定位,少数状况下都是通过硬件。

另一种解决内存超载的技术就是 虚拟内存 技术了,它比 替换(swapping)技术更简单而又更高效,是目前最新利用最宽泛的存储器形象技术:

虚拟内存的外围原理是:为每个程序设置一段 ” 间断 ” 的虚拟地址空间,把这个地址空间宰割成多个具备间断地址范畴的页 (page),并把这些页和物理内存做映射,在程序运行期间动静映射到物理内存。当程序援用到一段在物理内存的地址空间时,由硬件立即执行必要的映射;而当程序援用到一段不在物理内存中的地址空间时,由操作系统负责将缺失的局部装入物理内存并从新执行失败的指令:

虚拟地址空间依照固定大小划分成被称为页(page)的若干单元,物理内存中对应的则是页框(page frame)。这两者一般来说是一样的大小,如上图中的是 4KB,不过实际上计算机系统中个别是 512 字节到 1 GB,这就是虚拟内存的分页技术。因为是虚拟内存空间,每个过程调配的大小是 4GB (32 位架构),而实际上当然不可能给所有在运行中的过程都调配 4GB 的物理内存,所以虚拟内存技术还须要利用到后面介绍的 替换(swapping)技术,在过程运行期间只调配映射以后应用到的内存,临时不应用的数据则写回磁盘作为正本保留,须要用的时候再读入内存,动静地在磁盘和内存之间替换数据。

其实虚拟内存技术从某种角度来看的话,很像是糅合了基址寄存器和界线寄存器之后的新技术。它使得整个过程的地址空间能够通过较小的单元映射到物理内存,而不须要为程序的代码和数据地址进行重定位。

过程在运行期间产生的内存地址都是虚拟地址,如果计算机没有引入虚拟内存这种存储器形象技术的话,则 CPU 会把这些地址间接发送到内存地址总线上,间接拜访和虚拟地址雷同值的物理地址;如果应用虚拟内存技术的话,CPU 则是把这些虚拟地址通过地址总线送到内存治理单元(Memory Management Unit,MMU),MMU 将虚构地址映射为物理地址之后再通过内存总线去拜访物理内存:

虚拟地址(比方 16 位地址 8196=0010 000000000100)分为两局部:虚构页号(高位局部)和偏移量(低位局部),虚拟地址转换成物理地址是通过页表(page table)来实现的,页表由页表项形成,页表项中保留了页框号、批改位、拜访位、爱护位和 “ 在 / 不在 ” 位等信息,从数学角度来说页表就是一个函数,入参是虚构页号,输入是物理页框号,失去物理页框号之后复制到寄存器的高三位中,最初间接把 12 位的偏移量复制到寄存器的末 12 位形成 15 位的物理地址,即能够把该寄存器的存储的物理内存地址发送到内存总线:

在 MMU 进行地址转换时,如果页表项的 “ 在 / 不在 ” 位是 0,则示意该页面并没有映射到实在的物理页框,则会引发一个 缺页中断,CPU 陷入操作系统内核,接着操作系统就会通过页面置换算法抉择一个页面将其换出 (swap),以便为行将调入的新页面腾出地位,如果要换出的页面的页表项里的批改位曾经被设置过,也就是被更新过,则这是一个脏页 (dirty page),须要写回磁盘更新改页面在磁盘上的正本,如果该页面是 ” 洁净 ” 的,也就是没有被批改过,则间接用调入的新页面笼罩掉被换出的旧页面即可。

最初,还须要理解的一个概念是转换检测缓冲器(Translation Lookaside Buffer,TLB),也叫快表,是用来减速虚构地址映射的,因为虚拟内存的分页机制,页表个别是保留内存中的一块固定的存储区,导致过程通过 MMU 拜访内存比间接拜访内存多了一次内存拜访,性能至多降落一半,因而须要引入减速机制,即 TLB 快表,TLB 能够简略地了解成页表的高速缓存,保留了最高频被拜访的页表项,因为个别是硬件实现的,因而速度极快,MMU 收到虚拟地址时个别会先通过硬件 TLB 查问对应的页表号,若命中且该页表项的拜访操作非法,则间接从 TLB 取出对应的物理页框号返回,若不命中则穿透到内存页表里查问,并且会用这个从内存页表里查问到最新页表项替换到现有 TLB 里的其中一个,以备下次缓存命中。

至此,咱们介绍完了蕴含虚拟内存在内的多项计算机存储器形象技术,虚拟内存的其余内容比方针对大内存的多级页表、倒排页表,以及解决缺页中断的页面置换算法等等,当前有机会再独自写一篇文章介绍,或者各位读者也能够后行去查阅相干材料理解,这里就不再深刻了。

用户态和内核态

一般来说,咱们在编写程序操作 Linux I/O 之时十有八九是在用户空间和内核空间之间传输数据,因而有必要先理解一下 Linux 的用户态和内核态的概念。

首先是用户态和内核态:

从宏观上来看,Linux 操作系统的体系架构分为用户态和内核态(或者用户空间和内核)。内核从实质上看是一种软件 —— 管制计算机的硬件资源,并提供下层应用程序 (过程) 运行的环境。用户态即下层应用程序 (过程) 的运行空间,应用程序 (过程) 的执行必须依靠于内核提供的资源,这其中包含但不限于 CPU 资源、存储资源、I/O 资源等等。

古代操作系统都是采纳虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 2^32 B = 4G。操作系统的外围是内核,独立于一般的应用程序,能够拜访受爱护的内存空间,也有拜访底层硬件设施的所有权限。为了保障用户过程不能间接操作内核(kernel),保障内核的平安,操心零碎将虚拟空间划分为两局部,一部分为内核空间,一部分为用户空间。针对 Linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核应用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个过程应用,称为用户空间。

因为操作系统的资源是无限的,如果拜访资源的操作过多,必然会耗费过多的系统资源,而且如果不对这些操作加以辨别,很可能造成资源拜访的抵触。所以,为了缩小无限资源的拜访和应用抵触,Unix/Linux 的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。简略说就是有多大能力做多大的事,与零碎相干的一些特地要害的操作必须由最高特权的程序来实现。Intel 的 x86 架构的 CPU 提供了 0 到 3 四个特权级,数字越小,特权越高,Linux 操作系统中次要采纳了 0 和 3 两个特权级,别离对应的就是内核态和用户态。运行于用户态的过程能够执行的操作和拜访的资源都会受到极大的限度,而运行在内核态的过程则能够执行任何操作并且在资源的应用上没有限度。很多程序开始时运行于用户态,但在执行的过程中,一些操作须要在内核权限下能力执行,这就波及到一个从用户态切换到内核态的过程。比方 C 函数库中的内存调配函数 malloc(),它具体是应用 sbrk() 零碎调用来分配内存,当 malloc 调用 sbrk() 的时候就波及一次从用户态到内核态的切换,相似的函数还有 printf(),调用的是 wirte() 零碎调用来输入字符串,等等。

用户过程在零碎中运行时,大部分工夫是处在用户态空间里的,在其须要操作系统帮忙实现一些用户态没有特权和能力实现的操作时就须要切换到内核态。那么用户过程如何切换到内核态去应用那些内核资源呢?答案是:1) 零碎调用(trap),2) 异样(exception)和 3) 中断(interrupt)。

  • 零碎调用:用户过程被动发动的操作。用户态过程发动零碎调用被动要求切换到内核态,陷入内核之后,由操作系统来操作系统资源,实现之后再返回到过程。
  • 异样:被动的操作,且用户过程无奈预测其产生的机会。当用户过程在运行期间产生了异样(比方某条指令出了问题),这时会触发由以后运行过程切换到解决此异样的内核相干过程中,也即是切换到了内核态。异样包含程序运算引起的各种谬误如除 0、缓冲区溢出、缺页等。
  • 中断:当外围设备实现用户申请的操作后,会向 CPU 收回相应的中断信号,这时 CPU 会暂停执行下一条行将要执行的指令而转到与中断信号对应的处理程序去执行,如果后面执行的指令是用户态下的程序,那么转换的过程天然就会是从用户态到内核态的切换。中断包含 I/O 中断、内部信号中断、各种定时器引起的时钟中断等。中断和异样相似,都是通过中断向量表来找到相应的处理程序进行解决。区别在于,中断来自处理器内部,不是由任何一条专门的指令造成,而异样是执行以后指令的后果。

通过下面的剖析,咱们能够得出 Linux 的外部层级可分为三大部分:

  1. 用户空间;
  2. 内核空间;
  3. 硬件。

Linux I/O

I/O 缓冲区

在 Linux 中,当程序调用各类文件操作函数后,用户数据(User Data)达到磁盘(Disk)的流程如上图所示。

图中形容了 Linux 中文件操作函数的层级关系和内存缓存层的存在地位,两头的彩色实线是用户态和内核态的分界线。

read(2)/write(2) 是 Linux 零碎中最根本的 I/O 读写零碎调用,咱们开发操作 I/O 的程序时必定会接触到它们,而在这两个零碎调用和实在的磁盘读写之间存在一层称为 Kernel buffer cache 的缓冲区缓存。在 Linux 中 I/O 缓存其实能够细分为两个:Page CacheBuffer Cache,这两个其实是一体两面,独特组成了 Linux 的内核缓冲区(Kernel Buffer Cache):

  • 读磁盘:内核会先查看 Page Cache 里是不是曾经缓存了这个数据,若是,间接从这个内存缓冲区里读取返回,若否,则穿透到磁盘去读取,而后再缓存在 Page Cache 里,以备下次缓存命中;
  • 写磁盘:内核间接把数据写入 Page Cache,并把对应的页标记为 dirty,增加到 dirty list 里,而后就间接返回,内核会定期把 dirty list 的页缓存 flush 到磁盘,保障页缓存和磁盘的最终一致性。

Page Cache 会通过页面置换算法如 LRU 定期淘汰旧的页面,加载新的页面。能够看出,所谓 I/O 缓冲区缓存就是在内核和磁盘、网卡等外设之间的一层缓冲区,用来晋升读写性能的。

在 Linux 还不反对虚拟内存技术之前,还没有页的概念,因而 Buffer Cache 是基于操作系统读写磁盘的最小单位 — 块(block)来进行的,所有的磁盘块操作都是通过 Buffer Cache 来减速,Linux 引入虚拟内存的机制来治理内存后,页成为虚拟内存治理的最小单位,因而也引入了 Page Cache 来缓存 Linux 文件内容,次要用来作为文件系统上的文件数据的缓存,晋升读写性能,常见的是针对文件的 read()/write() 操作,另外也包含了通过 mmap() 映射之后的块设施,也就是说,事实上 Page Cache 负责了大部分的块设施文件的缓存工作。而 Buffer Cache 用来在系统对块设施进行读写的时候,对块进行数据缓存的零碎来应用,实际上负责所有对磁盘的 I/O 拜访:

因为 Buffer Cache 是对粒度更细的设施块的缓存,而 Page Cache 是基于虚拟内存的页单元缓存,因而还是会基于 Buffer Cache,也就是说如果是缓存文件内容数据就会在内存里缓存两份雷同的数据,这就会导致同一份文件保留了两份,冗余且低效。另外一个问题是,调用 write 后,无效数据是在 Buffer Cache 中,而非 Page Cache 中。这就导致 mmap 拜访的文件数据可能存在不统一问题。为了躲避这个问题,所有基于磁盘文件系统的 write,都须要调用 update_vm_cache() 函数,该操作会把调用 write 之后的 Buffer Cache 更新到 Page Cache 去。因为有这些设计上的弊病,因而在 Linux 2.4 版本之后,kernel 就将两者进行了对立,Buffer Cache 不再以独立的模式存在,而是以交融的形式存在于 Page Cache 中:

交融之后就能够对立操作 Page CacheBuffer Cache:解决文件 I/O 缓存交给 Page Cache,而当底层 RAW device 刷新数据时以 Buffer Cache 的块单位来理论解决。

I/O 模式

在 Linux 或者其余 Unix-like 操作系统里,I/O 模式个别有三种:

  1. 程序控制 I/O
  2. 中断驱动 I/O
  3. DMA I/O

上面我别离具体地解说一下这三种 I/O 模式。

程序控制 I/O

这是最简略的一种 I/O 模式,也叫忙期待或者轮询:用户通过发动一个零碎调用,陷入内核态,内核将零碎调用翻译成一个对应设施驱动程序的过程调用,接着设施驱动程序会启动 I/O 一直循环去查看该设施,看看是否曾经就绪,个别通过返回码来示意,I/O 完结之后,设施驱动程序会把数据送到指定的中央并返回,切回用户态。

比方发动零碎调用 read()

中断驱动 I/O

第二种 I/O 模式是利用中断来实现的:

流程如下:

  1. 用户过程发动一个 read() 零碎调用读取磁盘文件,陷入内核态并由其所在的 CPU 通过设施驱动程序向设施寄存器写入一个告诉信号,告知设施控制器 (咱们这里是磁盘控制器)要读取数据;
  2. 磁盘控制器启动磁盘读取的过程,把数据从磁盘拷贝到磁盘控制器缓冲区里;
  3. 实现拷贝之后磁盘控制器会通过总线发送一个中断信号到中断控制器,如果此时中断控制器手头还有正在解决的中断或者有一个和该中断信号同时达到的更高优先级的中断,则这个中断信号将被疏忽,而磁盘控制器会在前面继续发送中断信号直至中断控制器受理;
  4. 中断控制器收到磁盘控制器的中断信号之后会通过地址总线存入一个磁盘设施的编号,示意这次中断须要关注的设施是磁盘;
  5. 中断控制器向 CPU 置起一个磁盘中断信号;
  6. CPU 收到中断信号之后进行以后的工作,把以后的 PC/PSW 等寄存器压入堆栈保留现场,而后从地址总线取出设施编号,通过编号找到中断向量所蕴含的中断服务的入口地址,压入 PC 寄存器,开始运行磁盘中断服务,把数据从磁盘控制器的缓冲区拷贝到主存里的内核缓冲区;
  7. 最初 CPU 再把数据从内核缓冲区拷贝到用户缓冲区,实现读取操作,read() 返回,切换回用户态。

DMA I/O

并发零碎的性能高下究其基本,是取决于如何对 CPU 资源的高效调度和应用,而回头看后面的中断驱动 I/O 模式的流程,能够发现第 6、7 步的数据拷贝工作都是由 CPU 亲自实现的,也就是在这两次数据拷贝阶段中 CPU 是齐全被占用而不能解决其余工作的,那么这里显著是有优化空间的;第 7 步的数据拷贝是从内核缓冲区到用户缓冲区,都是在主存里,所以这一步只能由 CPU 亲自实现,然而第 6 步的数据拷贝,是从磁盘控制器的缓冲区到主存,是两个设施之间的数据传输,这一步并非肯定要 CPU 来实现,能够借助 DMA 来实现,加重 CPU 的累赘。

DMA 全称是 Direct Memory Access,也即间接存储器存取,是一种用来提供在外设和存储器之间或者存储器和存储器之间的高速数据传输。整个过程毋庸 CPU 参加,数据间接通过 DMA 控制器进行疾速地挪动拷贝,节俭 CPU 的资源去做其余工作。

目前,大部分的计算机都装备了 DMA 控制器,而 DMA 技术也反对大部分的外设和存储器。借助于 DMA 机制,计算机的 I/O 过程就能更加高效:

DMA 控制器外部蕴含若干个能够被 CPU 读写的寄存器:一个主存地址寄存器 MAR(寄存要替换数据的主存地址)、一个外设地址寄存器 ADR(寄存 I/O 设施的设施码,或者是设施信息存储区的寻址信息)、一个字节数寄存器 WC(对传送数据的总字数进行统计)、和一个或多个管制寄存器。

  1. 用户过程发动一个 read() 零碎调用读取磁盘文件,陷入内核态并由其所在的 CPU 通过设置 DMA 控制器的寄存器对它进行编程:把内核缓冲区和磁盘文件的地址别离写入 MAR 和 ADR 寄存器,而后把冀望读取的字节数写入 WC 寄存器,启动 DMA 控制器;
  2. DMA 控制器依据 ADR 寄存器里的信息晓得这次 I/O 须要读取的外设是磁盘的某个地址,便向磁盘控制器收回一个命令,告诉它从磁盘读取数据到其外部的缓冲区里;
  3. 磁盘控制器启动磁盘读取的过程,把数据从磁盘拷贝到磁盘控制器缓冲区里,并对缓冲区内数据的校验和进行测验,如果数据是无效的,那么 DMA 就能够开始了;
  4. DMA 控制器通过总线向磁盘控制器收回一个读申请信号从而发动 DMA 传输,这个信号和后面的中断驱动 I/O 大节里 CPU 发给磁盘控制器的读申请是一样的,它并不知道或者并不关怀这个读申请是来自 CPU 还是 DMA 控制器;
  5. 紧接着 DMA 控制器将疏导磁盘控制器将数据传输到 MAR 寄存器里的地址,也就是内核缓冲区;
  6. 数据传输实现之后,返回一个 ack 给 DMA 控制器,WC 寄存器里的值会减去相应的数据长度,如果 WC 还不为 0,则反复第 4 步到第 6 步,始终到 WC 里的字节数等于 0;
  7. 收到 ack 信号的 DMA 控制器会通过总线发送一个中断信号到中断控制器,如果此时中断控制器手头还有正在解决的中断或者有一个和该中断信号同时达到的更高优先级的中断,则这个中断信号将被疏忽,而 DMA 控制器会在前面继续发送中断信号直至中断控制器受理;
  8. 中断控制器收到磁盘控制器的中断信号之后会通过地址总线存入一个主存设施的编号,示意这次中断须要关注的设施是主存;
  9. 中断控制器向 CPU 置起一个 DMA 中断的信号;
  10. CPU 收到中断信号之后进行以后的工作,把以后的 PC/PSW 等寄存器压入堆栈保留现场,而后从地址总线取出设施编号,通过编号找到中断向量所蕴含的中断服务的入口地址,压入 PC 寄存器,开始运行 DMA 中断服务,把数据从内核缓冲区拷贝到用户缓冲区,实现读取操作,read() 返回,切换回用户态。

传统 I/O 读写模式

Linux 中传统的 I/O 读写是通过 read()/write() 零碎调用实现的,read() 把数据从存储器 (磁盘、网卡等) 读取到用户缓冲区,write() 则是把数据从用户缓冲区写出到存储器:

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

一次残缺的读磁盘文件而后写出到网卡的底层传输过程如下:

能够分明看到这里一共触发了 4 次用户态和内核态的上下文切换,别离是 read()/write() 调用和返回时的切换,2 次 DMA 拷贝,2 次 CPU 拷贝,加起来一共 4 次拷贝操作。

通过引入 DMA,咱们曾经把 Linux 的 I/O 过程中的 CPU 拷贝次数从 4 次缩小到了 2 次,然而 CPU 拷贝仍然是代价很大的操作,对系统性能的影响还是很大,特地是那些频繁 I/O 的场景,更是会因为 CPU 拷贝而损失掉很多性能,咱们须要进一步优化,升高、甚至是完全避免 CPU 拷贝。

零拷贝 (Zero-copy)

Zero-copy 是什么?

Wikipedia 的解释如下:

Zero-copy” describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.

零拷贝技术是指计算机执行操作时,CPU 不须要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节俭 CPU 周期和内存带宽。

Zero-copy 能做什么?

  • 缩小甚至完全避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作,从而缩小用户态 — 内核态上下文切换带来的零碎开销。
  • 缩小甚至完全避免操作系统内核缓冲区之间进行数据拷贝操作。
  • 帮忙用户过程绕开操作系统内核空间间接拜访硬件存储接口操作数据。
  • 利用 DMA 而非 CPU 来实现硬件接口和内核缓冲区之间的数据拷贝,从而解放 CPU,使之能去执行其余的工作,晋升零碎性能。

Zero-copy 的实现形式有哪些?

从 zero-copy 这个概念被提出以来,相干的实现技术便犹如雨后春笋,层出不穷。然而截至目前为止,并没有任何一种 zero-copy 技术能满足所有的场景需要,还是计算机领域那句无比经典的名言:”There is no silver bullet”!

而在 Linux 平台上,同样也有很多的 zero-copy 技术,新旧各不同,可能存在于不同的内核版本里,很多技术可能有了很大的改良或者被更新的实现形式所代替,这些不同的实现技术依照其核心思想能够演绎成大抵的以下三类:

  • 缩小甚至防止用户空间和内核空间之间的数据拷贝:在一些场景下,用户过程在数据传输过程中并不需要对数据进行拜访和解决,那么数据在 Linux 的 Page Cache 和用户过程的缓冲区之间的传输就齐全能够防止,让数据拷贝齐全在内核里进行,甚至能够通过更奇妙的形式防止在内核里的数据拷贝。这一类实现个别是通过减少新的零碎调用来实现的,比方 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 绕过内核的间接 I/O:容许在用户态过程绕过内核间接和硬件进行数据传输,内核在传输过程中只负责一些治理和辅助的工作。这种形式其实和第一种有点相似,也是试图防止用户空间和内核空间之间的数据传输,只是第一种形式是把数据传输过程放在内核态实现,而这种形式则是间接绕过内核和硬件通信,成果相似但原理齐全不同。
  • 内核缓冲区和用户缓冲区之间的传输优化:这种形式侧重于在用户过程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种办法连续了以往那种传统的通信形式,但更灵便。

缩小甚至防止用户空间和内核空间之间的数据拷贝

mmap()
#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

一种简略的实现计划是在一次读写过程中用 Linux 的另一个零碎调用 mmap() 替换原先的 read()mmap() 也即是内存映射(memory map):把用户过程空间的一段内存缓冲区(user buffer)映射到文件所在的内核缓冲区(kernel buffer)上。

利用 mmap() 替换 read(),配合 write() 调用的整个流程如下:

  1. 用户过程调用 mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓存区;
  2. DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  3. mmap() 返回,上下文从内核态切换回用户态;
  4. 用户过程调用 write(),尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态;
  5. CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;
  6. DMA 控制器将数据从套接字缓冲区拷贝到网卡实现数据传输;
  7. write() 返回,上下文从内核态切换回用户态。

通过这种形式,有两个长处:一是节俭内存空间,因为用户过程上的这一段内存是虚构的,并不真正占据物理内存,只是映射到文件所在的内核缓冲区上,因而能够节俭一半的内存占用;二是省去了一次 CPU 拷贝,比照传统的 Linux I/O 读写,数据不须要再通过用户过程进行转发了,而是间接在内核里就实现了拷贝。所以应用 mmap() 之后的拷贝次数是 2 次 DMA 拷贝,1 次 CPU 拷贝,加起来一共 3 次拷贝操作,比传统的 I/O 形式节俭了一次 CPU 拷贝以及一半的内存,不过因为 mmap() 也是一个零碎调用,因而用户态和内核态的切换还是 4 次。

mmap() 因为既节俭 CPU 拷贝次数又节俭内存,所以比拟适宜大文件传输的场景。尽管 mmap() 齐全是合乎 POSIX 规范的,然而它也不是完满的,因为它并不总是能达到现实的数据传输性能。首先是因为数据数据传输过程中仍然须要一次 CPU 拷贝,其次是内存映射技术是一个开销很大的虚拟存储操作:这种操作须要批改页表以及用内核缓冲区里的文件数据汰换掉以后 TLB 里的缓存以维持虚拟内存映射的一致性。然而,因为内存映射通常针对的是绝对较大的数据区域,所以对于雷同大小的数据来说,内存映射所带来的开销远远低于 CPU 拷贝所带来的开销。此外,应用 mmap() 还可能会遇到一些须要值得关注的非凡状况,例如,在 mmap() –> write() 这两个零碎调用的整个传输过程中,如果有其余的过程忽然截断了这个文件,那么这时用户过程就会因为拜访非法地址而被一个从总线传来的 SIGBUS 中断信号杀死并且产生一个 core dump。有两种解决办法:

  1. 设置一个信号处理器,专门用来解决 SIGBUS 信号,这个处理器间接返回,write() 就能够失常返回已写入的字节数而不会被 SIGBUS 中断,errno 错误码也会被设置成 success。然而这实际上是一个自欺欺人的解决方案,因为 BIGBUS 信号的带来的信息是零碎产生了一些很重大的谬误,而咱们却抉择疏忽掉它,个别不倡议采纳这种形式。
  2. 通过内核的文件租借锁(这是 Linux 的叫法,Windows 上称之为机会锁)来解决这个问题,这种办法相对来说更好一些。咱们能够通过内核对文件描述符上读 / 写的租借锁,当另外一个过程尝试对以后用户过程正在进行传输的文件进行截断的时候,内核会发送给用户一个实时信号:RT_SIGNAL_LEASE 信号,这个信号会通知用户内核正在毁坏你加在那个文件上的读 / 写租借锁,这时 write() 零碎调用会被中断,并且以后用户过程会被 SIGBUS 信号杀死,返回值则是中断前写的字节数,errno 同样会被设置为 success。文件租借锁须要在对文件进行内存映射之前设置,最初在用户过程完结之前开释掉。
sendfile()

在 Linux 内核 2.1 版本中,引入了一个新的零碎调用 sendfile()

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

从性能上来看,这个零碎调用将 mmap() + write() 这两个零碎调用合二为一,实现了一样成果的同时还简化了用户接口,其余的一些 Unix-like 的零碎像 BSD、Solaris 和 AIX 等也有相似的实现,甚至 Windows 上也有一个性能相似的 API 函数 TransmitFile

out_fd 和 in_fd 别离代表了写入和读出的文件描述符,in_fd 必须是一个指向文件的文件描述符,且要能反对类 mmap() 内存映射,不能是 Socket 类型,而 out_fd 在 Linux 内核 2.6.33 版本之前只能是一个指向 Socket 的文件描述符,从 2.6.33 之后则能够是任意类型的文件描述符。off_t 是一个代表了 in_fd 偏移量的指针,批示 sendfile() 该从 in_fd 的哪个地位开始读取,函数返回后,这个指针会被更新成 sendfile() 最初读取的字节地位处,表明此次调用共读取了多少文件数据,最初的 count 参数则是此次调用须要传输的字节总数。

应用 sendfile() 实现一次数据读写的流程如下:

  1. 用户过程调用 sendfile() 从用户态陷入内核态;
  2. DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  3. CPU 将内核缓冲区中的数据拷贝到套接字缓冲区;
  4. DMA 控制器将数据从套接字缓冲区拷贝到网卡实现数据传输;
  5. sendfile() 返回,上下文从内核态切换回用户态。

基于 sendfile(),整个数据传输过程中共产生 2 次 DMA 拷贝和 1 次 CPU 拷贝,这个和 mmap() + write() 雷同,然而因为 sendfile() 只是一次零碎调用,因而比前者少了一次用户态和内核态的上下文切换开销。读到这里,聪慧的读者应该会开始发问了:”sendfile() 会不会遇到和 mmap() + write() 类似的文件截断问题呢?”,很可怜,答案是必定的。sendfile() 一样会有文件截断的问题,但快慰的是,sendfile() 不仅比 mmap() + write() 在接口应用上更加简洁,而且解决文件截断时也更加优雅:如果 sendfile() 过程中遭逢文件截断,则 sendfile() 零碎调用会被中断杀死之前返回给用户过程其中断前所传输的字节数,errno 会被设置为 success,无需用户提前设置信号处理器,当然你要设置一个进行个性化解决也能够,也不须要像之前那样提前给文件描述符设置一个租借锁,因为最终后果还是一样的。

sendfile() 相较于 mmap() 的另一个劣势在于数据在传输过程中始终没有越过用户态和内核态的边界,因而极大地缩小了存储管理的开销。即便如此,sendfile() 仍然是一个适用性很窄的技术,最适宜的场景根本也就是一个动态文件服务器了。而且依据 Linus 在 2001 年和其余内核维护者的邮件列表内容,其实当初之所以决定在 Linux 上实现 sendfile() 仅仅是因为在其余操作系统平台上曾经率先实现了,而且有赫赫有名的 Apache Web 服务器曾经在应用了,为了兼容 Apache Web 服务器才决定在 Linux 上也实现这个技术,而且 sendfile() 实现上的简洁性也和 Linux 内核的其余局部集成得很好,所以 Linus 也就批准了这个提案。

然而 sendfile() 自身是有很大问题的,从不同的角度来看的话次要是:

  1. 首先一个是这个接口并没有进行标准化,导致 sendfile() 在 Linux 上的接口实现和其余类 Unix 零碎的实现并不相同;
  2. 其次因为网络传输的异步性,很难在接收端实现和 sendfile() 对接的技术,因而接收端始终没有实现对应的这种技术;
  3. 最初从性能方面考量,因为 sendfile() 在把磁盘文件从内核缓冲区(page cache)传输到到套接字缓冲区的过程中仍然须要 CPU 参加,这就很难防止 CPU 的高速缓存被传输的数据所净化。

此外,须要阐明下,sendfile() 的最后设计并不是用来解决大文件的,因而如果须要解决很大的文件的话,能够应用另一个零碎调用 sendfile64(),它反对对更大的文件内容进行寻址和偏移。

sendfile() with DMA Scatter/Gather Copy

上一大节介绍的 sendfile() 技术曾经把一次数据读写过程中的 CPU 拷贝的升高至只有 1 次了,然而人永远是贪婪和不知足的,当初如果想要把这仅有的一次 CPU 拷贝也去除掉,有没有方法呢?

当然有!通过引入一个新硬件上的反对,咱们能够把这个仅剩的一次 CPU 拷贝也给抹掉:Linux 在内核 2.4 版本里引入了 DMA 的 scatter/gather — 扩散 / 收集性能,并批改了 sendfile() 的代码使之和 DMA 适配。scatter 使得 DMA 拷贝能够不再须要把数据存储在一片间断的内存空间上,而是容许离散存储,gather 则可能让 DMA 控制器依据大量的元信息:一个蕴含了内存地址和数据大小的缓冲区描述符,收集存储在各处的数据,最终还原成一个残缺的网络包,间接拷贝到网卡而非套接字缓冲区,防止了最初一次的 CPU 拷贝:

sendfile() + DMA gather 的数据传输过程如下:

  1. 用户过程调用 sendfile(),从用户态陷入内核态;
  2. DMA 控制器应用 scatter 性能把数据从硬盘拷贝到内核缓冲区进行离散存储;
  3. CPU 把蕴含内存地址和数据长度的缓冲区描述符拷贝到套接字缓冲区,DMA 控制器可能依据这些信息生成网络包数据分组的报头和报尾
  4. DMA 控制器依据缓冲区描述符里的内存地址和数据大小,应用 scatter-gather 性能开始从内核缓冲区收集离散的数据并组包,最初间接把网络包数据拷贝到网卡实现数据传输;
  5. sendfile() 返回,上下文从内核态切换回用户态。

基于这种计划,咱们就能够把这仅剩的惟一一次 CPU 拷贝也给去除了(严格来说还是会有一次,然而因为这次 CPU 拷贝的只是那些微不足道的元信息,开销简直能够忽略不计),实践上,数据传输过程就再也没有 CPU 的参加了,也因而 CPU 的高速缓存再不会被净化了,也不再须要 CPU 来计算数据校验和了,CPU 能够去执行其余的业务计算工作,同时和 DMA 的 I/O 工作并行,此举能极大地晋升零碎性能。

splice()

sendfile() + DMA Scatter/Gather 的零拷贝计划尽管高效,然而也有两个毛病:

  1. 这种计划须要引入新的硬件反对;
  2. 尽管 sendfile() 的输入文件描述符在 Linux kernel 2.6.33 版本之后曾经能够反对任意类型的文件描述符,然而输出文件描述符仍然只能指向文件。

这两个毛病限度了 sendfile() + DMA Scatter/Gather 计划的实用场景。为此,Linux 在 2.6.17 版本引入了一个新的零碎调用 splice(),它在性能上和 sendfile() 十分类似,然而可能实现在任意类型的两个文件描述符时之间传输数据;而在底层实现上,splice()又比 sendfile() 少了一次 CPU 拷贝,也就是等同于 sendfile() + DMA Scatter/Gather,齐全去除了数据传输过程中的 CPU 拷贝。

splice() 零碎调用函数定义如下:

#include <fcntl.h>
#include <unistd.h>

int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

fd_in 和 fd_out 也是别离代表了输出端和输入端的文件描述符,这两个文件描述符必须有一个是指向管道设施的,这也是一个不太敌对的限度,尽管 Linux 内核开发的官网从这个零碎调用推出之时就承诺将来可能会重构去掉这个限度,然而他们许下这个承诺之后就如同杳无音信,现在 14 年过来了,仍旧杳无音讯 …

off_in 和 off_out 则别离是 fd_in 和 fd_out 的偏移量指针,批示内核从哪里读取和写入数据,len 则批示了此次调用心愿传输的字节数,最初的 flags 是零碎调用的标记选项位掩码,用来设置零碎调用的行为属性的,由以下 0 个或者多个值通过『或』操作组合而成:

  • SPLICE_F_MOVE:批示 splice() 尝试仅仅是挪动内存页面而不是复制,设置了这个值不代表就肯定不会复制内存页面,复制还是挪动取决于内核是否从管道中挪动内存页面,或者管道中的内存页面是否是残缺的;这个标记的初始实现有很多 bug,所以从 Linux 2.6.21 版本开始就曾经有效了,但还是保留了下来,因为在将来的版本里可能会从新被实现。
  • SPLICE_F_NONBLOCK:批示 splice() 不要阻塞 I/O,也就是使得 splice() 调用成为一个非阻塞调用,能够用来实现异步数据传输,不过须要留神的是,数据传输的两个文件描述符也最好是事后通过 O_NONBLOCK 标记成非阻塞 I/O,不然 splice() 调用还是有可能被阻塞。
  • SPLICE_F_MORE:告诉内核下一个 splice() 零碎调用将会有更多的数据传输过去,这个标记对于输入端是 socket 的场景十分有用。

splice() 是基于 Linux 的管道缓冲区 (pipe buffer) 机制实现的,所以 splice() 的两个入参文件描述符才要求必须有一个是管道设施,一个典型的 splice() 用法是:

int pfd[2];

pipe(pfd);

ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE);
assert(bytes != -1);

bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE);
assert(bytes != -1);

数据传输过程图:

应用 splice() 实现一次磁盘文件到网卡的读写过程如下:

  1. 用户过程调用 pipe(),从用户态陷入内核态,创立匿名单向管道,pipe() 返回,上下文从内核态切换回用户态;
  2. 用户过程调用 splice(),从用户态陷入内核态;
  3. DMA 控制器将数据从硬盘拷贝到内核缓冲区,从管道的写入端 ” 拷贝 ” 进管道,splice() 返回,上下文从内核态回到用户态;
  4. 用户过程再次调用 splice(),从用户态陷入内核态;
  5. 内核把数据从管道的读取端 ” 拷贝 ” 到套接字缓冲区,DMA 控制器将数据从套接字缓冲区拷贝到网卡;
  6. splice() 返回,上下文从内核态切换回用户态。

置信看完下面的读写流程之后,读者必定会十分困惑:说好的 splice()sendfile() 的改进版呢?sendfile() 好歹只须要一次零碎调用,splice() 竟然须要三次,这也就罢了,竟然两头还搞进去一个管道,而且还要在内核空间拷贝两次,这算个毛的改良啊?

我最开始理解 splice() 的时候,也是这个反馈,然而深刻学习它之后,才慢慢通晓个中奥秘,且听我细细道来:

先来理解一下 pipe buffer 管道,管道是 Linux 上用来供过程之间通信的信道,管道有两个端:写入端和读出端,从过程的视角来看,管道体现为一个 FIFO 字节流环形队列:

管道实质上是一个内存中的文件,也就是实质上还是基于 Linux 的 VFS,用户过程能够通过 pipe() 零碎调用创立一个匿名管道,创立实现之后会有两个 VFS 的 file 构造体的 inode 别离指向其写入端和读出端,并返回对应的两个文件描述符,用户过程通过这两个文件描述符读写管道;管道的容量单位是一个虚拟内存的页,也就是 4KB,总大小个别是 16 个页,基于其环形构造,管道的页能够循环应用,进步内存利用率。Linux 中以 pipe_buffer 构造体封装管道页,file 构造体里的 inode 字段里会保留一个 pipe_inode_info 构造体指代管道,其中会保留很多读写管道时所需的元信息,环形队列的头部指针页,读写时的同步机制如互斥锁、期待队列等:

struct pipe_buffer {
    struct page *page; // 内存页构造
    unsigned int offset, len; // 偏移量,长度
    const struct pipe_buf_operations *ops;
    unsigned int flags;
    unsigned long private;
};

struct pipe_inode_info {
    struct mutex mutex;
    wait_queue_head_t wait;
    unsigned int nrbufs, curbuf, buffers;
    unsigned int readers;
    unsigned int writers;
    unsigned int files;
    unsigned int waiting_writers;
    unsigned int r_counter;
    unsigned int w_counter;
    struct page *tmp_page;
    struct fasync_struct *fasync_readers;
    struct fasync_struct *fasync_writers;
    struct pipe_buffer *bufs;
    struct user_struct *user;
};

pipe_buffer 中保留了数据在内存中的页、偏移量和长度,以这三个值来定位数据,留神这里的页不是虚拟内存的页,而用的是物理内存的页框,因为管道时跨过程的信道,因而不能应用虚拟内存来示意,只能应用物理内存的页框定位数据;管道的失常读写操作是通过 pipe_write()/pipe_read() 来实现的,通过把数据读取 / 写入环形队列的 pipe_buffer 来实现数据传输。

splice() 是基于 pipe buffer 实现的,然而它在通过管道传输数据的时候却是零拷贝,因为它在写入读出时并没有应用 pipe_write()/pipe_read() 真正地在管道缓冲区写入读出数据,而是通过把数据在内存缓冲区中的物理内存页框指针、偏移量和长度赋值给前文提及的 pipe_buffer 中对应的三个字段来实现数据的 ” 拷贝 ”,也就是其实只拷贝了数据的内存地址等元信息。

splice() 在 Linux 内核源码中的外部实现是 do_splice() 函数,而写入读出管道则别离是通过 do_splice_to()do_splice_from(),这里咱们重点来解析下写入管道的源码,也就是 do_splice_to(),我当初手头的 Linux 内核版本是 v4.8.17,咱们就基于这个版本来剖析,至于读出的源码函数 do_splice_from(),原理是相通的,大家触类旁通即可。

splice() 写入数据到管道的调用链式:do_splice() –> do_splice_to() –> splice_read()

static long do_splice(struct file *in, loff_t __user *off_in,
              struct file *out, loff_t __user *off_out,
              size_t len, unsigned int flags)
{
...

    // 判断是写出 fd 是一个管道设施,则进入数据写入的逻辑
    if (opipe) {if (off_out)
            return -ESPIPE;
        if (off_in) {if (!(in->f_mode & FMODE_PREAD))
                return -EINVAL;
            if (copy_from_user(&offset, off_in, sizeof(loff_t)))
                return -EFAULT;
        } else {offset = in->f_pos;}

        // 调用 do_splice_to 把文件内容写入管道
        ret = do_splice_to(in, &offset, opipe, len, flags);

        if (!off_in)
            in->f_pos = offset;
        else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
            ret = -EFAULT;

        return ret;
    }

    return -EINVAL;
}

进入 do_splice_to() 之后,再调用 splice_read()

static long do_splice_to(struct file *in, loff_t *ppos,
             struct pipe_inode_info *pipe, size_t len,
             unsigned int flags)
{ssize_t (*splice_read)(struct file *, loff_t *,
                   struct pipe_inode_info *, size_t, unsigned int);
    int ret;

    if (unlikely(!(in->f_mode & FMODE_READ)))
        return -EBADF;

    ret = rw_verify_area(READ, in, ppos, len);
    if (unlikely(ret < 0))
        return ret;

    if (unlikely(len > MAX_RW_COUNT))
        len = MAX_RW_COUNT;

    // 判断文件的文件的 file 构造体的 f_op 中有没有可供使用的、反对 splice 的 splice_read 函数指针
    // 因为是 splice() 调用,因而内核会提前给这个函数指针指派一个可用的函数
    if (in->f_op->splice_read)
        splice_read = in->f_op->splice_read;
    else
        splice_read = default_file_splice_read;

    return splice_read(in, ppos, pipe, len, flags);
}

in->f_op->splice_read 这个函数指针依据文件描述符的类型不同有不同的实现,比方这里的 in 是一个文件,因而是 generic_file_splice_read(),如果是 socket 的话,则是 sock_splice_read(),其余的类型也会有对应的实现,总之咱们这里将应用的是 generic_file_splice_read() 函数,这个函数会持续调用外部函数 __generic_file_splice_read 实现以下工作:

  1. 在 page cache 页缓存里进行搜查,看看咱们要读取这个文件内容是否曾经在缓存里了,如果是则间接用,否则如果不存在或者只有局部数据在缓存中,则调配一些新的内存页并进行读入数据操作,同时会减少页框的援用计数;
  2. 基于这些内存页,初始化 splice_pipe_desc 构造,这个构造保留会保留文件数据的地址元信息,蕴含有物理内存页框地址,偏移、数据长度,也就是 pipe_buffer 所需的三个定位数据的值;
  3. 最初,调用 splice_to_pipe(),splice_pipe_desc 构造体实例是函数入参。
ssize_t splice_to_pipe(struct pipe_inode_info *pipe, struct splice_pipe_desc *spd)
{
...

    for (;;) {if (!pipe->readers) {send_sig(SIGPIPE, current, 0);
            if (!ret)
                ret = -EPIPE;
            break;
        }

        if (pipe->nrbufs < pipe->buffers) {int newbuf = (pipe->curbuf + pipe->nrbufs) & (pipe->buffers - 1);
            struct pipe_buffer *buf = pipe->bufs + newbuf;

            // 写入数据到管道,没有真正拷贝数据,而是内存地址指针的挪动,// 把物理页框、偏移量和数据长度赋值给 pipe_buffer 实现数据入队操作
            buf->page = spd->pages[page_nr];
            buf->offset = spd->partial[page_nr].offset;
            buf->len = spd->partial[page_nr].len;
            buf->private = spd->partial[page_nr].private;
            buf->ops = spd->ops;
            if (spd->flags & SPLICE_F_GIFT)
                buf->flags |= PIPE_BUF_FLAG_GIFT;

            pipe->nrbufs++;
            page_nr++;
            ret += buf->len;

            if (pipe->files)
                do_wakeup = 1;

            if (!--spd->nr_pages)
                break;
            if (pipe->nrbufs < pipe->buffers)
                continue;

            break;
        }

    ...
}

这里能够分明地看到 splice() 所谓的写入数据到管道其实并没有真正地拷贝数据,而是玩了个 tricky 的操作:只进行内存地址指针的拷贝而不真正去拷贝数据。所以,数据 splice() 在内核中并没有进行真正的数据拷贝,因而 splice() 零碎调用也是零拷贝。

还有一点须要留神,后面说过管道的容量是 16 个内存页,也就是 16 * 4KB = 64 KB,也就是说一次往管道里写数据的时候最好不要超过 64 KB,否则的话会 splice() 会阻塞住,除非在创立管道的时候应用的是 pipe2() 并通过传入 O_NONBLOCK 属性将管道设置为非阻塞。

即便 splice() 通过内存地址指针防止了真正的拷贝开销,然而算起来它还要应用额定的管道来实现数据传输,也就是比 sendfile() 多了两次零碎调用,这不是又减少了上下文切换的开销吗?为什么不间接在内核创立管道并调用那两次 splice(),而后只裸露给用户一次零碎调用呢?实际上因为 splice() 利用管道而非硬件来实现零拷贝的实现比 sendfile() + DMA Scatter/Gather 的门槛更低,因而起初的 sendfile() 的底层实现就曾经替换成 splice() 了。

至于说 splice() 自身的 API 为什么还是这种应用模式,那是因为 Linux 内核开发团队始终想把基于管道的这个限度去掉,但不晓得因为什么始终搁置,所以这个 API 也就始终没变动,只能等内核团队哪天想起来了这一茬,而后重构一下使之不再依赖管道,在那之前,应用 splice() 仍然还是须要额定创立管道来作为两头缓冲,如果你的业务场景很适宜应用 splice(),但又是性能敏感的,不想频繁地创立销毁 pipe buffer 管道缓冲区,那么能够参考一下 HAProxy 应用 splice() 时采纳的优化计划:事后调配一个 pipe buffer pool 缓存管道,每次调用 spclie() 的时候去缓存池里取一个管道,用完就放回去,循环利用,晋升性能。

send() with MSG_ZEROCOPY

Linux 内核在 2017 年的 v4.14 版本承受了来自 Google 工程师 Willem de Bruijn 在 TCP 网络报文的通用发送接口 send() 中实现的 zero-copy 性能 (MSG_ZEROCOPY) 的 patch,通过这个新性能,用户过程就可能把用户缓冲区的数据通过零拷贝的形式通过内核空间发送到网络套接字中去,这个新技术和前文介绍的几种零拷贝形式相比更加先进,因为后面几种零拷贝技术都是要求用户过程不能解决加工数据而是间接转发到指标文件描述符中去的。Willem de Bruijn 在他的论文里给出的压测数据是:采纳 netperf 大包发送测试,性能晋升 39%,而线上环境的数据发送性能则晋升了 5%~8%,官网文档陈说说这个个性通常只在发送 10KB 左右大包的场景下才会有显著的性能晋升。一开始这个个性只反对 TCP,到内核 v5.0 版本之后才反对 UDP。

这个性能的应用模式如下:

if (setsockopt(socket_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
        error(1, errno, "setsockopt zerocopy");

ret = send(socket_fd, buffer, sizeof(buffer), MSG_ZEROCOPY);

首先第一步,先给要发送数据的 socket 设置一个 SOCK_ZEROCOPY option,而后在调用 send() 发送数据时再设置一个 MSG_ZEROCOPY option,其实实践上来说只须要调用 setsockopt() 或者 send() 时传递这个 zero-copy 的 option 即可,两者选其一,然而这里却要设置同一个 option 两次,官网的说法是为了兼容 send() API 以前的设计上的一个谬误:send() 以前的实现会疏忽掉未知的 option,为了兼容那些可能曾经不小心设置了 MSG_ZEROCOPY option 的程序,故而设计成了两步设置。不过我猜还有一种可能:就是给使用者提供更灵便的应用模式,因为这个新性能只在大包场景下才可能会有显著的性能晋升,然而事实场景是很简单的,不仅仅是全副大包或者全副小包的场景,有可能是大包小包混合的场景,因而使用者能够先调用 setsockopt() 设置 SOCK_ZEROCOPY option,而后再依据理论业务场景中的网络包尺寸抉择是否要在调用 send() 时应用 MSG_ZEROCOPY 进行 zero-copy 传输。

因为 send() 可能是异步发送数据,因而应用 MSG_ZEROCOPY 有一个须要特地留神的点是:调用 send() 之后不能立即重用或开释 buffer,因为 buffer 中的数据不肯定曾经被内核读走了,所以还须要从 socket 关联的谬误队列里读取一下告诉音讯,看看 buffer 中的数据是否曾经被内核读走了:

pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
        error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
        error(1, errno, "recvmsg");

read_notification(msg);


uint32_t read_notification(struct msghdr *msg)
{
    struct sock_extended_err *serr;
    struct cmsghdr *cm;
    
    cm = CMSG_FIRSTHDR(msg);
    if (cm->cmsg_level != SOL_IP &&
        cm->cmsg_type != IP_RECVERR)
            error(1, 0, "cmsg");
    
    serr = (void *) CMSG_DATA(cm);
    if (serr->ee_errno != 0 ||
        serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)
            error(1, 0, "serr");
    
    return serr->ee _ data;
}

这个技术是基于 redhat 红帽在 2010 年给 Linux 内核提交的 virtio-net zero-copy 技术之上实现的,至于底层原理,简略来说就是通过 send() 把数据在用户缓冲区中的分段指针发送到 socket 中去,利用 page pinning 页锁定机制锁住用户缓冲区的内存页,而后利用 DMA 间接在用户缓冲区通过内存地址指针进行数据读取,实现零拷贝;具体的细节能够通过浏览 Willem de Bruijn 的论文 (PDF) 深刻理解。

目前来说,这种技术的次要缺点有:

  1. 只实用于大文件 (10KB 左右) 的场景,小文件场景因为 page pinning 页锁定和期待缓冲区开释的告诉音讯这些机制,甚至可能比间接 CPU 拷贝更耗时;
  2. 因为可能异步发送数据,须要额定调用 poll()recvmsg() 零碎调用期待 buffer 被开释的告诉音讯,减少代码复杂度,以及会导致屡次用户态和内核态的上下文切换;
  3. MSG_ZEROCOPY 目前只反对发送端,接收端暂不反对。

绕过内核的间接 I/O

能够看出,后面种种的 zero-copy 的办法,都是在千方百计地优化缩小或者去掉用户态和内核态之间以及内核态和内核态之间的数据拷贝,为了实现防止这些拷贝堪称是八仙过海,各显神通,采纳了各种各样的伎俩,那么如果咱们换个思路:其实这么吃力地去打消这些拷贝不就是因为有内核在掺和吗?如果咱们绕过内核间接进行 I/O 不就没有这些烦人的拷贝问题了吗?这就是 绕过内核间接 I/O 技术:

这种计划有两种实现形式:

  1. 用户间接拜访硬件
  2. 内核管制拜访硬件
用户间接拜访硬件

这种技术赋予用户过程间接拜访硬件设施的权限,这让用户过程能有间接读写硬件设施,在数据传输过程中只须要内核做一些虚拟内存配置相干的工作。这种无需数据拷贝和内核干涉的间接 I/O,实践上是最高效的数据传输技术,然而正如后面所说的那样,并不存在能解决所有问题的银弹,这种间接 I/O 技术尽管有可能十分高效,然而它的适用性也十分窄,目前只实用于诸如 MPI 高性能通信、丛集计算零碎中的近程共享内存等无限的场景。

这种技术实际上毁坏了古代计算机操作系统最重要的概念之一 —— 硬件形象,咱们之前提过,形象是计算机领域最最外围的设计思路,正式因为有了形象和分层,各个层级能力不用去关怀很多底层细节从而专一于真正的工作,才使得零碎的运作更加高效和疾速。此外,网卡通常应用性能较弱的 CPU,例如只蕴含简略指令集的 MIPS 架构处理器(没有不必要的性能,如浮点数计算等),也没有太多的内存来包容简单的软件。因而,通常只有那些基于以太网之上的专用协定会应用这种技术,这些专用协定的设计要比远比 TCP/IP 简略得多,而且多用于局域网环境中,在这种环境中,数据包失落和损坏很少产生,因而没有必要进行简单的数据包确认和流量管制机制。而且这种技术还须要定制的网卡,所以它是高度依赖硬件的。

与传统的通信设计相比,间接硬件拜访技术给程序设计带来了各种限度:因为设施之间的数据传输是通过 DMA 实现的,因而用户空间的数据缓冲区内存页必须进行 page pinning(页锁定),这是为了避免其物理页框地址被替换到磁盘或者被挪动到新的地址而导致 DMA 去拷贝数据的时候在指定的地址找不到内存页从而引发缺页谬误,而页锁定的开销并不比 CPU 拷贝小,所以为了防止频繁的页锁定零碎调用,应用程序必须调配和注册一个长久的内存池,用于数据缓冲。

用户间接拜访硬件的技术能够失去极高的 I/O 性能,然而其应用领域和实用场景也极其的无限,如集群或网络存储系统中的节点通信。它须要定制的硬件和专门设计的应用程序,但相应地对操作系统内核的改变比拟小,能够很容易地以内核模块或设施驱动程序的模式实现进去。间接拜访硬件还可能会带来重大的平安问题,因为用户过程领有间接拜访硬件的极高权限,所以如果你的程序设计没有做好的话,可能会耗费原本就无限的硬件资源或者进行非法地址拜访,可能也会因而间接地影响其余正在应用同一设施的应用程序,而因为绕开了内核,所以也无奈让内核替你去管制和治理。

内核管制拜访硬件

相较于用户间接拜访硬件技术,通过内核管制的间接拜访硬件技术更加的平安,它比前者在数据传输过程中会多干涉一点,但也仅仅是作为一个代理人这样的角色,不会参加到理论的数据传输过程,内核会管制 DMA 引擎去替用户过程做缓冲区的数据传输工作。同样的,这种形式也是高度依赖硬件的,比方一些集成了专有网络栈协定的网卡。这种技术的一个劣势就是用户集成去 I/O 时的接口不会扭转,就和一般的 read()/write() 零碎调用那样应用即可,所有的脏活累活都在内核里实现,用户接口友好度很高,不过须要留神的是,应用这种技术的过程中如果产生了什么不可预知的意外从而导致无奈应用这种技术进行数据传输的话,则内核会主动切换为最传统 I/O 模式,也就是性能最差的那种模式。

这种技术也有着和用户间接拜访硬件技术一样的问题:DMA 传输数据的过程中,用户过程的缓冲区内存页必须进行 page pinning 页锁定,数据传输实现后能力解锁。CPU 高速缓存内保留的多个内存地址也会被冲刷掉以保障 DMA 传输前后的数据一致性。这些机制有可能会导致数据传输的性能变得更差,因为 read()/write() 零碎调用的语义并不能提前告诉 CPU 用户缓冲区要参加 DMA 数据传输传输,因而也就无奈像内核缓冲区那样可依提前加载进高速缓存,进步性能。因为用户缓冲区的内存页可能散布在物理内存中的任意地位,因而一些实现不好的 DMA 控制器引擎可能会有寻址限度从而导致无法访问这些内存区域。一些技术比方 AMD64 架构中的 IOMMU,容许通过将 DMA 地址从新映射到内存中的物理地址来解决这些限度,但反过来又可能会导致可移植性问题,因为其余的处理器架构,甚至是 Intel 64 位 x86 架构的变种 EM64T 都不具备这样的个性单元。此外,还可能存在其余限度,比方 DMA 传输的数据对齐问题,又会导致无法访问用户过程指定的任意缓冲区内存地址。

内核缓冲区和用户缓冲区之间的传输优化

到目前为止,咱们探讨的 zero-copy 技术都是基于缩小甚至是防止用户空间和内核空间之间的 CPU 数据拷贝的,尽管有一些技术十分高效,然而大多都有适用性很窄的问题,比方 sendfile()splice() 这些,效率很高,然而都只实用于那些用户过程不须要间接解决数据的场景,比方动态文件服务器或者是间接转发数据的代理服务器。

当初咱们曾经晓得,硬件设施之间的数据能够通过 DMA 进行传输,然而却并没有这样的传输机制能够利用于用户缓冲区和内核缓冲区之间的数据传输。不过另一方面,广泛应用在古代的 CPU 架构和操作系统上的虚拟内存机制表明,通过在不同的虚拟地址上从新映射页面能够实现在用户过程和内核之间虚构复制和共享内存,只管一次传输的内存颗粒度绝对较大:4KB 或 8KB。

因而如果要在实现在用户过程内解决数据(这种场景比间接转发数据更加常见)之后再发送进来的话,用户空间和内核空间的数据传输就是不可避免的,既然避无可避,那就只能抉择优化了,因而本章节咱们要介绍两种优化用户空间和内核空间数据传输的技术:

  1. 动静重映射与写时拷贝 (Copy-on-Write)
  2. 缓冲区共享 (Buffer Sharing)
动静重映射与写时拷贝 (Copy-on-Write)

后面咱们介绍过利用内存映射技术来缩小数据在用户空间和内核空间之间的复制,通常简略模式下,用户过程是对共享的缓冲区进行同步阻塞读写的,这样不会有 data race 问题,然而这种模式下效率并不高,而晋升效率的一种办法就是异步地对共享缓冲区进行读写,而这样的话就必须引入爱护机制来防止数据抵触问题,写时复制 (Copy on Write) 就是这样的一种技术。

写入时复制Copy-on-writeCOW)是一种计算机程序设计畛域的优化策略。其核心思想是,如果有多个调用者(callers)同时申请雷同资源(如内存或磁盘上的数据存储),他们会独特获取雷同的指针指向雷同的资源,直到某个调用者试图批改资源的内容时,零碎才会真正复制一份专用正本(private copy)给该调用者,而其余调用者所见到的最后的资源依然放弃不变。这过程对其余的调用者都是通明的。此作法次要的长处是如果调用者没有批改该资源,就不会有正本(private copy)被创立,因而多个调用者只是读取操作时能够共享同一份资源。

举一个例子,引入了 COW 技术之后,用户过程读取磁盘文件进行数据处理最初写到网卡,首先应用内存映射技术让用户缓冲区和内核缓冲区共享了一段内存地址并标记为只读 (read-only),防止数据拷贝,而当要把数据写到网卡的时候,用户过程抉择了异步写的形式,零碎调用会间接返回,数据传输就会在内核里异步进行,而用户过程就能够持续其余的工作,并且共享缓冲区的内容能够随时再进行读取,效率很高,然而如果该过程又尝试往共享缓冲区写入数据,则会产生一个 COW 事件,让试图写入数据的过程把数据复制到本人的缓冲区去批改,这里只须要复制要批改的内存页即可,无需所有数据都复制过来,而如果其余拜访该共享内存的过程不须要批改数据则能够永远不须要进行数据拷贝。

COW 是一种建构在虚拟内存冲映射技术之上的技术,因而它须要 MMU 的硬件反对,MMU 会记录以后哪些内存页被标记成只读,当有过程尝试往这些内存页中写数据的时候,MMU 就会抛一个异样给操作系统内核,内核解决该异样时为该过程调配一份物理内存并复制数据到此内存地址,从新向 MMU 收回执行该过程的写操作。

COW 最大的劣势是节俭内存和缩小数据拷贝,不过却是通过减少操作系统内核 I/O 过程复杂性作为代价的。当确定采纳 COW 来复制页面时,重要的是留神闲暇页面的调配地位。许多操作系统为这类申请提供了一个闲暇的页面池。当过程的堆栈或堆要扩大时或有写时复制页面须要治理时,通常调配这些闲暇页面。操作系统调配这些页面通常采纳称为 按需填零 的技术。按需填零页面在须要调配之前先填零,因而会革除外面旧的内容。

局限性

COW 这种零拷贝技术比拟实用于那种多读少写从而使得 COW 事件产生较少的场景,因为 COW 事件所带来的零碎开销要远远高于一次 CPU 拷贝所产生的。此外,在理论利用的过程中,为了防止频繁的内存映射,能够重复使用同一段内存缓冲区,因而,你不须要在只用过一次共享缓冲区之后就解除掉内存页的映射关系,而是反复循环应用,从而晋升性能,不过这种内存页映射的长久化并不会缩小因为页表往返挪动和 TLB 冲刷所带来的零碎开销,因为每次接管到 COW 事件之后对内存页而进行加锁或者解锁的时候,页面的只读标记 (read-ony) 都要被更改为 (write-only)。

缓冲区共享 (Buffer Sharing)

从后面的介绍能够看出,传统的 Linux I/ O 接口,都是基于复制 / 拷贝的:数据须要在操作系统内核空间和用户空间的缓冲区之间进行拷贝。在进行 I/O 操作之前,用户过程须要事后调配好一个内存缓冲区,应用 read() 零碎调用时,内核会将从存储器或者网卡等设施读入的数据拷贝到这个用户缓冲区里;而应用 write() 零碎调用时,则是把用户内存缓冲区的数据拷贝至内核缓冲区。

为了实现这种传统的 I/O 模式,Linux 必须要在每一个 I/O 操作时都进行内存虚构映射和解除。这种内存页重映射的机制的效率重大受限于缓存体系结构、MMU 地址转换速度和 TLB 命中率。如果可能防止解决 I/O 申请的虚拟地址转换和 TLB 刷新所带来的开销,则有可能极大地晋升 I/O 性能。而缓冲区共享就是用来解决上述问题的一种技术。

最早反对 Buffer Sharing 的操作系统是 Solaris。起初,Linux 也逐渐反对了这种 Buffer Sharing 的技术,但时至今日仍然不够残缺和成熟。

操作系统内核开发者们实现了一种叫 fbufs 的缓冲区共享的框架,也即 疾速缓冲区(Fast Buffers),应用一个 fbuf 缓冲区作为数据传输的最小单位,应用这种技术须要调用新的操作系统 API,用户区和内核区、内核区之间的数据都必须严格地在 fbufs 这个体系下进行通信。fbufs 为每一个用户过程调配一个 buffer pool,外面会贮存预调配 (也能够应用的时候再调配) 好的 buffers,这些 buffers 会被同时映射到用户内存空间和内核内存空间。fbufs 只需通过一次虚拟内存映射操作即可创立缓冲区,无效地打消那些由存储一致性保护所引发的大多数性能损耗。

传统的 Linux I/O 接口是通过把数据在用户缓冲区和内核缓冲区之间进行拷贝传输来实现的,这种数据传输过程中须要进行大量的数据拷贝,同时因为虚拟内存技术的存在,I/O 过程中还须要频繁地通过 MMU 进行虚拟内存地址到物理内存地址的转换,高速缓存的汰换以及 TLB 的刷新,这些操作均会导致性能的损耗。而如果利用 fbufs 框架来实现数据传输的话,首先能够把 buffers 都缓存到 pool 里循环利用,而不须要每次都去重新分配,而且缓存下来的不止有 buffers 自身,而且还会把虚拟内存地址到物理内存地址的映射关系也缓存下来,也就能够防止每次都进行地址转换,从发送接收数据的层面来说,用户过程和 I/O 子系统比方设施驱动程序、网卡等能够间接传输整个缓冲区自身而不是其中的数据内容,也能够了解成是传输内存地址指针,这样就就防止了大量的数据内容拷贝:用户过程 / IO 子系统通过发送一个个的 fbuf 写出数据到内核而非间接传递数据内容,绝对应的,用户过程 / IO 子系统通过接管一个个的 fbuf 而从内核读入数据,这样就能缩小传统的 read()/write() 零碎调用带来的数据拷贝开销:

  1. 发送方用户过程调用 uf_allocate 从本人的 buffer pool 获取一个 fbuf 缓冲区,往其中填充内容之后调用 uf_write 向内核区发送指向 fbuf 的文件描述符;
  2. I/O 子系统接管到 fbuf 之后,调用 uf_allocb 从接管方用户过程的 buffer pool 获取一个 fubf 并用接管到的数据进行填充,而后向用户区发送指向 fbuf 的文件描述符;
  3. 接管方用户过程调用 uf_get 接管到 fbuf,读取数据进行解决,实现之后调用 uf_deallocate 把 fbuf 放回本人的 buffer pool。

fbufs 的缺点

共享缓冲区技术的实现须要依赖于用户过程、操作系统内核、以及 I/O 子系统 (设施驱动程序,文件系统等)之间协同工作。比方,设计得不好的用户过程容易就会批改曾经发送进来的 fbuf 从而净化数据,更要命的是这种问题很难 debug。尽管这个技术的设计方案十分精彩,然而它的门槛和限度却不比后面介绍的其余技术少:首先会对操作系统 API 造成变动,须要应用新的一些 API 调用,其次还须要设施驱动程序配合改变,还有因为是内存共享,内核须要很小心谨慎地实现对这部分共享的内存进行数据保护和同步的机制,而这种并发的同步机制是非常容易出 bug 的从而又减少了内核的代码复杂度,等等。因而这一类的技术还远远没有到倒退成熟和广泛应用的阶段,目前大多数的实现都还处于试验阶段。

总结

本文中我次要解说了 Linux I/O 底层原理,而后介绍并解析了 Linux 中的 Zero-copy 技术,并给出了 Linux 对 I/O 模块的优化和改良思路。

Linux 的 Zero-copy 技术能够演绎成以下三大类:

  • 缩小甚至防止用户空间和内核空间之间的数据拷贝:在一些场景下,用户过程在数据传输过程中并不需要对数据进行拜访和解决,那么数据在 Linux 的 Page Cache 和用户过程的缓冲区之间的传输就齐全能够防止,让数据拷贝齐全在内核里进行,甚至能够通过更奇妙的形式防止在内核里的数据拷贝。这一类实现个别是是通过减少新的零碎调用来实现的,比方 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 绕过内核的间接 I/O:容许在用户态过程绕过内核间接和硬件进行数据传输,内核在传输过程中只负责一些治理和辅助的工作。这种形式其实和第一种有点相似,也是试图防止用户空间和内核空间之间的数据传输,只是第一种形式是把数据传输过程放在内核态实现,而这种形式则是间接绕过内核和硬件通信,成果相似但原理齐全不同。
  • 内核缓冲区和用户缓冲区之间的传输优化:这种形式侧重于在用户过程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种办法连续了以往那种传统的通信形式,但更灵便。

本文从虚拟内存、I/O 缓冲区,用户态 & 内核态以及 I/O 模式等等知识点全面而又详尽地分析了 Linux 零碎的 I/O 底层原理,剖析了 Linux 传统的 I/O 模式的弊病,进而引入 Linux Zero-copy 零拷贝技术的介绍和原理解析,通过将零拷贝技术和传统的 I/O 模式进行辨别和比照,率领读者经验了 Linux I/O 的演变历史,通过帮忙读者了解 Linux 内核对 I/O 模块的优化改良思路,置信不仅仅是让读者理解 Linux 底层零碎的设计原理,更能对读者们在当前优化改良本人的程序设计过程中可能有所启发。

参考 & 延长浏览

  • MODERN OPERATING SYSTEMS
  • Zero Copy I: User-Mode Perspective
  • Message Passing for Gigabit/s Networks with“Zero-Copy”under Linux
  • ZeroCopy: Techniques, Benefits and Pitfalls
  • Zero-copy networking
  • Driver porting: Zero-copy user-space access
  • sendmsg copy avoidance with MSG_ZEROCOPY
  • It’s all about buffers: zero-copy, mmap and Java NIO
  • Linux Zero Copy
  • Two new system calls: splice() and sync_file_range()
  • Circular pipes
  • The future of the page cache
  • Provide a zero-copy method on KVM virtio-net
退出移动版