背景:
最近接手了一个以Netty+Protobuf作为底层通讯框架的项目,对IO通信机制和Netty的陌生,使得项目架构的解析举步维艰。于是花了点时间(恰逢国庆)深入地学习了NIO和Netty,沿途也见识到了一些Linux底层地知识。在此之后,对项目的架构设计深入地进行了剖析,有那么一瞬间一切豁然开朗,感受到世界的美好。
从本文开始开启一个IO系列模块, 用于总结IO相关的内容。包括IO底层机制、BIO、NIO、Netty以及基于NIO或Netty的开源框架的源码解析等内容。
本文是IO系列的第一篇,重点介绍IO底层的相关内容,会涉及到Linux底层的一些概念,如虚拟内存机制、系统调用、页缓存和套接字缓存、文件描述符、CPU中断等。
1.基本概念
由于java开发的服务器基本运行在Linux上,所以本文介绍的IO机制基于Linux系统。java层面的API,最终需要调用Linux内核提供的函数实现IO功能;因此想要深入理解IO,不可避免地涉及Linux相关的概念。
1.1 虚拟内存
Linux采用虚拟内存机制,即应用进程启动时,系统分配给应用的内存不是实际的物理内存,而是虚拟内存;进程访问内存时,CPU会把进程提供的虚拟内存地址转换为物理内存, 再去对应的物理地址上取数据,因此对于应用进程是透明的。
在32位的系统上(以下都以32位举例),每个进程都认为自己分配到了4G连续内存,这是系统欺骗应用进程的一种手段, 其实只有实际使用内存时,系统才会东拼西凑地分配出一部分内存给进程使用,因此一般物理上是不连续的。
另外,虚拟内存的实现不仅依赖物理内存,也依赖磁盘;在内存不足的时候,会占用部分磁盘空间;这就是内存快满的时候,电脑特别卡的原因(磁盘相对内存太慢)。
虚拟内存可以被分为两个区域:用户区域、内核区域;用户程序运行在内存的用户空间区域,操作系统内核和驱动程序运行在内核区域。因为虚拟内存机制,两者不能简单地通过指针来传递数据。
当用户程序需要访问硬件等系统资源时,需要切换到内核态,实现方式通过系统调用。
1.2 CPU中断机制
中断是系统响应硬件设备请求的一种机制:它会打断进程的正常调度和执行,然后调用内核中的中断程序来响应设备的请求。
在没有中断技术之前,CPU处理外设需要通过轮询机制——不断地询问键盘、鼠标、网卡等外设是否有事件发生,比较低效也比较耗资源;中断出现后,异步响应取代了同步操作,大幅度提高了管理外设地效率。
另外,CPU是个图灵机模型(线性地按序执行指令),通过中断机制可以实现序列指令执行完成前执行另一序列指令(CPU每次执行完一条指令就会检查有无中断指令),从而实现任务切换;Timer硬件以固定的间隔给CPU发出中断指令,使得CPU在每个时间片执行完成后可以实现进程切换,使操作系统具有进程切换的能力(任务调度功能)。
外设需要联系CPU时,主动给CPU发送一个中断请求信号;CPU会放下手下的事情,来处理外设的这个请求,处理完成后,再回去处理以前的工作,这里有一个保存和读取上下文的过程。中断可能同时发生多个,一个CPU一次只能处理一个,按照优先级-依次处理。
除了硬件中断,还存在软件中断机制,即程序引发的中断(程序中调用INTR中断指令引起中断),和硬件中断机制基本一致,且软中断基本都是I/O请求导致。CPU在收到INT指令时,通过中断描述符表中找到中断号对于的中断处理函数,具体处理流程如上图所示。
顺便说一句,多核CPU之间的通信需要通过中断方式,机制比单核复杂,感兴趣可以研究一下APIC。
1.3 系统调用
系统调用是系统内核预先提供的函数(用于给用户程序调用),和普通库函数的区别在于系统调用函数涉及底层硬件以及操作系统的安全性,因此需要通过OS来统一管理,系统调用会触发用户态和内核态的切换。
系统调用过程涉及用户态与内核态的概念,有必要介绍一下。
用户态与内核态
特权级是有效管理和控制程序执行的手段(安全性、集中管理硬件资源、避免访问冲突),用户态与内核态的区分也是为了限制不同程序的访问能力,关键的权力需要由高特权级的程序来执行,如对硬盘、网卡等硬件的访问或系统内核安全相关的操作等。
摘自《UNIX操作系统设计》:
[1] 用户态的进程能存取它们自己的指令和数据,但不能存取内核指令和数据(或其他进程的指令和数据); 然而核心态下的进程能够存取内核和用户地址;
[2] 某些机器指令是特权指令,在用户态下执行特权指令会引起错误;
硬件上对特权级提供了支持:在执行指令时会对指令进行特权级校验;X86架构支持0~3级,linux只使用了0级和3级:用户态拥有3-基本权限,内核态拥有0-高级权限。
用户程序只能访问用户空间数据,内核程序可以访问整个内存空间的数据;用户程序运行时,cpu工作在用户态;在需要访问受限数据(如:从磁盘读数据时),需要切换到内核态,
通过内核需要访问那些受限的数据。
用户态与内核态切换
异常、外设中断、系统调用会导致用户态与内核态的切换,系统调用最终依赖中断机制实现。每个进程都拥有两个堆栈:用户堆栈和内核堆栈,在用户态使用用户堆栈;切换到内核态时,使用内核堆栈。
系统调用
系统调用会导致用户态切换到内核态,实现方式是向CPU发出中断指令(INT 0X80), CPU收到中断指令后,暂停指向的任务,去处理中断请求: 通过中断号,到内核的中断表里查询出中断函数的地址,并调用中断函数,完成操作返回后,再从内核态切换到用户态。
系统调用详细流程如下所示:
[1] 将该系统调用对应的系统调用号存入exa寄存器,向CPU发出中断请求;
[2] CPU收到中断后,从用户进程描述符中提取内核堆栈的ESP,将当前进程的ESP(以及ss,eip,cs,eflags等)等信息保存至内核堆栈中;
[3] 将ESP指向内核堆栈, 实现用户栈->内核栈的切换;
[4] 从exa寄存器中取出系统调用号,从系统调用表中获取系统调用对应函数的入口地址,并调用该系统函数并执行(此时,程序运行在内核态);
[5] 函数执行完毕后,执行指令iret切回用户态;
[6] 执行用户态的程序的下一条指令;
其中:ESP是进程的堆栈寄存器:当CPU执行内核代码时,ESP指向内核堆栈,当CPU执行用户程序时,ESP指向用户堆栈;每个系统调用都存在一个系统调用号,比如write对应5;指令iret包含从内核堆栈中弹出保存的寄存器信息,以及将这个信息存储到寄存器中。
1.4 虚拟文件系统
在Linux系统中一切皆文件,即普通文件、目录、块设备、字符设备、网卡、套接字等都被看做文件;即Linux操作系统对这些设备提供了统一的处理接口,不因为设备不同而有所区分。实现方式如下图所示:
设备间的区分在设备驱动层实现,每个设备驱动都定义了自己的操作方法,且不同设备驱动各不相同。设备差异的屏蔽也在该层进行,不同的设备驱动对文件系统提供统一的访问规范,即file_operation()接口。因此文件系统调用file_operation()方法时,不需要知道具体对什么设备做操作,从而实现对所有设备一视同仁。
Linux系统一般存在多个分区且各个分区的文件系统可能不同(Linux支持几十种文件系统),通过引入虚拟文件系统(VFS)来屏蔽文件系统间的差异,对外提供统一的接口。
1.5 文件描述符
经过VFS和硬件驱动的屏蔽,Linux可以使用一套API来操作所有的设备(当作文件处理),即Linux中一切皆文件,而文件本质上就是一串二进制流。
信息的交换过程,就是计算机对这些流数据的收发操作(I/O操作),操作系统使用文件描述符来区分这些流;即Linux给每个“文件”都分配了一个文件描述符,可以理解为指向文件的指针,以后所有对文件的操作,都需要传递这个文件描述符。
int open(const char* path, int flags, model_t mode);
size_t read(int fd, void* buf, size_t count)
size_t write(int fd, const void* buf, size_t count);
int close(int fd);
open函数传入文件路径以及打开模式,返回一个文件描述符;read和write函数地第一个入参是文件描述符,用于指定操作对象,其中buf指向数据地内存地址,count表示读/写地字节数;给close函数传递fd,表示关闭该文件。
如下所示,Linux会有一些固定地文件描述符:
文件描述符 | 作用 | stdio流 | POSIX名称 |
---|---|---|---|
0 | 标准输入 | stdin | STDIN_FILENO |
1 | 标准输出 | stdout | STDOUT_FILENO |
2 | 标准错误 | stderr | STDERR_FILENO |
此外,我们创建一个ServerSocket对象或者通过ServerSocket对象生成一个Socket对象,都对应着一个文件描述符的生成。
1.6 DMA拷贝
摘自百度百科:
DMA技术是Direct Memory Access的缩写。其意思是“存储器直接访问”。它是指一种高速的数据传输操作,允许在外部设备和存储器之间直接读写数据,既不通过CPU,也不需要CPU干预。
在没有DMA技术之前,I/O过程需要CPU的参与:
[1] CPU发出读取数据的指令给磁盘控制器;
[2] 磁盘控制器收到指令后,开始准备数据,当磁盘缓存区的数据准备就绪后,就会向CPU发出中断请求;
[3] CPU收到请求后,会中断当前的任务转而执行中断处理程序:把磁盘缓存区的数据一次读取CPU寄存器内存,然后再把寄存器数据存入主内存;
由于CPU从磁盘读取数据较慢,此过程中CPU几乎全程参与,严重影响了系统的效率。
如上图所示,用户程序向系统发出读数据请求(系统调用->中断->CPU响应中断),系统会下发请求至磁盘控制器并立即返回,此时不阻塞,没有CPU时间浪费。
在磁盘控制器控制下,磁盘中的数据读取到磁盘缓存区,当数据准备好时(缓存区满或者数据准备完成),向CPU发出中断请求—通知CPU数据已准备好(“可以来读数据了”);
此时,在CPU的控制下,将数据从磁盘缓存区读到内核缓存区,再由内核缓存区读到用户缓存区;这两个过程CPU完全陷入其中,计算机效率大打折扣。
DMA出现后,CPU不再将IO请求发送给磁盘控制器,而是将请求转发给DMA(硬件设备),由DMA代理完成从磁盘读取到内核缓存区。DMA控制器之所以可以直接将磁盘数据读到内存中,是因为拥有总线权限(地址、数据);在进行数据传输前,DMA控制器会通过中断向CPU临时申请总线的权限;在数据传输完成后,也是通过中断机制提醒CPU收回权限;因此, 在DMA传输数据期间,CPU不具备访内的功能;
数据从磁盘拷贝入内核缓存区后,DMAC通过中断通知CPU(回收总线权限&&读取数据),CPU此时将数据从内核缓存区读取到用户缓存区。
可以这么说,DMA技术的出现,磁盘与内存之间的数据传输不再需要CPU的参与。
2.缓存机制
缓存机制的出现一方面是为了均衡不同设备的速度差异,另一方面是减少不必要的读写次数以降低IO频率。
如上图所示:
(1) 绿色部分存在于用户空间,包括程序自己分配的缓存以及Linux中C库分配的缓存。
通常,程序读取数据的方式不是一次读一个字节(效率太低);而是在堆内存中分配一个字节数组,以数组为单位进行读写,此时数组就是缓冲区。
java由于具有平台独立性,本身不具备IO能力,因此java中IO相关的API最终还是依赖操作系统提供的函数(不具备缓存)或者C库(基于系统函数进行了一次封装)。如果依赖的是C库,则会在用户空间进行了一层缓存。
此时,用户程序读写数据直接操作的是用户缓存区,而不是真实地读写了磁盘,减少了用户缓存区和内核缓存区之间以及内存与硬件之间的拷贝次数,提高了程序的执行效率。
(2) 当调用fflush或者Clib buffer存满时,会将数据刷到内核缓存区(Page Cache);Linux内部存在IO调度机制,会自己决定什么时候将数据写入到磁盘中。
(3) 当Linux需要将数据写入到磁盘时,写入的对象是磁盘缓存区,由磁盘控制器决定什么时候写入到磁盘;读取数据时也是从磁盘缓存区中读取。
当我们不是把数据写入到磁盘而是通过网卡发送到网络上时,需要将数据从用户缓存区拷贝到内核缓存区,再拷贝到套接字缓存区,然后再以DMA方式拷贝到网卡缓存区,再由网卡控制器决定什么时候将网卡缓冲区中的数据发送到网络上。
通过缓存机制,可以减少用户态和内核态的来回切换,减少用户内存与内核内存之间的拷贝,减少内存与硬件设备之间的拷贝。总之,缓存的存在是为了提高IO效率,但是同时也存在着问题——如果用户进程将数据写到了用户缓冲区,写完后会表现为写入成功,此时电脑如果发生down机现象,写入的数据就会丢失。
3.零拷贝过程
在没有零拷贝之前,从磁盘将文件发送到网卡的操作如下图所示:
// 伪代码
byte[] buffer = call_read();
call_write(buffer);
先拷贝文件(从磁盘->内核缓存区),再拷贝文件(内核缓存区->用户缓存区),再拷贝数据(用户缓存区->socket缓存区),再拷贝数据(socket换存区->网卡)。明显可以看出:用户进程没有修改文件的情况下,数据拷贝到用户缓存区毫无意义。
在mmap(内存映射零拷贝技术),通过内存映射技术使得用户进程可以访问内核空间的技术,减少了内核空间向用户空间的拷贝操作,详细流程如下所示:
// 伪代码
byte[] buffer = call_mmap();
call_write(buffer);
进一步地,调用sendfie实现零拷贝:
// 伪代码
call_sendfie();
在Linux内核2.4版本中,省去了内核缓冲区->socket缓存区的拷贝操作:
零拷贝技术使得数据传输不经过用户缓存区,减少了用户态和内核态上下文切换的次数,使得IO效率得到提升;但如果用户程序需要修改文件内容,则不适合使用零拷贝技术。
附注
本文主要用于个人学习、总结和复习,也用于技术分享;有任何不同意见或者问题,欢迎评论区留言或邮箱shengyu@buaa.edu.cn。
独学而无友, 则孤陋而寡闻