本篇介绍Linux三种IO复用接口,主要对比其特性,非接口使用和原理解析。
1. 对比
接口 | 优点和使用场景 | 缺点 | 底层实现/原因 |
---|---|---|---|
select | 1. 可移植性比epoll好 2.适用于并发场景较少或初学者使用 | 1. fd限制。监测的文件描述符有上限(1024),可以用FD_SETSIZE改变,但会增加开销 2. 需要频繁在用户态和内核态之间复制fd集合,fd多时效率低 3.每次调用都要遍历整个文件描述符合集 | 底层用fd数组,每次调用都需要将数组从用户态拷贝到内核态 |
poll | 1. 无fd数量限制 | 1. 内核检查时仍需遍历,并发高时效率低 2. 频繁增删fd时,效率可能不如select | 原理与select类似,用链表代替数组 |
epoll | 1. 无fd数量限制 2. 仅在fd状态变化时通知应用,减少不必要的检查(O(1)) 3. epoll_ctl注册fd后,不需要在每次调用时复制fd集合,内存拷贝开销小 4. 支持水平触发(LT)和边缘触发(ET) 适用于:需要同时处理大量并发连接的场景,如负载均衡器、代理服务器等 | 缺点说的人很少,缺陷有一篇文章参考Epoll的缺陷 | 底层使用红黑树+双向链表 |
2. select/poll 运行流程
这篇文章IO多路复用里的动图做的很好(现在它是我的了,hh)
3. Epoll优越性的原理(参考xiaolincoding)
select/poll 的问题主要是用线性结构去保存fd合集,因此内核检查时都需要遍历,复杂度为O(n),n较大时,效率下降明显。
epoll通过下面两点解决这个问题
- 使用红黑树保存进程所有待检测的fd。红黑树增删查的复杂度都是O(logn),所以每次操作时只需要传入待检测的socket,减少数据拷贝。
- 使用事件驱动机制,内核里使用一个链表来记录就绪事件。当事件发生时,通过回调将其加入该链表,当用户调用 epoll_wait() 函数时,只会返回有事件发生的fd的个数,无需轮询整个fd合集,极大提高效率。
4. 边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)
水平触发LT- 只要fd就绪(可读或可写),如果可读,epoll_wait会持续醒来,直到所有数据被读完;(如果socket可写,应用程序写完后应移除EPOLLOUT事件,否则epoll_wait仍会触发)
边缘触发ET- 当发生可读事件时,epoll_wait只苏醒一次,即使应用程序没用从内核读出或者读完数据,后续也不会苏醒,所以要尽量一次读完缓冲区数据。
select, poll 只有LT模式,epoll默认也是水平触发LT,可以切换成ET。
注意点:
- 使用LT模式,没必要一次读完,后续还会收到通知
- 使用ET模式,应尽量配合非阻塞IO。因为ET模式下,事件发生后应用会尽量读写数据,如果fd是阻塞的,当读完时,进程会阻塞住。如果fd是非阻塞的,读写完后read和write返回错误EAGIN/EWOULDBLOCK.
5. 示例:用IO多路复用实现监测多个异步连接
#include <vector>
#include <sys/poll.h>
// ...
std::vector<int> clientfds; // 存储多个客户端套接字
// 循环创建多个非阻塞套接字并尝试连接
for (int i = 0; i < num_connections; ++i) {
int clientfd = socket(AF_INET, SOCK_STREAM, 0);
if (clientfd == -1) {
// 错误处理...
continue;
}
// 设置套接字为非阻塞模式
// ...
// 尝试连接服务器
struct sockaddr_in serveraddr;
// 初始化 serveraddr
// ...
if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1) {
if (errno == EINPROGRESS) {
// 连接正在进行,添加到监测列表
clientfds.push_back(clientfd);
} else {
// 真正的错误,关闭套接字
close(clientfd);
// 错误处理...
}
}
}
// 为所有非阻塞连接设置pollfd数组
std::vector<pollfd> pollfds;
for (int fd : clientfds) {
pollfd event;
event.fd = fd;
event.events = POLLOUT;
pollfds.push_back(event);
}
// 设置超时时间
int timeout = 3000;
// 使用poll监测所有连接
int ret = poll(pollfds.data(), pollfds.size(), timeout);
if (ret > 0) {
for (size_t i = 0; i < pollfds.size(); ++i) {
if (pollfds[i].revents & POLLOUT) {
// 检查连接状态
int err;
socklen_t len = sizeof(err);
if (getsockopt(pollfds[i].fd, SOL_SOCKET, SO_ERROR, &err, &len) == 0 && !err) {
std::cout << "Connection " << pollfds[i].fd << " established successfully." << std::endl;
} else {
std::cout << "Connection " << pollfds[i].fd << " failed." << std::endl;
}
close(pollfds[i].fd); // 关闭套接字
} else if (pollfds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
// 发生错误,处理错误
std::cout << "Connection " << pollfds[i].fd << " error occurred." << std::endl;
close(pollfds[i].fd); // 关闭套接字
}
}
} else if (ret == 0) {
// 超时
std::cout << "Connection attempt timed out." << std::endl;
} else {
// poll出错
std::cout << "poll failed." << std::endl;
}
// ...
遗留问题
所有人都说poll和select几乎一样。poll的fd合集是链表,它存在于用户空间,调用时究竟是复制了整个fd合集,还是只传递了指针给内核,内核检查的时候不是直接遍历的用户空间的fd合集吗?
参考文档
【1】https://blog.csdn.net/qq_34827674/article/details/115619261
【2】https://blog.csdn.net/v123411739/article/details/124699602?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522171405243016800213061387%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=171405243016800213061387&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_positive~default-1-124699602-null-null.142v100pc_search_result_base4&utm_term=IO%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8&spm=1018.2226.3001.4187