当前位置: 首页 > 科技观察

为什么你不会写P8程序员的代码?零拷贝理解

时间:2023-03-16 14:08:25 科技观察

计算机处理的任务大致可以分为两类:CPU密集型和IO密集型。目前流行的互联网应用都是IO密集型的。传统的IO标准接口是基于数据拷贝的。在追赶P8方面。为什么IO接口是基于数据复制的?为了让广大码农更好的沉浸在自己的一亩三分地,不让他们分心去关心计算机的硬件资源分配,操作系统诞生了。操作系统本质上是一个管家,其目的是更加公平合理地为各个进程分配硬件资源。在操作系统出现之前,程序员需要面对各种各样的硬件,就像这样:在这个时期,程序员真正掌握了全局。控制全局的后果是你需要控制所有的细节,这显然不利于生产力的释放。操作系统应用程序诞生了。计算机系统变成这样:现在应用程序不需要直接与硬件交互。从IO的角度来看,操作系统变成了一个类似路由器的角色,将应用程序提交的数据分发给具体的硬件。up,或者从硬件接收数据,分发给相应的进程。数据传输是通过什么?也就是我们常说的buffer。所谓缓冲区就是一块可用的内存空间,用来临时存放数据。操作系统这个中间人带来的问题是,你需要先把东西交给操作系统,然后操作系统再交给硬件,这就不可避免地涉及到数据拷贝。这也是传统IO操作必然需要数据拷贝的原因。完整的操作系统说明可以参考博主的《深入理解操作系统》。但是,数据复制有性能损失。下面我们通过一个例子,让大家对这个问题有一个更直观的认识。一个web服务器浏览器打开一个网页需要大量的数据,包括图片、html文件、css文件、js文件等,当浏览器请求这些文件时,服务器端的工作其实很简单:服务器端只需要从磁盘读取文件。抓取文件扔到网络上发送出去。代码基本上类似于:read(fileDesc,buf,len);write(socket,buf,len);这两段代码很简单,第一行代码从文件中读取数据存入buf,然后存入buf数据通过网络发送。注意观察buf,整个过程server没有对buf中的数据做任何修改,buf中的数据在用户态一挥袖就回了内核态,没有带走任何数据云。这两行看似简单的代码到底发生了什么?答案是这样的:程序看似简单的两行代码,底层却比较复杂。看到这张图,你真的应该很欣赏这个操作系统。操作系统就像一个极其称职的管家,为你打理一切脏活累活,让你在用户状态下从容不迫地指导国家。这简单的两行代码涉及到:四次数据拷贝和四次上下文切换:read函数会涉及到用户态到内核态的切换,操作系统会向磁盘发起IO请求。数据被复制到内核的缓冲区中。请注意,此数据副本不需要CPU参与。之后,操作系统开始将这段数据从内核中拷贝到用户态的缓冲区中。这时read()函数返回,从内核态切换回用户态。这时,read(fileDesc,buf,len);这行代码返回了,把刚出炉的数据装进了buf。接下来,发送函数再次引起用户态和内核态之间的切换。这时需要将数据从用户态buf复制到网络协议子系统的buf中。具体来说,buf属于代码中使用的socket。之后send函数返回,再次从内核态返回到用户态;这时候程序员就认为数据发送成功了,但实际上数据可能还停留在内核中。之后开始第四次数据拷贝,使用DMA技术将socketbuf中的数据拷贝到网卡中,然后真正发送出去。这就是最下面看似简单的两行代码的完整过程。你觉得这个过程有什么问题吗?有发现问题的同学一定注意到了,既然在用户态都没有进行数据修改,为什么还要费这么大的力气让数据在用户态一日游呢?直接在把内核态从磁盘传到网卡上不就行了吗?恭喜,你做对了!这种优化思路就是所谓的零拷贝技术,ZeroCopy。一般来说,优化数据拷贝有三个方向:用户态不需要真正访问数据,就像上面的例子,用户态根本不需要知道buf里有什么。在这种情况下,不需要将数据从内核态复制到用户态,然后再从用户态复制回内核态。数据不需要用户态感知,数据复制完全发生在内核态。内核态并不真正需要访问数据,用户态程序可以绕过内核直接与硬件交互,从而避免了内核的参与,减少了数据拷贝的可能性。内核不需要知道数据。如果内核态和用户态要交互,优化用户态和内核态数据交互的方式。知道了解题思路,我们再来看看计算机系统中为了实现零拷贝的巧妙设计。mmap没错,就是mmap。我们在文章《mmap可以让程序员实现哪些骚操作》中有详细的解释。你能想到mmap也能实现零拷贝吗?对于本文提到的网络服务器,我们可以这样修改代码:buf=mmap(file,len);写(套接字,buf,len);你可能会想,直接把read换成mmap会有什么优化吗?如果真正了解mmap就会知道,mmap只是将文件内容映射到进程地址空间,在进程中,并没有真正的拷贝到进程地址空间,省去了一次从内核态到用户态的数据拷贝。同样,调用write时,数据直接从内核buf复制到socketbuf,而不是在read/write方法中将用户态数据复制到socketbuf。可以看到我们使用mmap保存了一份数据副本,上下文切换还是四次。mmap虽然可以省掉数据拷贝,但是维护文件和地址空间的映射关系是有代价的。除非CPU复制数据的时间超过维护映射关系的成本,否则基于mmap的程序性能可能不如传统的read/write。另外,如果映射文件被其他进程截断,你的进程在Linux下会立即收到SIGBUS信号,所以这个异常需要正确处理。除了mmap,还有其他方式可以实现零拷贝。你没看错sendfile,这个系统调用是专门用来解决linux系统下数据拷贝问题的:#includessize_tsendfile(intout_fd,intin_fd,off_t*offset,size_tcount);Windows也有一个功能类似的API:TransmitFile。这个系统调用的目的是在两个文件描述之间复制数据,但值得注意的是,数据复制的过程完全是在内核模式下完成的,所以在这个web服务器的例子中我们将简化这两行代码对于一行,也就是在这里调用sendfile。使用sendfile会保存两次数据拷贝,因为数据不需要传输到用户态:调用sendfile后,DMA机制会先从磁盘拷贝数据到内核buf,然后再从内核拷贝数据buf到对应的socketbuf,最后使用DMA机制将数据从socketbuf复制到网卡。我们可以看到,与传统的read/write相比,少了一份数据拷贝,内核态和用户态之间只有两次切换。可能有同学已经看出来了,这好像不是零拷贝。内核中不是有另外一份从内核态buf到socketbuf的数据拷贝吗?这个副本似乎没有必要。确实,要解决这个问题,单纯的软件机制是不够的,我们需要硬件来帮忙一点,这就是DMAGatherCopy。sendfile和DMAGatherCopy传统的DMA机制必须从一个连续的空间传输数据,像这样:很明显,你需要在源头把所有需要的数据复制到一个连续的空间:现在肯定有同学问,为什么不让DMA直接从多个来源收集数据?这称为DMA收集复制。有了这个特性,就不需要将内核文件buf中的数据复制到socketbuf中,而是网卡使用DMAGatherCopy机制,直接将报文头和要传输的数据组装在一起发送出去。在这种机制的加持下,CPU甚至根本不需要接触需要传输的数据,程序使用sendfile编写的代码也不需要任何改动,进一步提高了程序性能。目前流行的消息中间件kafka是基于sendfile实现高效传输文件的。其实你应该已经看到了,高效IO的秘诀其实很简单:让CPU尽量少参与。其实sendfile的使用场景比较有限。大前提是用户态不需要看到操作数据,只能从文件描述符向socket传输数据,而DMAGatherCopy也需要硬件支持。有没有一种不依赖硬件特性,能够以零拷贝的方式在任意两个文件描述符之间高效传输数据的方法呢?答案是肯定的!这是Linux下的另一个系统调用:splice。这里的splice需要再次强调一下无论是sendfile还是这里的splice系统调用,使用它的主要前提是不需要在用户态看到要传输的数据。我们再看看传统的读写方式。在这种方法中,数据必须从内核态复制到用户态,然后再从用户态复制回内核态。既然用户态不需要对数据进行任何操作,为什么不让数据的传递直接在内核态进行呢??既然我们有了目标,那么实现它的方法是什么?答案是使用Linux世界中进程间通信所用的管道,pipe。仍以网络服务器为例,DMA将数据从磁盘复制到文件buf,然后将数据写入管道。再次调用splice后,数据从pipeline中读入socketbuf,然后通过DMA发送出去。值得注意的是,Writedatatopipeline和readingdatafrompipeline并没有真正复制数据,只是传递了与数据相关的必要信息。您会看到splice和sendfile非常相似。其实sendfile系统调用修改后,是基于splice实现的。既然有splice,为什么还要保留sendfile呢?答案很简单。如果直接删除sendfile,那么之前依赖这个系统调用的任何程序都将无法正常运行。综上所述,本文介绍了很多零拷贝优化技巧,但是注意,一定要注意,如果你的程序对性能要求不是特别高,哪怕慢1ns,忘记这些所谓的优化技巧这篇文章解释了,老实说Practicalread/write,比起这些所谓的tricks,memorycopy还算不错。本文只是告诉你在追求高性能系统的过程中有哪些乱七八糟的设计。