什么是 epoll?
epoll 是 Linux 内核的可扩展 I/O 事件通知机制。取代了 select 与 poll 系统函数,让需要大量操作文件描述符的程序得以发挥更优异的性能。旧有的系统函数所花费的事件复杂度为 O(1),epoll 的时间复杂度为 O(logn)。epoll 实现的功能与 poll 类似,都是监听多个文件描述符上的事件。
epoll 通过使用红黑树搜索被监控的文件描述符。在 epoll 实例上注册事件时,epoll 会将该事件添加到epoll 实例的红黑树上并注册一个回调函数,当事件发生时会将事件添加到就绪链表中。
epoll 是 Linux 下实现多路复用 I/O 的重要方式,这里涉及到了 I/O,所以我们有必要将 I/O 的两种
C/S 模型
TCP服务端通信的常规步骤
- 使用 socket() 创建 TCP 套接字(socket)
- 将创建的套接字绑定到一个本地地址和端口上(Bind)
- 将套接字设置为监听模式,准备接受客户端请求(listen)
- 等待客户端请求到来:当请求到来后,接受连接请求,返回一个对应于此连接请求的套接字(accept)
- 用 accept 返回的套接字和客户端进行通信(使用 write() / send() 或 send() / recv())
- 返回等待另一个客户请求
- 关闭套接字
服务端套接字的建立过程
//server.cpp 代码(通信模块): //服务端地址 ip地址 + 端口号 struct sockaddr_in serverAddr; serverAddr.sin_family = PF_INET; serverAddr.sin_port = htons(SERVER_PORT); serverAddr.sin_addr.s_addr = inet_addr(SERVER_HOST); //服务端创建监听socket int listener = socket(PF_INET, SOCK_STREAM, 0); if(listener < 0) { perror("listener"); exit(-1);} printf("listen socket created "); //将服务端地址与监听socket绑定 if( bind(listener, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) { perror("bind error"); exit(-1); } //开始监听 int ret = listen(listener, 5); if(ret < 0) { perror("listen error"); exit(-1);} printf("Start to listen: %s ", SERVER_HOST);
上面的代码中用到了 sockaddr_in 这样的数据结构
struct sockaddr_in { short sin_family; //AF_INET u_short sin_port; // port struct in_addr sin_addr ; // IP address,
unsigned long char sin_zero[8]; // align };
其中,
sin_family 是地址家族,一般都是“AF_xxx”的形式。通常大多用的都是 AF_INET,代表 TCP/IP 协议族。
sin_zero 没有实际意义,只是为了跟 SOCKADDR 结构在内存中对齐
TCP客户端通信的常规步骤
- 创建套接字(socket)
- 使用 connect 建立到达服务器的连接(connect)
- 客户端进行通信(使用 write() / send() 或 send() / recv())
- 使用 close() 关闭客户端
//client.cpp代码(通信模块): //客户要连接的服务端地址( ip地址 + 端口号) struct sockaddr_in serverAddr; serverAddr.sin_family = PF_INET; serverAddr.sin_port = htons(SERVER_PORT); serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP); // 创建套接字(socket) int sock = socket(PF_INET, SOCK_STREAM, 0); if(sock < 0) { perror("sock error"); exit(-1); } //向服务器发出连接请求(connect) if(connect(sock, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) { perror("connect error"); exit(-1); }
其中,
htons() 函数:将主机字节序转换为网络字节序
函数原型:
u_short PASCAL FAR htons (u_short hostshort);
阻塞与非阻塞socket
通常的,对于一个文件描述符指定的文件或设备,有两种工作方式:阻塞与非阻塞方式。
- 阻塞方式是指:当试图对该文件描述符进行读写时,如果当时没有数据可读,或者暂时不可写,程序就进入等待状态,直到有东西可以读或者写为止。
- 非阻塞方式是指:如果没有数据可读,或者不可写,读写函数马上返回,而不会等待。
为了使 I/O 事件更加的高效,所以应该将 socket 设置为非阻塞方式。
//utility.h代码(设置非阻塞函数模块): //将文件描述符设置为非阻塞方式(利用fcntl函数) int setnonblocking(int sockfd) { fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0)| O_NONBLOCK); return 0; }
关于 fcntl 的详细用法可以参考这篇博客。
程序接口
int epoll_create(int size);
函数描述:
在内核中创建epoll
实例并返回一个epoll
文件描述符。 在最初的实现中,调用者通过 size
参数告知内核需要监听的文件描述符数量。如果监听的文件描述符数量超过 size, 则内核会自动扩容。而现在 size 已经没有这种语义了,但是调用者调用时 size 依然必须大于 0,以保证后向兼容性。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数描述:
向 epfd 对应的内核 epoll
实例添加、修改或删除对 fd 上事件 event 的监听。op 可以为 EPOLL_CTL_ADD
, EPOLL_CTL_MOD
, EPOLL_CTL_DEL
分别对应的是添加新的事件,修改文件描述符上监听的事件类型,从实例上删除一个事件。如果 event 的 events 属性设置了 EPOLLET
flag,那么监听该事件的方式是边缘触发。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
函数描述:
当 timeout 为 0 时,epoll_wait 永远会立即返回(非阻塞scoket)。而 timeout 为 -1 时,epoll_wait 会一直阻塞直到任一已注册的事件变为就绪(阻塞scoket)。当 timeout 为一正整数时,epoll 会阻塞直到计时 timeout 毫秒终了或已注册的事件变为就绪。因为内核调度延迟,阻塞的时间可能会略微超过 timeout 毫秒。
触发模式
epoll 提供了两种触发模式,一种是边缘触发,另一种是状态触发。当 epoll 处于边缘触发状态下时,epoll_wait 仅会在新的事件首次加入到 epoll 队列中时返回。而在状态触发模式下 epoll_wait 将在事件状态未变更前不断被触发。
服务器使用epoll的步骤如下:
- 调用 epoll_create 函数在 Linux 内核中创建一个事件表。
- 然后将文件描述符(监听套接字 listener)添加到所创建的事件表中。
- 在主循环中,调用 epoll_wait 等待返回就绪的文件描述符集合。
- 分别处理就绪的事件集合,在这次的聊天室项目中主要有两类事件:新用户连接事件和用户发来消息事件。
可以使用下面的方法将一个socket添加到内核事件表中:
//utility.h(添加 socket 模块): //将文件描述符 fd 添加到 epollfd 标示的内核事件表中, 并注册 EPOLLIN 和 EPOOLET 事件,EPOLLIN 是数据可读事件;EPOOLET 表明是 ET 工作方式。最后将文件描述符设置非阻塞方式 /** * @param epollfd: epoll句柄 * @param fd: 文件描述符 * @param enable_et : enable_et = true, 采用epoll的ET工 作方式;否则采用LT工作方式 **/ void addfd( int epollfd, int fd, bool enable_et ) { struct epoll_event ev; ev.data.fd = fd; ev.events = EPOLLIN; if( enable_et ) ev.events = EPOLLIN | EPOLLET; epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev); setnonblocking(fd); printf("fd added to epoll! "); }
上面的函数中用到了 struct epoll_event ev 该结构题的具体实现如下所示:
//参数event告诉内核需要监听的事件,event的结构如下: struct epoll_event { __uint32_t events; //Epoll events epoll_data_t data; //User data variable }; //其中介绍events是宏的集合,本项目主要使用EPOLLIN(表示对应的文件描述符可以读,即读事件发生)
练习:利用epoll实现聊天服务器
通过上面的代码对epoll的工作原理有了一个大致的了解,下面可以运用本次学习的内容编写一个简单的聊天服务器。
根据 C/S 模型我们可以先让服务端创建一个scoket连接,然后等待客户端的接入(),客户端接入后服务端会通过 accept 函数返回一个客户端的scoket文件描述符,然后将该文件描述符加入到一个全局的list变量中,用以保存所有客户端的文件描述符fd。
通过使用 epoll_wait 函数可以统计出就绪事件的数目,通过就绪事件的文件描述符和服务端监听事件的文件描述符进行对比,判断是否是新的事件接入,然后服务器对所有的客户端进行广播。
客户端主要用到了父进程和子进程以及管道相关的知识,子进程负责从屏幕上进行文字的输入,然后将这些文字写入管道中。父进程主要负责两项工作,其一,负责从管道中读出子进程写入的数据,并向服务端发送这些数据,服务端收到这些数据后,再通过广播的方式向其他的客户端发送消息。其二,负责接收服务端发过来的消息,并输出。
以上就是利用epoll实现聊天服务器的基本思路具体的代码实现。此外如果还想对这个简单的聊天服务器进行扩展的话,还可以从以下几个方面来考虑:线程池、多线程编程、超时重传、确认收包等等。
采用非阻塞的I/O+事件驱动epoll+线程池来实现单reactor 模式
I / O 复用结合线程池,就是reactor模式基本设计思想。
- reactor 模式,通过一个或多个输入同时传递给服务处理器(基于事件驱动)
- 服务器端处理传入的多个请求,并将他们同步分派到相应的处理线程,因此 reactor 模式也叫作dispatcher 模式。
- reactor 模式使用 I/O 复用监听事件,收到事件后,分发给某个线程,这点是网络服务器高并发处理的关键。
非阻塞的I/O + 事件驱动epoll + 线程池 来实现单 reactor 模式的工作流程如下:
- 主线程向 epoll 内核事件表内注册 socket 上的可读就绪事件
- 主线程调用 epoll_wait 等待 socket 上有数据可读
- 当 socket 上有数据可读,epoll_wait 通知主线程。主线程从 socket 读入事件放入请求队列
- 睡眠在请求队列上的某个可读工作线程被唤醒,从 socket 上读数据,处理客户的请求,然后向 epoll 内核事件表注册写的就绪事件
- 主线程调用 epoll_wait 等待数据可写
线程池的优势:
- 通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销
- 当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性
- 通过适当调整线程池的大小,可以创建足够多的线程以便处理器保持忙碌状态。同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败
总结
本次我们通过使用epoll实现一个简单的聊天服务器,对epoll有了一个大致的了解。epoll可以使 I/O 多路复用更加的高效,用到的数据结构包括红黑树和就绪队列,使得 epoll 比 select/poll 更加的高效。当一个事件发生(比如说读文件),epoll 无须遍历整个监听的描述符集,只需要遍历那些被内核 I/O 事件唤醒而加入就绪队列的描述符集合就好了。
关于 epoll 的一些总结
- 支持一个进程打开大数目的scoket描述符(fd)
- I/O 效率不会随着文件描述符的增加而线性下降,因为获取事件的时候无须遍历整个文件描述符集,只需要遍历被内核I/O事件异步唤醒而加入就绪队列中的文件描述符集就好了。
- epoll除了提供 select / poll 那种 I/O 事件的水平触发外,还提供了边缘触发模式。这使得用户空间程序有可能缓存I/O状态,减少 epoll_wait / wpoll_pwait 的调用,提高应用程序效率。使mmap加速内核与用户空间消息的传递。