Unix下的5种I/O模型


本文是《UNIUX网络编程》关于IO模型章节的一个小记,它们分别是:
阻塞IO
非阻塞IO
IO复用(select和poll)
信号驱动式IO(SIGIO)
异步IO(POSIX的aio系列函数)
阻塞I/O模型

本例子中,我们吧recvfrom函数视为系统调用,为的是区分应用进程和内核,不论它是如何实现的。在上图中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓存或者发生错误才会返回。进行在recvfrom开始到它返回的整段时间内是被阻塞的,recvfrom成功返回后,应用进程开始处理数据报。
在调用recvfrom到返回,可以分为两个过程:
内核等待数据
把数据从内核复制到用户空间。
可以看到使用阻塞式IO在进行在上面两个过程中都处于等待(阻塞)当中。后面讲到的其他模型和阻塞IO的区别就在于不同的方式处理上面两个进程。
非阻塞IO

非阻塞IO和阻塞IO的最大区别在于等待数据这个过程,在阻塞IO中内核处于等待数据的过程中,进程是处于阻塞状态的。而在非阻塞IO中不会阻塞,当数据报没有准备好的时候,会直接返回一个EWOULDBLOCK错误。
应用程序像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询,但是持续轮询内核以查看某个操作是否就绪,往往浪费大量的cpu时间。
IO复用(select和poll)

相比阻塞IO,IO复用把等待数据和复制数据到用户缓存区两个操作分开做了,需要两次系统调用。这样看来IO复用不具有什么优势,事实上select的优势在于我们可以等待多个描述符就绪。(socket数据或者是文件数据)
信号驱动式IO(SIGIO)

我们首先开启套接字的信号驱动式IO功能,并且通过sigaction系统调用安装一个信号处理函数,该系统调用立刻返回,我们的进行继续工作。
当数据准备好之后,内核就为该进程产生一个SIGIO信号,这个时候进程就知道数据准备好了,可以让内核进行复制数据到用户空间操作了。这个模式比起阻塞IO来说,等待数据这步不需要阻塞。
异步IO(POSIX的aio系列函数)

应用程序先进行一次系统调度,告诉内核需要监听的描述符,缓存区指针,缓存区大小,文件偏移,该系统调度立马返回。待内核把数据准备好,并且复制到用户空间了,再通知进程,在异步IO中,进程完全不需要阻塞。
上面看到的四种模式,在复制数据到用户空间这个过程中,进程都需要阻塞的,唯独异步IO在这个过程中,不需要阻塞。
模型对比

POSIX把两个属于定义如下:
同步IO操作:导致请求进程阻塞,直到IO操作完成。
异步IO操作:不导致请求阻塞。
可以得出结论,阻塞IO、非阻塞IO、复用IO、信号驱动式IO都为同步IO操作,异步IO模型与POSIX的异步IO操作匹配。
上文源自:互联网
再识一些IO底层原理
本节从底层讲解了下从BIO到NIO的一个过程,着重介绍了IO多路复用的几个系统调用select()、poll()、epoll(),分析了下各自的优劣,技术都是持续发展演进的;算是对上面5种I/O模型的展开。
1、IO 概念
IO是Input和Output的缩写,即输入和输出。广义上的围绕计算机的输入输出有很多:鼠标、键盘、扫描仪等等。而我们今天要探讨的是在计算机里面,主要是作用在内存、网卡、硬盘等硬件设备上的输入输出操作。
谈起IO的模型,多数人有比较模糊的概念,“阻塞”、“非阻塞”,“同步”、“异步”有什么区别?这里尝试简单地去解释下为啥会出现这种现象,其中一个很重要的原因就是大家看到的资料对概念的解释都站在了不同的角度,有的站在了底层内核的视角,有的直接在java层面或者Netty框架层面给大家介绍API,所以给大家造成了一定程度的困扰。这里将会从底层内核的层面给大家讲解下IO。因为万变不离其宗,只有了解了底层原理,不管语言层面如何花里胡哨,我们都能以不变应万变。
2、用户和内核空间
为了便于大家理解复杂的IO以及零拷贝相关的技术,还是得花点时间在回顾下操作系统相关的知识。这一节重点看下用户空间和内核空间(也称用户态或内核态),基于此后面才能更好地讲解多路复用和零拷贝。

硬件层(Hardware)
包括和我们熟知的和IO相关的CPU、内存、磁盘和网卡几个硬件;
内核空间(Kernel Space)
计算机开机后首先会运行内核程序,内核程序占用的一块私有的空间就是内核空间,并且可支持访问CPU所有的指令集(ring0 - ring3)以及所有的内存空间、IO及硬件设备;
用户空间(User Space)
每个普通的用户进程都有一个单独的用户空间,用户空间只能访问受限的资源(CPU的“保护模式”)也就是说用户空间是无法直接操作像内存、网卡和磁盘等硬件的;
如上所述,那可能会有疑问,用户空间的进程想要去访问或操作磁盘和网卡该怎么办呢?
为此,操作系统在内核中开辟了一块唯一且合法的调用入口“System Call Interface”,也就是我们常说的系统调用,系统调用为上层用户提供了一组能够操作底层硬件的API。这样一来,用户进程就可以通过系统调用访问到操作系统内核,进而就能够间接地完成对底层硬件的操作。这个访问的过程也即用户态到内核态的切换。常见的系统调用有很多,比如:内存映射mmap()、文件操作类的open()、IO读写read()、write()等等。
3、IO模型
3.1、 BIO(Blocking IO)
先看一下大家都熟悉的BIO模型:
创建ServerSocket,并监听8080端口;
主线程进入死循环,用来阻塞监听客户端的连接,socket.accept();
数据读取,socket.read();
写入数据,socket.write();
问题
以上三个步骤:accept(...)、read(...)、write(...)都会造成线程阻塞。上述这个代码使用了单线程,会导致主线程会直接夯死在阻塞的地方。
优化
我们要知道一点“进程的阻塞是不会消耗CPU资源的”,所以在多核的环境下,我们可以创建多线程,把接收到的请求抛给多线程去处理,这样就有效地利用了计算机的多核资源。甚至为了避免创建大量的线程处理请求,我们还可以进一步做优化,创建一个线程池,利用池化技术,对暂时处理不了的请求做一个缓冲。
3.2、著名的“C10K”问题
“C10K”即“client more than 10k”用来指代数量庞大的客户端;
BIO看上去非常的简单,事实上采用“BIO+线程池”来处理少量的并发请求还是比较合适的,也是最优的。但是面临数量庞大的客户端和请求,这时候使用多线程的弊端就逐渐凸显出来了:
严重依赖线程,线程还是比较耗系统资源的(一个线程大约占用1M的空间);
频繁地创建和销毁代价很大,因为涉及到复杂的系统调用;
线程间上下文切换的成本很高,因为发生线程切换前,需要保留上一个任务的状态,以便切回来的时候,可以再次加载这个任务的状态。如果线程数量庞大,会造成线程做上下文切换的时间甚至大于线程执行的时间,CPU负载变高。
3.3、NIO非阻塞模型
下面开始真正走向Java NIO或者Netty框架所描述的“非阻塞”,NIO叫Non-Blocking IO或者New IO,由于BIO可能会引入的大量线程,所以可以简单地理解NIO处理问题的方式是通过单线程或者少量线程达到处理大量客户端请求的目的。为了达成这个目的,首先要做的就是把阻塞的过程非阻塞化。要想做到非阻塞,那必须得要有内核的支持,同时需要对用户空间的进程暴露系统调用函数。所以,这里的“非阻塞”可以理解成系统调用API级别的,而真正底层的IO操作都是阻塞的,我们后面会慢慢介绍。
事实上,内核已经对“非阻塞”做好了支持,举个我们刚刚说的的accept()方法阻塞的例子(Tips:java中的accept方法对应的系统调用函数也叫accept),看下官方文档对其非阻塞部分的描述。

官方文档对accetp()系统调用的描述是通过把"flags"参数设成"SOCK_NONBLOCK"就可以达到非阻塞的目的,非阻塞之后线程会一直处理轮询调用,这时候可以通过每次返回特殊的异常码“EAGAIN”或"EWOULDBLOCK"告诉主程序还没有连接到达可以继续轮询。
我们可以很容易想象程序非阻塞之后的一个大致过程。所以非阻塞模式有个最大的特点就是:用户进程需要不断去主动询问内核数据准备好了没有!
下面我们通过一段伪代码,看下这个调用过程:
// 循环遍历
while(1) {
// 遍历fd集合
for (fdx in range(fd1, fdn)) {
// 如果fdx有数据
if (null != fdx.data) {
// 进行读取和处理
read(fdx)&handle(fdx);
}
}
}
这种调用方式也暴露出非阻塞模式的最大的弊端,就是需要让用户进程不断切换到内核态,对连接状态或读写数据做轮询。有没有一种方式来简化用户空间for循环轮询的过程呢?那就是我们下面要重点介绍的IO多路复用模型。
4、IO多路复用模型
非阻塞模型会让用户进程一直轮询调用系统函数,频繁地做内核态切换。想要做优化其实也比较简单,我们假想个业务场景,A业务系统会调用B的基础服务查询单个用户的信息。随着业务的发展,A的逻辑变复杂了,需要查100个用户的信息。很明显,A希望B提供一个批量查询的接口,用集合作为入参,一次性把数据传递过去就省去了频繁的系统间调用。
多路复用实际也差不多就是这个实现思路,只不过入参这个“集合”需要你注册/填写感兴趣的事件,读fd、写fd或者连接状态的fd等,然后交给内核帮你进行处理。
那我们就具体来看看多路复用里面大家都可能听过的几个系统调用 - select()、poll()、epoll()。
4.1 select()
select() 构造函数信息如下所示:
/**
* select()系统调用
*
* 参数列表:
* nfds - 值为最大的文件描述符+1
* *readfds - 用户检查可读性
* *writefds - 用户检查可写性
* *exceptfds - 用于检查外带数据
* *timeout - 超时时间的结构体指针
*/
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
官方文档对select()的描述:
DESCRIPTION
select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation (e.g.,input possible). A file descriptor is considered ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.
select()允许程序监控多个fd,阻塞等待直到一个或多个fd到达"就绪"状态。
内核使用select()为用户进程提供了类似批量的接口,函数本身也会一直阻塞直到有fd为就绪状态返回。下面我们来具体看下select()函数实现,以便我们更好地分析它有哪些优缺点。在select()函数的构造器里,我们很容易看到"fd_set"这个入参类型。它是用位图算法bitmap实现的,使用了一个大小固定的数组(fd_set设置了FD_SETSIZE固定长度为1024),数组中的每个元素都是0和1这样的二进制byte,0,1映射fd对应位置上是否有读写事件,举例:如果fd == 5,那么fd_set = 000001000。
同时 fd_set 定义了四个宏来处理bitmap:
FD_ZERO(&set); // 初始化,清空的作用,使集合中不含任何fd
FD_SET(fd, &set); // 将fd加入set集合,给某个位置赋值的操作
FD_CLR(fd, &set); // 将fd从set集合中清除,去掉某个位置的值
FD_ISSET(fd, &set); // 校验某位置的fd是否在集合中
使用bitmap算法的好处非常明显,运算效率高,占用内存少(使用了一个byte,8bit)。我们用伪代码和图片来描述下用户进程调用select()的过程:

假设fds为{1,2,3,5,7}对应的bitmap为"01110101",抛给内核空间轮询,当有读写事件时重新标记同时停止阻塞,然后整体返回用户空间。由此我们可以看到select()系统调用的弊端也是比较明显的:
复杂度O(n),轮询的任务交给了内核来做,复杂度并没有变化,数据取出后也需要轮询哪个fd上发生了变动;
用户态还是需要不断切换到内核态,直到所有的fds数据读取结束,整体开销依然很大;
fd_set有大小的限制,目前被硬编码成了1024;
fd_set不可重用,每次操作完都必须重置;
4.2 poll()
poll() 构造函数信息如下所示:
/**
* poll()系统调用
*
* 参数列表:
* *fds - pollfd结构体
* nfds - 要监视的描述符的数量
* timeout - 等待时间
*/
int poll(struct pollfd *fds, nfds_t nfds, int *timeout);
### pollfd的结构体
struct pollfd{
int fd;// 文件描述符
short event;// 请求的事件
short revent;// 返回的事件
}
官方文档对poll()的描述:
DESCRIPTION
poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.
poll() 非常像select(),它也是阻塞等待直到一个或多个fd到达"就绪"状态。
看官方文档描述可以知道,poll()和select()是非常相似的,唯一的区别在于poll()摒弃掉了位图算法,使用自定义的结构体pollfd,在pollfd内部封装了fd,并通过event变量注册感兴趣的可读可写事件(POLLIN、POLLOUT),最后把 pollfd 交给内核。当有读写事件触发的时候,我们可以通过轮询 pollfd,判断revent确定该fd是否发生了可读可写事件。
老样子我们用伪代码来描述下用户进程调用 poll() 的过程:

poll() 相对于select(),主要的优势是使用了pollfd的结构体:
没有了bitmap大小1024的限制;
通过结构体中的revents置位;
但是用户态到内核态切换及O(n)复杂度的问题依旧存在。
4.3 epoll()
epoll()应该是目前最主流,使用范围最广的一组多路复用的函数调用,像我们熟知的Nginx、Redis都广泛地使用了此种模式。接下来我们重点分析下,epoll()的实现采用了“三步走”策略,它们分别是epoll_create()、epoll_ctl()、epoll_wait()。
4.3.1 epoll_create()
/**
* 返回专用的文件描述符
*/
int epoll_create(int size);
用户进程通过 epoll_create() 函数在内核空间里面创建了一块空间(为了便于理解,可以想象成创建了一块白板),并返回了描述此空间的fd。
4.3.2 epoll_ctl()
/**
* epoll_ctl()系统调用
*
* 参数列表:
* epfd - 由epoll_create()返回的epoll专用的文件描述符
* op - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
* fd - 关联的文件描述符
* event - 指向epoll_event的指针
*/
int epoll_ctl(int epfd, int op, int fd , struce epoll_event *event );
刚刚我们说通过epoll_create()可以创建一块具体的空间“白板”,那么通过epoll_ctl() 我们可以通过自定义的epoll_event结构体在这块“白板上”注册感兴趣的事件了。
注册 - EPOLL_CTL_ADD
修改 - EPOLL_CTL_MOD
删除 - EPOLL_CTL_DEL
4.3.3 epoll_wait()
/**
* epoll_wait()返回n个可读可写的fds
*
* 参数列表:
* epfd - 由epoll_create()返回的epoll专用的文件描述符
* epoll_event - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
* maxevents - 每次能处理的事件数
* timeout - 等待I/O事件发生的超时值;-1相当于阻塞,0相当于非阻塞。一般用-1即可
*/
int epoll_wait(int epfd, struce epoll_event *event , int maxevents, int timeout);
epoll_wait() 会一直阻塞等待,直到硬盘、网卡等硬件设备数据准备完成后发起硬中断,中断CPU,CPU会立即执行数据拷贝工作,数据从磁盘缓冲传输到内核缓冲,同时将准备完成的fd放到就绪队列中供用户态进行读取。用户态阻塞停止,接收到具体数量的可读写的fds,返回用户态进行数据处理。
整体过程可以通过下面的伪代码和图示进一步了解:

epoll() 基本上完美地解决了 poll() 函数遗留的两个问题:
没有了频繁的用户态到内核态的切换;
O(1)复杂度,返回的"nfds"是一个确定的可读写的数量,相比于之前循环n次来确认,复杂度降低了不少;
5、同步、异步
细心的朋友可能会发现,本节一直在解释“阻塞”和“非阻塞”,“同步”、“异步”的概念没有涉及,其实在很多场景下同步&异步和阻塞&非阻塞基本上是一个同义词。阻塞和非阻塞适合从系统调用API层面来看,就像我们本文介绍的select()、poll()这样的系统调用,同步和异步更适合站在应用程序的角度来看。应用程序在同步执行代码片段的时候结果不会立即返回,这时候底层IO操作不一定是阻塞的,也完全有可能是非阻塞。所以说:
阻塞和非阻塞:读写没有就绪或者读写没有完成,函数是否要一直等待还是采用轮询;
同步和异步:同步是读写由应用程序完成。异步是读写由操作系统来完成,并通过回调的机制通知应用程序。
这边顺便提两种大家可能会经常听到的模式:Reactor和Preactor。
Reactor 模式:主动模式。
Preactor 模式:被动模式。
Kafka为什么快,什么是mmap
这类的问题都逃不过的一个点就是零拷贝,虽然还有一些其他的原因,但更主要的就是零拷贝。
传统IO
在开始谈零拷贝之前,首先要对传统的IO方式有一个概念。基于传统的IO方式,底层实际上通过调用read()和write()来实现。通过read()把数据从硬盘读取到内核缓冲区,再复制到用户缓冲区;然后再通过write()写入到socket缓冲区,最后写入网卡设备。

整个过程发生了4次用户态和内核态的上下文切换和4次拷贝,具体流程如下:
用户进程通过read()方法向操作系统发起调用,此时上下文从用户态转向内核态
DMA控制器把数据从硬盘中拷贝到读缓冲区
CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,read()返回
用户进程通过write()方法发起调用,上下文从用户态转为内核态
CPU将应用缓冲区中数据拷贝到socket缓冲区
DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

那么这里指的用户态、内核态指的是什么?上下文切换又是什么?
简单来说,用户空间指的就是用户进程的运行空间,内核空间就是内核的运行空间。如果进程运行在内核空间就是内核态,运行在用户空间就是用户态。
为了安全起见,它们之间是互相隔离的,而在用户态和内核态之间的上下文切换也是比较耗时的。从上面可以看到,一次简单的IO过程产生了4次上下文切换,这个无疑在高并发场景下会对性能产生较大的影响。
那么什么又是DMA拷贝呢?
因为对于一个IO操作而言,都是通过CPU发出对应的指令来完成,但是相比CPU来说,IO的速度太慢了,CPU有大量的时间处于等待IO的状态。
因此就产生了DMA(Direct Memory Access)直接内存访问技术,本质上来说他就是一块主板上独立的芯片,通过它来进行内存和IO设备的数据传输,从而减少CPU的等待时间。
但是无论谁来拷贝,频繁的拷贝耗时也是对性能的影响。
零拷贝
零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域,这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
那么对于零拷贝而言,并非真的是完全没有数据拷贝的过程,只不过是减少用户态和内核态的切换次数以及CPU拷贝的次数。这里仅仅有针对性的来谈谈几种常见的零拷贝技术。
mmap+write
mmap+write简单来说就是使用mmap替换了read+write中的read操作,减少了一次CPU的拷贝。
mmap主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。

整个过程发生了4次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态
DMA控制器把数据从硬盘中拷贝到读缓冲区
上下文从内核态转为用户态,mmap调用返回
用户进程通过write()方法发起调用,上下文从用户态转为内核态
CPU将读缓冲区中数据拷贝到socket缓冲区
DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回
mmap的方式节省了一次CPU拷贝,同时由于用户进程中的内存是虚拟的,只是映射到内核的读缓冲区,所以可以节省一半的内存空间,比较适合大文件的传输。
sendfile
相比mmap来说,sendfile同样减少了一次CPU拷贝,而且还减少了2次上下文切换。

sendfile是Linux 2.1内核版本后引入的一个系统调用函数,通过使用sendfile数据可以直接在内核空间进行传输,因此避免了用户空间和内核空间的拷贝,同时由于使用sendfile替代了read+write从而节省了一次系统调用,也就是2次上下文切换。

整个过程发生了2次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
DMA控制器把数据从硬盘中拷贝到读缓冲区
CPU将读缓冲区中数据拷贝到socket缓冲区
DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回
sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。
sendfile+DMA Scatter/Gather
Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。
它将读缓冲区中的数据描述信息--内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程。

整个过程发生了2次用户态和内核态的上下文切换和2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:
用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
sendfile()调用返回,上下文从内核态切换回用户态
DMA gather和sendfile一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。
应用场景
对于MQ而言,无非就是生产者发送数据到MQ然后持久化到磁盘,之后消费者从MQ读取数据。RocketMQ来说这两个步骤使用的是mmap+write,而Kafka则是使用mmap+write持久化数据,发送数据使用sendfile。
小结
由于CPU和IO速度的差异问题,产生了DMA技术,通过DMA搬运来减少CPU的等待时间。
传统的IOread+write方式会产生2次DMA拷贝+2次CPU拷贝,同时有4次上下文切换。
而通过mmap+write方式则产生2次DMA拷贝+1次CPU拷贝,4次上下文切换,通过内存映射减少了一次CPU拷贝,可以减少内存使用,适合大文件的传输。
sendfile方式是新增的一个系统调用函数,产生2次DMA拷贝+1次CPU拷贝,但是只有2次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对IO数据不可见,适用于静态文件服务器。
sendfile+DMA gather方式产生2次DMA拷贝,没有CPU拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。
内存映射-Mmap
内存映射,简而言之就是将内核空间的一段内存区域映射到用户空间。映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,相反内核空间对这段区域的修改也直接反映用户空间。那么对于内核空间与用户空间两者之间需要大量数据传输等操作的话效率是非常高的。当然也可以将内核空间的一段内存区域同时映射到多个进程,这样还可以实现进程间的共享内存通信。
系统调用mmap()就是用来实现上面说的内存映射。最长见的操作就是文件(在Linux下设备也被看做文件)的操作,可以将某文件映射至内存(进程空间),如此可以把对文件的操作转为对内存的操作,以此避免更多的lseek()与read()、write()操作,这点对于大文件或者频繁访问的文件而言尤其受益。
概述
mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap执行相反的操作,删除特定地址区域的对象映射。
当使用mmap映射文件到进程后,就可以直接操作这段虚拟地址进行文件的读写等操作,不必再调用read,write等系统调用。但需注意,直接对该段内存写时不会写入超过当前文件大小的内容。
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
通常使用mmap()的三种情况: 提高I/O效率、匿名内存映射、共享内存进程通信。
用户空间 mmap()函数 void *mmap(void *start, size_t length, int prot, int flags,int fd, off_t offset),下面就其参数解释如下:
start:用户进程中要映射的用户空间的起始地址,通常为NULL(由内核来指定)
length:要映射的内存区域的大小
prot:期望的内存保护标志
flags:指定映射对象的类型
fd:文件描述符(由open函数返回)
offset:设置在内核空间中已经分配好的的内存区域中的偏移,例如文件的偏移量,大小为PAGE_SIZE的整数倍
返回值:mmap()返回被映射区的指针,该指针就是需要映射的内核空间在用户空间的虚拟地址

内存映射的应用
X Window服务器
众多内存数据库如MongoDB操作数据,就是把文件磁盘内容映射到内存中进行处理,为什么会提高效率? 很多人不解。下面就深入分析内存文件映射。
通过malloc来分配大内存其实调用的是mmap,可见在malloc(10)的时候调用的是brk,malloc(10 * 1024 * 1024)调用的是mmap
mmap()用于共享内存的两种方式
使用普通文件提供的内存映射:适用于任何进程之间;此时,需要打开或创建一个文件,然后再调用mmap();典型调用代码如下:
fd=open(name, flag, mode);
if(fd<0)
...
ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0);
使用特殊文件提供匿名内存映射:适用于具有亲缘关系的进程之间;由于父子进程特殊的亲缘关系,在父进程中先调用mmap(),然后调用fork()。那么在调用fork()之后,子进程继承父进程匿名映射后的地址空间,同样也继承mmap()返回的地址,这样,父子进程就可以通过映射区域进行通信了。注意,这里不是一般的继承关系。一般来说,子进程单独维护从父进程继承下来的一些变量。而mmap()返回的地址,却由父子进程共同维护。对于具有亲缘关系的进程实现共享内存最好的方式应该是采用匿名内存映射的方式。此时,不必指定具体的文件,只要设置相应的标志即可。
示例
驱动+应用
首先在驱动程序分配一页大小的内存,然后用户进程通过mmap()将用户空间中大小也为一页的内存映射到内核空间这页内存上。映射完成后,驱动程序往这段内存写10个字节数据,用户进程将这些数据显示出来。
#include <linux/miscdevice.h>
#include <linux/delay.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
#include <linux/mm.h>
#include <linux/fs.h>
#include <linux/types.h>
#include <linux/delay.h>
#include <linux/moduleparam.h>
#include <linux/slab.h>
#include <linux/errno.h>
#include <linux/ioctl.h>
#include <linux/cdev.h>
#include <linux/string.h>
#include <linux/list.h>
#include <linux/pci.h>
#include <linux/gpio.h>
#define DEVICE_NAME "mymap"
static unsigned char array[10]={0,1,2,3,4,5,6,7,8,9};
static unsigned char *buffer;
static int my_open(struct inode *inode, struct file *file){
return 0;
}
static int my_map(struct file *filp, struct vm_area_struct *vma){
unsigned long page;
unsigned char i;
unsigned long start = (unsigned long)vma->vm_start;
//unsigned long end = (unsigned long)vma->vm_end;
unsigned long size = (unsigned long)(vma->vm_end - vma->vm_start);
//得到物理地址
page = virt_to_phys(buffer);
//将用户空间的一个vma虚拟内存区映射到以page开始的一段连续物理页面上
if(remap_pfn_range(vma,start,page>>PAGE_SHIFT,size,PAGE_SHARED))//第三个参数是页帧号,由物理地址右移PAGE_SHIFT得到
return -1;
//往该内存写10字节数据
for(i=0;i<10;i++)
buffer[i] = array[i];
return 0;
}
static struct file_operations dev_fops = {
.owner = THIS_MODULE,
.open = my_open,
.mmap = my_map,
};
static struct miscdevice misc = {
.minor = MISC_DYNAMIC_MINOR,
.name = DEVICE_NAME,
.fops = &dev_fops,
};
static int __init dev_init(void){
int ret;
//注册混杂设备
ret = misc_register(&misc);
//内存分配
buffer = (unsigned char *)kmalloc(PAGE_SIZE,GFP_KERNEL);
//将该段内存设置为保留
SetPageReserved(virt_to_page(buffer));
return ret;
}
static void __exit dev_exit(void){
//注销设备
misc_deregister(&misc);
//清除保留
ClearPageReserved(virt_to_page(buffer));
//释放内存
kfree(buffer);
}
module_init(dev_init);
module_exit(dev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("LKN@SCUT");
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <linux/fb.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#define PAGE_SIZE 4096
int main(int argc , char *argv[]){
int fd;
int i;
unsigned char *p_map;
//打开设备
fd = open("/dev/mymap",O_RDWR);
if(fd < 0){
printf("open fail\n");
exit(1);
}
//内存映射
p_map = (unsigned char *)mmap(0, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED,fd, 0);
if(p_map == MAP_FAILED){
printf("mmap fail\n");
goto here;
}
//打印映射后的内存中的前10个字节内容
for(i=0;i<10;i++)
printf("%d\n",p_map[i]);
here:
munmap(p_map, PAGE_SIZE);
return 0;
}
进程间共享内存
UNIX访问文件的传统方法是用open打开它们,如果有多个进程访问同一个文件,则每一个进程在自己的地址空间都包含有该文件的副本,这不必要地浪费了存储空间。下图说明了两个进程同时读一个文件的同一页的情形。系统要将该页从磁盘读到高速缓冲区中,每个进程再执行一个存储器内的复制操作将数据从高速缓冲区读到自己的地址空间。

现在考虑另一种处理方法共享存储映射:进程A和进程B都将该页映射到自己的地址空间,当进程A第一次访问该页中的数据时,它生成一个缺页中断。内核此时读入这一页到内存并更新页表使之指向它。以后当进程B访问同一页面而出现缺页中断时,该页已经在内存,内核只需要将进程B的页表登记项指向次页即可。如下图所示:

下面就是进程A和B共享内存的示例。两个程序映射同一个文件到自己的地址空间, 进程A先运行, 每隔两秒读取映射区域, 看是否发生变化。进程B后运行, 它修改映射区域, 然后退出, 此时进程A能够观察到存储映射区的变化。
进程A的代码:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <error.h>
#define BUF_SIZE 100
int main(int argc, char **argv){
int fd, nread, i;
struct stat sb;
char *mapped, buf[BUF_SIZE];
for (i = 0; i < BUF_SIZE; i++){
buf[i] = '#';
}
/* 打开文件 */
if ((fd = open(argv[1], O_RDWR)) < 0){
perror("open");
}
/* 获取文件的属性 */
if ((fstat(fd, &sb)) == -1) {
perror("fstat");
}
/* 将文件映射至进程的地址空间 */
if ((mapped = (char *)mmap(NULL, sb.st_size, PROT_READ |
PROT_WRITE, MAP_SHARED, fd, 0)) == (void *)-1) {
perror("mmap");
}
/* 文件已在内存, 关闭文件也可以操纵内存 */
close(fd);
/* 每隔两秒查看存储映射区是否被修改 */
while (1) {
printf("%s\n", mapped);
sleep(2);
}
return 0;
}
进程B的代码:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <error.h>
#define BUF_SIZE 100
int main(int argc, char **argv){
int fd, nread, i;
struct stat sb;
char *mapped, buf[BUF_SIZE];
for (i = 0; i < BUF_SIZE; i++) {
buf[i] = '#';
}
/* 打开文件 */
if ((fd = open(argv[1], O_RDWR)) < 0) {
perror("open");
}
/* 获取文件的属性 */
if ((fstat(fd, &sb)) == -1) {
perror("fstat");
}
/* 私有文件映射将无法修改文件 */
if ((mapped = (char *)mmap(NULL, sb.st_size, PROT_READ |
PROT_WRITE, MAP_PRIVATE, fd, 0)) == (void *)-1) {
perror("mmap");
}
/* 映射完后, 关闭文件也可以操纵内存 */
close(fd);
/* 修改一个字符 */
mapped[20] = '9';
return 0;
}
匿名映射实现父子进程通信
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUF_SIZE 100
int main(int argc, char** argv){
char *p_map;
/* 匿名映射,创建一块内存供父子进程通信 */
p_map = (char *)mmap(NULL, BUF_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if(fork() == 0) {
sleep(1);
printf("child got a message: %s\n", p_map);
sprintf(p_map, "%s", "hi, dad, this is son");
munmap(p_map, BUF_SIZE); //实际上,进程终止时,会自动解除映射。
exit(0);
}
sprintf(p_map, "%s", "hi, this is father");
sleep(2);
printf("parent got a message: %s\n", p_map);
return 0;
}
mmap进行内存映射的原理
mmap系统调用的最终目的是将设备或文件映射到用户进程的虚拟地址空间,实现用户进程对文件的直接读写,这个任务可以分为以下三步:
1.在用户虚拟地址空间中寻找空闲的满足要求的一段连续的虚拟地址空间,为映射做准备(由内核mmap系统调用完成)
假如vm_area_struct描述的是一个文件映射的虚存空间,成员vm_file便指向被映射的文件的file结构,vm_pgoff是该虚存空间起始地址在vm_file文件里面的文件偏移,单位为物理页面。mmap系统调用所完成的工作就是准备这样一段虚存空间,并建立vm_area_struct结构体,将其传给具体的设备驱动程序.
2.建立虚拟地址空间和文件或设备的物理地址之间的映射(设备驱动完成) 建立文件映射的第二步就是建立虚拟地址和具体的物理地址之间的映射,这是通过修改进程页表来实现的。mmap方法是file_opeartions结构的成员:int (*mmap)(struct file *,struct vm_area_struct *);
linux有2个方法建立页表:
使用remap_pfn_range一次建立所有页表。int remap_pfn_range(struct vm_area_struct *vma, unsigned long virt_addr, unsigned long pfn, unsigned long size, pgprot_t prot)。
使用nopage VMA方法每次建立一个页表项。 struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int *type);
3.当实际访问新映射的页面时的操作(由缺页中断完成)
page cache及swap cache中页面的区分:一个被访问文件的物理页面都驻留在page cache或swap cache中,一个页面的所有信息由struct page来描述。struct page中有一个域为指针mapping ,它指向一个struct address_space类型结构。page cache或swap cache中的所有页面就是根据address_space结构以及一个偏移量来区分的。
文件与 address_space结构的对应:一个具体的文件在打开后,内核会在内存中为之建立一个struct inode结构,其中的i_mapping域指向一个address_space结构。这样一个文件就对应一个address_space结构,一个 address_space与一个偏移量能够确定一个page cache 或swap cache中的一个页面。因此,当要寻址某个数据时,很容易根据给定的文件及数据在文件内的偏移量而找到相应的页面。
进程调用mmap()时,只是在进程空间内新增了一块相应大小的缓冲区,并设置了相应的访问标识,但并没有建立进程空间到物理页面的映射。因此,第一次访问该空间时,会引发一个缺页异常。
对于共享内存映射情况,缺页异常处理程序首先在swap cache中寻找目标页(符合address_space以及偏移量的物理页),如果找到,则直接返回地址;如果没有找到,则判断该页是否在交换区 (swap area),如果在,则执行一个换入操作;如果上述两种情况都不满足,处理程序将分配新的物理页面,并把它插入到page cache中。进程最终将更新进程页表。 注:对于映射普通文件情况(非共享映射),缺页异常处理程序首先会在page cache中根据address_space以及数据偏移量寻找相应的页面。如果没有找到,则说明文件数据还没有读入内存,处理程序会从磁盘读入相应的页面,并返回相应地址,同时进程页表也会更新。
所有进程在映射同一个共享内存区域时,情况都一样,在建立线性地址与物理地址之间的映射之后,不论进程各自的返回地址如何,实际访问的必然是同一个共享内存区域对应的物理页面。
阻塞IO
非阻塞IO
IO复用(select和poll)
信号驱动式IO(SIGIO)
异步IO(POSIX的aio系列函数)
阻塞I/O模型

本例子中,我们吧recvfrom函数视为系统调用,为的是区分应用进程和内核,不论它是如何实现的。在上图中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓存或者发生错误才会返回。进行在recvfrom开始到它返回的整段时间内是被阻塞的,recvfrom成功返回后,应用进程开始处理数据报。
在调用recvfrom到返回,可以分为两个过程:
内核等待数据
把数据从内核复制到用户空间。
可以看到使用阻塞式IO在进行在上面两个过程中都处于等待(阻塞)当中。后面讲到的其他模型和阻塞IO的区别就在于不同的方式处理上面两个进程。
非阻塞IO

非阻塞IO和阻塞IO的最大区别在于等待数据这个过程,在阻塞IO中内核处于等待数据的过程中,进程是处于阻塞状态的。而在非阻塞IO中不会阻塞,当数据报没有准备好的时候,会直接返回一个EWOULDBLOCK错误。
应用程序像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询,但是持续轮询内核以查看某个操作是否就绪,往往浪费大量的cpu时间。
IO复用(select和poll)

相比阻塞IO,IO复用把等待数据和复制数据到用户缓存区两个操作分开做了,需要两次系统调用。这样看来IO复用不具有什么优势,事实上select的优势在于我们可以等待多个描述符就绪。(socket数据或者是文件数据)
信号驱动式IO(SIGIO)

我们首先开启套接字的信号驱动式IO功能,并且通过sigaction系统调用安装一个信号处理函数,该系统调用立刻返回,我们的进行继续工作。
当数据准备好之后,内核就为该进程产生一个SIGIO信号,这个时候进程就知道数据准备好了,可以让内核进行复制数据到用户空间操作了。这个模式比起阻塞IO来说,等待数据这步不需要阻塞。
异步IO(POSIX的aio系列函数)

应用程序先进行一次系统调度,告诉内核需要监听的描述符,缓存区指针,缓存区大小,文件偏移,该系统调度立马返回。待内核把数据准备好,并且复制到用户空间了,再通知进程,在异步IO中,进程完全不需要阻塞。
上面看到的四种模式,在复制数据到用户空间这个过程中,进程都需要阻塞的,唯独异步IO在这个过程中,不需要阻塞。
模型对比

POSIX把两个属于定义如下:
同步IO操作:导致请求进程阻塞,直到IO操作完成。
异步IO操作:不导致请求阻塞。
可以得出结论,阻塞IO、非阻塞IO、复用IO、信号驱动式IO都为同步IO操作,异步IO模型与POSIX的异步IO操作匹配。
上文源自:互联网
再识一些IO底层原理
本节从底层讲解了下从BIO到NIO的一个过程,着重介绍了IO多路复用的几个系统调用select()、poll()、epoll(),分析了下各自的优劣,技术都是持续发展演进的;算是对上面5种I/O模型的展开。
1、IO 概念
IO是Input和Output的缩写,即输入和输出。广义上的围绕计算机的输入输出有很多:鼠标、键盘、扫描仪等等。而我们今天要探讨的是在计算机里面,主要是作用在内存、网卡、硬盘等硬件设备上的输入输出操作。
谈起IO的模型,多数人有比较模糊的概念,“阻塞”、“非阻塞”,“同步”、“异步”有什么区别?这里尝试简单地去解释下为啥会出现这种现象,其中一个很重要的原因就是大家看到的资料对概念的解释都站在了不同的角度,有的站在了底层内核的视角,有的直接在java层面或者Netty框架层面给大家介绍API,所以给大家造成了一定程度的困扰。这里将会从底层内核的层面给大家讲解下IO。因为万变不离其宗,只有了解了底层原理,不管语言层面如何花里胡哨,我们都能以不变应万变。
2、用户和内核空间
为了便于大家理解复杂的IO以及零拷贝相关的技术,还是得花点时间在回顾下操作系统相关的知识。这一节重点看下用户空间和内核空间(也称用户态或内核态),基于此后面才能更好地讲解多路复用和零拷贝。

硬件层(Hardware)
包括和我们熟知的和IO相关的CPU、内存、磁盘和网卡几个硬件;
内核空间(Kernel Space)
计算机开机后首先会运行内核程序,内核程序占用的一块私有的空间就是内核空间,并且可支持访问CPU所有的指令集(ring0 - ring3)以及所有的内存空间、IO及硬件设备;
用户空间(User Space)
每个普通的用户进程都有一个单独的用户空间,用户空间只能访问受限的资源(CPU的“保护模式”)也就是说用户空间是无法直接操作像内存、网卡和磁盘等硬件的;
如上所述,那可能会有疑问,用户空间的进程想要去访问或操作磁盘和网卡该怎么办呢?
为此,操作系统在内核中开辟了一块唯一且合法的调用入口“System Call Interface”,也就是我们常说的系统调用,系统调用为上层用户提供了一组能够操作底层硬件的API。这样一来,用户进程就可以通过系统调用访问到操作系统内核,进而就能够间接地完成对底层硬件的操作。这个访问的过程也即用户态到内核态的切换。常见的系统调用有很多,比如:内存映射mmap()、文件操作类的open()、IO读写read()、write()等等。
3、IO模型
3.1、 BIO(Blocking IO)
先看一下大家都熟悉的BIO模型:
创建ServerSocket,并监听8080端口;
主线程进入死循环,用来阻塞监听客户端的连接,socket.accept();
数据读取,socket.read();
写入数据,socket.write();
问题
以上三个步骤:accept(...)、read(...)、write(...)都会造成线程阻塞。上述这个代码使用了单线程,会导致主线程会直接夯死在阻塞的地方。
优化
我们要知道一点“进程的阻塞是不会消耗CPU资源的”,所以在多核的环境下,我们可以创建多线程,把接收到的请求抛给多线程去处理,这样就有效地利用了计算机的多核资源。甚至为了避免创建大量的线程处理请求,我们还可以进一步做优化,创建一个线程池,利用池化技术,对暂时处理不了的请求做一个缓冲。
3.2、著名的“C10K”问题
“C10K”即“client more than 10k”用来指代数量庞大的客户端;
BIO看上去非常的简单,事实上采用“BIO+线程池”来处理少量的并发请求还是比较合适的,也是最优的。但是面临数量庞大的客户端和请求,这时候使用多线程的弊端就逐渐凸显出来了:
严重依赖线程,线程还是比较耗系统资源的(一个线程大约占用1M的空间);
频繁地创建和销毁代价很大,因为涉及到复杂的系统调用;
线程间上下文切换的成本很高,因为发生线程切换前,需要保留上一个任务的状态,以便切回来的时候,可以再次加载这个任务的状态。如果线程数量庞大,会造成线程做上下文切换的时间甚至大于线程执行的时间,CPU负载变高。
3.3、NIO非阻塞模型
下面开始真正走向Java NIO或者Netty框架所描述的“非阻塞”,NIO叫Non-Blocking IO或者New IO,由于BIO可能会引入的大量线程,所以可以简单地理解NIO处理问题的方式是通过单线程或者少量线程达到处理大量客户端请求的目的。为了达成这个目的,首先要做的就是把阻塞的过程非阻塞化。要想做到非阻塞,那必须得要有内核的支持,同时需要对用户空间的进程暴露系统调用函数。所以,这里的“非阻塞”可以理解成系统调用API级别的,而真正底层的IO操作都是阻塞的,我们后面会慢慢介绍。
事实上,内核已经对“非阻塞”做好了支持,举个我们刚刚说的的accept()方法阻塞的例子(Tips:java中的accept方法对应的系统调用函数也叫accept),看下官方文档对其非阻塞部分的描述。

官方文档对accetp()系统调用的描述是通过把"flags"参数设成"SOCK_NONBLOCK"就可以达到非阻塞的目的,非阻塞之后线程会一直处理轮询调用,这时候可以通过每次返回特殊的异常码“EAGAIN”或"EWOULDBLOCK"告诉主程序还没有连接到达可以继续轮询。
我们可以很容易想象程序非阻塞之后的一个大致过程。所以非阻塞模式有个最大的特点就是:用户进程需要不断去主动询问内核数据准备好了没有!
下面我们通过一段伪代码,看下这个调用过程:
// 循环遍历
while(1) {
// 遍历fd集合
for (fdx in range(fd1, fdn)) {
// 如果fdx有数据
if (null != fdx.data) {
// 进行读取和处理
read(fdx)&handle(fdx);
}
}
}
这种调用方式也暴露出非阻塞模式的最大的弊端,就是需要让用户进程不断切换到内核态,对连接状态或读写数据做轮询。有没有一种方式来简化用户空间for循环轮询的过程呢?那就是我们下面要重点介绍的IO多路复用模型。
4、IO多路复用模型
非阻塞模型会让用户进程一直轮询调用系统函数,频繁地做内核态切换。想要做优化其实也比较简单,我们假想个业务场景,A业务系统会调用B的基础服务查询单个用户的信息。随着业务的发展,A的逻辑变复杂了,需要查100个用户的信息。很明显,A希望B提供一个批量查询的接口,用集合作为入参,一次性把数据传递过去就省去了频繁的系统间调用。
多路复用实际也差不多就是这个实现思路,只不过入参这个“集合”需要你注册/填写感兴趣的事件,读fd、写fd或者连接状态的fd等,然后交给内核帮你进行处理。
那我们就具体来看看多路复用里面大家都可能听过的几个系统调用 - select()、poll()、epoll()。
4.1 select()
select() 构造函数信息如下所示:
/**
* select()系统调用
*
* 参数列表:
* nfds - 值为最大的文件描述符+1
* *readfds - 用户检查可读性
* *writefds - 用户检查可写性
* *exceptfds - 用于检查外带数据
* *timeout - 超时时间的结构体指针
*/
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
官方文档对select()的描述:
DESCRIPTION
select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation (e.g.,input possible). A file descriptor is considered ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.
select()允许程序监控多个fd,阻塞等待直到一个或多个fd到达"就绪"状态。
内核使用select()为用户进程提供了类似批量的接口,函数本身也会一直阻塞直到有fd为就绪状态返回。下面我们来具体看下select()函数实现,以便我们更好地分析它有哪些优缺点。在select()函数的构造器里,我们很容易看到"fd_set"这个入参类型。它是用位图算法bitmap实现的,使用了一个大小固定的数组(fd_set设置了FD_SETSIZE固定长度为1024),数组中的每个元素都是0和1这样的二进制byte,0,1映射fd对应位置上是否有读写事件,举例:如果fd == 5,那么fd_set = 000001000。
同时 fd_set 定义了四个宏来处理bitmap:
FD_ZERO(&set); // 初始化,清空的作用,使集合中不含任何fd
FD_SET(fd, &set); // 将fd加入set集合,给某个位置赋值的操作
FD_CLR(fd, &set); // 将fd从set集合中清除,去掉某个位置的值
FD_ISSET(fd, &set); // 校验某位置的fd是否在集合中
使用bitmap算法的好处非常明显,运算效率高,占用内存少(使用了一个byte,8bit)。我们用伪代码和图片来描述下用户进程调用select()的过程:

假设fds为{1,2,3,5,7}对应的bitmap为"01110101",抛给内核空间轮询,当有读写事件时重新标记同时停止阻塞,然后整体返回用户空间。由此我们可以看到select()系统调用的弊端也是比较明显的:
复杂度O(n),轮询的任务交给了内核来做,复杂度并没有变化,数据取出后也需要轮询哪个fd上发生了变动;
用户态还是需要不断切换到内核态,直到所有的fds数据读取结束,整体开销依然很大;
fd_set有大小的限制,目前被硬编码成了1024;
fd_set不可重用,每次操作完都必须重置;
4.2 poll()
poll() 构造函数信息如下所示:
/**
* poll()系统调用
*
* 参数列表:
* *fds - pollfd结构体
* nfds - 要监视的描述符的数量
* timeout - 等待时间
*/
int poll(struct pollfd *fds, nfds_t nfds, int *timeout);
### pollfd的结构体
struct pollfd{
int fd;// 文件描述符
short event;// 请求的事件
short revent;// 返回的事件
}
官方文档对poll()的描述:
DESCRIPTION
poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.
poll() 非常像select(),它也是阻塞等待直到一个或多个fd到达"就绪"状态。
看官方文档描述可以知道,poll()和select()是非常相似的,唯一的区别在于poll()摒弃掉了位图算法,使用自定义的结构体pollfd,在pollfd内部封装了fd,并通过event变量注册感兴趣的可读可写事件(POLLIN、POLLOUT),最后把 pollfd 交给内核。当有读写事件触发的时候,我们可以通过轮询 pollfd,判断revent确定该fd是否发生了可读可写事件。
老样子我们用伪代码来描述下用户进程调用 poll() 的过程:

poll() 相对于select(),主要的优势是使用了pollfd的结构体:
没有了bitmap大小1024的限制;
通过结构体中的revents置位;
但是用户态到内核态切换及O(n)复杂度的问题依旧存在。
4.3 epoll()
epoll()应该是目前最主流,使用范围最广的一组多路复用的函数调用,像我们熟知的Nginx、Redis都广泛地使用了此种模式。接下来我们重点分析下,epoll()的实现采用了“三步走”策略,它们分别是epoll_create()、epoll_ctl()、epoll_wait()。
4.3.1 epoll_create()
/**
* 返回专用的文件描述符
*/
int epoll_create(int size);
用户进程通过 epoll_create() 函数在内核空间里面创建了一块空间(为了便于理解,可以想象成创建了一块白板),并返回了描述此空间的fd。
4.3.2 epoll_ctl()
/**
* epoll_ctl()系统调用
*
* 参数列表:
* epfd - 由epoll_create()返回的epoll专用的文件描述符
* op - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
* fd - 关联的文件描述符
* event - 指向epoll_event的指针
*/
int epoll_ctl(int epfd, int op, int fd , struce epoll_event *event );
刚刚我们说通过epoll_create()可以创建一块具体的空间“白板”,那么通过epoll_ctl() 我们可以通过自定义的epoll_event结构体在这块“白板上”注册感兴趣的事件了。
注册 - EPOLL_CTL_ADD
修改 - EPOLL_CTL_MOD
删除 - EPOLL_CTL_DEL
4.3.3 epoll_wait()
/**
* epoll_wait()返回n个可读可写的fds
*
* 参数列表:
* epfd - 由epoll_create()返回的epoll专用的文件描述符
* epoll_event - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
* maxevents - 每次能处理的事件数
* timeout - 等待I/O事件发生的超时值;-1相当于阻塞,0相当于非阻塞。一般用-1即可
*/
int epoll_wait(int epfd, struce epoll_event *event , int maxevents, int timeout);
epoll_wait() 会一直阻塞等待,直到硬盘、网卡等硬件设备数据准备完成后发起硬中断,中断CPU,CPU会立即执行数据拷贝工作,数据从磁盘缓冲传输到内核缓冲,同时将准备完成的fd放到就绪队列中供用户态进行读取。用户态阻塞停止,接收到具体数量的可读写的fds,返回用户态进行数据处理。
整体过程可以通过下面的伪代码和图示进一步了解:

epoll() 基本上完美地解决了 poll() 函数遗留的两个问题:
没有了频繁的用户态到内核态的切换;
O(1)复杂度,返回的"nfds"是一个确定的可读写的数量,相比于之前循环n次来确认,复杂度降低了不少;
5、同步、异步
细心的朋友可能会发现,本节一直在解释“阻塞”和“非阻塞”,“同步”、“异步”的概念没有涉及,其实在很多场景下同步&异步和阻塞&非阻塞基本上是一个同义词。阻塞和非阻塞适合从系统调用API层面来看,就像我们本文介绍的select()、poll()这样的系统调用,同步和异步更适合站在应用程序的角度来看。应用程序在同步执行代码片段的时候结果不会立即返回,这时候底层IO操作不一定是阻塞的,也完全有可能是非阻塞。所以说:
阻塞和非阻塞:读写没有就绪或者读写没有完成,函数是否要一直等待还是采用轮询;
同步和异步:同步是读写由应用程序完成。异步是读写由操作系统来完成,并通过回调的机制通知应用程序。
这边顺便提两种大家可能会经常听到的模式:Reactor和Preactor。
Reactor 模式:主动模式。
Preactor 模式:被动模式。
Kafka为什么快,什么是mmap
这类的问题都逃不过的一个点就是零拷贝,虽然还有一些其他的原因,但更主要的就是零拷贝。
传统IO
在开始谈零拷贝之前,首先要对传统的IO方式有一个概念。基于传统的IO方式,底层实际上通过调用read()和write()来实现。通过read()把数据从硬盘读取到内核缓冲区,再复制到用户缓冲区;然后再通过write()写入到socket缓冲区,最后写入网卡设备。

整个过程发生了4次用户态和内核态的上下文切换和4次拷贝,具体流程如下:
用户进程通过read()方法向操作系统发起调用,此时上下文从用户态转向内核态
DMA控制器把数据从硬盘中拷贝到读缓冲区
CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,read()返回
用户进程通过write()方法发起调用,上下文从用户态转为内核态
CPU将应用缓冲区中数据拷贝到socket缓冲区
DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

那么这里指的用户态、内核态指的是什么?上下文切换又是什么?
简单来说,用户空间指的就是用户进程的运行空间,内核空间就是内核的运行空间。如果进程运行在内核空间就是内核态,运行在用户空间就是用户态。
为了安全起见,它们之间是互相隔离的,而在用户态和内核态之间的上下文切换也是比较耗时的。从上面可以看到,一次简单的IO过程产生了4次上下文切换,这个无疑在高并发场景下会对性能产生较大的影响。
那么什么又是DMA拷贝呢?
因为对于一个IO操作而言,都是通过CPU发出对应的指令来完成,但是相比CPU来说,IO的速度太慢了,CPU有大量的时间处于等待IO的状态。
因此就产生了DMA(Direct Memory Access)直接内存访问技术,本质上来说他就是一块主板上独立的芯片,通过它来进行内存和IO设备的数据传输,从而减少CPU的等待时间。
但是无论谁来拷贝,频繁的拷贝耗时也是对性能的影响。
零拷贝
零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域,这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
那么对于零拷贝而言,并非真的是完全没有数据拷贝的过程,只不过是减少用户态和内核态的切换次数以及CPU拷贝的次数。这里仅仅有针对性的来谈谈几种常见的零拷贝技术。
mmap+write
mmap+write简单来说就是使用mmap替换了read+write中的read操作,减少了一次CPU的拷贝。
mmap主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。

整个过程发生了4次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态
DMA控制器把数据从硬盘中拷贝到读缓冲区
上下文从内核态转为用户态,mmap调用返回
用户进程通过write()方法发起调用,上下文从用户态转为内核态
CPU将读缓冲区中数据拷贝到socket缓冲区
DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回
mmap的方式节省了一次CPU拷贝,同时由于用户进程中的内存是虚拟的,只是映射到内核的读缓冲区,所以可以节省一半的内存空间,比较适合大文件的传输。
sendfile
相比mmap来说,sendfile同样减少了一次CPU拷贝,而且还减少了2次上下文切换。

sendfile是Linux 2.1内核版本后引入的一个系统调用函数,通过使用sendfile数据可以直接在内核空间进行传输,因此避免了用户空间和内核空间的拷贝,同时由于使用sendfile替代了read+write从而节省了一次系统调用,也就是2次上下文切换。

整个过程发生了2次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
DMA控制器把数据从硬盘中拷贝到读缓冲区
CPU将读缓冲区中数据拷贝到socket缓冲区
DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回
sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。
sendfile+DMA Scatter/Gather
Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。
它将读缓冲区中的数据描述信息--内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程。

整个过程发生了2次用户态和内核态的上下文切换和2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:
用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
sendfile()调用返回,上下文从内核态切换回用户态
DMA gather和sendfile一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。
应用场景
对于MQ而言,无非就是生产者发送数据到MQ然后持久化到磁盘,之后消费者从MQ读取数据。RocketMQ来说这两个步骤使用的是mmap+write,而Kafka则是使用mmap+write持久化数据,发送数据使用sendfile。
小结
由于CPU和IO速度的差异问题,产生了DMA技术,通过DMA搬运来减少CPU的等待时间。
传统的IOread+write方式会产生2次DMA拷贝+2次CPU拷贝,同时有4次上下文切换。
而通过mmap+write方式则产生2次DMA拷贝+1次CPU拷贝,4次上下文切换,通过内存映射减少了一次CPU拷贝,可以减少内存使用,适合大文件的传输。
sendfile方式是新增的一个系统调用函数,产生2次DMA拷贝+1次CPU拷贝,但是只有2次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对IO数据不可见,适用于静态文件服务器。
sendfile+DMA gather方式产生2次DMA拷贝,没有CPU拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。
内存映射-Mmap
内存映射,简而言之就是将内核空间的一段内存区域映射到用户空间。映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,相反内核空间对这段区域的修改也直接反映用户空间。那么对于内核空间与用户空间两者之间需要大量数据传输等操作的话效率是非常高的。当然也可以将内核空间的一段内存区域同时映射到多个进程,这样还可以实现进程间的共享内存通信。
系统调用mmap()就是用来实现上面说的内存映射。最长见的操作就是文件(在Linux下设备也被看做文件)的操作,可以将某文件映射至内存(进程空间),如此可以把对文件的操作转为对内存的操作,以此避免更多的lseek()与read()、write()操作,这点对于大文件或者频繁访问的文件而言尤其受益。
概述
mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap执行相反的操作,删除特定地址区域的对象映射。
当使用mmap映射文件到进程后,就可以直接操作这段虚拟地址进行文件的读写等操作,不必再调用read,write等系统调用。但需注意,直接对该段内存写时不会写入超过当前文件大小的内容。
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
通常使用mmap()的三种情况: 提高I/O效率、匿名内存映射、共享内存进程通信。
用户空间 mmap()函数 void *mmap(void *start, size_t length, int prot, int flags,int fd, off_t offset),下面就其参数解释如下:
start:用户进程中要映射的用户空间的起始地址,通常为NULL(由内核来指定)
length:要映射的内存区域的大小
prot:期望的内存保护标志
flags:指定映射对象的类型
fd:文件描述符(由open函数返回)
offset:设置在内核空间中已经分配好的的内存区域中的偏移,例如文件的偏移量,大小为PAGE_SIZE的整数倍
返回值:mmap()返回被映射区的指针,该指针就是需要映射的内核空间在用户空间的虚拟地址

内存映射的应用
X Window服务器
众多内存数据库如MongoDB操作数据,就是把文件磁盘内容映射到内存中进行处理,为什么会提高效率? 很多人不解。下面就深入分析内存文件映射。
通过malloc来分配大内存其实调用的是mmap,可见在malloc(10)的时候调用的是brk,malloc(10 * 1024 * 1024)调用的是mmap
mmap()用于共享内存的两种方式
使用普通文件提供的内存映射:适用于任何进程之间;此时,需要打开或创建一个文件,然后再调用mmap();典型调用代码如下:
fd=open(name, flag, mode);
if(fd<0)
...
ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0);
使用特殊文件提供匿名内存映射:适用于具有亲缘关系的进程之间;由于父子进程特殊的亲缘关系,在父进程中先调用mmap(),然后调用fork()。那么在调用fork()之后,子进程继承父进程匿名映射后的地址空间,同样也继承mmap()返回的地址,这样,父子进程就可以通过映射区域进行通信了。注意,这里不是一般的继承关系。一般来说,子进程单独维护从父进程继承下来的一些变量。而mmap()返回的地址,却由父子进程共同维护。对于具有亲缘关系的进程实现共享内存最好的方式应该是采用匿名内存映射的方式。此时,不必指定具体的文件,只要设置相应的标志即可。
示例
驱动+应用
首先在驱动程序分配一页大小的内存,然后用户进程通过mmap()将用户空间中大小也为一页的内存映射到内核空间这页内存上。映射完成后,驱动程序往这段内存写10个字节数据,用户进程将这些数据显示出来。
#include <linux/miscdevice.h>
#include <linux/delay.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
#include <linux/mm.h>
#include <linux/fs.h>
#include <linux/types.h>
#include <linux/delay.h>
#include <linux/moduleparam.h>
#include <linux/slab.h>
#include <linux/errno.h>
#include <linux/ioctl.h>
#include <linux/cdev.h>
#include <linux/string.h>
#include <linux/list.h>
#include <linux/pci.h>
#include <linux/gpio.h>
#define DEVICE_NAME "mymap"
static unsigned char array[10]={0,1,2,3,4,5,6,7,8,9};
static unsigned char *buffer;
static int my_open(struct inode *inode, struct file *file){
return 0;
}
static int my_map(struct file *filp, struct vm_area_struct *vma){
unsigned long page;
unsigned char i;
unsigned long start = (unsigned long)vma->vm_start;
//unsigned long end = (unsigned long)vma->vm_end;
unsigned long size = (unsigned long)(vma->vm_end - vma->vm_start);
//得到物理地址
page = virt_to_phys(buffer);
//将用户空间的一个vma虚拟内存区映射到以page开始的一段连续物理页面上
if(remap_pfn_range(vma,start,page>>PAGE_SHIFT,size,PAGE_SHARED))//第三个参数是页帧号,由物理地址右移PAGE_SHIFT得到
return -1;
//往该内存写10字节数据
for(i=0;i<10;i++)
buffer[i] = array[i];
return 0;
}
static struct file_operations dev_fops = {
.owner = THIS_MODULE,
.open = my_open,
.mmap = my_map,
};
static struct miscdevice misc = {
.minor = MISC_DYNAMIC_MINOR,
.name = DEVICE_NAME,
.fops = &dev_fops,
};
static int __init dev_init(void){
int ret;
//注册混杂设备
ret = misc_register(&misc);
//内存分配
buffer = (unsigned char *)kmalloc(PAGE_SIZE,GFP_KERNEL);
//将该段内存设置为保留
SetPageReserved(virt_to_page(buffer));
return ret;
}
static void __exit dev_exit(void){
//注销设备
misc_deregister(&misc);
//清除保留
ClearPageReserved(virt_to_page(buffer));
//释放内存
kfree(buffer);
}
module_init(dev_init);
module_exit(dev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("LKN@SCUT");
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <linux/fb.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#define PAGE_SIZE 4096
int main(int argc , char *argv[]){
int fd;
int i;
unsigned char *p_map;
//打开设备
fd = open("/dev/mymap",O_RDWR);
if(fd < 0){
printf("open fail\n");
exit(1);
}
//内存映射
p_map = (unsigned char *)mmap(0, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED,fd, 0);
if(p_map == MAP_FAILED){
printf("mmap fail\n");
goto here;
}
//打印映射后的内存中的前10个字节内容
for(i=0;i<10;i++)
printf("%d\n",p_map[i]);
here:
munmap(p_map, PAGE_SIZE);
return 0;
}
进程间共享内存
UNIX访问文件的传统方法是用open打开它们,如果有多个进程访问同一个文件,则每一个进程在自己的地址空间都包含有该文件的副本,这不必要地浪费了存储空间。下图说明了两个进程同时读一个文件的同一页的情形。系统要将该页从磁盘读到高速缓冲区中,每个进程再执行一个存储器内的复制操作将数据从高速缓冲区读到自己的地址空间。

现在考虑另一种处理方法共享存储映射:进程A和进程B都将该页映射到自己的地址空间,当进程A第一次访问该页中的数据时,它生成一个缺页中断。内核此时读入这一页到内存并更新页表使之指向它。以后当进程B访问同一页面而出现缺页中断时,该页已经在内存,内核只需要将进程B的页表登记项指向次页即可。如下图所示:

下面就是进程A和B共享内存的示例。两个程序映射同一个文件到自己的地址空间, 进程A先运行, 每隔两秒读取映射区域, 看是否发生变化。进程B后运行, 它修改映射区域, 然后退出, 此时进程A能够观察到存储映射区的变化。
进程A的代码:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <error.h>
#define BUF_SIZE 100
int main(int argc, char **argv){
int fd, nread, i;
struct stat sb;
char *mapped, buf[BUF_SIZE];
for (i = 0; i < BUF_SIZE; i++){
buf[i] = '#';
}
/* 打开文件 */
if ((fd = open(argv[1], O_RDWR)) < 0){
perror("open");
}
/* 获取文件的属性 */
if ((fstat(fd, &sb)) == -1) {
perror("fstat");
}
/* 将文件映射至进程的地址空间 */
if ((mapped = (char *)mmap(NULL, sb.st_size, PROT_READ |
PROT_WRITE, MAP_SHARED, fd, 0)) == (void *)-1) {
perror("mmap");
}
/* 文件已在内存, 关闭文件也可以操纵内存 */
close(fd);
/* 每隔两秒查看存储映射区是否被修改 */
while (1) {
printf("%s\n", mapped);
sleep(2);
}
return 0;
}
进程B的代码:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <error.h>
#define BUF_SIZE 100
int main(int argc, char **argv){
int fd, nread, i;
struct stat sb;
char *mapped, buf[BUF_SIZE];
for (i = 0; i < BUF_SIZE; i++) {
buf[i] = '#';
}
/* 打开文件 */
if ((fd = open(argv[1], O_RDWR)) < 0) {
perror("open");
}
/* 获取文件的属性 */
if ((fstat(fd, &sb)) == -1) {
perror("fstat");
}
/* 私有文件映射将无法修改文件 */
if ((mapped = (char *)mmap(NULL, sb.st_size, PROT_READ |
PROT_WRITE, MAP_PRIVATE, fd, 0)) == (void *)-1) {
perror("mmap");
}
/* 映射完后, 关闭文件也可以操纵内存 */
close(fd);
/* 修改一个字符 */
mapped[20] = '9';
return 0;
}
匿名映射实现父子进程通信
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUF_SIZE 100
int main(int argc, char** argv){
char *p_map;
/* 匿名映射,创建一块内存供父子进程通信 */
p_map = (char *)mmap(NULL, BUF_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if(fork() == 0) {
sleep(1);
printf("child got a message: %s\n", p_map);
sprintf(p_map, "%s", "hi, dad, this is son");
munmap(p_map, BUF_SIZE); //实际上,进程终止时,会自动解除映射。
exit(0);
}
sprintf(p_map, "%s", "hi, this is father");
sleep(2);
printf("parent got a message: %s\n", p_map);
return 0;
}
mmap进行内存映射的原理
mmap系统调用的最终目的是将设备或文件映射到用户进程的虚拟地址空间,实现用户进程对文件的直接读写,这个任务可以分为以下三步:
1.在用户虚拟地址空间中寻找空闲的满足要求的一段连续的虚拟地址空间,为映射做准备(由内核mmap系统调用完成)
假如vm_area_struct描述的是一个文件映射的虚存空间,成员vm_file便指向被映射的文件的file结构,vm_pgoff是该虚存空间起始地址在vm_file文件里面的文件偏移,单位为物理页面。mmap系统调用所完成的工作就是准备这样一段虚存空间,并建立vm_area_struct结构体,将其传给具体的设备驱动程序.
2.建立虚拟地址空间和文件或设备的物理地址之间的映射(设备驱动完成) 建立文件映射的第二步就是建立虚拟地址和具体的物理地址之间的映射,这是通过修改进程页表来实现的。mmap方法是file_opeartions结构的成员:int (*mmap)(struct file *,struct vm_area_struct *);
linux有2个方法建立页表:
使用remap_pfn_range一次建立所有页表。int remap_pfn_range(struct vm_area_struct *vma, unsigned long virt_addr, unsigned long pfn, unsigned long size, pgprot_t prot)。
使用nopage VMA方法每次建立一个页表项。 struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int *type);
3.当实际访问新映射的页面时的操作(由缺页中断完成)
page cache及swap cache中页面的区分:一个被访问文件的物理页面都驻留在page cache或swap cache中,一个页面的所有信息由struct page来描述。struct page中有一个域为指针mapping ,它指向一个struct address_space类型结构。page cache或swap cache中的所有页面就是根据address_space结构以及一个偏移量来区分的。
文件与 address_space结构的对应:一个具体的文件在打开后,内核会在内存中为之建立一个struct inode结构,其中的i_mapping域指向一个address_space结构。这样一个文件就对应一个address_space结构,一个 address_space与一个偏移量能够确定一个page cache 或swap cache中的一个页面。因此,当要寻址某个数据时,很容易根据给定的文件及数据在文件内的偏移量而找到相应的页面。
进程调用mmap()时,只是在进程空间内新增了一块相应大小的缓冲区,并设置了相应的访问标识,但并没有建立进程空间到物理页面的映射。因此,第一次访问该空间时,会引发一个缺页异常。
对于共享内存映射情况,缺页异常处理程序首先在swap cache中寻找目标页(符合address_space以及偏移量的物理页),如果找到,则直接返回地址;如果没有找到,则判断该页是否在交换区 (swap area),如果在,则执行一个换入操作;如果上述两种情况都不满足,处理程序将分配新的物理页面,并把它插入到page cache中。进程最终将更新进程页表。 注:对于映射普通文件情况(非共享映射),缺页异常处理程序首先会在page cache中根据address_space以及数据偏移量寻找相应的页面。如果没有找到,则说明文件数据还没有读入内存,处理程序会从磁盘读入相应的页面,并返回相应地址,同时进程页表也会更新。
所有进程在映射同一个共享内存区域时,情况都一样,在建立线性地址与物理地址之间的映射之后,不论进程各自的返回地址如何,实际访问的必然是同一个共享内存区域对应的物理页面。