I/O 多路复用技术
几种常见的I/O多路复用技术,包括:select、poll、epoll
1、select函数
select是一种I/O多路复用技术,它可以同时监视多个文件描述符的状态变化,包括可读、可写和异常等。
select是最早的I/O多路复用技术之一,它使用fd_set数据结构来管理文件描述符集合,并通过select函数来监视这些文件描述符的状态变化。select函数会阻塞,直到有文件描述符就绪或超时。
select的缺点是,它使用的fd_set数据结构有大小限制,通常为1024个文件描述符,而且每次调用select都需要将fd_set数据结构从用户态复制到内核态,效率较低。
select函数的原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds:需要监视的最大文件描述符值加1。
- readfds:指向可读文件描述符集合的指针。
- writefds:指向可写文件描述符集合的指针。
- exceptfds:指向异常文件描述符集合的指针。
- timeout:超时时间,可以设置为NULL表示永远等待,或者设置为一个时间值,表示等待指定时间后超时。
select函数的返回值表示就绪文件描述符的数量,如果返回0表示超时,如果返回-1表示出错。
使用select函数的步骤如下:
-
创建并初始化fd_set数据结构,用于存储需要监视的文件描述符集合。
-
将需要监视的文件描述符添加到对应的fd_set数据结构中。
-
调用select函数,等待文件描述符的状态变化。
-
检查select函数的返回值,判断是否有文件描述符就绪。
-
遍历文件描述符集合,检查每个文件描述符的状态,进行相应的处理。
下面是一个简单的示例代码,演示了如何使用select函数实现I/O复用:
#include <iostream>
#include <sys/select.h>
#include <unistd.h>
int main() {
fd_set readfds;
FD_ZERO(&readfds);
int fd1 = 0; // 标准输入
int fd2 = 1; // 标准输出
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);
int maxfd = std::max(fd1, fd2) + 1;
while (true) {
fd_set tmpfds = readfds;
int ret = select(maxfd, &tmpfds, NULL, NULL, NULL);
if (ret == -1) {
std::cerr << "select error" << std::endl;
break;
} else if (ret == 0) {
std::cout << "timeout" << std::endl;
continue;
}
if (FD_ISSET(fd1, &tmpfds)) {
// fd1有数据可读
char buf[1024];
ssize_t n = read(fd1, buf, sizeof(buf));
if (n > 0) {
std::cout << "read from fd1: " << std::string(buf, n) << std::endl;
} else if (n == 0) {
std::cout << "fd1 closed" << std::endl;
break;
} else {
std::cerr << "read error" << std::endl;
break;
}
}
if (FD_ISSET(fd2, &tmpfds)) {
// fd2可写
std::string message = "Hello, world!";
ssize_t n = write(fd2, message.c_str(), message.size());
if (n > 0) {
std::cout << "write to fd2: " << message << std::endl;
} else {
std::cerr << "write error" << std::endl;
break;
}
}
}
return 0;
}
在上述示例中,我们使用select函数同时监视标准输入和标准输出。当标准输入有数据可读时,我们从标准输入读取数据并输出;当标准输出可写时,我们向标准输出写入数据。通过使用select函数,我们可以实现同时处理多个文件描述符的I/O操作,提高程序的效率。
2、poll函数
类似于select,它可以同时监视多个文件描述符的状态变化。
poll是select的改进版本,它使用pollfd数据结构来管理文件描述符集合,并通过poll函数来监视这些文件描述符的状态变化。poll函数不再有fd_set的大小限制,并且只需要将pollfd数据结构从用户态复制到内核态一次,效率较高。但是,poll仍然需要遍历整个文件描述符集合来查找就绪的文件描述符,效率较低。
poll函数的原型如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
- fds:指向pollfd结构体数组的指针,每个pollfd结构体表示一个文件描述符及其关注的事件。
- nfds:pollfd结构体数组的大小。
- timeout:超时时间,可以设置为-1表示永远等待,或者设置为一个非负数,表示等待指定时间后超时。
poll函数的返回值表示就绪文件描述符的数量,如果返回0表示超时,如果返回-1表示出错。
使用poll函数的步骤如下:
- 创建并初始化pollfd结构体数组,用于存储需要监视的文件描述符及其关注的事件。
- 将需要监视的文件描述符及其关注的事件设置到对应的pollfd结构体中。
- 调用poll函数,等待文件描述符的状态变化。
- 检查poll函数的返回值,判断是否有文件描述符就绪。
- 遍历pollfd结构体数组,检查每个文件描述符的revents字段,进行相应的处理。
下面是一个简单的示例代码,演示了如何使用poll函数实现I/O复用:
#include <iostream>
#include <poll.h>
#include <unistd.h>
int main() {
struct pollfd fds[2];
int fd1 = 0; // 标准输入
int fd2 = 1; // 标准输出
fds[0].fd = fd1;
fds[0].events = POLLIN;
fds[1].fd = fd2;
fds[1].events = POLLOUT;
while (true) {
int ret = poll(fds, 2, -1);
if (ret == -1) {
std::cerr << "poll error" << std::endl;
break;
} else if (ret == 0) {
std::cout << "timeout" << std::endl;
continue;
}
if (fds[0].revents & POLLIN) {
// fd1有数据可读
char buf[1024];
ssize_t n = read(fd1, buf, sizeof(buf));
if (n > 0) {
std::cout << "read from fd1: " << std::string(buf, n) << std::endl;
} else if (n == 0) {
std::cout << "fd1 closed" << std::endl;
break;
} else {
std::cerr << "read error" << std::endl;
break;
}
}
if (fds[1].revents & POLLOUT) {
// fd2可写
std::string message = "Hello, world!";
ssize_t n = write(fd2, message.c_str(), message.size());
if (n > 0) {
std::cout << "write to fd2: " << message << std::endl;
} else {
std::cerr << "write error" << std::endl;
break;
}
}
}
return 0;
}
在上述示例中,我们使用poll函数同时监视标准输入和标准输出。当标准输入有数据可读时,我们从标准输入读取数据并输出;当标准输出可写时,我们向标准输出写入数据。
3、epoll函数
epoll是一种高效的I/O多路复用技术,它是Linux特有的,相比于select和poll,epoll在性能和可扩展性上有很大的优势。
它使用epoll_ctl函数来注册文件描述符,并使用epoll_wait函数来等待文件描述符的状态变化。epoll使用红黑树和双链表数据结构来管理文件描述符集合,可以高效地处理大量的文件描述符。epoll提供了三种工作模式:LT(水平触发)、ET(边缘触发)和EPOLLONESHOT(一次性触发),可以根据需要选择不同的工作模式。
三种工作模式:边缘触发(EPOLLET)、水平触发(EPOLLIN)和一次性触发(EPOLLONESHOT)。
-
边缘触发(EPOLLET):
- 当文件描述符上有新的数据可读或可写时,epoll会通知应用程序。
- 边缘触发模式下,应用程序需要一次性读取或写入所有的数据,否则下次epoll通知时不会再次触发。
- 边缘触发模式适用于需要高效处理大量数据的场景,因为它只在数据状态发生变化时通知应用程序。
-
水平触发(EPOLLIN):
- 当文件描述符上有新的数据可读时,epoll会通知应用程序。
- 水平触发模式下,应用程序可以多次读取数据,直到没有数据可读为止。
- 水平触发模式适用于需要按需读取数据的场景,因为它可以在每次epoll通知时读取一部分数据。
-
一次性触发(EPOLLONESHOT):
- 当文件描述符上有新的数据可读或可写时,epoll会通知应用程序,并将该文件描述符设置为一次性触发模式。
- 一次性触发模式下,应用程序需要重新设置文件描述符的触发模式,以便下次再次触发。
- 一次性触发模式适用于需要对每个事件进行独立处理的场景,因为它可以确保每个事件只触发一次。
epoll的核心是epoll_ctl和epoll_wait两个系统调用。
- epoll_ctl函数用于注册文件描述符和事件,其原型如下:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明:
- epfd:epoll实例的文件描述符,通过epoll_create函数创建。
- op:操作类型,可以是EPOLL_CTL_ADD(添加文件描述符)、EPOLL_CTL_MOD(修改文件描述符)或EPOLL_CTL_DEL(删除文件描述符)。
- fd:需要注册的文件描述符。
- event:指向epoll_event结构体的指针,用于指定事件类型和数据。
- epoll_wait函数用于等待文件描述符的事件就绪,其原型如下:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明:
- epfd:epoll实例的文件描述符。
- events:指向epoll_event结构体数组的指针,用于存储就绪的事件。
- maxevents:events数组的大小,表示最多可以存储多少个就绪的事件。
- timeout:超时时间,可以设置为-1表示永远等待,或者设置为一个非负数,表示等待指定时间后超时。
epoll_event结构体用于描述事件类型和数据,其定义如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; // 事件类型
epoll_data_t data; // 事件数据
};
使用epoll的步骤如下:
- 创建epoll实例,通过epoll_create函数获取一个epoll实例的文件描述符。
- 创建并初始化epoll_event结构体,用于存储文件描述符和关注的事件。
- 使用epoll_ctl函数注册文件描述符和事件。
- 调用epoll_wait函数等待文件描述符的事件就绪。
- 检查epoll_wait函数的返回值,判断是否有事件就绪。
- 遍历就绪的事件,根据事件类型进行相应的处理。
下面是一个简单的示例代码,演示了如何使用epoll实现I/O复用:
#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
int main() {
int epollfd = epoll_create(1);
if (epollfd == -1) {
std::cerr << "epoll_create error" << std::endl;
return -1;
}
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = 0; // 标准输入
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, 0, &event) == -1) {
std::cerr << "epoll_ctl error" << std::endl;
return -1;
}
struct epoll_event events[10];
while (true) {
int nfds = epoll_wait(epollfd, events, 10, -1); // 返回事件个数
if (nfds == -1) {
std::cerr << "epoll_wait error" << std::endl;
break;
}
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == 0) {
// 标准输入有数据可读
char buf[1024];
ssize_t n = read(0, buf, sizeof(buf));
if (n > 0) {
std::cout << "read from stdin: " << std::string(buf, n) << std::endl;
} else if (n == 0) {
std::cout << "stdin closed" << std::endl;
break;
} else {
std::cerr << "read error" << std::endl;
break;
}
}
}
}
close(epollfd);
return 0
}
4、select、poll、epoll总结
-
select:select是最古老的I/O复用方法,它使用一个位图来表示文件描述符的状态,每次调用select时,需要将所有待监视的文件描述符复制到内核中,这个过程会带来一定的开销。此外,select的位图大小有限,通常为1024,因此在处理大量文件描述符时,需要使用循环调用select来处理。这种方式会导致性能下降。
-
poll:poll是select的改进版本,它使用一个结构体数组来表示文件描述符的状态,可以处理更多的文件描述符。与select相比,poll的性能有所提升,但在处理大量文件描述符时,仍然需要使用循环调用poll来处理。
-
epoll:epoll是Linux特有的I/O复用方法,它使用一个事件驱动的方式来处理文件描述符。epoll通过将文件描述符添加到内核事件表中,当有事件发生时,内核会通知应用程序。与select和poll相比,epoll在处理大量文件描述符时具有更好的性能,因为它使用了事件驱动的方式,不需要循环调用。
总的来说,epoll在处理大量文件描述符时具有更好的性能,而select和poll在处理少量文件描述符时可能更加简单和方便。因此,在选择I/O复用方法时,需要根据具体的应用场景和需求来进行选择。