高级IO以及IO多路复用(select、poll、epoll网络编程)1:https://developer.aliyun.com/article/1384004
4.3 select网络编程
在TCP服务器中,监听socket,获取新连接的,本质需要先三次握手,即客户端向服务端发送SYN连接请求。建立连接的本质,其实也是IO操作。
一个建立好的连接我们称之为读事件就绪,而listensocket 也只需要关心读事件就绪!如果TCP服务器自己直接调用accept函数,如果此时客户端发送连接的请求还没有就绪,那么该进程就会阻塞式等待连接请求的数据就绪。并且建立连接后,每次读写数据还需要等待数据达到缓冲区的最低水位线才进行数据拷贝,这样势必也会导致服务器的性能低下。
因此,我们可以把listenSock,以及读写相关的sock交付给select()函数进行监管,以下是一个利用select()函数实现的多路复用TCP服务器:
简单封装Sock:
#pragma once #include <iostream> #include <cstring> #include <cstdlib> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> class Sock { public: static const int gbacklog = 3; static int Socket() { int listenSock = socket(PF_INET, SOCK_STREAM, 0); if(listenSock < 0) { exit(1); } //运行服务器快速重启 int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); return listenSock; } static void Bind(int sock, uint16_t port) { struct sockaddr_in local; memset(&local, 0, sizeof local); local.sin_family = PF_INET; local.sin_addr.s_addr = INADDR_ANY; local.sin_port = htons(port); if(bind(sock, (const sockaddr*)&local, sizeof local) < 0) { exit(2); } } static void Listen(int sock) { if(listen(sock, gbacklog) < 0) { exit(3); } } static int Accept(int sock, std::string* clientIp, uint16_t* clientPort) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int sock_fd = accept(sock, (sockaddr*)&peer, &len); if(sock_fd < 0) { exit(4); } if(clientIp) *clientIp = inet_ntoa(peer.sin_addr); if(clientPort) *clientPort = ntohs(peer.sin_port); return sock_fd; } };
服务端代码:
#include <iostream> #include "sock.hpp" #include <unistd.h> #include <sys/select.h> using namespace std; #define DEL -1 // 设置默认的文件描述符 int fdsArray[sizeof(fd_set) * 8] = {0}; // 保存所有合法的fd int gnum = sizeof(fdsArray) / sizeof(fdsArray[0]); // fdsArray中保存最多的fd个数 // 打印fdsArray中的文件描述符 static void showArray(int arr[], int n) { cout << "当前合法的 sock list# "; for (int i = 0; i < n; ++i) { if (arr[i] == DEL) continue; else cout << arr[i] << " "; } cout << endl; } static void HandlerEvent(int listenSock, fd_set &readfds) { // 首先判断fdsArray中的文件描述符是listenSock还是读文件描述符,并且过滤没有设置的文件描述符 for (int i = 0; i < gnum; ++i) { if (fdsArray[i] == DEL) continue; if (i == 0 && fdsArray[i] == listenSock) { // 判断listenSock是否就绪 if (FD_ISSET(listenSock, &readfds)) { cout << "已经有一个新的连接请求就绪了,需要接收连接请求!" << endl; string clientIp; uint16_t clientPort = 0; int sock = Sock::Accept(listenSock, &clientIp, &clientPort); if (sock < 0) { // 建立连接失败 return; } cout << "建立新连接成功:" << clientIp << ": " << clientPort << " | sock: " << sock << endl; // 把新的sock托管给select,设置进fdsArray数组 int i = 0; for (; i < gnum; ++i) { if (fdsArray[i] == DEL) break; } if (i == gnum) { cerr << "服务器已经达到上限,无法同时保持更多的连接!" << endl; close(sock); } else { fdsArray[i] = sock; showArray(fdsArray, gnum); } } } else // 处理普通的IO事件 { if (FD_ISSET(fdsArray[i], &readfds)) { // 此时一个是一个普通合法的IO请求就绪了 char buff[1024]; // 存在bug,因为此时不会阻塞,如果数据量过大会导致读取数据不完整 ssize_t s = recv(fdsArray[i], buff, sizeof(buff), 0); if (s > 0) { buff[s] = 0; cout << "clent[" << fdsArray[i] << "]# " << buff << endl; } else if (s == 0) { cout << "client[" << fdsArray[i] << "] quit, server close!" << endl; close(fdsArray[i]); fdsArray[i] = DEL; showArray(fdsArray, gnum); } else { // 该文件描述符异常 cerr << "client[" << fdsArray[i] << "] error, server close! " << endl; close(fdsArray[i]); fdsArray[i] = DEL; showArray(fdsArray, gnum); } } } } } void usage(std::string process) { cerr << "\nUsage: " << process << " [port]\n" << endl; } int main(int argc, char *argv[]) { if (argc != 2) { usage(argv[0]); exit(-1); } int listenSock = Sock::Socket(); Sock::Bind(listenSock, atoi(argv[1])); Sock::Listen(listenSock); // 初始化fdsArray for (int i = 0; i < gnum; ++i) fdsArray[i] = DEL; fdsArray[0] = listenSock; // 默认第一个是listenSock while (true) { // 每次重新调用select的时候都要重新设定参数 int maxFd = DEL; fd_set readfds; FD_ZERO(&readfds); for (int i = 0; i < gnum; ++i) { if (fdsArray[i] == DEL) continue; FD_SET(fdsArray[i], &readfds); // 将合法的fd设置进readfds // 更新最大fd if (fdsArray[i] > maxFd) maxFd = fdsArray[i]; } timeval timeout = {3, 0}; int n = select(maxFd + 1, &readfds, nullptr, nullptr, &timeout); switch (n) { case 0: cout << " time out ... " << (unsigned long)time(nullptr) << endl; break; case -1: cerr << errno << ": " << strerror(errno) << endl; break; default: // 等待成功 HandlerEvent(listenSock, readfds); break; } } return 0; }
运行结果:
4.4 setsockopt函数(补充)
在上面封装Sock
的代码中使用到了setsockopt
函数,以下是对其的补充介绍:
setsocketopt
函数是一个用于设置套接字选项的系统调用函数。它可以用来设置套接字的各种选项,例如超时,缓冲区大小等。函数原型如下:
#include <sys/socket.h> int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
其中,参数的含义如下:
- sockfd:表示要设置的套接字描述符;
- level:表示要设置的选项所在的协议层。通常为SOL_SOCKET表示设置的是套接字级别的选项,或者是某个具体协议的协议和;
- optname:表示要设置的选项名1,具体的选项名取决于所设置的协议层;
- optval:表示要设置的选项的值,是一个指向选项值的指针;
- optlen:表示要设置的选项的值的长度。
返回值:
调用成功返回 0,失败则返回 -1,并且将错误信息设置进errno
变量。
以下是一些常用的选项名和说明:
- SO_REUSEADDR:允许在同一个端口上启动同一服务器的多个实例,用于服务器程序重启后快速恢复到正常服务状态;
- SO_REUSEPORT:允许多个进程或线程在同一端口上绑定,实现端口共享;
- SO_KEEPALIVE:开启TCP的keepalive机制,检测连接是否仍然存活;
- SO_RCVBUF和SO_SNDBUF:设置套接字接收和发送缓冲区的大小;
- TCP_NODELAY:禁止Nagle算法,即数据发送时不缓存等待其他数据,直接发送。
例如,下面的代码设置了套接字的超时时间为10秒:
#include <sys/socket.h> #include <netinet/in.h> #include <netinet/tcp.h> #include <arpa/inet.h> int sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); struct timeval timeout = {10, 0}; setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
4.5 select的特点
select()
函数具有以下特点:
- 可以同时监视多个文件描述符的状态变化,实现了一次等待多个IO事件的机制,避免了使用多个线程或进程进行IO操作时的复杂性和开销;
- 支持设置超时时间,可以等待一段时间后返回,避免了长时间等待IO事件而导致的程序阻塞;
- 可以同时等待多种IO事件的发生,包括可读、可写和异常事件,适用于各种类型的IO操作;
- 在等待IO事件的过程中,进程会被阻塞,直到有任意一个文件描述符就绪,但是在等待的过程中,其他进程可以继续执行,因此可以提高程序的效率;
- select()函数会修改传入的文件描述符集合,所以需要每次调用select()后重新设置集合。
4.6 select的缺点
虽然select()
函数是一种多路复用IO机制,但是它也存在以下缺点:
- 受限于文件描述符数量:select()函数需要将所有需要监听的文件描述符都加入到一个文件描述符集合中,并将这个集合传递给函数,但在某些系统中,文件描述符集合的大小是由限制的,比如POSIX标准规定,文件描述符集合的大小默认不能超过FD_SETSIZE(一般是1024)。
- 需要不断轮询:在等待IO事件的过程中,select()函数需要轮询所有需要监听的文件描述符,这样会浪费CPU资源,并且导致调用方的延迟。当需要监听的文件描述符量很大时,这个缺点尤为明显;
- 无法处理大量的连接:当需要处理大量的连接时,使用select()函数可能会遇到性能问题,因为它需要在所有的连接直接进行切换,当连接数量非常大时,这个缺点尤为明显;
- 不方便扩展:当需要添加或者删除监听的文件描述符时,需要重新设置文件描述符集合,并重新调用select()函数,这样既会导致调用方的延迟还会浪费CPU资源。同时,在某些系统中,每次调用select()函数时,都需要将文件描述符集合从用户空间复制到内核空间,这也会导致一定的性能损失。
综上所述,select() 函数虽然是一种多路复用IO机制,但是在一些特定的场景下,它可能会存在一些性能问题和限制,因此需要根据具体的应用场景选择合适的多路复用机制。
五、poll
5.1 认识poll函数
poll
函数和select
函数一样,也是用于实现IO多路复用的系统调用函数,可以用于监视一组文件描述符中的任意一个变为可读、可写或者异常状态,并返回就绪的文件描述符个数。在Linux系统中,它可以替代阻塞式IO或者select
函数,提高程序的效率和性能。
poll
函数原型如下:
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
- fds:指向pollfd结构体数组的指针,每个pollfd结构体描述了一个待监视的文件描述符及其关注的事件;
- nfds:fds数组中元素的数量;
- timeout:超时时间,以毫秒为单位。如果设置为 “-1”,poll函数将一直阻塞直到至少有一个文件描述符就绪。
函数返回值:
- 返回值大于0:就绪文件描述符的数量;
- 返回值等于0:发生超时;
- 返回值小于0:发生错误,并且将错误信息存储到errno变量中。
pollfd
结构体:
pollfd
结构体定义如下:
struct pollfd { int fd; // 文件描述符 short events; // 关注的事件 short revents; // 实际发生的事件,与 events 的取值相同或为其子集。 };
events
和revents
的取值:
事件 | 描述 | 是否可作为输入 | 是否可作为输出 |
POLLIN | 数据可读(包括普通和优先数据) | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Linux不支持) | 是 | 是 |
POLLOUT | 数据可写(包括普通和优先数据) | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作。由GUN引入 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起,比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件 | 否 | 是 |
POLLNAVL | 文件描述符没有打开 | 否 | 是 |
如果想要同时设置多个事件,可以用按位或操作进行合并。
5.2 poll网络编程
和上文使用select
函数编写的服务器代码一样,只是将select
函数替换成了poll
函数。与select
函数相比,使用poll
实现服务器的时候不需要再每次调用poll
的时候又重新设置参数,并且监视的文件描述符数量没有上限,由我们自己决定。
#include <iostream> #include "sock.hpp" #include <unistd.h> #include <poll.h> using namespace std; #define DEL -1 // 设置默认的文件描述符 #define NUM 1024 pollfd fdsArray[NUM]; // 保存所有合法的fd // 打印fdsArray中的文件描述符 static void showArray(pollfd arr[], int n) { cout << "当前合法的 sock list# "; for (int i = 0; i < n; ++i) { if (arr[i].fd == DEL) continue; else cout << arr[i].fd << " "; } cout << endl; } static void HandlerEvent(int listenSock) { // 首先判断fdsArray中的文件描述符是listenSock还是读文件描述符,并且过滤没有设置的文件描述符 for (int i = 0; i < NUM; ++i) { if (fdsArray[i].fd == DEL) continue; if (i == 0 && fdsArray[i].fd == listenSock) { // 判断listenSock是否读就绪,与POLLIN进行按位与运算 if (fdsArray[i].revents & POLLIN) { cout << "已经有一个新的连接请求就绪了,需要接收连接请求!" << endl; string clientIp; uint16_t clientPort = 0; int sock = Sock::Accept(listenSock, &clientIp, &clientPort); if (sock < 0) { // 建立连接失败 return; } cout << "建立新连接成功:" << clientIp << ": " << clientPort << " | sock: " << sock << endl; // 把新的sock托管给select,设置进fdsArray数组 int i = 0; for (; i < NUM; ++i) { if (fdsArray[i].fd == DEL) break; } if (i == NUM) { cerr << "服务器已经达到上限,无法同时保持更多的连接!" << endl; close(sock); } else { fdsArray[i].fd = sock; fdsArray[i].events = POLLIN; fdsArray[i].revents = 0; showArray(fdsArray, NUM); } } } else // 处理普通的IO事件 { if (fdsArray[i].revents & POLLIN) { // 此时一个是一个普通合法的IO请求就绪了 char buff[1024]; // 存在bug,因为此时不会阻塞,如果数据量过大会导致读取数据不完整 ssize_t s = recv(fdsArray[i].fd, buff, sizeof(buff), 0); if (s > 0) { buff[s] = 0; cout << "clent[" << fdsArray[i].fd << "]# " << buff << endl; } else if (s == 0) { cout << "client[" << fdsArray[i].fd << "] quit, server close!" << endl; close(fdsArray[i].fd); fdsArray[i].fd = DEL; fdsArray[i].events = 0; fdsArray[i].revents = 0; showArray(fdsArray, NUM); } else { // 该文件描述符异常 cerr << "client[" << fdsArray[i].fd << "] error, server close! " << endl; close(fdsArray[i].fd); fdsArray[i].fd = DEL; fdsArray[i].events = 0; fdsArray[i].revents = 0; showArray(fdsArray, NUM); } } } } } void usage(std::string process) { cerr << "\nUsage: " << process << " [port]\n" << endl; } int main(int argc, char *argv[]) { if (argc != 2) { usage(argv[0]); exit(-1); } int listenSock = Sock::Socket(); Sock::Bind(listenSock, atoi(argv[1])); Sock::Listen(listenSock); // 初始化fdsArray for (int i = 0; i < NUM; ++i) { fdsArray[i].fd = DEL; fdsArray[i].events = 0; fdsArray[i].revents = 0; } fdsArray[0].fd = listenSock; fdsArray[0].events = POLLIN; // listenSock只关心读操作 // int timeout = -1; // 设置为 "-1",`poll`函数将一直阻塞直到至少有一个文件描述符就绪 int timeout = 1000; while (true) { // poll函数不需要每次重新设置参数 int n = poll(fdsArray, NUM, timeout); switch (n) { case 0: cout << " time out ... " << (unsigned long)time(nullptr) << endl; break; case -1: cerr << errno << ": " << strerror(errno) << endl; break; default: // 等待成功 HandlerEvent(listenSock); break; } } return 0; }
运行结果:
5.3 poll函数的优点
- 支持同时监听多个文件描述符,可以在一个 poll() 调用中监听多个 I/O 事件,避免了多次系统调用。
- 能够监听复杂的 I/O 事件,如对于一个 TCP 连接,可以同时监听读和写事件。
- poll() 没有最大文件描述符数的限制,可以监听任意数量的文件描述符。
- 在处理大量文件描述符时,poll() 的效率比 select() 更高,并且代码实现起来更简单。
5.4 poll函数的缺点
- 调用时需要传入一个数组,数组长度取决于需要监听的文件描述符数,可能需要使用动态分配内存,导致一定的额外开销。
- poll() 不支持超时重连,即当一个 I/O 事件发生时,如果不立即处理,下一次 poll() 调用将不会通知你。
- poll() 函数是系统调用,与内核交互需要额外的开销。
- 不是所有的操作系统都支持 poll(),尤其是旧的操作系统。
六、epoll
6.1 认识epoll函数
epoll
是Linux下一种高效的IO多路复用机制,可用于管理大量的文件描述符,能够处理大规模的并发连接,比传统的 select
和 poll
函数更加高效。它几乎具备了前两者的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
epoll 可以分为以下三个函数:
1、epoll_create()
epoll_create()函数用于创建一个新的epoll实例,它的参数size是一个整数,表示需要监听的文件描述符数量。该函数返回一个整数类型的文件描述符,表示新创建的epoll实例。
int epoll_create(int size);
2、epoll_ctl()
epoll_ctl()
函数用于向epoll
实例中添加、修改或删除文件描述符。它的参数epollfd
是epoll
实例的文件描述符,op
是要执行的操作类型,fd
是要添加、修改或删除的文件描述符,event
是要监听的事件类型。
int epoll_ctl(int epollfd, int op, int fd, struct epoll_event *event);
其中,op
的值可以是以下三种之一:
EPOLL_CTL_ADD
:添加文件描述符到epoll
实例中。EPOLL_CTL_MOD
:修改已经添加到epoll
实例中的文件描述符的监听事件。EPOLL_CTL_DEL
:从epoll
实例中删除文件描述符。
struct 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; /* 用户数据 */ }
其中,events的值可以是以下几种:
- EPOLLIN: 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭)。
- EPOLLOUT:表示对应的文件描述符可以写。
- EPOLLPRI :表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来)。
- EPOLLERR:表示对应的文件描述符发生错误。
- EPOLLHUP:表示对应的文件描述符被挂断。
- EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
3、epoll_wait()
epoll_wait()
函数用于等待文件描述符上的事件发生。它的参数 epollfd
是 epoll
实例的文件描述符,events
是一个数组,用于存储发生事件的文件描述符。
int epoll_wait(int epollfd, struct epoll_event *events, int maxevents, int timeout);
其中,maxevents
表示events
数组的长度,timeout
表示等待事件的超时时间(以毫秒为单位)。如果timeout
的值为 -1,则表示一直等待直到有文件描述符就绪。
epoll_wait()
函数返回一个整数类型的值,表示发生事件的文件描述符数量。
6.2 epoll工作原理
epoll
之所以会比select
和poll
有更高的效率和可扩展性,其原因在于它采用了以下三个重要的优化技术:
1、采用红黑树作为事件的存储的数据结构
当某一进程调用epoll_create
时,Linux内核会创建一个eventpoll
结构体,这个结构体中有两个成员与epoll的使用方式密切相关。
struct eventpoll{ .... /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/ struct rb_root rbr; /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/ struct list_head rdlist; .... };
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
2、采用事件回调机制
避免了在内核态和用户态之间的频繁切换,当某个文件描述符上有事件发生时,内核直接回调用户注册的回调函数,通知应用程序。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。在epoll中,对于每一个事件,都会建立一个epitem结构体。
struct epitem { struct rb_node rbn;//红黑树节点 struct list_head rdllink;//双向链表节点 struct epoll_filefd ffd; //事件句柄信息 struct eventpoll *ep; //指向其所属的eventpoll对象 struct epoll_event event; //期待发生的事件类型 }
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。这个操作的时间复杂度是O(1)。
3、采用边缘触发模式
只有在文件描述符状态发生变化时才通知应用程序,而不是像水平触发模式一样,只要文件描述符上有数据就通知应用程序。这样可以避免应用程序在处理事件时漏掉某些数据,减少CPU的无效操作。关于边缘触发模式和水平触发模式见下文。
epoll的工作流程可概括如下:
- 应用程序调用epoll_create创建一个epoll实例,获得一个文件描述符。
- 应用程序调用epoll_ctl向epoll实例中添加、修改或删除的文件描述符及其对应的事件。
- 内核根据添加的文件描述符,建立一个红黑树,并把文件描述符和其对应的事件节点加入到红黑树中。
- 应用程序调用epoll_wait阻塞等待事件发生,当有文件描述符上的事件发生时,epoll_wait返回事件列表。
- 应用程序处理事件列表,处理完后回到第4步,继续等待事件的发生。
6.3 epoll工作模式
epoll
的工作模式有的两种,LT(Level Triggered,水平触发模式)模式和 ET(Edge Triggered,边缘触发模式)模式,它们用于描述内核何时通知应用程序有关文件描述符的事件。
LT模式
在 LT 模式下,当文件描述符就绪时,epoll_wait 函数会返回,并将该文件描述符加入到就绪队列中,通知应用程序有数据可读或可写,应用程序需要不断读取或写入数据直到文件描述符中没有数据可读或可写。如果应用程序没有对就绪的文件描述符进行操作,则 epoll_wait 函数会一直阻塞等待。
ET模式
在 ET 模式下,epoll_wait 函数仅在文件描述符状态发生改变时才会返回,并将该文件描述符加入到就绪队列中,通知应用程序有新的数据可读或可写。应用程序需要立即对就绪的文件描述符进行操作,如果应用程序没有对就绪的文件描述符进行操作,则 epoll_wait 函数不会再次返回。在 ET 模式下,应用程序需要使用非阻塞 I/O 操作,以避免因为某个文件描述符的阻塞 I/O 操作而导致阻塞其他文件描述符。
两种模式的对比:
- LT 模式更加简单,易于理解和实现,因为它类似于轮询。相比之下,ET 模式更为复杂,需要程序员具有更高的编程技能和经验。
- LT模式适用于需要长时间读取或写入的文件描述符,因为应用程序可以反复查询文件描述符是否已经准备好。相比之下,ET模式更适用于需要实时响应的场景,因为它只会在状态变化时通知应用程序。
- 在 LT 模式下,内核通知应用程序有关文件描述符的事件时,应用程序需要循环调用I/O函数,而在 ET 模式下,应用程序只需要在状态变化时调用一次I/O函数即可。
- 由于 ET 模式的事件处理方式更加实时,因此它在高并发、高性能的场景中表现更好。相比之下,LT 模式的轮询方式可能会导致效率降低。
ET模式为什么只支持非阻塞读写?
ET 模式只支持非阻塞 I/O 操作的原因是因为 ET 模式的工作方式是只在文件描述符上发生状态变化时通知应用程序。如果应用程序在 ET 模式下使用阻塞 I/O 操作,例如读取或写入数据时阻塞在系统调用中,那么即使文件描述符的状态已经发生了变化,应用程序也无法感知到这个变化,从而无法正确处理事件。这会导致应用程序的错误行为,甚至可能导致死锁等问题。
因此,在 ET 模式下,应用程序必须使用非阻塞 I/O 操作,以便在 epoll_wait 函数返回时,及时对就绪的文件描述符进行操作。在非阻塞 I/O 操作中,如果没有数据可读或可写,读取或写入函数会立即返回,并返回一个错误码(例如 EAGAIN 或 EWOULDBLOCK),应用程序需要根据错误码来确定是否继续等待数据可读或可写,或者是否进行其他操作。这种方式可以避免应用程序因为某个文件描述符的阻塞 I/O 操作而导致阻塞其他文件描述符,从而提高系统的并发处理能力。
需要注意的是,在使用 ET 模式时,应用程序需要处理 EAGAIN 或 EWOULDBLOCK 错误码,这种错误码是非阻塞 I/O 操作的正常情况。如果应用程序没有正确处理这些错误码,可能会导致应用程序的异常行为。
6.4 epoll网络编程
这里实现的服务器功能与前面使用select
函数和poll
实现的服务器功能一样,只是对服务器代码进行了简单的封装:
#pragma once #include <iostream> #include <string> #include <functional> #include <cstdlib> #include "sock.hpp" #include <unistd.h> #include "Log.hpp" #include <sys/epoll.h> class EpollServer { using func_t = std::function<int(int)>; // 回调函数 static const int gsize = 128; // 最大文件描述符数量 static const int num = 256; // event数组长度 public: EpollServer(uint16_t port, func_t func) : _port(port), _func(func), _listensock(-1), _epfd(-1) { Init(); } ~EpollServer() { if (_listensock != -1) close(_listensock); if (_epfd != -1) close(_epfd); } void Init() { _listensock = Sock::Socket(); Sock::Bind(_listensock, _port); Sock::Listen(_listensock); // 创建epoll实例 _epfd = epoll_create(gsize); if (_epfd < 0) { logMsg(FATAL, "%d:%s", errno, strerror(errno)); exit(3); } logMsg(DEBUG, "创建监听套接字成功: %d", _listensock); logMsg(DEBUG, "创建epoll实例成功: %d", _epfd); } void HandlerEvent(epoll_event revs[], int n) { for (int i = 0; i < n; ++i) { int sock = revs[i].data.fd; uint32_t revent = revs[i].events; if (revent & EPOLLIN) // 读事件就绪 { if (sock == _listensock) { // listensock std::string clientip; uint16_t clientport = 0; // 监听socket就绪,获取新连接 int sockfd = Sock::Accept(_listensock, &clientip, &clientport); if (sockfd < 0) { logMsg(FATAL, "%d:%s", errno, strerror(errno)); continue; } // 托管给epoll epoll_event ev; ev.data.fd = sockfd; ev.events = EPOLLIN; int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev); assert(n == 0); (void)n; } else { // 普通IO int n = _func(sock); if (n < 0 || n == 0) { // 先移除,再关闭 int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr); assert(n == 0); (void)n; logMsg(DEBUG, "client quit: %d", sock); close(sock); } } } else { //... } } } void Run() { // 1. 首先添加listensock到epoll epoll_event ev; ev.data.fd = _listensock; ev.events = EPOLLIN; int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock, &ev); assert(n == 0); (void)n; epoll_event revs[num]; int timeout = 10000; while (true) { int n = epoll_wait(_epfd, revs, num, timeout); switch (n) { case 0: std::cout << " time out ... " << (unsigned long)time(nullptr) << std::endl; break; case -1: std::cerr << errno << ": " << strerror(errno) << std::endl; break; default: // 等待成功 HandlerEvent(revs, n); break; } } } private: int _listensock; int _epfd; int _port; func_t _func; };
运行结果:
6.5 epoll的优点
相比于select和poll,epoll在性能和功能上有许多优点:
- 高性能:epoll使用红黑树作为事件存储的数据结构,可以快速地添加、删除和查找事件。而select和poll使用线性列表存储事件,每次查找事件都需要遍历整个列表,效率低下。
- 高并发:epoll使用事件通知机制,只有在事件发生时才会通知应用程序,可以避免轮询的开销,减少系统调用次数,同时支持多个文件描述符的并发操作。
- 可扩展性:epoll支持水平触发和边缘触发两种模式,可以根据不同场景灵活选择。而select和poll只支持水平触发模式。
- 内存占用低:epoll通过事件通知机制避免了轮询的开销,同时只需要存储活动的文件描述符,相比之下,select和poll需要存储全部的文件描述符。
- 更好的可读性:epoll使用事件驱动的编程模型,可以更清晰地描述应用程序的行为,代码更加可读性和易于维护。
总的来说,epoll在性能和可扩展性方面具有明显优势,适用于高并发、高性能的网络编程场景。而select和poll在简单的网络编程场景下也可以使用,但在处理大量的并发连接时,效率会明显下降。