计算机基础
本篇是操作系统的最后一篇啦!泪目( Ĭ ^ Ĭ )键盘可以说是我们最常使用的输入硬件设备了,但身为码农的你,你知道键盘敲入A字母时,操作系统期间发生了啥不 ๑乛◡乛๑?不知道吧,不知道吧。那就进入码农の奇妙冒险吧。
操作系统篇6-设备管理&网络系统
设备控制器
我们的电脑设备可以接非常多的输入输出设备,比如键盘、鼠标、显示器、网卡、硬盘、打印机、音响等等,每个设备的用法和功能都不同,那操作系统是如何把这些输入输出设备统一管理。
为了屏蔽设备之间的差异,每个设备都有一个叫设备控制器(Device Control)的组件,比如硬盘有硬盘控制器、显示器有视频控制器、二乔有西撒、炭治郎有炎柱大哥,而你有我 ( ̄(∞) ̄) 。
因为这些控制器都很清楚的知道对应设备的用法和功能,所以CPU是通过设备控制器来和设备打交道的。
设备控制器里有芯片,它可执行自己的逻辑,也有自己的寄存器,用来与CPU进行通信,比如:
●通过写入这些寄存器,操作系统可以命令设备发送数据、接收数据、开启或关闭,或者执行某些其他操作。
●通过读取这些寄存器,操作系统可以了解设备的状态,是否准备好接收一个新的命令等
实际上,控制器是有三类寄存器。它们分别是状态寄存器(Status Reqister),命令寄存器(Command Reqister)以及数据寄存器(Data Reqister),如下图:
这三个寄存器的作用:
●数据寄存器CPU向I/O设备写入需要传输的数据,比如要打印的内容是[Hello」 ,CPU 就要先发送一个H字符给到对应的I/O设备。
●命令寄存器,CPU发送一个命令, 告诉I/O设备,要进行输入输出操作,于是就会交给I/O设备去工作,任务完成后,会把状态寄存器里面的状态标记为完成。
●状态寄存器,目的是告诉CPU,现在已经在工作或工作已经完成,如果已经在工作状态,CPU 再发送数据或者命令过来,都是没有用的,直到前面的工作已经完成,状态寄存标记成已完成,CPU 才能发送下一个字符和命令。
CPU通过读、写设备控制器中的寄存器来控制设备,这可比CPU直接控制输入输出设备,要方便和标准很多。
另外,输入输出设备可分为两大类:块设备(Block Device)和字符设备(Character Device)。
●块设备,把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备。
●字符设备,以字符为单位发送或接收一个字符流, 字符设备是不可寻址的,也没有任何寻道操作,鼠标是常见的字符设备。
块设备通常传输的数据量会非常大,于是控制器设立了一个可读写的数据缓冲区。
●CPU写入数据到控制器的缓冲区时,当缓冲区的数据囤够了一部分, 才会发给设备。
●CPU从控制器的缓冲区读取数据时,也需要缓冲区囤够了一部分, 才拷贝到内存。
这样做是为了,减少对设备的操作次数。
那CPU是如何与设备的控制寄存器和数据缓冲区进行通信的?存在两个方法:
●端口I/O,每个控制寄存器被分配一个I/O端口,可以通过特殊的汇编指令操作这些寄存器,比如in/out类似的指令。
●内存映射I/O,将所有控制寄存器映射到内存空间中,这样就可以像读写内存一样读写数据缓冲区。
I/O控制方式
在前面我知道,每种设备都有一个设备控制器,控制器相当于一个小CPU,它可以自己处理一些事情, 但有个问题是,当CPU给设备发送了一个指令,让设备控制器去读设备的数据,它读完的时候,要怎么通知CPU呢?
控制器的寄存器一般会有状态标记位,用来标识输入或输出操作是否完成。于是,我们想到第一种轮询等待的方法,让CPU一直查寄存器的状态,直到状态标记为完成,很明显,这种方式非常的傻瓜,它会占用CPU的全部时间。
那我们就想到第二种方法——中断,通知操作系统数据已经准备好了。我们一般会有一个硬件的中断控制器,当设备完成任务后触发中断到中断控制器,中断控制器就通知CPU,一个中断产生了, CPU需要停下当前手里的事情来处理中断。
另外,中断有两种, 一种软中断,例如代码调用INT指令触发, 一种是硬件中断,就是硬件通过中断控制器触发的。
但中断的方式对于频繁读写数据的磁盘,并不友好,这样CPU容易经常被打断,会占用CPU大量的时间。对于这一类设备的问题的解决方法是使用DMA (Direct Memory Access)功能,它可以使得设备在CPU不参与的情况下,能够自行完成把设备I/0数据放入到内存。那要实现DMA功能要有「DMA 控制器」硬件的支持。
DMA的工作方式如下:
●CPU需对DMA控制器下发指令,告诉它想读取多少数据,读完的数据放在内存的某个地方就可以了;
●接下来, DMA控制器会向磁盘控制器发出指令,通知它从磁盘读数据到其内部的缓冲区中,接着磁盘控制器将缓冲区的数据传输到内存;
当磁盘控制器把数据传输到内存的操作完成后,磁盘控制器在总线上发出一个确认成功的信号到DMA控制器;
● DMA控制器收到信号后,DMA控制器发中断通知CPU指令完成,CPU就可以直接用内存里面现成的数据了;
可以看到,CPU 当要读取磁盘数据的时候,只需给DMA控制器发送指令,然后返回去做其他事情,当磁盘数据拷贝到内存后,DMA控制机器通过中断的方式,告诉CPU数据已经准备好了,可以从内存读数据了。仅仅在传送开始和结束时需要CPU干预。
设备驱动程序
虽然设备控制器屏蔽了设备的众多细节,但每种设备的控制器的寄存器、缓冲区等使用模式都是不同的,所以为了屏蔽「设备控制器」的差异,引入了设备驱动程序。
设备控制器不属于操作系统范畴,它是属于硬件,而设备驱动程序属于操作系统的一部分,操作系统的内核代码可以像本地调用代码一样使用设备驱动程序的接
口,而设备驱动程序是面向设备控制器的代码,它发出操控设备控制器的指令后,才可以操作设备控制器。
不同的设备控制器虽然功能不同,但是设备驱动程序会提供统一的接口给操作系统,这样不同的设备驱动程序,就可以以相同的方式接入操作系统。如下图:
前面提到了不少关于中断的事情,设备完成了事情,则会发送中断来通知操作系统。那操作系统就需要有一个地方来处理这个中断,这个地方也就是在设备驱动程序里,它会及时响应控制器发来的中断请求,并根据这个中断的类型调用响应的中断处理程序进行处理。
通常,设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。
我们来看看,中断处理程序的处理流程:
1.在I/O时,设备控制器如果已经准备好数据,则会通过中断控制器向CPU发送中断请求;
2.保护被中断进程的CPU上下文;
3.转入相应的设备中断处理函数;
4.进行中断处理;
5.恢复被中断进程的,上下文:
通用块层
对于块设备,为了减少不同块设备的差异带来的影响,Linux 通过一个统一的通用块层, 来管理不同的块设备。
通用块层是处于文件系统和磁盘驱动中间的一个块设备抽象层,它主要有两个功能:
●第一个功能,向上为文件系统和应用程序,提供访问块设备的标准接口,向下把各种不同的磁盘设备抽象为统一的块设备 ,并在内核层面,提供一个框架来管理这些设备的驱动程序;
●第二功能,通用层还会给文件系统和应用程序发来的I/O请求排队,接着会对队列重新排序、请求合并等方式,也就是I/O调度,主要目的是为了提高磁盘读写的效率。
Linux内存支持5种I/O调度算法,分别是:
●没有调度算法
●先入先出调度算法
●完全公平调度算法
●优先级调度
●最终期限调度算法
第一种,没有调度算法,是的,你没听错,它不对文件系统和应用程序的I/O做任何处理,这种算法常用在虚拟机/O中,此时磁盘I/O调度算法交由物理机系统负责。
第二种,先入先出调度算法,这是最简单的I/O调度算法,先进入I/O调度队列的I/O请求先发生。
第三种,完全公平调度算法,大部分系统都把这个算法作为默认的I/O调度器,它为每个进程维护了一个I/O调度队列,并按照时间片来均匀分布每个进程的I/O请求。
第四种,优先级调度算法,顾名思义,优先级高的I/O请求先发生,它适用于运行大量进程的系统, 像是桌面环境、多媒体应用等。
第五种,最终期限调度算法,分别为读、写请求创建了不同的I/O队列,这样可以提高机械磁盘的吞吐量,并确保达到最终期限的请求被优先处理,适用于在I/O压力比较大的场景,比如数据库等。
存储系统I/O软件分层
前面说到了不少东西,设备、设备控制器、驱动程序、通用块层,现在再结合文件系统原理,我们来看看Linux 存储系统的I/O 软件分层。
可以把Linux存储系统的I/O由上到下可以分为三个层次,分别是文件系统层、通用块层、设备层。他们整个的层次关系如下图:
键盘敲入字母时,期间发生了什么?
我们先来看看CPU的硬件架构图:
CPU里面的内存接口,直接和系统总线通信,然后系统总线再接入一个I/O桥接器,这个I/O桥接器,另一边接入了内存总线,使得CPU和内存通信。再另一边,又接入了一个I/O总线,用来连接I/O设备,比如键盘、显示器等。
那当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给CPU发送中断请求。
CPU收到中断请求后,操作系统会保存被中断进程的CPU上下文,然后调用键盘的中断处理程序。
键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的ASCII 码,比如用户在键盘输入的是字母A,是显示字符,于是就会把扫描码翻译成A字符的ASCII码。
得到了显示字符的ASCII码后,就会把ASCII码放到读缓冲区队列,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从读缓冲区队列读取数据放到写缓冲区队列,最后把写缓冲区队列的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。显示出结果后,恢复被中断进程的上下文。
过半预警!!!你终于看到了一半了,休息一小会儿在看咯,一直都在,加油!(*゜Д゜)σ凸←自爆按钮
网络系统
一台机器将自己想要表达的内容,按照某种约定好的格式发送出去,当另外一台机器收到这些信息后,也能够按照约定好的格式解析出来,从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议(Networking Protocol).
网络为什么要分层?
我们这里先构建一个相对简单的场景,之后几节内容,我们都要基于这个场景进行讲解。
我们假设这里就涉及三台机器。Linux 服务器A和Linux服务器B处于不同的网段,通过中间的Linux服务器作为路由器进行转发。
说到网络协议,我们还需要简要介绍一下两种网络协议模型,一种是OSI的标准七层模型,一种是业界标准的TCP/IP模型。它们的对应关系如下图所示:
用多个层次和组合,来满足不同服务器和设备的通信需求。|
我们这里简单介绍一下网络协议的几个层次。
我们从哪一个层次开始呢?从第三层,网络层开始,因为这一层有我们熟悉的IP地址。也因此,这一层我们也叫IP层。
我们通常看到的IP地址都是这个样子的: 192.168.1.100/24. 斜杠前面是IP地址,这个地址被点分隔为四个部分,每个部分8位,总共是32位。斜线后面24的意思是,
32位中,前24位是网络号,后8位是主机号。
为什么要这样分呢?我们可以想象,虽然全世界组成一张大的互联网,美国的网站你也能够访问的,但是这个网络不是一整个的。 你们小区有一个网络,你们公司也有一个网络,联通、移动、电信运营商也各有各的网络,所以一个大网络是被分成个小的网络。
那如何区分这些网络呢?这就是网络号的概念。一个网络里面会有多个设备,这些设备的网络号一样,主机号不一样。不信你可以观察一下你家里的手机、 电视、电脑。
连接到网络上的每一个设备都至少有一个IP地址,用于定位这个设备。无论是近在咫尺的你旁边同学的电脑,还是远在天边的电商网站,都可以通过IP 地址进行定位。因此,IP 地址类似互联网上的邮寄地址,是有全局定位功能的。
就算你要访问美国的一个地址,也可以从你身边的网络出发,通过不断的打听道儿,经过多个网络,最终到达目的地址,和快递员送包裹的过程差不多。打听道儿的协议也在第三层,称为路由协议(Routing protocol) ,将网络包从一个网络转发给另一个网络的设备称为路由器。
(PS:总而言之,第三层干的事情,就是网络包从一个起始的IP地址,沿着路由协议指的道儿,经过多个网络,通过多次路由器转发,到达目标IP地址。)
从第三层,我们往下看,第二层是数据链路层。有时候我们简称为二层或者MAC层。所谓MAC,就是每个网卡都有的唯一的硬件地址(不绝对唯一 ,相对大概率唯一即可)。这虽然也是一个地址,但是这个地址是没有全局定位功能的。
就像给你送外卖的小哥,不可能根据手机尾号找到你家,但是手机尾号有本地定位功能的,只不过这个定位主要靠“吼”。外卖小哥到了你的楼层就开始大喊:“尾号 xXxx的,你外卖到了!”
MAC地址的定位功能局限在一个网络里面,也即同一个网络号下的IP地址之间,可以通过MAC进行定位和通信。从IP地址获取MAC地址要通过ARP协议,是通过在本
地发送广播包,也就是“吼”,获得的MAC地址。
由于同一个网络内的机器数量有限,通过MAC地址的好处就是简单。匹配上MAC地址就接收,匹配不上就不接收,没有什么所谓路由协议这样复杂的协议。当然坏处就是,MAC地址的作用范围不能出本地网络,所以一旦跨网络通信,虽然IP地址保持不变,但是MAC地址每经过一个路由器就要换一次。
我们看前面的图。服务器A发送网络包给服务器B,原IP地址始终是192.168.1.100,目标IP地址始终是192.168.2.100, 但是在网络1里面,原MAC地址是MAC1,目标
MAC地址是路由器的MAC2,路由器转发之后,原MAC地址是路由器的MAC3,目标MAC地址是MAC4。
所以第二层干的事情,就是网络包在本地网络中的服务器之间定位及通信的机制。
我们再往下看,第一层,物理层,这一层就是物理设备。例如连着电脑的网线,我们能连上的WiFi
从第三层往上看,第四层是传输层,这里面有两个著名的协议TCP和UDP。尤其是TCP,更是广泛使用,在IP层的代码逻辑中,仅仅负责数据从一个IP地址发送给另一个IP地址,丢包、乱序、重传、拥塞,这些IP层都不管。外理这些问题的代码逻辑写在了传输层的TCP协议里面。
我们常称,TCP 是可靠传输协议,也是难为它了。因为从第一层到第三层都不可靠,网络包说丢就丢,是TCP这一层通过各种编号、 重传等机制,让本来不可靠的网络对于更上层来讲,变得“看起来”可靠。哪有什么应用层岁月静好,只不过TCP层帮你负重前行。
传输层再往上就是应用层,例如咱们在浏览器里面输入的HTTP, Java 服务端写的Servlet,都是这一层的。
二层到四层都是在Linux内核里面处理的,应用层例如浏览器、Nginx、 Tomcat 都是用户态的。内核里面对于网络包的处理是不区分应用的。
从四层再往上,就需要区分网络包发给哪个应用。在传输层的TCP和UDP协议里面,都有端口的概念,不同的应用监听不同的端口。例如,服务端Nginx监听80、Tomcat监听8080;再如客户端浏览器监听一个随机端口, FTP 客户端监听另外-一个随机端口。
应用层和内核互通的机制,就是通过Socket系统调用。所以经常有人会问,Socket 属于哪一层,其实它哪一层都不属于 ,它属于操作系统的概念,而非网络协议分层的概念。只不过操作系统选择对于网络协议的实现模式是,二到四层的处理代码在内核里面,七层的处理代码让应用自己去做,两者需要跨内核态和用户态通信,就需要一个系统调用完成这个衔接,这就是Socket.
发送数据包
网络分完层之后,对于数据包的发送,就是层层封装的过程。
就像下面的图中展示的一样, 在Linux服务器B上部署的服务端Nginx和Tomcat,都是通过Socket监听80和8080端口。这个时候,内核的数据结构就知道了。如果遇到
发送到这两个端口的,就发送给这两个进程。
在Linux服务器A上的客户端,打开一个Firefox连接Ngnix。也是通过Socket,客户端会被分配一个随机端口12345。同理,打开一个Chrome连接Tomcat,同样通过
Socket分配随机端口12346。
在客户端浏览器。我们将请求封装为HTTP协议,通过Socket发送到内核。内核的网络协议栈里面,在TCP层创建用于维护连接、序列号、重传、拥塞控制的数据结构,将HTTP包加上TCP头,发送给IP层,IP 层加上IP头,发送给MAC层,MAC层加上MAC头,从硬件网卡发出去。
网络包会先到达网络1的交换机。我们常称交换机为1二层设备,这是因为,交换机只会处理到第二层, 然后它会将网络包的MAC头拿下来,发现目标MAC是在自己右面的网口,于是就从这个网口发出去。
应用层通过Socket监听某个端口,因而读取的时候,内核会根据TCP头中的端口号,将网络包发给相应的应用。
HTTP层的头和正文,是应用层来解析的。通过解析,应用层知道了客户端的请求,例如购买一个商品,还是请求一个网页。 当应用层处理完HTTP的请求,会将结果仍然封装为HTTP的网络包,通过Socket接口.发送给内核。
内核会经过层层封装,从物理网口发送出去,经过网络2的交换机, Linux 路由器到达网络1,经过网络1的交换机,到达Linux服务器A。在Linux服务器A上,经过层层
解封装,通过socket接口,根据客户端的随机端口号,发送给客户端的应用程序,浏览器。于是浏览器就能够显示出一个绚丽多彩的页面了。
即便在如此简单的一个环境中,网络包的发送过程,竟然如此的复杂。
零拷贝
为什么要有DMA技术
在没有DMA技术前,I/O的过程是这样的:
●CPU发出对应的指令给磁盘控制器,然后返回;
●磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;
●CPU收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间CPU是无法执行其他任务的。
为了方便你理解,我画了一副图:
可以看到,整个数据的传输过程,都要需要CPU亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用CPU来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了DMA技术,也就是直接内存访问(Direct Memory Access)技术。
什么是DMA技术?简单理解就是,在进行I/O设备和内存的数据传输的时候,数据搬运的工作全部交给DMA控制器,而CPU不再参与任何与数据搬运相关的事情,这样CPU就可以去处理别的事务.
那使用DMA控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。
具体过程:
●用户进程调用read方法,向操作系统发出I/O请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
●操作系统收到请求后,进一步将I/O请求发送DMA,然后让CPU执行其他任务;
●DMA进一步将I/O请求发送给磁盘;
●磁盘收到DMA的/0请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向DMA发起中断信号,告知自己缓冲区已满;
●DMA收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用CPU, CPU可以执行其他任务;
●当DMA读取了足够多的数据,就会发送中断信号给CPU;
●CPU收到DMA的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;
可以看到,整个数据传输的过程,CPU不再参与数据搬运的工作,而是全程由DMA完成,但是CPU在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要CPU来告诉DMA控制器。
早期DMA只存在在主板上,如今由于I/O设备越来越多,数据传输的需求也不尽相同,所以每个I/O设备里面都有自己的DMA控制器。
传统的文件传输有多糟糕?
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统I/O的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的I/O接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
1. read(file, tmp. buf, 1en);
2. write(socket, tmp. buf, len);
代码很简单,虽然就两行代码,但是这里面发生了不少的事情。
首先,期间共发生了4次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是read() ,一次是write() ,每次系统调用都得先从用户态切换到内核态,等
内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了4次数据拷贝,其中两次是DMA的拷贝,另外两次则是通过CPU拷贝的,下面说一 下这个过程:
●第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过DMA搬运的。
●第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由CPU完成的。
●第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的socket的缓冲区里,这个过程依然还是由CPU搬运的。
●第四次拷贝,把内核的socket缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由DMA搬运的。
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了4次,过多的数据拷贝无疑会消耗CPU资源,大大降低了系统性能。
这种简单又传统的文件传输方式,存在冗余的.上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少用户态与内核态的上下文切换和内存拷贝的次数。
如何优化文件传输的性能?
先来看看,如何减少用户态与内核态的上下文切换的次数呢?
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。
而一次系统调用必然会发生2次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换到次数,就要减少系统调用的次数。
再来看看,如何减少数据拷贝的次数?
在前面我们知道了,传统的文件传输方式会历经4次数据拷贝,而且这里面,从内核的读缓冲区拷贝到用户的缓冲区里, 再从用户的缓冲区里拷贝到socket的缓冲区里,这个过程是没有必要的。
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
如何实现零拷贝
零拷贝技术实现的方式通常有2种:
●mmap + write
●sendfile
下面就谈一谈,它们是如何减少上下文切换和数据拷则的次数。
mmap + write
在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销, 我们可以用mmap()替换read()系统调用函数。
1 buf = mmap(fle, len);
2 write(sockfd, buf, len);
mmap()系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
具体过程如下:
●应用进程调用了mmap() 后, DMA会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核共享这个缓冲区;
●应用进程再调用write(),操作系统直接将内核缓冲区的数据拷贝到socket缓冲区中,这一切都发生在内核态, 由CPU来搬运数据;
●最后,把内核的socket缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由DMA搬运的。
我们可以得知,通过使用mmap()来代替read(),可以减少- -次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过CPU把内核缓冲区的数据拷贝到socket缓冲区里,而且仍然需要4次上下文切换,因为系统调用还是2次。
sendfile
在Linux内核版本2.1中,提供了一个专门发送文件的系统调用函数sendfile(),函数形式如下:
1 #include <sys/socket.h>
2 ssize_t sendfile(int out_fd, int in_fd, off_t *offset,size_t count);
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的read()和write()这两个系统调用,这样就可以减少一次系统调用,也就减少了2次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到socket缓冲区里,不再拷贝到用户态,这样就只有2次上下文切换,和3次数据拷贝。如下图:
但是这还不是真正的零拷贝技术,如果网卡支持SG-DMA (The Scatter- Gather Direct Memory Acess)技术(和普通的DMA有所不同),我们可以进一步减少通过 CPU把内核缓冲区里的数据拷贝到socket缓冲区的过程。
你可以在你的Linux系统通过下面这个命令,查看网卡是否支持scatter-gather特性:
1 $ ethtool -k ethe | grep scatter-gather
2 scatter-gather: on
于是,从Linux 内核2.4版本开始起,对于支持网卡支持SG-DMA技术的情况下,sendfile( )系统调用的过程发生了点变化,具体过程如下:
●第一步,通过DMA将磁盘上的数据拷贝到内核缓冲区里;
●第二步,缓冲区描述符和数据长度传到socket缓冲区,这样网卡的SG-DMA控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到socket缓冲区中,这样就减少了一次数据拷贝;
所以,这个过程之中,只进行了2次数据拷贝,如下图:
这就是所谓的零拷贝(Zero-copy) 技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了2次上下文切换和数据拷贝次数,只需要2次上下文切换和数据拷贝次数,就可以完成文件的传输,而且2次的数据拷贝过程,都不需要通过CPU, 2次都是由DMA来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
PageCache有什么作用?
回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝内核缓冲区里,这个内核缓冲区实际上是磁盘高速缓存(PageCache)。
由于零拷贝使用了PageCache技术,可以使得零拷贝进一步提升了性能, 我们接下来看看PageCache是如何做到这一点的。
读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把读写磁盘替换成读写内存。于是, 我们会通过DMA把磁盘里的数据搬运到内存里,这样就可以用
读内存替换读磁盘。
但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。
那问题来了,选择哪些磁盘数据拷贝到内存呢?
我们都知道程序运行的时候,具有局部性,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用PageCache来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在PageCache找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存PageCache中。
还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始顺序读取数据,但是旋转磁头这个
物理动作是非常耗时的,为了降低它的影响,PageCache使用了预读功能。
比如,假设read方法每次只会读32 KB的字节,虽然read刚开始只会读0 ~ 32 KB的字节,但内核会把其后面的32 ~ 64 KB也读取到PageCache,这样后面读取32 ~
64 KB的成本就很低,如果在32 ~ 64 KB淘汰出PageCache前,进程读取到它了,收益就非常大。
所以, PageCache的优点主要是两个:
●缓存最近被访问的数据;
● 预读功能;
这两个做法,将大大提高读写磁盘的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费DMA多做的一次数据拷贝, 造成性能的降低,即使使用了PageCache的零拷贝也会损失性能。
这是因为如果你有很多GB级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入PageCache中,于是PageCache空间很快被这些大文件占满。
另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来2个问题:
● PageCache由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到PageCache, 于是这样磁盘读写的性能就会下降了;
● PageCache中的大文件数据,由于没有享受到缓存带来的好处,但却耗费DMA多拷贝到PageCache一次;
所以,针对大文件的传输,不应该使用PageCache,也就是说不应该使用零拷贝技术,因为可能由于PageCache被大文件占据,而导致热点小文件无法利用到
PageCache,这样在高并发的环境下,会带来严重的性能问题。
大文件传输用什么方式实现?
那针对大文件的传输,我们应该使用什么方式呢?
我们先来看看最初的例子,当调用read 方法读取文件时,进程实际上会阻塞在read方法调用,因为要等待磁盘数据的返回,如下图:
具体过程:
●当调用read方法时,会阻塞着,此时内核会向磁盘发起I/O请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起I/O中断,告知内核磁盘数据已经准备好;
●内核收到I/O中断后,就将数据从磁盘控制器缓冲区拷贝到PageCache里;
●最后,内核再把PageCache中的数据拷贝到用户缓冲区,于是read调用就正常返回了。
对于阻塞的问题,可以用异步I/O来解决,它工作方式如下图:
它把读操作分为两部分:
●前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
●后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
而且,我们可以发现,异步I/O并没有涉及到PageCache,所以使用异步I/O就意味着要绕开PageCache.
绕开PageCache的I/O叫直接I/O,使用PageCache的I/O则叫缓存I/O。通常,对于磁盘,异步I/O只支持直接I/O。
前面也提到,大文件的传输不应该使用PageCache,因为可能由于PageCache被大文件占据,而导致热点小文件无法利用到PageCache.
于是,在高井发的场景下,针对大文件的传输的方式,应该使用异步I/O+直接I/O来替代零拷贝技术。
直接I/O应用场最常见的两种:
●应用程序已经实现了磁盘数据的缓存,那么可以不需要PageCache再次缓存,减少额外的性能损耗。在MySQL数据库中,可以通过参数设置开启直接l/O,默认是不开
启;
●传输大文件的时候,由于大文件难以命中PageCache缓存,而且会占满PageCache导致热点文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直
接I/O。
另外,由于直接I/O绕过了PageCache,就无法享受内核的这两点的优化:
●内核的I/O调度算法会缓存尽可能多的/0请求在PageCache中,最后合井成一个更大的I/O请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
●内核也会预读后续的I/O请求放在PageCache中,一样是为了减少对磁盘的操作;
于是,传输大文件的时候,使用异步I/O +直接I/O了,就可以无阻塞地读取文件了。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
●传输大文件的时候,使用异步I/O+ 直接I/O;
, 传输小文件的时候,则使用零拷贝技术;
在Nginx中,我们可以用如下配置,来根据文件的大小来使用不同的方式:
另外,输入输出设备可分为两大类:块设备(Block Device)和字符设备(Character
另外,输入输出设备可分为两大类:块设备(Block Device)和字符设备(Character Device)。
●块设备,把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备。
evice)。
●块设备,把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备。
当文件大小大于directio值后,使用异步I/O +直接I/O,否则使用零拷贝技术。
感谢您的阅读,希望您能摄取到知识!加油!冲冲冲!(发现光,追随光,成为光,散发光!)我是程序员耶耶!有缘再见。<-biubiu-⊂(`ω´∩)