使用epoll()进行socket编程处理多客户连接的TCP服务器实例
在网络编程中,当需要使用单线程处理多客户端的连接时,常使用select()或者poll()来处理,但是当并发数量非常大时,select()和poll()的性能并不好,epoll()的性能大大好于select()和poll(),在编写大并发的服务器软件时,epoll()应该是首选的方案,本文介绍epoll()在网络编程中的使用方法,本文提供了一个具体的实例,并附有完整的源代码,本文实例在 ubuntu 20.04 下编译测试完成,gcc 版本号 9.4.0。
1 基本概念
- 在『网络编程专栏』中,有两篇文章都涉及到了使用
select()
处理多个socket
连接: - 在『网络编程专栏』中,有一篇文章都涉及到了使用
poll()
处理多个socket
连接: poll()
和select()
的编程方法非常相似,但 epoll 有较大区别;- epoll 完成与
poll()
相似的工作:监视多个文件描述符看它们是否可以进行 I/O 操作; - epoll 的核心概念就是 epoll 实例,从用户空间的角度看,一个 epll 实例就是内核中的一个数据结构,可以被看成是下列两个列表的容器;
- Interest List:(有时也被称为 epoll set)进程向 epoll 登记的需要被监视的文件描述符集;
- Ready List:可以无阻塞地进行 I/O 操作的文件描述符集,Ready List 是 Interest List 的一个子集,内核实时地把可以进行 I/O 操作的文件描述符从 Interest List 填充到 Ready List;
- 使用 epoll 的过程就是将要监视的文件描述符向 epoll 登记进入 Interest List,然后从 Ready List 中处理那些可以进行 I/O 操作的文件描述符;
- 使用 epoll 有三个基本的函数,后面会详细介绍这三个函数的使用方法:
epoll_create1()
- 用于建立一个 epoll 实例;epoll_ctl()
- 用于向 epoll 实例的 Interest List 中添加要监视的文件描述符,或者修改/删除 Interest List 中的文件描述符;epoll_wait()
- 用于监视已经登记的文件描述符集,当有一个或多个被监视的文件描述符可以进行 I/O 操作时返回;
- 在调用
epoll_wait()
后,有两种触发方式可以使epoll_wait()
返回,边沿触发(Edge-Triggered)和电平触发(Level-Triggered),这两个词是从电子电路中引申过来的,熟悉电子电路的或者做嵌入式编程的读者应该对此有些了解; - 可以用一个例子来说明这两种触发方式的不同,假设在下列条件下,看看边沿触发和电平触发有什么不同:
- 将一个管道(pipe)读出端的文件描述符 rfd 登记到 epoll 实例上进行监视;
- 在管道的写入端写入 2kb 的数据;
- 调用
epoll_wait()
会返回文件描述符 rfd,表示在 rfd 上有数据可以读取; - 从 rfd 中读取 1kb 的数据;
- 再次调用
epoll_wait()
;
- 当使用电平触发(Level-Triggered)方式时,只要 rfd 中仍然还有数据没有读出,
epoll_wait()
就会被触发返回,由于写入了 2kb 数据但只读出了 1kb,所以在第 5 步时,epoll_wait()
会返回 rfd 有数据可读; - 当使用边沿触发(Edge-Triggered)方式时,只有当 rfd 从没有数据可读变为有数据可读时才会触发
epoll_wait()
返回,虽然读缓冲区中仍有 1kb 的数据没有被读出,但在第 5 步时epoll_wait()
是不会返回的; - 当使用电平触发方式时,epoll 实际上只是一个运行的比较快的
poll()
,可以在任何使用poll()
的地方使用电平触发方式的 epoll,epoll 真正的意义在于其边缘触发方式; - 由于边沿触发方式的特点,
epoll_wait()
被触发后必须将读缓冲区的数据全部读出,否则可能会有数据丢失,所以当使用边沿触发方式时,通常需要将文件描述符设置成非阻塞方式,然后循环读取,直至出现 EAGAIN 错误代码为止,如下...... int done = 0; // not done int nbytes = 0; // how many bytes to read do { nbytes = recv(fd, buffer, sizeof(buffer), 0); if (nbytes > 0) { buffer[nbytes] = '\0'; ... continue; } else if (rc == 0) { // the socket discinnected break; } else if (errno == EINTR) { // if errno==EINTR, it means socket is not closed, just because some network errors happened continue; } else if (errno == EAGAIN) { done = 1; break; } else { perror("recv() failed"); break; } } while (1); ......
- 本文后面的实例中将演示边沿触发方式的具体编程方法;
2 epoll 的基本使用方法
- 前面提到过了使用 epoll 的三个基本函数,本节将着重介绍这些函数的使用方法及相关的数据结构;
epoll_create1() - 创建一个 epoll 实例
#include <sys/epoll.h> int epoll_create(int size); int epoll_create1(int flags);
- 这两个函数都是创建一个 epoll 实例,在 epoll_create() 中的参数 size 表示在这个 epoll 实例上所管理的最大描述符的数量,但从 Linux 2.6.8 以后,这个参数已经无效,但参数 size 必须是一个大于 0 的整数;
- 实际上通常都是使用 epoll_create1() 来建立一个 epoll 实例,参数 flags 通常设为 0;
- 调用成功,函数返回 epoll 实例的文件描述符,调用失败时返回 -1,errno 中是错误代码;
epoll_ctl() - epoll 文件描述符的控制接口
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 这个函数用于向 epoll 实例的 Interest List 中添加、修改和删除文件描述符,具体操作取决于参数 op;
- epfd 为 epoll_create1() 返回的 epoll 实例的文件描述符
- 当 op 为 EPOLL_CTL_ADD 时,表示要添加一个文件描述符 fd 进入 epoll 实例的 Interest List 中;
- 当 op 为 EPOLL_CTL_MOD 时,表示要修改一个已经在 Interest List 中的文件描述符 fd;
- 当 op 为 EPOLL_CTL_DEL 时,表示要将一个已经在 Interest List 中的文件描述符 fd 从 Intersst List 中删除;
- 参数 fd 为想要操作的文件描述符;
- 参数 event 在添加和修改时是有意义的,在删除时可以设置为 NULL;
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 events */ epoll_data_t data; /* User data variable */ };
- struct epoll_event 中的 events 是一个位掩码,由以下零个或多个可用事件类型组合而成(这里仅列出常用的几个):
- EPOLLIN:相应的文件描述符上有数据可读;
- EPOLLOUT:相应的文件描述符上可以进行写操作;
- EPOLLET:使用边沿触发方式;
下面代码将一个文件描述符 fd 加入到 epoll 实例 epfd 的 Interest List 中,使用边沿触发方式,当可以进行读操作时触发 epoll_wait() 返回:
...... int epfd = epoll_create1(0); ... struct epoll_event event; memset(&event, 0 , sizeof(struct epoll_event)); // Set up the structure epoll_event event.data.fd = fd; event.events = EPOLLIN | EPOLLET; // Add a new descriptor to the interest list if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) == -1) { perror("EPOLL_CTL_ADD failed"); } ......
- 该函数调用成功时返回 0,失败时返回 -1,errno 中为错误代码;
epoll_wait() - 等待 epoll 文件描述符上的 I/O 事件
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 调用
epoll_wait()
后,当 epoll 实例中被监视的文件描述符有事件产生或者超时时间 timeout 到,该函数将返回; - 参数 epfd 为使用
epoll_create1()
返回的 epoll 实例的文件描述符; - 参数 events 中将返回所有有事件产生的 fd,
events->data.fd
为产生事件的文件描述符,events->events
为实际产生的事件(位掩码); - 参数 maxevent 为返回事件的最大值,必须大于 0,
epoll_wait()
在参数 events 中返回的事件不会大于 maxevents; - 参数 timeout 为超时时间,单位为毫秒,
epoll_wait()
等待 timeout 时长后不论是否有事件产生都会返回,将 timeout 设为 -1,epoll_wait()
将一直等待直至有事件产生,将 timeout 设为 0,epoll_sait()
将立即返回,不论是否有事件产生; epoll_wait()
调用成功时,返回一个正整数,表示在参数 events 中有多少个事件;epoll_wait()
因超时返回时,将返回 0;epoll_wait()
调用失败将返回 -1,errno 中为错误代码;epoll_wait()
可以被信号打断,此时,错误代码为 EINTR,通常情况下如果 errno 为 EINTR 时可以重新调用epoll_wait()
;
- 调用
3 epoll 进行 socket 编程的基本步骤
- 尽管 epoll 监视的事件是文件描述符的事件,但通常不会用在普通文件(指文件系统下的文件),一个普通文件将永远处于可读或者可写的状态,epoll 更多地是用在 socket 编程上;
epoll 进行 socket 编程的基本步骤:
- 使用
socket()
建立需要侦听的 socket; - 使用
setsockopt()
设置 socket 为可重复使用; - 使用
ioctl()
设置 socket 为非阻塞; - 使用
bind()
绑定服务器的地址和端口; - 使用
listen()
开始侦听端口; - 以上步骤和使用
select()/poll()
编程时是一致的; - 使用
epoll_create1()
构建一个 epoll 实例 epfd; - 构建一个结构
struct epoll_event ev
,将服务器侦听 socket 加入到加入到结构中,并设置 EPOLLIN 事件及边沿触发方式(EPOLLET); - 使用
epoll_ctl()
的EPOLL_CTL_ADD
方法将侦听 socket 加入到 epoll 实例 epfd 的 Interest List 中; 启动
epoll_wait()
;- 返回 0 表示调用超时,可以重新启动
epoll_wait()
; - 返回
<0
表示epoll_wait()
出错,errno 中为错误代码; 返回
>0
表示有需要处理的 socket,进行处理;要处理的 socket 通常又分为两种,一种是正在侦听的 socket,如果有
EPOLLIN
事件表示有客户端发出了连接请求,使用accept()
接受连接将产生一个新的 socket,这个新的 socket 要按照步骤 7、8 的方法加入到 epoll 实例的 Interest List 中,以便在 epoll 中可以被监视,因为我们使用的边沿触发方式,所以还要记得使用ioctl()
将这个新的 socket 设置成非阻塞;另一类 socket 就是已经和服务器建立连接的一个或多个客户端的 socket,这类 socket 有 EPOLLIN 事件产生可能是有数据发送回来,也可能是因为连接中断,在调用
recv()
从 socket 中接收数据时,如果返回值>0
表示确实有数据发送回来,要做出相应处理,如果返回值为 0 则表示这个连接已经中断,此时只需将该 socket 关闭即可,理论上说,当一个 socket 被关闭后,epoll 会自动地将该 socket 从 Interest List 中删除,所以通常我们不需要显式地使用 epoll_ctl() 的 EPOLL_CTL_DEL 方法从 epoll 实例的 Interest List 中删除这个 socket;
- 返回 0 表示调用超时,可以重新启动
回到步骤 9,再次启动
epoll_wait()
;
- 使用
4 实例:一个使用 epoll() 的 TCP 服务器
- 源程序:epoll-server.c(点击文件名下载源程序,建议使用UTF-8字符集)演示了使用 epoll 完成的一个 TCP 服务器;
- 编译:
gcc -Wall -g epoll-server.c -o epoll-server
- 运行:
./epoll-server
- 该程序是一个多进程程序,程序会建立一个服务端进程和若干个(默认为 3 个,由宏 MAX_CONNECTIONS 控制)客户端进程;
- 服务端进程侦听在端口 8888 上,等待客户端进程的连接;
- 启动
epoll_wait()
监视 socket; - 服务端在接受客户端请求后,将新连接的 socket 加入到 epoll 实例中,并向客户端发送一条欢迎信息;
- 客户端在连接建立以后向服务端发送一条信息,服务端在收到客户端信息后会将该信息原封不动地发送回客户端;
- 客户端判断收到的信息与自己发出的信息一样后,主动关闭连接,然后退出进程;
- 服务端发现连接中断后,关闭该 socket,并使用 epoll_ctl() 的 EPOLL_CTL_DEL 方法从 epoll 中删除该失效 socket(这一步可以没有),然后继续启动
epoll_wait()
监视 socket; - 服务进程中拦截了 SIGINT 信号,这个信号可以使用
ctrl + c
产生,服务进程在收到这个信号后将退出进程; - 主进程监视客户端进程的退出,当所有客户端进程都已退出后,向服务端进程发送 SIGINT 信号,使服务端进程退出,整个程序运行结束;
- 该程序的客户端进程的程序与文章《使用poll()代替select()处理多客户连接的TCP服务器实例》中的客户端程序完全一样;
运行截图:
欢迎订阅 『网络编程专栏』
欢迎访问我的博客:https://whowin.cn
email: hengch@163.com
Subscribe to my newsletter
Read articles from whowin directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
whowin
whowin
一枚有30多年经验的退休程序员,主要从事嵌入式软件开发