C++网络编程之IO多路复用(三)
概述
在前两篇文章中,我们介绍了如何使用select和poll进行IO多路复用。select通常有一个固定的文件描述符数量上限(通常是1024),poll虽然没有严格的文件描述符数量限制,但在实际使用中也可能受到系统资源的限制。相比之下,epoll支持非常大的文件描述符数量(理论上可以达到系统文件描述符的最大值),因此更适合高并发场景。在本篇中,我们将重点介绍epoll。
epoll
epoll是Linux特有的IO多路复用接口,专为大规模并发场景设计。epoll相比于select和poll有更高的性能,因为它可以处理大量的文件描述符,并且在获取事件时不需要遍历整个文件描述符集合。epoll的内部实现中使用了红黑树和就绪链表两种数据结构,以便高效地管理和查询事件。
epoll的核心思想是:创建一个事件表,这个事件表会将所有需要监控的文件描述符和它们对应的事件关联起来。当某个文件描述符上的事件发生时,内核会将该事件添加到就绪列表中。用户程序只需要从就绪列表中读取事件即可,而不需要像select或poll那样每次都需要遍历整个文件描述符集来查找哪些事件已经就绪。
与epoll相关的系统API主要有三个:epoll_create、epoll_ctl、epoll_wait,下面分别进行介绍。
1、epoll_create函数用于创建一个epoll实例,并返回一个新的文件描述符。在较新的Linux版本中,推荐使用epoll_create1函数。epoll_create1是epoll_create的改进版,允许指定标志位。
int epoll_create(int size);
int epoll_create1(int flags);
size:这个参数在现代内核中已经不再使用,但在调用时仍需要传递一个大于零的值。它是历史遗留下来的,用于向后兼容。
flags:可以是0或EPOLL_CLOEXEC。EPOLL_CLOEXEC标志表示执行exec函数族时,关闭此文件描述符。
返回值:成功时返回新的文件描述符(非负整数),失败时返回-1,并设置errno。
2、epoll_ctl函数用于向epoll实例添加、修改或删除感兴趣的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd:由epoll_create或epoll_create1创建的epoll文件描述符。
op:操作类型,可以是以下之一:
(1)EPOLL_CTL_ADD:将文件描述符fd添加到epoll实例中。
(2)EPOLL_CTL_MOD:修改文件描述符fd在epoll实例中的事件。
(3)EPOLL_CTL_DEL:从epoll实例中移除文件描述符fd。
fd:要操作的文件描述符。
event:指向epoll_event结构体的指针,定义了监听的事件类型和关联的数据。
返回值:成功时返回0,失败时返回-1,并设置errno。
3、epoll_wait函数用于等待并获取就绪的IO事件。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd:由epoll_create或epoll_create1创建的epoll文件描述符。
events:指向epoll_event数组的指针,用来存放发生的事件。
maxevents:events数组的最大长度。
timeout:等待时间,单位为毫秒。-1表示无限等待,0表示立即返回,不阻塞。
返回值:成功时返回发生的事件数量,失败时返回-1,并设置errno。
边缘触发模式
epoll支持边缘触发(Edge-Triggered,即ET)模式,select和poll不支持,它们仅支持水平触发模式(Level-Triggered,即LT)。边缘触发模式是一种高效的IO事件通知机制,它与水平触发模式有所不同。在边缘触发模式下,epoll只会在文件描述符的状态发生变化时通知应用程序一次。这意味着,如果应用程序没有处理完所有数据,内核将不会再次通知该事件,直到新的数据到达或状态再次发生变化。
epoll的边缘触发模式具有以下几个特点。
1、仅在状态变化时通知。当文件描述符从不可读变为可读,或者从不可写变为可写时,epoll会通知应用程序。如果应用程序没有完全读取或写入所有数据,那么剩余的数据将不会再次触发事件,直到有新的数据到来或状态再次变化。
2、非阻塞IO。在边缘触发模式下,必须使用非阻塞IO。这是因为当read或write调用返回EAGAIN或EWOULDBLOCK时,表示当前没有更多数据可以读取或写入,但并不意味着文件描述符已经不可读或不可写。应用程序需要继续尝试读取或写入,直到所有数据处理完毕。
3、更高的性能。由于边缘触发模式减少了内核和用户空间之间的上下文切换次数,因此通常比水平触发模式更高效,特别是在高并发场景中。
4、更复杂的编程模型。使用边缘触发模式需要更加小心地处理IO操作,以确保不会遗漏任何数据。
实战代码
在下面的示例代码中,我们使用epoll函数实现了TCP服务器的IO多路复用。
首先,我们创建一个监听套接字listen_sock,将其绑定到指定的端口8888,并开始监听连接请求。
然后,我们使用epoll_create1创建一个epoll实例,并检查是否成功。接着,将监听套接字listen_sock添加到 epoll实例中,并设置为边缘触发模式(EPOLLET)和可读事件(EPOLLIN)。
在主循环中,我们使用epoll_wait函数等待IO事件的发生。epoll_wait会阻塞,直到有事件发生或超时,返回值nfds是就绪事件的数量。遍历events数组中的每一个就绪事件,检查每个事件的文件描述符fd是否为监听套接字listen_sock。
如果事件的文件描述符是监听套接字listen_sock,则表示有新的连接请求。调用accept接受新连接,并创建一个新的客户端套接字conn_sock。将新连接的套接字添加到epoll实例中,并设置为边缘触发模式(EPOLLET)和可读事件(EPOLLIN)。
如果事件的文件描述符不是监听套接字listen_sock,则表示该文件描述符上有数据可读。调用read函数读取数据,并回显给客户端。如果读取到0字节,表示客户端已断开连接。如果读取失败,也认为客户端断开连接。在这两种情况下,关闭套接字,并从epoll实例中移除该套接字。
最后,当程序退出时,关闭所有打开的套接字和epoll实例。
#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>using namespace std;#define MAX_EVENTS 10int main()
{int listen_sock = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock == -1){cout << "Create socket failed" << endl;return 1;}struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(8888);server_addr.sin_addr.s_addr = INADDR_ANY;if (bind(listen_sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1){cout << "Bind failed" << endl;close(listen_sock);return 1;}if (listen(listen_sock, 5) == -1){cout << "Listen failed" << endl;close(listen_sock);return 1;}// 创建epoll实例int epoll_fd = epoll_create1(0);if (epoll_fd == -1){cout << "Create epoll failed" << endl;close(listen_sock);return 1;}// 添加监听Socketstruct epoll_event ev, events[MAX_EVENTS];ev.events = EPOLLIN | EPOLLET;ev.data.fd = listen_sock;epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_sock, &ev);while (true){// 等待并获取就绪的IO事件int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);if (nfds == -1){cout << "Wait epoll failed" << endl;break;}for (int i = 0; i < nfds; ++i){if (events[i].data.fd == listen_sock){// 新连接struct sockaddr_in client_addr;socklen_t client_len = sizeof(client_addr);int conn_sock = accept(listen_sock, (struct sockaddr*)&client_addr, &client_len);if (conn_sock != -1){ev.events = EPOLLIN | EPOLLET;ev.data.fd = conn_sock;epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_sock, &ev);}else{cout << "New connection" << endl;}}else{// 处理数据char buf[1024];ssize_t nread = read(events[i].data.fd, buf, sizeof(buf));if (nread > 0){// 接收并回显数据cout << "Received data: " << string(buf, nread) << endl;write(events[i].data.fd, buf, nread);}else if (nread == 0){// 客户端关闭连接epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, nullptr);close(events[i].data.fd);}else{// 发生错误cout << "Read error" << endl;epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, nullptr);close(events[i].data.fd);}}}}close(listen_sock);close(epoll_fd);return 0;
}