导言 | 本文邀请到腾讯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-gatherscatter-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办法:
@Overridepubliclong 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 对齐的地址并返回// 这里很奇妙的应用了位运算,性能upupfunc 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 共享雷同的 buffery += ", 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”,领本文作者举荐材料。