异步IO
在 I/O 多路复用中,进程通过系统调用 select()或 poll()来主动查询文件描述符上是否可以执行 I/O 操作。而在异步 I/O 中,当文件描述符上可以执行 I/O 操作时,进程可以请求内核为自己发送一个信号。之后进程就可以执行任何其它的任务直到文件描述符可以执行 I/O 操作为止,此时内核会发送信号给进程。所以要使用异步 I/O,还得结合前面所学习的信号相关的内容,所以异步 I/O 通常也称为信号驱动 I/O。
要使用异步 I/O,程序需要按照如下步骤来执行:
⚫ 通过指定 O_NONBLOCK 标志使能非阻塞 I/O。
⚫ 通过指定 O_ASYNC 标志使能异步 I/O。
⚫ 设置异步 I/O 事件的接收进程。也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程,通常将调用进程设置为异步 I/O 事件的接收进程。
⚫ 为内核发送的通知信号注册一个信号处理函数。默认情况下,异步 I/O 的通知信号是 SIGIO,所以内核会给进程发送信号 SIGIO。在 8.2 小节中简单地提到过该信号。
⚫ 以上步骤完成之后,进程就可以执行其它任务了,当 I/O 操作就绪时,内核会向进程发送一个 SIGIO信号,当进程接收到信号时,会执行预先注册好的信号处理函数,我们就可以在信号处理函数中进行 I/O 操作。
O_ASYNC 标志
O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件,当文件描述符可执行 I/O 操作时,内核会向异步 I/O 事件的接收进程发送 SIGIO 信号(默认情况下)。在 2.3 小节介绍 open()函数时,给大家提到过该标志,但并未介绍该标志的作用,该标志主要用于异步 I/O。
需要注意的是:在调用 open()时无法通过指定 O_ASYNC 标志来使能异步 I/O,但可以使用 fcntl()函数添加 O_ASYNC 标志使能异步 I/O,譬如:
int flag;
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_ASYNC; //将 O_ASYNC 标志添加到 flag
fcntl(fd, F_SETFL, flag); //重新设置 flag
设置异步 I/O 事件的接收进程
为文件描述符设置异步 I/O 事件的接收进程,也就是设置异步 I/O 的所有者。同样也是通过 fcntl()函数进行设置,操作命令 cmd 设置为 F_SETOWN,第三个参数传入接收进程的进程 ID(PID),通常将调用进程的 PID 传入,譬如:
fcntl(fd, F_SETOWN, getpid());
注册 SIGIO 信号的处理函数
通过 signal()或 sigaction()函数为 SIGIO 信号注册一个信号处理函数,当进程接收到内核发送过来的SIGIO 信号时,会执行该处理函数,所以我们应该在处理函数当中执行相应的 I/O 操作。
使用示例
示例代码 13.3.1 演示了以异步 I/O 方式读取鼠标,当进程接收到 SIGIO 信号时,执行信号处理函数sigio_handler(),在该函数中调用 read()读取鼠标数据。
优化异步 I/O
上一小节介绍了异步 I/O 的原理以及使用方法,在一个需要同时检查大量文件描述符(譬如数千个)的应用程序中,例如某种类型的网络服务端程序,与 select()和 poll()相比,异步 I/O 能够提供显著的性能优势。之所以如此,原因在于:对于异步 I/O,内核可以“记住”要检查的文件描述符,且仅当这些文件描述符上可执行 I/O 操作时,内核才会向应用程序发送信号。
而对于 select()或 poll()函数来说,内部实现原理其实是通过轮训的方式来检查多个文件描述符是否可执行 I/O 操作,所以,当需要检查的文件描述符数量较多时,随之也将会消耗大量的 CPU 资源来实现轮训检查操作。当需要检查的文件描述符并不是很多时,使用 select()或 poll()是一种非常不错的方案!
Tips:当需要检查大量文件描述符时,可以使用 epoll 解决 select()或 poll()性能低的问题,本书并不会介绍 epoll 相关内容,如果读者有兴趣可以自行查阅书籍进行学习。在性能表现上,epoll 与异步 I/O 方式相似,但是 epoll 有一些胜过异步 I/O 的优点。不管是异步 I/O、还是 epoll,在需要检查大量文件描述符的应用程序当中,在这种情况下,它们的性能相比于 select()或 poll()有着显著的优势!
本小节将对上一小节所讲述的异步 I/O 进行优化,既然要对其进行优化,那必然存在着一些缺陷,如下所示:
⚫ 默认的异步 I/O 通知信号 SIGIO 是非排队信号。SIGIO 信号是标准信号(非实时信号、不可靠信号),所以它不支持信号排队机制,譬如当前正在执行 SIGIO 信号的处理函数,此时内核又发送多次 SIGIO 信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进程,并且只能传递一次,而其它后续的信号都会丢失。
⚫ 无法得知文件描述符发生了什么事件。在示例代码 13.3.1 的信号处理函数 sigio_handler()中,直接调用了 read()函数读取鼠标,而并未判断文件描述符是否处于可读就绪态,事实上,示例代码 13.3.1这种异步 I/O 方式并未告知应用程序文件描述符上发生了什么事件,是可读取还是可写入亦或者发生异常等。
所以本小节我们将会针对以上列举出的两个缺陷进行优化。
使用实时信号替换默认信号 SIGIO
SIGIO 作为异步 I/O 通知的默认信号,是一个非实时信号,我们可以设置不使用默认信号,指定一个实时信号作为异步 I/O 通知信号,如何指定呢?同样也是使用 fcntl()函数进行设置,调用函数时将操作命令cmd 参数设置为 F_SETSIG,第三个参数 arg 指定一个实时信号编号即可,表示将该信号作为异步 I/O 通知信号,譬如:
fcntl(fd, F_SETSIG, SIGRTMIN);
上述代码指定了 SIGRTMIN 实时信号作为文件描述符 fd 的异步 I/O 通知信号,而不再使用默认的 SIGIO信号。当文件描述符 fd 可执行 I/O 操作时,内核会发送实时信号 SIGRTMIN 给调用进程。
如果第三个参数 arg 设置为 0,则表示指定 SIGIO 信号作为异步 I/O 通知信号,也就是回到了默认状态。
使用 sigaction()函数注册信号处理函数
在应用程序当中需要为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指定 SA_SIGINFO,表示使用 sa_sigaction 指向的函数作为信号处理函数,而不使用 sa_handler 指向的函数。因为 sa_sigaction 指向的函数作为信号处理函数提供了更多的参数,可以获取到更多信息,函数定义参考示例代码 8.4.2 中关于 struct sigaction 结构体的描述。
函数参数中包括一个 siginfo_t 指针,指向 siginfo_t 类型对象,当触发信号时该对象由内核构建。siginfo_t结构体中提供了很多信息,我们可以在信号处理函数中使用这些信息,具体定义请参考示例代码 8.4.3,就对于异步 I/O 事件而言,传递给信号处理函数的 siginfo_t 结构体中与之相关的字段如下:
⚫ si_signo:引发处理函数被调用的信号。这个值与信号处理函数的第一个参数一致。
⚫ si_fd:表示发生异步 I/O 事件的文件描述符;
⚫ si_code:表示文件描述符 si_fd 发生了什么事件,读就绪态、写就绪态或者是异常事件等。该字段中可能出现的值以及它们对应的描述信息参见表 13.4.1。
⚫ si_band:是一个位掩码,其中包含的值与系统调用 poll()中返回的 revents 字段中的值相同。如表13.4.1 所示,si_code 中可能出现的值与 si_band 中的位掩码有着一一对应关系。
所以,由此可知,可以在信号处理函数中通过对比 siginfo_t 结构体的 si_code 变量来检查文件描述符发生了什么事件,以采取相应的 I/O 操作。
使用示例
通过 13.4.1 小节和 13.4.2 小节的学习,我们已经知道了如何针对 13.4 小节开头提出的异步 I/O 存在的两个缺陷进行优化。示例代码 13.4.1 是对示例代码 13.3.1 进行了优化,使用实时信号+sigaction 解决:默认异步 I/O 通知信号 SIGIO 可能存在丢失以及信号处理函数中无法判断文件描述符所发生的 I/O 事件这两个问题。
调用 sigaction()注册信号处理函数时,sa_flags 指定了 SA_SIGINFO,所以将使用 sa_sigaction 指向的函数 io_handler 作为信号处理函数,io_handler 共有 3 个参数,参数 sig 等于引发信号处理函数被调用的信号值,参数 info 附加了很多信息,前面已有介绍,这里不再重述。
对上述示例代码进行编译时,出现了一些报错信息,如下所示:
报错提示没有定义F_SETSIG,确实如此,我们需要定义了_GNU_SOURCE宏之后才能使用F_SETSIG,这个宏在 4.9.3 小节向大家介绍过,这里不再重述!
这里笔者选择直接在源文件中使用#define 定义_GNU_SOURCE 宏,如下所示:
再次进行编译测试即可
存储映射 I/O
存储映射 I/O(memory-mapped I/O)是一种基于内存区域的高级 I/O 操作,它能将一个文件(并非局限于普通的文件,包括设备文件等,linux下一切皆文件)映射到用户进程地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据(对文件进行 read 操作),将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行 write 操作)。这样就可以在不使用基本 I/O 操作函数 read()和 write()的情况下执行 I/O 操作。
存储映射I/O(Memory-Mapped I/O)是一种将磁盘文件与存储空间中的一个缓冲区相映射的技术,使得对缓冲区的读写操作直接反映到文件中,从而可以在不使用传统的read和write系统调用的情况下执行I/O操作。以下是关于存储映射I/O的详细解释:
-
工作原理
-
映射过程:通过调用mmap函数,将一个指定文件映射到存储区域中。这个过程会创建一个映射区,其起始地址由系统自动选择或由用户指定(如果addr参数为非NULL)。该映射区的大小由len参数指定,而prot参数则定义了对该区域的访问权限(如只读、可写等)。
-
数据同步:当向缓冲区写入数据时,相应的字节会自动写入文件;从缓冲区读取数据时,则相当于读取文件中的相应字节。这种机制避免了频繁的文件I/O操作,提高了数据处理的效率。
-
-
主要特点
-
高效性:存储映射I/O通过减少文件I/O操作的次数,显著提高了文件处理的效率。它允许程序像访问内存一样访问文件内容,从而提高了数据处理的速度。
-
灵活性:存储映射I/O支持多种访问模式(如只读、可写等),并且可以根据需要调整映射区的大小和位置。
-
跨进程通信:在Unix系统中,存储映射I/O还可以用于进程间通信(IPC),允许多个进程共享同一个映射区,从而实现高效的数据交换。
-
-
应用场景
-
文件处理:特别适用于需要频繁读写大文件的场景,如数据库系统、日志文件处理等。
-
进程间通信:在Unix系统中,存储映射I/O是实现进程间通信的一种有效方式,可以用于共享数据和状态信息。
-
设备访问:在某些嵌入式系统中,外设的寄存器可以通过存储映射I/O进行访问,从而实现对外设的控制和数据读取。
-
综上所述,存储映射I/O是一种强大的技术,它通过将文件或设备映射到进程的虚拟地址空间中,实现了高效的文件处理和进程间通信。然而,在使用时也需要注意其限制和潜在的问题,如文件大小的限制、内存使用的增加以及同步问题的复杂性等。
mmap()和 munmap()函数
更多参考:
linux源码解读(二十五):mmap原理和实现方式 - 第七子007 - 博客园 (cnblogs.com)
为了实现存储映射 I/O 这一功能,我们需要告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中,这由系统调用 mmap()来实现。其函数原型如下所示:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
使用该函数需要包含头文件<sys/mman.h>。
函数参数和返回值含义如下:
addr:参数 addr 用于指定映射到内存区域的起始地址。通常将其设置为 NULL,这表示由系统选择该映射区的起始地址,这是最常见的设置方式;如果参数 addr 不为 NULL,则表示由自己指定映射区的起始地址,此函数的返回值是该映射区的起始地址。
length:参数 length 指定映射长度,表示将文件中的多大部分映射到内存区域中,以字节为单位,譬如 length=1024 * 4,表示将文件的 4K 字节大小映射到内存区域中。
offset:文件映射的偏移量,通常将其设置为 0,表示从文件头部开始映射;所以参数 offset 和参数 length就确定了文件的起始位置和长度,将文件的这部分映射到内存区域中,如图 13.5.1 所示。
fd:文件描述符,指定要映射到内存区域中的文件。
prot:参数 prot 指定了映射区的保护要求,可取值如下:
⚫ PROT_EXEC:映射区可执行;
⚫ PROT_READ:映射区可读;
⚫ PROT_WRITE:映射区可写;
⚫ PROT_NONE:映射区不可访问。
可将 prot 指定为为 PROT_NONE,也可将其设置为 PROT_EXEC、PROT_READ、PROT_WRITE 中一个或多个(通过按位或运算符任意组合)。对指定映射区的保护要求不能超过文件 open()时的访问权限,譬如,文件是以只读权限方式打开的,那么对映射区的不能指定为 PROT_WRITE。
flags:参数 flags 可影响映射区的多种属性,参数 flags 必须要指定以下两种标志之一:
⚫ MAP_SHARED:此标志指定当对映射区写入数据时,数据会写入到文件中,也就是会将写入到映射区中的数据更新到文件中,并且允许其它进程共享。
⚫ MAP_PRIVATE:此标志指定当对映射区写入数据时,会创建映射文件的一个私人副本(copy-on-write),对映射区的任何操作都不会更新到文件中,仅仅只是对文件副本进行读写。
除此之外,还可将以下标志中的 0 个或多个组合到参数 flags 中,通过按位或运算符进行组合:
⚫ MAP_FIXED:在未指定该标志的情况下,如果参数 addr 不等于 NULL,表示由调用者自己指定映射区的起始地址,但这只是一种建议、而并非强制,所以内核并不会保证使用参数 addr 指定的值作为映射区的起始地址;如果指定了 MAP_FIXED 标志,则表示要求必须使用参数 addr 指定的值作为起始地址,如果使用指定值无法成功建立映射时,则放弃!通常,不建议使用此标志,因为这不利于移植。
⚫ MAP_ANONYMOUS:建立匿名映射,此时会忽略参数 fd 和 offset,不涉及文件,而且映射区域无法和其它进程共享。
⚫ MAP_ANON:与 MAP_ANONYMOUS 标志同义,不建议使用。
⚫ MAP_DENYWRITE:该标志被忽略。
⚫ MAP_EXECUTABLE:该标志被忽略。
⚫ MAP_FILE:兼容性标志,已被忽略。
⚫ MAP_LOCKED:对映射区域进行上锁。
除了以上标志之外,还有其它一些标志,这里便不再介绍,可通过 man 手册进行查看。在众多标志当中,通常情况下,参数 flags 中只指定了 MAP_SHARED。
返回值:成功情况下,函数的返回值便是映射区的起始地址;发生错误时,返回(void *)-1,通常使用MAP_FAILED 来表示,并且会设置 errno 来指示错误原因。
对于 mmap()函数,参数 addr 和 offset 在不为 NULL 和 0 的情况下,addr 和 offset 的值通常被要求是系统页大小的整数倍,可通过 sysconf()函数获取页大小,如下所示(以字节为单位):
sysconf(_SC_PAGE_SIZE)
或
sysconf(_SC_PAGESIZE)
虽然对 addr 和 offset 有这种限制,但对于参数 length 长度来说,却没有这种要求,如果映射区的长度不是页长度的整数倍时,会怎么样呢?对于这个问题的答案,我们首先需要了解到,对于 mmap()函数来说,当文件成功被映射到内存区域时,这段内存区域(映射区)的大小通常是页大小的整数倍,即使参数 length并不是页大小的整数倍。如果文件大小为 96 个字节,我们调用 mmap()时参数 length 也是设置为 96,假设系统页大小为 4096 字节(4K),则系统通常会提供 4096 个字节的映射区,其中后 4000 个字节会被设置为0,可以修改后面的这 4000 个字节,但是并不会影响到文件。但如果访问 4000 个字节后面的内存区域,将会导致异常情况发生,产生 SIGBUS 信号。
对于参数 length 仍然需要注意,参数 length 的值不能大于文件大小,即文件被映射的部分不能超出文件。
与映射区相关的两个信号
⚫ SIGSEGV:如果映射区被 mmap()指定成了只读的,那么进程试图将数据写入到该映射区时,将会产生 SIGSEGV 信号,此信号由内核发送给进程。在第八章中给大家介绍过该信号,该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。
⚫ SIGBUS:如果映射区的某个部分在访问时已不存在,则会产生 SIGBUS 信号。例如,调用 mmap()进行映射时,将参数 length 设置为文件长度,但在访问映射区之前,另一个进程已将该文件截断(譬如调用 ftruncate()函数进行截断),此时如果进程试图访问对应于该文件已截去部分的映射区,进程将会受到内核发送过来的 SIGBUS 信号,同样,该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。
munmap()解除映射
通过 open()打开文件,需要使用 close()将将其关闭;同理,通过 mmap()将文件映射到进程地址空间中的一块内存区域中,当不再需要时,必须解除映射,使用 munmap()解除映射关系,其函数原型如下所示:
#include <sys/mman.h>
int munmap(void *addr, size_t length);
同样,使用该函数需要包含头文件<sys/mman.h>。
munmap()系统调用解除指定地址范围内的映射,参数 addr 指定待解除映射地址范围的起始地址,它必须是系统页大小的整数倍;参数 length 是一个非负整数,指定了待解除映射区域的大小(字节数),被解除映射的区域对应的大小也必须是系统页大小的整数倍,即使参数 length 并不等于系统页大小的整数倍,与mmap()函数相似。
需要注意的是,当进程终止时也会自动解除映射(如果程序中没有显式调用 munmap()),但调用 close()关闭文件时并不会解除映射。
通常将参数 addr 设置为 mmap()函数的返回值,将参数 length 设置为 mmap()函数的参数 length,表示解除整个由 mmap()函数所创建的映射。
使用示例
通过以上介绍,接下来我们编写一个简单地示例代码,使用存储映射 I/O 进行文件复制。
示例代码 13.5.1 演示了使用存储映射 I/O 实现文件复制操作,将源文件中的内容全部复制到另一个目标文件中,其效果类似于 cp 命令。
当执行程序的时候,将源文件和目标文件传递给应用程序,该程序首先会将源文件和目标文件打开,源文件以只读方式打开,而目标文件以可读、可写方式打开,如果目标文件不存在则创建它,并且将文件的大小截断为 0。然后使用 fstat()函数获取源文件的大小,接着调用 ftruncate()函数设置目标文件的大小与源文件大小保持一致。然后对源文件和目标文件分别调用 mmap(),将文件映射到内存当中;对于源文件,调用 mmap()时将参数 prot 指定为 PROT_READ,表示对它的映射区会进行读取操作;对于目标文件,调用 mmap()时将参数 port指定为 PROT_WRITE,表示对它的映射区会进行写入操作。最后调用 memcpy()将源文件映射区中的内容复制到目标文件映射区中,完成文件的复制操作。
接下来我们进行测试,笔者使用当前目录下的 srcfile 作为源文件,dstfile 作为目标文件,先看看源文件srcfile 的内容,如下所示:
目标文件 dstfile 并不存在,我们需要在程序中进行创建,编译程序、运行:
由打印信息可知,程序运行完之后,生成了目标文件 dstfile,使用 cat 命令查看到其内容与源文件 srcfile相同,本测试程序成功实现了文件复制功能!
mprotect()函数
使用系统调用 mprotect()可以更改一个现有映射区的保护要求,其函数原型如下所示:
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
使用该函数,同样需要包含头文件<sys/mman.h>。
参数 prot 的取值与 mmap()函数的 prot 参数的一样,mprotect()函数会将指定地址范围的保护要求更改为参数 prot 所指定的类型,参数 addr 指定该地址范围的起始地址,addr 的值必须是系统页大小的整数倍;
参数 len 指定该地址范围的大小。
mprotect()函数调用成功返回 0;失败将返回-1,并且会设置 errno 来只是错误原因。
msync()函数
在第四章中提到过,read()和 write()系统调用在操作磁盘文件时不会直接发起磁盘访问(读写磁盘硬件),而是仅仅在用户空间缓冲区和内核缓冲区之间复制数据,在后续的某个时刻,内核会将其缓冲区中的数据写入(刷新至)磁盘中,所以由此可知,调用 write()写入到磁盘文件中的数据并不会立马写入磁盘,而是会先缓存在内核缓冲区中,所以就会出现 write()操作与磁盘操作并不同步,也就是数据不同步。
对于存储 I/O 来说亦是如此,写入到文件映射区中的数据也不会立马刷新至磁盘设备中,而是会在我们将数据写入到映射区之后的某个时刻将映射区中的数据写入磁盘中。所以会导致映射区中的内容与磁盘文件中的内容不同步。我们可以调用 msync()函数将映射区中的数据刷写、更新至磁盘文件中(同步操作),系统调用 msync()类似于 fsync()函数,不过 msync()作用于映射区。该函数原型如下所示:
#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);
使用该函数,同样需要包含头文件<sys/mman.h>。
参数 addr 和 length 指定了需同步的内存区域的起始地址和大小。对于参数 addr 来说,同样也要求必须是系统页大小的整数倍,也就是与系统页大小对齐。譬如,调用 msync()时,将 addr 设置为 mmap()函数的返回值,将 length 设置为 mmap()函数的 length 参数,将对文件的整个映射区进行同步操作。
参数 flags 应指定为 MS_ASYNC 和 MS_SYNC 两个标志之一,除此之外,还可以根据需求选择是否指定 MS_INVALIDATE 标志,作为一个可选标志。
⚫ MS_ASYNC:以异步方式进行同步操作。调用 msync()函数之后,并不会等待数据完全写入磁盘之后才返回。
⚫ MS_SYNC:以同步方式进行同步操作。调用 msync()函数之后,需等待数据全部写入磁盘之后才返回。
⚫ MS_INVALIDATE:是一个可选标志,请求使同一文件的其它映射无效(以便可以用刚写入的新值更新它们)。
msync()函数在调用成功情况下返回 0;失败将返回-1、并设置 errno。
munmap()函数并不影响被映射的文件,也就是说,当调用 munmap()解除映射时并不会将映射区中的内容写到磁盘文件中。如果 mmap()指定了 MAP_SHARED 标志,对于文件的更新,会在我们将数据写入到映射区之后的某个时刻将映射区中的数据更新到磁盘文件中,由内核根据虚拟存储算法自动进行。
如果 mmap()指定了 MAP_PRIVATE 标志,在解除映射之后,进程对映射区的修改将会丢弃!
普通 I/O 与存储映射 I/O 比较
通过前面的介绍,相信大家对存储映射 I/O 之间有了一个新的认识,本小节我们再来对普通 I/O 方式和存储映射 I/O 做一个简单的总结。
普通 I/O 方式的缺点
普通 I/O 方式一般是通过调用 read()和 write()函数来实现对文件的读写,使用 read()和 write()读写文件时,函数经过层层的调用后,才能够最终操作到文件,中间涉及到很多的函数调用过程,数据需要在不同的缓存间倒腾,效率会比较低。同样使用标准 I/O(库函数 fread()、fwrite())也是如此,本身标准 I/O 就是对普通 I/O 的一种封装。
那既然效率较低,为啥还要使用这种方式呢?原因在于,只有当数据量比较大时,效率的影响才会比较明显,如果数据量比较小,影响并不大,使用普通的 I/O 方式还是非常方便的。
存储映射 I/O 的优点
存储映射 I/O 的实质其实是共享,与 IPC 之内存共享很相似。譬如执行一个文件复制操作来说,对于普通 I/O 方式,首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写入到目标文件中,如下所示:
而对于存储映射 I/O 来说,由于源文件和目标文件都已映射到了应用层的内存区域中,所以直接操作映射区来实现文件复制,如下所示:
首先非常直观的一点就是,使用存储映射 I/O 减少了数据的复制操作,所以在效率上会比普通 I/O 要高,其次上面也讲了,普通 I/O 中间涉及到了很多的函数调用过程,这些都会导致普通 I/O 在效率上会比存储映射 I/O 要低。
前面提到存储映射 I/O 的实质其实是共享,如何理解共享呢?其实非常简单,我们知道,应用层与内核层是不能直接进行交互的,必须要通过操作系统提供的系统调用或库函数来与内核进行数据交互,包括操作硬件。通过存储映射 I/O 将文件直接映射到应用程序地址空间中的一块内存区域中,也就是映射区;直接将磁盘文件直接与映射区关联起来,不用调用 read()、write()系统调用,直接对映射区进行读写操作即可操作磁盘上的文件,而磁盘文件中的数据也可反应到映射区中,这就是一种共享,可以认为映射区就是应用层与内核层之间的共享内存。
存储映射 I/O 的不足
存储映射 I/O 方式并不是完美的,它所映射的文件只能是固定大小,因为文件所映射的区域已经在调用mmap()函数时通过 length 参数指定了。另外,文件映射的内存区域的大小必须是系统页大小的整数倍,譬如映射文件的大小为 96 字节,假定系统页大小为 4096 字节,那么剩余的 4000 字节全部填充为 0,虽然可以通过映射地址访问剩余的这些字节数据,但不能在映射文件中反应出来,由此可知,使用存储映射 I/O 在进行大数据量操作时比较有效;对于少量数据,使用普通 I/O 方式更加方便!
存储映射 I/O 的应用场景
由上面介绍可知,存储映射 I/O 在处理大量数据时效率高,对于少量数据处理不是很划算,所以通常来说,存储映射 I/O 会在视频图像处理方面用的比较多,譬如在第二篇内容,我们将会介绍 Framebuffer 编程,通俗点说就是 LCD 编程,就会使用到存储映射 I/O。
补充
应用层调用mmap之后在驱动层会执行什么函数?
在Linux操作系统中,mmap是一种将文件或其他对象映射到进程的虚拟地址空间的技术。当应用层调用mmap后,在驱动层会执行一系列函数来完成内存映射的过程。以下是详细的解释:
-
驱动程序的mmap函数:在驱动层,mmap函数是标准虚拟文件系统(VFS)struct file_operations提供的接口之一。当用户进程尝试映射设备内存时,系统会调用这个函数。如果驱动层未实现该函数,进程调用mmap()时会返回-ENODEV错误。
-
remap_pfn_range函数:在驱动程序的mmap函数中,通常会调用remap_pfn_range()函数来建立映射关系。这个函数负责将用户空间的虚拟地址与物理地址关联起来。
-
vm_area_struct结构体:在mmap系统调用过程中,内核会帮助找到一块未被映射的虚拟地址区间,并将其描述为一个vm_area_struct结构体。这个结构体包含了映射区域的各种信息,如起始地址、结束地址以及页帧号等。
-
缺页中断处理:当进程首次访问映射的虚拟地址时,由于页表项尚未建立,会产生缺页中断。内核的缺页中断处理程序会根据需要分配内存页,并更新页表,使得虚拟地址能够正确映射到物理地址。
综上所述,当应用层调用mmap后,在驱动层会执行驱动程序的mmap函数,该函数内部可能会调用remap_pfn_range()来建立映射关系。同时,内核还会涉及到vm_area_struct结构体和缺页中断处理程序来确保虚拟地址能够正确映射到物理地址。