《Linux高性能服务器编程》阅读笔记:
1. epoll机制的相关函数
epoll和select()、poll()不同,select()和poll()是通过该函数(单个)实现IO复用,而epoll用一组函数来实现IO复用。epoll把用户关心的文件描述符上的事件专门放在一个内核事件表(结构体)中,从而无须像select()和poll()那样每次调用都需要重复传入文件描述符集(如下poll()中的fds参数)或事件集(如下select()中的参数read_fds, write_fds, exception_fds):
ret = select(connfd + 1, &read_fds, &write_fds, &exception_fds, &timeout);
ret = poll(&fds, 1, 4000);
但是epoll需要使用一个额外的文件描述符来唯一标识这个内核事件表,该描述符用epoll_creat()创建:
#include <sys/epoll.h>
int epoll_create(int size);
size参数用于告诉内核该事件表需要多大,现在该参数已经没作用。函数执行成功返回内核事件的文件描述符。
epoll_ctl()函数用于控制内核事件表:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
(1) epfd参数是要操作的内核事件表的文件描述符
(2) op参数指定具体的操作类型,有如下3种取值:
EPOLL_CTL_ADD: 往事件表中增加fd上的监听事件
EPOLL_CTL_MOD: 修改fd上的监听事件
EPOLL_CTL_DEL: 删除fd上的监听事件
(3) fd参数是监听的文件的文件描述符
(4) event参数指定监听事件,它是epoll_event结构体类型的指针,epoll_event的原型如下:
struct epoll_event {
uint32_t events; /* Epoll事件 */
epoll_data_t data; /* 用户数据 */
};
events成员描述具体的监听类型,epoll支持的事件类型和poll()基本相同,epoll事件类型的宏是在poll()事件类型的宏前加上’E’,如epoll的数据可读事件是EPOLLIN,可写事件是EPOLLOUT。另外,epoll有两个特殊的事件类型–EPOLLET和EPOLLONESHOT,这是epoll的边沿触发和水平触发相关。data成员存储用户数据,其类型epoll_data_t的定义为:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
该类型是一个联合体,使用最多的成员是fd,它指定事件所从属目标文件描述符(跟epoll_ctl()的参数三fd相同)。ptr指针成员可用来指定与fd相关的用户数据,但epoll_data_t是一个联合体不能同时使用ptr和fd成员,所以若要将描述符和用户数据关联只能使用其它手段,如放弃使用fd成员,在ptr指向的用户数据中包含fd。
epoll_ctl()调用多次以实现监听多个文件多个事件。函数执行成功返回0,失败返回-1并设置errno。
创建和设置好内核事件表后,就可以使用epoll_wait()系统调用实现在一段超时时间内监听一组文件描述符上的事件,函数原型为:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
(1) epfd参数是要操作的内核事件表的文件描述符
(2) events参数存放所有就绪的事件,它是由内核从(epfd参数指定的)内核事件表的就绪事件中拷贝过来的。
(3) maxevents参数指定最大监听多少个事件,它必须大于0
(4) timeout参数指定超时时间
特别强调一点,epoll_wait()函数若检测到事件就绪就将所有就绪的事件从epfd参数执行的内核事件表中拷贝到参数二events中,所以参数二events只存放就绪事件,而不像select()和poll()中的fds参数那样,既用于传入用户注册的事件,又用于输出内核检测到的就绪事件,epoll_wait()大大提高了应用程序索引就绪文件描述符的效率:
//索引select()返回的就绪文件描述符
fd_set read_fds[2];
FD_SET(connfd1, &read_fds[0]); //将connfd1加入就绪读监听集合
FD_SET(connfd2, &read_fds[1]); //将connfd2加入就绪读监听集合
timeout.tv_usec = 0; //超时时间为4s
timeout.tv_sec = 4;
ret = select(connfd2 + 1, read_fds, NULL, NULL, &timeout); //函数若是因为就绪事件,就绪和未就绪的事件都存在reads中
if (FD_ISSET(connfd1, reads))
{
ret = recv(connfd1, buf, sizeof(buf) - 1, 0);
if (ret == 0)
{
printf("connfd1 exit...\n");
break;
}
printf("connfd1: get %d bytes of normal data: %s\n", ret, buf);
}
else if (FD_ISSET(connfd2, reads))
{
ret = recv(connfd2, buf, sizeof(buf) - 1, 0);
if (ret == 0)
{
printf("connfd2 exit...\n");
break;
}
printf("connfd2: get %d bytes of normal data: %s\n", ret, buf);
}
//索引poll()返回的就绪文件描述符
struct pollfd fds[2];
fds[0].fd = connfd1;
fds[0].events = POLLIN;
fds[1].fd = connfd1;
fds[1].events = POLLIN;
ret = poll(fds, 2, 4000); //函数若是因为就绪事件返回,就绪和未就绪的事件都存在fds中
for (int i = 0; i < 2; i++)
{
if (fds[i].revent & POLLIN)
{
ret = recv(fds[i].fd, buf, sizeof(buf) - 1, 0);
}
}
//索引epoll()返回的就绪文件描述符
struct epoll_event event;
event.events = EPOLLIN;
event.events.data = connfd1;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd1, &event);
event.events.data = connfd2;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd2, &event);
struct epoll_event revents[MAX];
ret = epoll_wait(epfd, revents, MAX, -1); //函数若是因为就绪事件返回,revents只存放就绪事件
for (int = 0; i < ret; i++)
{
int sockdf = revents[i].data.fd;
//...
}
2. epoll的电平触发和边沿触发
TL(Level Trigger, 电平)和ET(Edge Trigger, 边沿)触发方式是epoll对文件描述符的两种操作模式:
(1) LT模式是epoll默认的工作模式,在这种模式下,当eoll_wait()检测到对应文件描述符有就绪事件并将此事件通知应用程序后,应用程序可以不立即处理该事件,因为当应用程序下次调用epoll_wait()时,epoll_wait()还会再次向应用程序通告此事件直到该事件被处理。比如读取数据操作,LT模式下,应用程序可以想读多少就读多少,还未读出的,下次epoll_wait()还会告知。
(2) ET模式下,当epoll_wait()检测到对应文件描述符有就绪事件并将该事件告知应用程序后,应用程序必须立即处理该事件,因为后续调用epoll_wait()时,该函数将不会再向应用程序通知这一事件。比如读事件,ET模式下,应用程序必须一次性将缓冲区的数据读取,否则将因为epoll_wait()下次不会再告知还有数据未读的事件,应用程序将无法得到完整的数据。
可见,TL模式下的epoll等价于高效的poll()。当往epoll内核事件表中(通过epoll_ctl()的event参数)注册一个文件描述符的EPOLLET时,epoll将以ET模式来操作该文件描述符,ET模式是epoll的高效的工作模式,它在很大程度上降低了同一个epoll事件被重复触发的次数。
下面代码是服务端分别采用ET模式、LT模式的epoll处理客户端的读事件就绪的代码:
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <libgen.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <errno.h>
#define ERRP(con, ret, ...) do \
{ \
if (con) \
{ \
perror(__VA_ARGS__); \
ret; \
} \
}while(0)
#define BUFSIZE 5
#define MAX_EVENT 1024
static const char* ip = "192.168.239.136";
static int port = 9660;
//设置目的文件描述符为非阻塞
int set_fd_non_block(int fd)
{
int old_opt = fcntl(fd, F_GETFL);
int new_opt = old_opt | O_NONBLOCK;
fcntl(fd, F_SETFL, new_opt);
return old_opt;
}
//将描述符的就绪读事件加入到epoll内核事件表中
void add_to_event_in(int epfd, int fd, int is_et)
{
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if (is_et)
{
event.events |= EPOLLET;
}
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
//设置fd为非阻塞IO
set_fd_non_block(fd);
}
//epoll边沿模式处理读数据就绪事件
void et_to_operator(struct epoll_event* event, int cnt, int epfd, int socket_fd)
{
int i;
char buf[BUFSIZE] = {0};
for (i = 0; i < cnt; i++) //客户端连接事件
{
int fd = event[i].data.fd;
if (fd == socket_fd)
{
struct sockaddr_in cli_addr;
socklen_t addr_len = sizeof(cli_addr);
int connfd = accept(fd, (struct sockaddr* )&cli_addr, &addr_len);
printf("connect success: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
add_to_event_in(epfd, connfd, 1); //将与客户端通信用的连接socket的就绪读事件注册到epoll内核事件表
//触发方式为边沿,且将该socket设置为非阻塞
}
else if (event[i].events & EPOLLIN) //客户端有数据发来,注意此时的触发方式是边沿触发,所以需要一次性读完所有数据
{
printf("Edge trigger once\n");
while (1)
{
memset(buf, 0, BUFSIZE);
int ret = recv(fd, buf, BUFSIZE - 1, 0);
if (ret < 0)
{
//非阻塞IO在读取数据事,有数据就返回大于0的值,返回EAGAIN或者EWOULDBLOCK表示无数据可读
//这个两宏分别表示"期望再次读"和"期望阻塞在这里等"的意思
if ((errno == EAGAIN) || (errno == EWOULDBLOCK))
{
break;
}
close(fd);
break;
}
else if (ret == 0) //客户端已关闭
{
printf("client is exit\n");
close(fd);
}
else
{
printf("get %d byte data of connect: %s\n", ret, buf);
}
}
}
else
{
printf("Other things\n");
}
}
}
//epoll电平模式处理读数据就绪事件
void lt_to_operator(struct epoll_event* event, int cnt, int epfd, int socket_fd)
{
int i;
char buf[BUFSIZE] = {0};
for (i = 0; i < cnt; i++)
{
int fd = event[i].data.fd;
if (fd == socket_fd) //客户端连接事件
{
struct sockaddr_in cli_addr;
socklen_t addr_len = sizeof(cli_addr);
int connfd = accept(fd, (struct sockaddr* )&cli_addr, &addr_len);
printf("connect success: %s:%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
add_to_event_in(epfd, connfd, 0); //将与客户端通信用的连接socket的就绪读事件注册到epoll内核事件表,触发方式为电平
//触发方式为电平,且将该socket设置为非阻塞
}
else if (event[i].events & EPOLLIN) //客户端有数据发来,注意此时的触发方式是电平触发,所以可以不用一次性读完所有数据
{
printf("Level trigger once\n");
int ret = recv(fd, buf, BUFSIZE - 1, 0);
if (ret < 0)
{
close(fd);
continue;
}
else if (ret == 0) //客户端已关闭
{
close(fd);
break;
}
printf("get %d byte data of connect: %s\n", ret, buf);
}
else
{
printf("Other things\n");
}
}
}
int main(void)
{
//创建监听socket
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
ERRP(socket_fd <= 0, return -1, "socket");
//命名socket
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int ret = bind(socket_fd, (struct sockaddr* )&address, sizeof(address));
ERRP(ret < 0, goto ERR1, "connect");
//为socket创建监听队列
ret = listen(socket_fd, 5);
ERRP(ret < 0, goto ERR1, "listen");
//创建epoll内核事件表
int epfd = epoll_create(5);
ERRP(epfd < 0, goto ERR1, "epoll_create");
//将监听socket的可读事件注册到epoll内核事件表中
struct epoll_event events[MAX_EVENT];
add_to_event_in(epfd, socket_fd, 1);
while (1)
{
//不限制时间的阻塞监听事件的就绪
ret = epoll_wait(epfd, events, MAX_EVENT, -1);
ERRP(ret < 0, goto ERR2, "epoll_wait");
//执行到这里说明有就绪事件发生,以边沿模式或者电平模式处理就绪事件(可读)
et_to_operator(events, ret, epfd, socket_fd);
//lt_to_operator(events, ret, epfd, socket_fd);
}
ERR2:
close(epfd);
ERR1:
close(socket_fd);
return 0;
}
客户端代码可用在Linux IO复用–select()和poll()所给的客户端代码。为了验证上述,需要做点小改动,将普通数据的发送字节数增加到超过5(因为上面服务端代码的应用程序接收缓冲区等于5),如:
修改前: const char* normal_data = "1234";
修改后: const char* normal_data = "1234abcdefg";
服务端采用ET模式处理就绪读事件运行结果:
服务端采用LT模式处理就绪读事件运行结果:
提两个常见的epoll问题:
问: epoll机制中的connfd(连接文件描述符)一定要设置为非阻塞吗?
(1) 水平触发模式下,阻塞和非阻塞的效果是一样的。假设水平触发模式下的connfd是阻塞的,服务端没读取完全部的数据时,epoll_wait()还是会继续触该就绪读事件直到程序读取完全,也就是说读取全部数据后epoll_wait()不会产生就绪读事件,也不会调用阻塞的recv()操作,所以程序不会发生阻塞。这与将connfd设置为非阻塞的情况是一致的。但是建议还是将connfd设置为非阻塞的。
(2) 边沿触发模式下,阻塞和非阻塞的效果的相差大了。假设边沿触发模式下的connfd是阻塞的,服务端没读取完全部的数据,epoll_wait()并不会继续触发该就绪读事件,然而接收缓冲区里若有数据没读取完,这将影响本端下次数据的接收(比如影响接收通告窗口大小或者客户端在等待本端接收完完整数据后的回应),所以需要在阻塞的recv()外再加上一层循环才能保证接收到完成的数据,这样就引入一个致命的问题,当recv()无数据可读时将发生阻塞,直到有数据可读。在这阻塞过程中将无法处理其他IO请求。所以,边沿触发模式下,一定要将connfd设置为非阻塞的,要读取完整数据还是加一层循环,只是在没有数据时recv()不会阻塞,且程序可以通过判断其返回值为EAGAIN或者EWOULDBLOCK得知内核接收缓冲区已无数据,break该循环。
以上通过读操作来解释,写操作同理。
问: epoll机制中的socket_fd(监听文件描述符)一定要设置成非阻塞吗?epoll机制要采用边沿触发模式监听该描述符还是采用电平触发模式?
(1) 如上程序并不需要考虑socket_fd是否阻塞。虽然accept()默认是阻塞等待连接的,但是它是在epoll_wait()之后调用的,epoll_wait()得以返回说明肯定有就绪事件,此时要想执行accept()处理新的连接请求,是有经过event就绪事件判断的,在判断就绪事件是新的连接请求后才会执行accept(),所以不必讨论socket_fd是否设置为阻塞。
(2) 一般要用epoll机制的水平模式处理socket_fd。假设是边沿模式,那么在高并发时刻,可能存在好多连接请求同时发生,而边沿触发只会触发一次,它并不会理会应用程序处不处理发生的请求,假设应用程序没有处理全部请求,那么客户端将无法连接。水平模式就不一样了,应用程序没有全部处理这些高并发的请求,epoll_wait()将会继续告知该事件。