目录
[I/O 多路复用](#I/O 多路复用)
非阻塞IO
非阻塞IO:
非阻塞IO(Non-blocking IO)是指当进程发起IO操作时,即使数据还没有准备好,操作会立即返回,而不会阻塞进程的执行。如果数据尚未准备好,操作会返回错误或特定的状态,告知进程数据尚不可用。
这里我们只需要设置一下文件描述符的属性即可。
-
fcntl()
:用于设置文件描述符的属性,其中可以通过F_SETFL
标志设置文件描述符为非阻塞模式:O_NONBLOCK-
原型:
int fcntl(int fd, int cmd, ... /* arg */ );
-
参数说明:
fd
:文件描述符。cmd
:要执行的命令,F_SETFL
表示设置文件状态标志。arg
:设置的标志,通常使用O_NONBLOCK
来启用非阻塞模式。 -
返回值: 成功时返回文件描述符的状态,失败时返回-1,并设置
errno
。
设置非阻塞模式后,当进行
read()
或write()
操作时,如果数据不可用,系统不会阻塞当前进程,而是返回-1,并将errno
设置为EAGAIN
。 -
-
read()
:当文件描述符处于非阻塞模式时,如果没有数据可读,read()
会立即返回-1
,并设置errno
为EAGAIN
,而不会阻塞进程。-
原型:
ssize_t read(int fd, void *buf, size_t count);
-
返回值: 如果没有数据可读,返回-1,
errno
设置为EAGAIN
。如果成功读取数据,则返回实际读取的字节数。
-
-
write()
:同样地,当文件描述符处于非阻塞模式时,如果无法立即完成写入操作,write()
会返回-1
,并设置errno
为EAGAIN
。-
原型:
ssize_t write(int fd, const void *buf, size_t count);
-
返回值: 如果无法立即写入数据,返回-1,
errno
设置为EAGAIN
。如果成功写入数据,则返回实际写入的字节数。
-
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("file.txt", O_RDONLY | O_NONBLOCK);
if (fd == -1) {
perror("open");
return 1;
}
char buffer[100];
ssize_t bytesRead = read(fd, buffer, sizeof(buffer));
if (bytesRead == -1) {
if (errno == EAGAIN) {
printf("No data available.\n");
} else {
perror("read");
}
} else {
write(STDOUT_FILENO, buffer, bytesRead);
}
close(fd);
return 0;
}
I/O 多路复用
I/O 多路复用(I/O multiplexing)是操作系统提供的一种机制,允许一个线程或进程通过一个或多个接口同时处理多个I/O流。这种机制特别适合于需要同时处理大量I/O操作的场景,例如高并发的网络服务器。Linux中的I/O多路复用通常使用select()
、poll()
、epoll()
等系统调用来实现。
本文将详细介绍Linux I/O多路复用的概念、API、使用方法以及在实际开发中的应用,帮助开发者更好地理解和运用这些机制。
I/O多路复用的背景
在传统的I/O模型中,一个进程通常只能同时进行一个I/O操作。当进行I/O操作时,进程需要等待数据的到来,这会导致进程阻塞。为了避免因为等待I/O操作而浪费CPU时间,操作系统提供了I/O多路复用机制,使得进程或线程能够同时监听多个I/O流,从而减少阻塞,提高程序的效率。
I/O多路复用机制在网络编程中尤为重要,尤其是在处理大量并发连接时。如果为每个连接创建一个线程或进程,资源开销非常大,且会导致上下文切换带来较大的性能损失。而使用I/O多路复用,则可以通过单个进程或线程来处理多个I/O操作,从而显著提升性能。
I/O多路复用的基本原理
I/O多路复用的核心思想是通过一个调用来同时监听多个I/O事件(如读、写等),当其中某个事件发生时,程序可以立即处理相关I/O。常见的I/O多路复用方法有以下几种:
-
select:最早的多路复用机制,使用一个固定大小的文件描述符集来监听多个I/O事件。
-
poll :是
select()
的改进版本,支持动态大小的文件描述符集,但性能依然有限。 -
epoll:Linux特有的高效I/O多路复用机制,具有较好的扩展性和性能,尤其在高并发场景下表现突出。
select函数
select()
是最早实现的I/O多路复用接口,它能够监听多个文件描述符上的读写事件。当某个文件描述符准备好进行I/O操作时,select()
返回,并通知应用程序进行处理。
select()
的API原型:
#include <sys/select.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
-
参数说明:
-
nfds
:需要监视的文件描述符的数量。通常是文件描述符的最大值加1。 -
readfds
:需要检测是否可以读取的文件描述符集合。 -
writefds
:需要检测是否可以写入的文件描述符集合。 -
exceptfds
:需要检测是否有异常的文件描述符集合。 -
timeout
:等待时间,单位为秒。如果超时则返回,若为NULL
则表示一直阻塞,直到事件发生。
-
-
返回值:
-
返回文件描述符的数量,表示哪些文件描述符准备好进行I/O操作。
-
失败时返回
-1
,并设置errno
。
-
使用select()
的示例代码:
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>
#include <fcntl.h>
int main() {
fd_set readfds;
struct timeval timeout;
int fd = open("test.txt", O_RDONLY);
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
timeout.tv_sec = 5;
timeout.tv_usec = 0;
int ret = select(fd + 1, &readfds, NULL, NULL, &timeout);
if (ret == -1) {
perror("select");
close(fd);
return 1;
}
if (ret == 0) {
printf("Timeout occurred!\n");
} else {
if (FD_ISSET(fd, &readfds)) {
printf("File is ready for reading\n");
}
}
close(fd);
return 0;
}
此示例代码演示了如何使用select()
来监听文件是否可读,并设置了一个5秒的超时时间。select()
会检查文件是否可读,并在超时或事件发生时返回。
select()
的局限性:
-
性能问题 :
select()
在每次调用时都需要重新构建文件描述符集合,这对于大量文件描述符时效率较低。 -
最大文件描述符限制 :
select()
的文件描述符集合是固定大小的,通常为1024,这使得它不适合用于高并发应用。
poll函数
poll()
是对select()
的改进,解决了select()
的一些限制,如文件描述符数量限制问题。poll()
使用一个动态的pollfd
结构数组来表示需要监视的文件描述符,适应性更强。
poll()
的API原型:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
参数说明:
-
fds
:指向pollfd
结构数组的指针,每个pollfd
结构表示一个需要监听的文件描述符。 -
nfds
:表示fds
数组的元素数量。 -
timeout
:等待的超时时间,单位为毫秒。如果设置为-1
,则表示永久阻塞。
-
-
返回值:
-
返回准备好进行I/O操作的文件描述符数量。
-
超时返回0,错误返回-1。
-
poll()
的示例代码:
#include <stdio.h>
#include <unistd.h>
#include <poll.h>
#include <fcntl.h>
int main() {
struct pollfd pfds[1];
int fd = open("test.txt", O_RDONLY);
pfds[0].fd = fd;
pfds[0].events = POLLIN;
int ret = poll(pfds, 1, 5000); // 5秒超时
if (ret == -1) {
perror("poll");
close(fd);
return 1;
}
if (ret == 0) {
printf("Timeout occurred!\n");
} else {
if (pfds[0].revents & POLLIN) {
printf("File is ready for reading\n");
}
}
close(fd);
return 0;
}
poll()
的优势在于,它没有select()
那样的文件描述符数量限制,并且可以更灵活地监听不同类型的事件(如读、写、异常)。
epoll函数
epoll
是Linux特有的高效I/O多路复用机制,解决了select()
和poll()
在高并发场景下的性能瓶颈。epoll
使用事件驱动的方式,可以避免文件描述符集合的拷贝和遍历,从而提高性能,尤其适合处理大量并发连接的网络应用。
epoll
的工作原理:
-
创建epoll实例 :通过
epoll_create()
或epoll_create1()
函数创建一个epoll实例。 -
注册事件 :使用
epoll_ctl()
注册需要监听的文件描述符及其对应的事件。 -
等待事件 :使用
epoll_wait()
等待并获取发生的事件。
epoll
的API
-
epoll_create()
:创建一个epoll实例。int epoll_create(int size);
size
参数被忽略,通常传递128即可。 -
epoll_create1()
:创建epoll实例,并可指定额外的标志,如EPOLL_CLOEXEC
。int epoll_create1(int flags);
-
epoll_ctl()
:添加、删除或修改文件描述符的事件。int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epoll_wait()
:等待并返回准备好事件的文件描述符。int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll()
的示例代码:
#include <stdio.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#define MAX_EVENTS 10
int main() {
int epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create");
return 1;
}
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = STDIN_FILENO;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) {
perror("epoll_ctl");
close(epfd);
return 1;
}
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
close(epfd);
return 1;
}
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == STDIN_FILENO) {
printf("Input ready for reading\n");
}
}
close(epfd);
return 0;
}
epoll
的优势在于:
-
高性能 :尤其在大量并发连接的情况下,
epoll
避免了每次都遍历所有文件描述符。 -
支持边缘触发 :
epoll
支持边缘触发(Edge Triggered)和水平触发(Level Triggered)模式。边缘触发模式下,只有当文件描述符状态发生变化时才会通知应用程序,从而减少不必要的调用。
异步IO
异步I/O的基本原理是:当应用程序发起I/O操作时,它并不等待操作完成,而是立即返回。操作系统会在I/O操作完成时通过信号、回调函数或者其他方式通知进程,进程再去检查结果或进行下一步操作。这样,进程可以在等待I/O操作的同时做其他事情,提高了CPU资源的利用率。
常见的异步I/O工作流程如下:
-
发起异步I/O请求:应用程序调用异步I/O接口,发起I/O请求。
-
操作完成后通知:操作系统在I/O操作完成后,通过信号、回调等方式通知应用程序。
-
应用程序继续处理:应用程序根据通知的结果进行下一步操作。
Linux中的异步I/O实现
Linux提供了两种主要的异步I/O机制:
-
基于信号的异步I/O:
- 使用
SIGIO
信号来通知进程某个I/O操作完成。进程需要设置自己的信号处理器,并通过fcntl()
或ioctl()
系统调用来启用异步I/O。
- 使用
-
基于
libaio
的异步I/O:libaio
是一个用户空间库,提供了异步I/O的高级接口。它使用内核提供的异步I/O支持,允许应用程序提交多个I/O请求,并在I/O完成时获取结果。
-
基于
io_uring
的异步I/O(推荐):io_uring
是Linux内核5.1引入的新型异步I/O接口,比传统的AIO接口效率更高,支持零拷贝、批量提交和高效的事件通知。
基于信号的异步I/O
使用信号进行异步I/O的核心是通过SIGIO
信号和fcntl()
系统调用进行设置。应用程序可以为一个文件描述符注册SIGIO
信号,当文件描述符准备好进行I/O操作时,内核会发送SIGIO
信号给进程。进程接收到信号后,可以继续执行后续操作。
使用信号进行异步I/O的步骤:
-
设置文件描述符的异步I/O标志 : 使用
fcntl()
系统调用设置文件描述符为异步I/O模式。 -
捕捉
SIGIO
信号 : 进程需要设置信号处理程序来捕获SIGIO
信号。 -
在信号处理程序中进行I/O操作: 在信号处理程序中检查I/O是否完成,如果完成则进行后续操作。
代码示例(基于信号的异步I/O):
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <errno.h>
void sigio_handler(int signo) {
printf("Received SIGIO signal, performing I/O operation\n");
// 此处可以执行I/O操作
}
int main() {
// 设置SIGIO信号处理函数
signal(SIGIO, sigio_handler);
// 打开文件并设置文件描述符为异步I/O模式
int fd = open("test.txt", O_RDONLY);
if (fd == -1) {
perror("open");
return 1;
}
// 设置文件描述符为异步I/O
if (fcntl(fd, F_SETFL, O_ASYNC) == -1) {
perror("fcntl");
close(fd);
return 1;
}
// 将文件描述符的异步通知发送到当前进程
if (fcntl(fd, F_SETOWN, getpid()) == -1) {
perror("fcntl");
close(fd);
return 1;
}
// 进入循环,等待信号
while (1) {
pause(); // 等待SIGIO信号
}
close(fd);
return 0;
}
在这个示例中,SIGIO
信号会在文件描述符可读时发送到进程,进程接收到信号后在信号处理函数中执行I/O操作。
基于libaio
的异步I/O
libaio
库是Linux提供的一个用户空间库,它通过内核提供的AIO支持来实现异步I/O。与基于信号的异步I/O不同,libaio
提供了一组系统调用接口,允许应用程序发起多个异步I/O请求,并在所有请求完成后一次性处理结果。libaio
通过提交I/O请求并返回一个io_context
对象来管理I/O操作。
主要API:
-
io_setup()
:创建AIO上下文。 -
io_submit()
:提交异步I/O请求。 -
io_getevents()
:获取已完成的异步I/O请求。 -
io_destroy()
:销毁AIO上下文。
代码示例 (基于libaio
的异步I/O):
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <libaio.h>
#define BUFFER_SIZE 1024
int main() {
io_context_t ctx;
struct iocb iocb[1];
struct io_event ev;
char buffer[BUFFER_SIZE];
int fd = open("test.txt", O_RDONLY);
if (fd == -1) {
perror("open");
return 1;
}
// 初始化AIO上下文
if (io_setup(128, &ctx) < 0) {
perror("io_setup");
close(fd);
return 1;
}
// 准备异步I/O操作
memset(&iocb, 0, sizeof(iocb));
io_prep_pread(&iocb[0], fd, buffer, BUFFER_SIZE, 0); // 读取文件到缓冲区
iocb[0].data = buffer;
// 提交异步I/O操作
if (io_submit(ctx, 1, &iocb) < 0) {
perror("io_submit");
io_destroy(ctx);
close(fd);
return 1;
}
// 等待I/O操作完成
int ret = io_getevents(ctx, 1, 1, &ev, NULL);
if (ret < 0) {
perror("io_getevents");
io_destroy(ctx);
close(fd);
return 1;
}
printf("Read %ld bytes: %s\n", ev.res, buffer);
// 销毁AIO上下文并关闭文件
io_destroy(ctx);
close(fd);
return 0;
}
这个示例使用libaio
的异步I/O机制,向文件读取数据。程序通过io_submit()
提交读取操作,io_getevents()
用来等待I/O操作完成。
基于io_uring
的异步I/O
io_uring
是Linux内核5.1版本引入的一种新的异步I/O机制,目的是提供比libaio
更高效的I/O处理方式。io_uring
通过使用环形缓冲区来管理I/O请求和结果,避免了传统AIO模型中的上下文切换和系统调用开销。
io_uring
的基本原理:
-
提交环(Submission Queue, SQ):应用程序将I/O请求提交到提交环,提交的I/O请求包含了操作所需的数据和参数。
-
完成环(Completion Queue, CQ):I/O操作完成后,内核将结果写入完成环,应用程序可以读取这些结果。
主要API:
-
io_uring_setup()
:设置io_uring
实例。 -
io_uring_enter()
:提交和等待I/O操作的完成。 -
io_uring_register()
:注册I/O操作所需的资源。 -
io_uring_cqe()
:获取完成事件。
代码示例 (基于io_uring
的异步I/O):
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <liburing.h>
#define QUEUE_SIZE 64
int main() {
struct io_uring ring;
struct io_uring_cqe *cqe;
struct io_uring_sqe *sqe;
char buffer[1024];
int fd = open("test.txt", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
// 初始化io_uring
io_uring_queue_init(QUEUE_SIZE, &ring, 0);
// 准备异步读取请求
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buffer, sizeof(buffer), 0);
// 提交请求
io_uring_submit(&ring);
// 等待完成
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res < 0) {
perror("read failed");
} else {
printf("Read %d bytes: %s\n", cqe->res, buffer);
}
// 清理
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
close(fd);
return 0;
}
在这个示例中,io_uring
用于异步读取文件,程序提交读取请求并等待完成。