关于linux:从Linux零拷贝深入了解LinuxIO

6次阅读

共计 15325 个字符,预计需要花费 39 分钟才能阅读完成。

导言 | 本文邀请到腾讯 CSIG 后盾开发工程师 kevineluo 从文件传输场景以及零拷贝技术深究 Linux I/ O 的倒退过程、优化伎俩以及理论利用。I/ O 相干的各类优化曾经深刻到了日常开发者接触到的语言、中间件以及数据库的方方面面。通过理解和学习相干技术和思维,开发者能对日后本人的程序设计以及性能优化上有所启发。

前言

存储器是计算机的核心部件之一,在齐全现实的状态下,存储器应该要同时具备以下三种个性:第一,速度足够快:存储器的存取速度该当快于 CPU 执行一条指令,这样 CPU 的效率才不会受限于存储器;第二,容量足够大:容量可能存储计算机所需的全副数据;第三,价格足够便宜:价格低廉,所有类型的计算机都能装备。

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

从顶至底,古代计算机里的存储器类型别离有:寄存器、高速缓存、主存和磁盘,这些存储器的速度逐级递加而容量逐级递增。

存取速度最快的是 寄存器,因为寄存器的制作资料和 CPU 是雷同的,所以速度和 CPU 一样快,CPU 拜访寄存器是没有时延的,然而因为价格昂贵,因而容量也极小,个别 32 位的 CPU 装备的寄存器容量是 32✖️32Bit,64 位的 CPU 则是 64✖️64Bit,不论是 32 位还是 64 位,寄存器容量都小于 1KB,且寄存器也必须通过软件自行治理。

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

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

至于 磁盘 则是图中离用户最远的一层了,读写速度相差内存上百倍;另一方面天然针对磁盘操作的优化也十分多,如零拷贝、direct I/O、异步 I / O 等等,这些优化的目标都是为了进步零碎的吞吐量;另外操作系统内核中也有磁盘高速缓存区、PageCache、TLB 等,能够无效的缩小磁盘的拜访次数。

现实情况中,大部分零碎在由小变大的过程中,最先呈现瓶颈的就是 I /O,尤其是在古代网络应用从 CPU 密集型转向了 I / O 密集型的大背景下,I/ O 越发成为大多数利用的性能瓶颈。

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

这篇文章就从文件传输场景,以及 零拷贝 技术深究 Linux I/ O 的倒退过程、优化伎俩以及理论利用。

须要理解的词

DMA:DMA,全称 Direct Memory Access,即间接存储器拜访,是为了防止 CPU 在磁盘操作时承当过多的中断负载而设计的;在磁盘操作中,CPU 可将总线控制权交给 DMA 控制器,由 DMA 输入读写命令,间接管制 RAM 与 I / O 接口进行 DMA 传输,无需 CPU 间接控制传输,也没有中断解决形式那样保留现场和复原现场过程,使得 CPU 的效率大大提高。

MMU:Memory Management Unit—内存治理单元,次要实现:

竞争拜访爱护治理需要:须要严格的拜访爱护,动静治理哪些内存页 / 段或区,为哪些应用程序所用。这属于资源的竞争拜访治理需要;

高效的翻译转换治理需要:须要实现疾速高效的映射翻译转换,否则零碎的运行效率将会低下;

高效的虚实内存替换需要:须要在理论的虚拟内存与物理内存进行内存页 / 段替换过程中疾速高效。

Page Cache:为了防止每次读写文件时,都须要对硬盘进行读写操作,Linux 内核应用页缓存(Page Cache)机制来对文件中的数据进行缓存。

此外,因为读取磁盘数据的时候,须要找到数据所在的地位,然而对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「程序」读取数据,然而旋转磁头这个物理动作是十分耗时的,为了升高它的影响,PageCache 应用了「预读性能」

比方,假如 read 办法每次只会读 32KB 的字节,尽管 read 刚开始只会读 0~32KB 的字节,但内核会把其前面的 32~64KB 也读取到 PageCache,这样前面读取 32~64KB 的老本就很低,如果在 32~64KB 淘汰出 PageCache 前,有过程读取到它了,收益就十分大。

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

虚拟内存为每个过程提供了一个 统一的、公有且间断残缺的内存空间;所有古代操作系统都应用虚拟内存,应用虚拟地址取代物理地址,次要有以下几点益处:

第一点,利用上述的第一条个性能够优化,能够把 内核空间和用户空间的虚构地址映射到同一个物理地址,这样在 I / O 操作时就不须要来回复制了。

第二点,多个虚拟内存能够指向同一个物理地址;第三点,虚拟内存空间能够远远大于物理内存空间;第四点,利用层面可治理间断的内存空间,缩小出错。

NFS文件系统:网络文件系统是 FreeBSD 反对的文件系统中的一种,也被称为 NFS;NFS 容许一个零碎在网络上与它人共享目录和文件,通过应用 NFS,用户和程序能够象拜访本地文件一样拜访远端零碎上的文件。

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

为什么要有 DMA

在没有 DMA 技术前,I/ O 的过程是这样的:首先,CPU 收回对应的指令给磁盘控制器,而后返回;其次,磁盘控制器收到指令后,于是就开始筹备数据,会把数据放入到磁盘控制器的外部缓冲区中,而后产生一个中断;最初,CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进本人的寄存器,而后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是被阻塞的状态,无奈执行其余工作。

整个数据的传输过程,都要须要 CPU 亲自参加拷贝数据,而且这时 CPU 是被阻塞的;简略的搬运几个字符数据那没问题,然而如果咱们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,必定忙不过来。

计算机科学家们发现了事件的严重性后,于是就创造了 DMA 技术,也就是 间接内存拜访(Direct Memory Access)技术。简略了解就是,在进行 I /O 设施和内存的数据传输的时候,数据搬运的工作全副交给 DMA 控制器,而 CPU 不再参加任何与数据搬运相干的事件,这样 CPU 就能够去解决别的事务

具体流程如下图:

首先,用户过程调用 read 办法,向操作系统收回 I / O 申请,申请读取数据到本人的内存缓冲区中,过程进入阻塞状态;其次,操作系统收到申请后,进一步将 I / O 申请发送 DMA,开释 CPU;再次,DMA 进一步将 I / O 申请发送给磁盘;从次,磁盘收到 DMA 的 I / O 申请,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发动中断信号,告知本人缓冲区已满;最初,DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 仍然能够执行其它事务;另外,当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;除此之外,CPU 收到中断信号,将数据从内核拷贝到用户空间,零碎调用返回。

在有了 DMA 后,整个数据传输的过程,CPU 不再参加与磁盘交互的数据搬运工作,而是全程由 DMA 实现,然而 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都须要 CPU 来通知 DMA 控制器。

晚期 DMA 只存在在主板上,现在因为 I / O 设施越来越多,数据传输的需要也不尽相同,所以每个 I / O 设施外面都有本人的 DMA 控制器。

传统文件传输的缺点

有了 DMA 后,咱们的磁盘 I / O 就一劳永逸了吗?并不是的;拿咱们比拟相熟的下载文件举例,服务端要提供此性能,比拟直观的形式就是:将磁盘中的文件读出到内存,再通过网络协议发送给客户端。

具体的 I / O 工作形式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I / O 接口从磁盘读取或写入。

代码通常如下,个别会须要两个零碎调用:

read(file, tmp_buf, len)
write(socket, tmp_buf, len)

代码很简略,尽管就两行代码,然而这外面产生了不少的事件:

这其中有: 4 次用户态与内核态的上下文切换 两次零碎调用 read()和 write()中,每次零碎调用都得先从 用户态切换到内核态 ,等内核实现工作后,再从内核态切换回用户态; 上下文切换 的老本并不小,一次切换须要耗时几十纳秒到几微秒,在高并发场景下很容易成为性能瓶颈。(参考 线程切换和协程切换的老本差异

4 次数据拷贝 两次由 DMA 实现拷贝,另外两次则是由 CPU 实现拷贝;咱们只是搬运一份数据,后果却搬运了 4 次,过多的数据拷贝无疑会耗费额定的资源,大大降低了零碎性能。

所以,要想进步文件传输的性能,就须要缩小 用户态与内核态的上下文切换 内存拷贝 的次数。

如何优化传统文件传输——缩小「用户态与内核态的上下文切换」:读取磁盘数据的时候,之所以要产生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设施的过程都须要交由操作系统内核来实现,所以个别要通过内核去实现某些工作的时候,就须要应用操作系统提供的零碎调用函数。

而一次零碎调用必然会产生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完工作后,再切换回用户态交由过程代码执行。

缩小「数据拷贝」次数 :后面提到,传统的文件传输方式会历经 4 次数据拷贝;但很显著的能够看到: 从内核的读缓冲区拷贝到用户的缓冲区 从用户的缓冲区里拷贝到 socket 的缓冲区 这两步是没有必要的。

因为在下载文件,或者说狭义的文件传输场景中,咱们并不需要在用户空间对数据进行 再加工,所以数据并不需要回到用户空间中。

零拷贝

那么 零拷贝 技术就应运而生了,它就是为了解决咱们在下面提到的场景——跨过与用户态交互的过程,间接将数据从文件系统挪动到网络接口而产生的技术。

1)零拷贝实现原理

零拷贝技术实现的形式通常有 3 种:mmap+write、sendfile、splice。

  • mmap + write

在后面咱们晓得,read()零碎调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了省去这一步,咱们能够用 mmap()替换 read()零碎调用函数,伪代码如下:

buf = mmap(file, len)
write(sockfd, buf, len)

mmap 的函数原型如下:

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

mmap()零碎调用函数会在调用过程的虚拟地址空间中创立一个新映射,间接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不须要再进行任何的数据拷贝操作。

具体过程如下:首先,利用过程调用了 mmap()后,DMA 会把磁盘的数据拷贝到内核的缓冲区里,利用过程跟操作系统内核「共享」这个缓冲区;其次,利用过程再调用 write(),操作系统间接将内核缓冲区的数据拷贝到 socket 缓冲区中,这所有都产生在内核态,由 CPU 来搬运数据;最初,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

咱们能够看到,通过应用 mmap()来代替 read(),能够缩小一次数据拷贝的过程。但这还不是最现实的零拷贝,因为依然须要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,且依然须要 4 次上下文切换,因为零碎调用还是 2 次

  • sendfile

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的零碎调用函数 sendfile()如下:

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数别离是目标端和源端的文件描述符,前面两个参数是源端的偏移量和复制数据的长度,返回值是理论复制数据的长度。

首先,它能够代替后面的 read()和 write()这两个零碎调用,这样就能够缩小一次零碎调用,也就缩小了 2 次上下文切换的开销。其次,该零碎调用,能够间接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

带有 scatter/gather 的 sendfile 形式:Linux 2.4 内核进行了优化,提供了带有 scatter/gather 的 sendfile 操作,这个操作能够把最初一次 CPU COPY 去除。其原理就是在内核空间 Read BUffer 和 Socket Buffer 不做数据复制,而是将 Read Buffer 的内存地址、偏移量记录到相应的 Socket Buffer 中,这样就不须要复制。其本质和虚拟内存的解决办法思路统一,就是内存地址的记录。

你能够在你的 Linux 零碎通过上面这个命令,查看网卡是否反对 scatter-gather 个性:

$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是,从 Linux 内核 2.4 版本开始起,对于反对网卡反对 SG-DMA 技术的状况下,sendfile()零碎调用的过程产生了点变动,具体过程如下:

第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就能够间接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不须要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就缩小了一次数据拷贝。所以,这个过程之中,只进行了 2 次数据拷贝,如下图:

  • splice 形式

splice 调用和 sendfile 十分类似,用户应用程序必须领有两个曾经关上的文件描述符,一个示意输出设施,一个示意输出设备。与 sendfile 不同的是,splice 容许任意两个文件相互连贯,而并不只是文件与 socket 进行数据传输。对于从一个文件描述符发送数据到 socket 这种特例来说,始终都是应用 sendfile 零碎调用,而 splice 始终以来就只是一种机制,它并不仅限于 sendfile 的性能。也就是说sendfile 是 splice 的一个子集

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

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

首先,用户过程调用 pipe(),从用户态陷入内核态;创立匿名单向管道,pipe()返回,上下文从内核态切换回用户态;其次,用户过程调用 splice(),从用户态陷入内核态;再次,DMA 控制器将数据从硬盘拷贝到内核缓冲区,从管道的写入端 ” 拷贝 ” 进管道,splice()返回,上下文从内核态回到用户态;从次,用户过程再次调用 splice(),从用户态陷入内核态;最初,内核把数据从管道的读取端拷贝到 socket 缓冲区,DMA 控制器将数据从 socket 缓冲区拷贝到网卡;另外,splice()返回,上下文从内核态切换回用户态。

在 Linux2.6.17 版本引入了 splice,而在 Linux 2.6.23 版本中,sendfile 机制的实现曾经没有了,然而其 API 及相应的性能还在,只不过 API 及相应的性能是利用了 splice 机制来实现的。和 sendfile 不同的是,splice 不须要硬件反对。

零拷贝的理论利用

1)Kafka

事实上,Kafka 这个开源我的项目,就利用了「零拷贝」技术,从而大幅晋升了 I/ O 的吞吐率,这也是 Kafka 在解决海量数据为什么这么快的起因之一。如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo 办法:

@Overridepublic
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {return fileChannel.transferTo(position, count, socketChannel);}

2)Nginx

Nginx 也反对零拷贝技术,个别默认是开启零拷贝技术,这样有利于进步文件传输的效率,是否开启零拷贝技术的配置如下:

http
{
...    
   sendfile on
...
}

大文件传输场景

1)零拷贝还是最优选吗

在大文件传输的场景下,零拷贝技术并不是最优抉择;因为在零拷贝的任何一种实现中,都会有「DMA 将数据从磁盘拷贝到内核缓存区——Page Cache」这一步,然而,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的升高,即便应用了 PageCache 的零拷贝也会损失性能。

这是因为在大文件传输场景下,每当用户拜访这些大文件的时候,内核就会把它们载入 PageCache 中,PageCache 空间很快被这些大文件占满;且因为文件太大,可能某些局部的文件数据被再次拜访的概率比拟低,这样就会带来 2 个问题:PageCache 因为长时间被大文件占据,其余「热点」的小文件可能就无奈充沛应用到 PageCache,于是这样磁盘读写的性能就会降落了;PageCache 中的大文件数据,因为没有享受到缓存带来的益处,但却消耗 DMA 多拷贝到 PageCache 一次。

2)异步 I /O+direct I/O

那么大文件传输场景下咱们该抉择什么计划呢?让咱们先来回顾一下咱们在文章结尾介绍 DMA 时最早提到过的同步 I /O:

这里的 同步 体现在当过程调用 read 办法读取文件时,过程实际上会阻塞在 read 办法调用,因为要期待磁盘数据的返回,并且咱们当然不心愿过程在读取大文件时被阻塞,对于阻塞的问题,能够用异步 I / O 来解决,即:

它把读操作分为两局部:前半部分,内核向磁盘发动读申请,然而能够 不期待数据就位就返回 ,于是过程此时能够解决其余工作;后半局部,当内核将磁盘中的数据拷贝到过程缓冲区后,过程将接管到内核的 告诉,再去解决数据。

而且,咱们能够发现,异步 I / O 并没有波及到 PageCache;应用异步 I / O 就意味着要绕开 PageCache,因为填充 PageCache 的过程在内核中必须阻塞。所以异步 I / O 中应用的是 direct I/O(比照应用 PageCache 的 buffer I/O),这样能力不阻塞过程,立刻返回。

direct I/ O 利用场景常见的两种:

第一种,应用程序曾经实现了磁盘数据的缓存,那么能够不须要 PageCache 再次缓存,缩小额定的性能损耗。在 MySQL 数据库中,能够通过参数设置开启 direct I/O,默认是不开启;第二种,传输大文件的时候,因为大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无奈充分利用缓存,从而增大了性能开销,因而,这时应该应用 \`direct I/O。

当然,因为 direct I/ O 绕过了 PageCache,就无奈享受内核的这两点的优化:内核的 I / O 调度算法会缓存尽可能多的 I / O 申请在 PageCache 中,最初「合并 」成一个更大的 I / O 申请再发给磁盘,这样做是为了缩小磁盘的寻址操作;内核也会「 预读」后续的 I / O 申请放在 PageCache 中,一样是为了缩小对磁盘的操作。

理论利用中也有相似的配置,在 nginx 中,咱们能够用如下配置,来依据文件的大小来应用不同的形式传输:

location /video/ {

    sendfile on;

    aio on;

    directio 1024m;

}

当文件大小大于 directio 值后,应用「异步 I/O + 间接 I/O」,否则应用「零拷贝技术」。

3)应用 direct I/ O 须要留神的点

首先,贴一下咱们的 Linus(Linus Torvalds)对 O\_DIRECT 的评估:

“The thing that has always disturbed me about O\_DIRECT is that the whole interface is just stupid, and was probably designed by a deranged monkey on some serious mind-controlling substances.” —Linus

一般来说能引得 Linus 开骂的货色,那是肯定有很多坑的。在 Linux 的 man page 中咱们能够看到 O\_DIRECT 下有一个 Note,这里我就不贴出来了。

总结一下其中须要留神的点如下:

第一点,地址对齐限度。O\_DIRECT 会带来强制的地址对齐限度,这个对齐的大小也跟文件系统 / 存储介质相干,并且以后没有不依赖文件系统本身的接口提供指定文件 / 文件系统是否有这些限度的信息

Linux 2.6 以前,总传输大小、用户的对齐缓冲区起始地址、文件偏移量必须都是逻辑文件系统的数据块大小的倍数,这里说的数据块 (block) 是一个逻辑概念,是文件系统捆绑肯定数量的间断扇区而来,因而通常称为“文件系统逻辑块”,可通过以下命令获取:

blockdev --getss

Linux2.6 当前对齐的基数变为物理上的存储介质的 sector size 扇区大小,对应物理存储介质的最小存储粒度,可通过以下命令获取:

blockdev --getpbsz

带来这个限度的起因也很简略,内存对齐这件小事通常是内核来解决的,而 O\_DIRECT 绕过了内核空间,那么内核解决的所有事件都须要用户本人来解决,后盾回复“Linux I/O”获取具体解释。

第二点,O\_DIRECT 平台不兼容。这应该是大部分跨平台利用须要留神到的点,O\_DIRECT 自身就是 Linux 中才有的货色,在语言层面 / 利用层面须要思考这里的兼容性保障,比方在 Windows 下其实也有相似的机制 FILE\_FLAG\_NO\_BUFFERIN 用法相似;再比方 macOS 下的 F\_NOCACHE 尽管相似 O\_DIRECT,但理论应用中也有差距。

第三点,不要并发地运行 fork 和 O\_DIRECT I/O。如果 O\_DIRECT I/ O 中应用到的内存 buffer 是一段公有的映射(虚拟内存),如任何应用上文中提到过的 mmap 并以 MAP\_PRIVATE flag 申明的虚拟内存,那么相干的 O\_DIRECT I/O(不论是异步 I/O / 其它子线程中的 I/O)都必须在调用 fork 零碎调用前执行结束;否则会造成数据净化或产生未定义的行为。

以下状况这个限度不存在:相干的内存 buffer 是应用 shmat 调配或是应用 mmap 以 MAP\_SHARED flag 申明的;相干的内存 buffer 是应用 madvise 以 MADV\_DONTFORK 申明的(留神这种形式下该内存 buffer 在子过程中不可用)。

第四点,防止对同一文件混合应用 O\_DIRECT 和一般 I/O。在应用层须要防止对同一文件(尤其是对同一文件的雷同偏移区间内 )混合应用 O\_DIRECT 和一般 I /O;即便咱们的文件系统可能帮咱们解决和保障这里的 一致性问题,总体来说整个 I / O 吞吐量也会比独自应用某一种 I / O 形式要小。同样的,应用层也要防止对同一文件混合应用 direct I/ O 和 mmap。

第五点,NFS 协定下的 O\_DIRECT。尽管 NFS 文件系统就是为了让用户像拜访本地文件一样去拜访网络文件,但 O\_DIRECT 在 NFS 文件系统中的体现和本地文件系统不同,比拟老版本的内核或是魔改过的内核可能并不反对这种组合。

这是因为在 NFS 协定中并不反对传递 flag 参数 到服务器,所以 O\_DIRECT I/ O 实际上只绕过了本地客户端的 Page Cache,但服务端 / 同步客户端依然会对这些 I / O 进行 cache。

当客户端申请服务端进行 I / O 同步来保障 O\_DIRECT 的同步语义时,一些服务器的性能体现不佳(尤其是当这些 I / O 很小时);还有一些服务器罗唆设置为 坑骗客户端 ,间接返回客户端「 数据已写入存储介质 」,这样就能够肯定水平上防止 I / O 同步带来的性能损失,但另一方面,当服务端断电时就无奈保障未实现 I / O 同步的数据的 数据完整性 了。Linux 的 NFS 客户端也没有下面说过的地址对齐的限度。

4)在 Golang 中应用 direct I/O

direct io 必须要满足 3 种对齐规定:io 偏移扇区对齐,长度扇区对齐,内存 buffer 地址扇区对齐;前两个还比拟好满足,然而调配的内存地址仅凭原生的伎俩是无奈间接达成的。

先比照一下 c 语言,libc 库是调用 posix\_memalign 间接调配出符合要求的内存块,但 Golang 中要怎么实现呢?在 Golang 中,io 的 buffer 其实就是字节数组,天然是用 make 来调配,如下:

buffer := make([]byte, 4096)

但 buffer 中的 data 字节数组首地址并不一定是对齐的。办法也很简略,就是 先调配一个比预期要大的内存块,而后在这个内存块里找对齐地位;这是一个任何语言皆通用的办法,在 Go 里也是可用的。

比方,我当初须要一个 4096 大小的内存块,要求地址依照 512 对齐,能够这样做:先调配 4096+512 大小的内存块,假如失去的内存块首地址是 p1;而后在[p1, p1+512] 这个地址范畴找,肯定能找到 512 对齐的地址 p2;返回 p2,用户能失常应用 [p2, p2+4096] 这个范畴的内存块而不越界。以上就是基本原理了,具体实现如下:

// 从 block 首地址往后找到合乎 AlignSize 对齐的地址并返回

// 这里很奇妙的应用了位运算,性能 upup

func alignment(block []byte, AlignSize int) int {return int(uintptr(unsafe.Pointer(&block[0])) & uintptr(AlignSize-1))

}

 

// 调配 BlockSize 大小的内存块

// 地址按 AlignSize 对齐

func AlignedBlock(BlockSize int) []byte {

   // 调配一个大小比理论须要的稍大

   block := make([]byte, BlockSize+AlignSize)

   // 计算到下一个地址对齐点的偏移量

   a := alignment(block, AlignSize)

   offset := 0

   if a != 0 {offset = AlignSize - a}

   // 偏移指定地位,生成一个新的 block,这个 block 就满足地址对齐了

   block = block[offset : offset+BlockSize]

   if BlockSize != 0 {

      // 最初做一次地址对齐校验

      a = alignment(block, AlignSize)

      if a != 0 {log.Fatal("Failed to align block")

      }

   }

   return block

}

所以,通过以上 AlignedBlock 函数调配进去的内存肯定是 512 地址对齐的,惟一的毛病就是 在调配较小内存块时对齐的额定开销显得比拟大

开源实现:Github 上就有开源的 Golang direct I/ O 实现:ncw/directio。应用也很简略,O\_DIRECT 模式关上文件:

// 创立句柄
fp, err := directio.OpenFile(file, os.O_RDONLY, 0666)

读数据:

// 创立地址依照 4k 对齐的内存块
buffer := directio.AlignedBlock(directio.BlockSize)
// 把文件数据读到内存块中
_, err := io.ReadFull(fp, buffer)

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

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

后面提到过的虚拟内存机制和 mmap 等都表明,通过在不同的虚拟地址上从新映射页面能够实现在用户过程和内核之间虚构复制和共享内存;因而如果要在实现在用户过程内解决数据(这种场景比间接转发数据更加常见)之后再发送进来的话,用户空间和内核空间的数据传输就是不可避免的,既然避无可避,那就只能抉择优化了。

两种优化用户空间和内核空间数据传输的技术:动静重映射与写时拷贝 (Copy-on-Write)、缓冲区共享(Buffer Sharing)。

1)写时拷贝 (Copy-on-Write)

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

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

下图为 COW 在 Linux 中的利用之一:fork/clone,fork 出的子过程共享父过程的物理空间,当父子过程 有内存写入操作时 ,read-only 内存页产生中断, 将触发的异样的内存页复制一份(其余的页还是共享父过程的)。

局限性 :COW 这种零拷贝技术比拟实用于那种 多读少写从而使得 COW 事件产生较少的场景,而在其它场景下反而可能造成负优化,因为 COW 事件所带来的零碎开销要远远高于一次 CPU 拷贝所产生的。

此外,在理论利用的过程中,为了防止频繁的内存映射,能够重复使用同一段内存缓冲区,因而,你不须要在只用过一次共享缓冲区之后就解除掉内存页的映射关系,而是反复循环应用,从而晋升性能。

但这种内存页映射的长久化并不会缩小因为页表往返挪动 / 换页和 TLB flush 所带来的零碎开销,因为每次接管到 COW 事件之后对内存页而进行加锁或者解锁的时候,内存页的只读标记 (read-ony) 都要被更改为 (write-only)。

COW 的理论利用——Redis 的长久化机制:Redis 作为典型的内存型利用,肯定是有内核缓冲区和用户缓冲区之间的传输优化的。

Redis 的长久化机制中,如果采纳 bgsave 或者 bgrewriteaof 命令,那么会 fork 一个子过程来将数据存到磁盘中。总体来说Redis 的读操作是比写操作多的(在正确的应用场景下),因而这种状况下应用 COW 能够缩小 fork() 操作的阻塞工夫

语言层面的利用

写时复制的思维在很多语言中也有利用,相比于传统的深层复制,能带来很大性能晋升;比方 C ++98 规范下的 std::string 就采纳了写时复制的实现:

std::string x("Hello");

std::string y = x;  // x、y 共享雷同的 buffer

y += ", World!";    // 写时复制,此时 y 应用一个新的 buffer

                    // x 仍然应用旧的 buffer

Golang 中的 string,slice 也应用了相似的思维,在复制 / 切片等操作时都不会扭转底层数组的指向,变量共享同一个底层数组,仅当进行 append / 批改等操作时才可能进行真正的 copy(append 时如果超过了以后切片的容量,就须要调配新的内存)。

2)缓冲区共享(Buffer Sharing)

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

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

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

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

总结

从晚期的 I / O 到 DMA,解决了阻塞 CPU 的问题;而为了省去 I / O 过程中不必要的上下文切换和数据拷贝过程,零拷贝技术就呈现了。所谓的零拷贝 (Zero-copy) 技术,就是完完全全不须要在内存层面拷贝数据,省去 CPU 搬运数据的过程。

零拷贝技术的文件传输方式相比传统文件传输的形式,缩小了 2 次上下文切换和数据拷贝次数 ,只须要 2 次上下文切换和数据拷贝次数,就能够实现文件的传输,而且 2 次的数据拷贝过程,都不须要通过 CPU,2 次都是由 DMA 来搬运。总体来看, 零拷贝技术至多能够把文件传输的性能进步一倍以上,以下是各计划具体的老本比照:

零拷贝技术是基于 PageCache 的,PageCache 会缓存最近拜访的数据,晋升了拜访缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还帮助 I/ O 调度算法实现了 I / O 合并与预读,这也是程序读比随机读性能好的起因之一;这些劣势,进一步晋升了零拷贝的性能。

但当面对大文件传输时,不能应用零拷贝,因为可能因为 PageCache 被大文件占据,导致「热点」小文件无奈利用到 PageCache 的问题,并且大文件的缓存命中率不高,这时就须要应用「异步 I /O+direct I/O」的形式;在应用 direct I/ O 时也须要留神许多的坑点,毕竟连 Linus 也会被 O\_DIRECT ‘disturbed’ 到。

而在更宽泛的场景下,咱们还须要留神到内 核缓冲区和用户缓冲区之间的传输优化,这种形式侧重于在用户过程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化,连续了以往那种传统的通信形式,但更灵便。

I/ O 相干的各类优化天然也曾经深刻到了日常咱们接触到的语言、中间件以及数据库的方方面面,通过理解和学习这些技术和思维,也能对日后本人的程序设计以及性能优化上有所启发。

你可能感兴趣的腾讯工程师作品

| 由浅入深读透 vue 源码:diff 算法

| 优雅应答故障:QQ 音乐怎么做高可用架构体系?

| PB 级数据秒级剖析:腾讯云原生湖仓 DLC 架构揭秘

| 详解全网最快 Go 泛型跳表【内附源码】

技术盲盒:前端 | 后端 |AI 与算法| 运维 | 工程师文化

公众号后盾回复“Linux I/O”,领本文作者举荐材料。

正文完
 0