高级 I/O
- 1. 阻塞 I/O 与非阻塞 I/O
- 2. 阻塞 I/O 所带来的困境
- 3. 何为 I/O 多路复用以及原理
- select()函数介绍
- poll()函数介绍
- 总结
- 4. 何为异步 I/O 以及原理
- 5. 存储映射 I/O
- 7. 文件加锁
1. 阻塞 I/O 与非阻塞 I/O
这里举个例子,譬如对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒,这就是阻塞式 I/O 常见的一种表现;如果是非阻塞式 I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误!
普通文件的读写操作是不会阻塞的,不管读写多少个字节数据,read()或 write()一定会在有限的时间内返回,所以普通文件一定是以非阻塞的方式进行 I/O 操作,这是普通文件本质上决定的;但是对于某些文件类型,譬如上面所介绍的管道文件、设备文件等,它们既可以使用阻塞式 I/O 操作,也可以使用非阻塞式 I/O进行操作。
当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式 I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞;而对于非阻塞 I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃!
所以阻塞式 I/O 的优点在于能够提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态,交出 CPU资源,将 CPU 资源让给别人使用;而非阻塞式则是抓紧利用 CPU 资源,譬如不断地去轮训,这样就会导致该程序占用了非常高的 CPU 使用率!
2. 阻塞 I/O 所带来的困境
/* 读鼠标 */
memset(buf, 0, sizeof(buf));
ret = read(fd, buf, sizeof(buf));
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
/* 读键盘 */
memset(buf, 0, sizeof(buf));
ret = read(0, buf, sizeof(buf));
printf("键盘: 成功读取<%d>个字节数据\n", ret);
上述程序中先读了鼠标,在接着读键盘,所以由此可知,在实际测试当中,需要先动鼠标在按键盘(按下键盘上的按键、按完之后按下回车),这样才能既成功读取鼠标、又成功读取键盘,程序才能够顺利运行结束。因为 read 此时是阻塞式读取,先读取了鼠标,没有数据可读将会一直被阻塞,后面的读取键盘将得不到执行。这就是阻塞式 I/O 的一个困境。
我们可以使用非阻塞式 I/O 解决它。但是非阻塞的程序CPU占用太高不推荐,后面的方法可以解决该问题。
3. 何为 I/O 多路复用以及原理
I/O 多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作。I/O 多路复用技术是为了解决:在并发式 I/O 场景中进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程不阻塞于某个特定的I/O 系统调用。
select()函数介绍
系统调用 select()可用于执行 I/O 多路复用操作,调用 select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)。
/* 同时读取键盘和鼠标 */
while (loops--)
{
FD_ZERO(&rdfds);
FD_SET(0, &rdfds); // 添加键盘
FD_SET(fd, &rdfds); // 添加鼠标
ret = select(fd + 1, &rdfds, NULL, NULL, NULL);
if (0 > ret)
{
perror("select error");
goto out;
}
else if (0 == ret)
{
fprintf(stderr, "select timeout.\n");
continue;
}
/* 检查键盘是否为就绪态 */
if (FD_ISSET(0, &rdfds))
{
ret = read(0, buf, sizeof(buf));
if (0 < ret)
printf("键盘: 成功读取<%d>个字节数据\n", ret);
}
/* 检查鼠标是否为就绪态 */
if (FD_ISSET(fd, &rdfds))
{
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
}
}
- 有一个或多个文件描述符就绪
返回值:返回准备好的文件描述符的数量(即就绪的文件描述符数量)。
就绪条件:任何一个文件描述符在指定的 readfds、writefds 或 exceptfds 中变为可读、可写或出现异常。 - 超时
返回值:返回 0。
超时条件:如果设置了 timeout 参数并且在指定的时间内没有任何文件描述符就绪,select() 会在超时后返回 0。 - 出错
返回值:返回 -1。
错误条件:当出现错误(如无效的参数、调用失败等)时,select() 会返回 -1,并设置 errno 来指示错误类型。常见的错误包括:
EBADF:传入的文件描述符无效。
EINTR:调用被信号中断。
poll()函数介绍
poll()函数返回值含义与 select()函数的返回值是一样的,有如下几种情况:
⚫ 返回-1 表示有错误发生,并且会设置 errno。
⚫ 返回 0 表示该调用在任意一个文件描述符成为就绪态之前就超时了。
⚫ 返回一个正整数表示有一个或多个文件描述符处于就绪态了,返回值表示 fds 数组中返回的 revents变量不为 0 的 struct pollfd 对象的数量。
总结
在使用 select()或 poll()时需要注意一个问题,当监测到某一个或多个文件描述符成为就绪态(可以读或写)时,需要执行相应的 I/O 操作,以清除该状态,否则该状态将会一直存在
4. 何为异步 I/O 以及原理
在 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 操作。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#define MOUSE "/dev/input/event3"
static int fd;
static void sigio_handler(int sig)
{
static int loops = 5;
char buf[100] = {0};
int ret;
if (SIGIO != sig)
return;
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
loops--;
if (0 >= loops)
{
close(fd);
exit(0);
}
}
int main(void)
{
int flag;
/* 打开鼠标设备文件<使能非阻塞 I/O> */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 使能异步 I/O */
flag = fcntl(fd, F_GETFL);
flag |= O_ASYNC;
fcntl(fd, F_SETFL, flag);
/* 设置异步 I/O 的所有者 */
fcntl(fd, F_SETOWN, getpid());
/* 为 SIGIO 信号注册信号处理函数 */
signal(SIGIO, sigio_handler);
for (;;)
sleep(1);
}
优化异步 I/O
⚫ 默认的异步 I/O 通知信号 SIGIO 是非排队信号。SIGIO 信号是标准信号(非实时信号、不可靠信号),所以它不支持信号排队机制,譬如当前正在执行 SIGIO 信号的处理函数,此时内核又发送多次 SIGIO 信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进程,并且只能传递一次,而其它后续的信号都会丢失。
⚫ 无法得知文件描述符发生了什么事件。在示例代码的信号处理函数 sigio_handler()中,直接调用了 read()函数读取鼠标,而并未判断文件描述符是否处于可读就绪态,事实上,这种异步 I/O 方式并未告知应用程序文件描述符上发生了什么事件,是可读取还是可写入亦或者发生异常等。
1 使用实时信号替换默认信号 SIGIO
fcntl(fd, F_SETSIG, SIGRTMIN);
2 使用 sigaction()函数注册信号处理函数
在应用程序当中需要为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指定 SA_SIGINFO,表示使用 sa_sigaction 指向的函数作为信号处理函数,而不使用 sa_handler 指向的函数。
5. 存储映射 I/O
存储映射 I/O(memory-mapped I/O)是一种基于内存区域的高级 I/O 操作,它能将一个文件映射到进程地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据(对文件进行 read 操作),将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行 write 操作)。这样就可以在不使用基本 I/O 操作函数 read()和 write()的情况下执行 I/O 操作。
为了实现存储映射 I/O 这一功能,我们需要告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中,这由系统调用 mmap()来实现。
mmap()函数
对于 mmap()函数,参数 addr 和 offset 在不为 NULL 和 0 的情况下,addr 和 offset 的值通常被要求是系统页大小的整数倍,可通过 sysconf()函数获取页大小。
在使用 mmap() 创建内存映射时,与映射区相关的两个信号是 SIGSEGV 和 SIGBUS。这两个信号通常会在特定条件下触发,表示访问映射区域时发生了错误。下面是对这两个信号的详细解释:
-
SIGSEGV (Segmentation Fault)
触发条件:
当进程尝试写入一个只读的映射区域时,会产生 SIGSEGV 信号。例如,如果在调用 mmap() 时指定了 PROT_READ(只读保护)而不包括 PROT_WRITE(可写保护),然后进程试图向该区域写入数据,就会引发此信号。 -
SIGBUS (Bus Error)
触发条件:
当进程试图访问一个不存在的内存地址时,会触发 SIGBUS 信号。例如,假设你使用 mmap() 映射一个文件,并且在映射后,另一个进程通过 ftruncate() 函数截断了该文件。如果进程随后尝试访问映射区域中与被截断部分对应的地址,就会引发 SIGBUS 信号。
munmap()
通过 open()打开文件,需要使用 close()将将其关闭;同理,通过 mmap()将文件映射到进程地址空间中
的一块内存区域中,当不再需要时,必须解除映射,使用 munmap()解除映射关系。
mprotect()函数
使用系统调用 mprotect()可以更改一个现有映射区的保护要求
msync()函数
对于存储 I/O 来说亦是如此,写入到文件映射区中的数据也不会立马刷新至磁盘设备中,而是会在我们将数据写入到映射区之后的某个时刻将映射区中的数据写入磁盘中。所以会导致映射区中的内容与磁盘文件中的内容不同步。我们可以调用 msync()函数将映射区中的数据刷写、更新至磁盘文件中(同步操作),系统调用 msync()类似于 fsync()函数,不过 msync()作用于映射区。
普通 I/O 与存储映射 I/O 比较
普通 I/O 实现文件复制示例图
存储映射 I/O 实现文件复制
7. 文件加锁
对于有些应用程序,进程有时需要确保只有它自己能够对某一文件进行 I/O 操作,在这段时间内不允许其它进程对该文件进行 I/O 操作。为了向进程提供这种功能,Linux 系统提供了文件锁机制。
前面学习过互斥锁、自旋锁以及读写锁,文件锁与这些锁一样,都是内核提供的锁机制,锁机制实现用于对共享资源的访问进行保护;只不过互斥锁、自旋锁、读写锁与文件锁的应用场景不一样,互斥锁、自旋锁、读写锁主要用在多线程环境下,对共享资源的访问进行保护,做到线程同步。
而文件锁,顾名思义是一种应用于文件的锁机制,当多个进程同时操作同一文件时,我们怎么保证文件数据的正确性,linux 通常采用的方法是对文件上锁,来避免多个进程同时操作同一文件时产生竞争状态。譬如进程对文件进行 I/O 操作时,首先对文件进行上锁,将其锁住,然后再进行读写操作;只要进程没有对文件进行解锁,那么其它的进程将无法对其进行操作;这样就可以保证,文件被锁住期间,只有它(该进程)可以对其进行读写操作。
文件锁的分类
文件锁可以分为建议性锁和强制性锁两种。
flock()函数加锁
系统调用 flock(),使用该函数可以对文件加锁或者解锁,但是 flock()函数只能产生建议性锁
fcntl()函数加锁
fcntl()函数在前面章节内容中已经多次用到了,它是一个多功能文件描述符管理工具箱,通过配合不同的 cmd 操作命令来实现不同的功能。
⚫ flock()仅支持对整个文件进行加锁/解锁;而 fcntl()可以对文件的某个区域(某部分内容)进行加锁
/解锁,可以精确到某一个字节数据。
⚫ flock()仅支持建议性锁类型;而 fcntl()可支持建议性锁和强制性锁两种类型。
lockf()函数加锁
lockf()函数是一个库函数,其内部是基于 fcntl()来实现的,所以 lockf()是对 fcntl 锁的一种封装,具体的使用方法这里便不再介绍。