C++技能系列
Linux通信架构系列
C++高性能优化编程系列
深入理解软件架构设计系列
高级C++并发线程编程
期待你的关注哦!!!
现在的一切都是为将来的梦想编织翅膀,让梦想在现实中展翅高飞。
Now everything is for the future of dream weaving wings, let the dream fly in reality.
(1)I/O多路复用技术用于监控多个TCP连接上的数据收发,而epoll就是一种在Linux上使用的I/O多路复用并支持高并发的典型技术。传统的select、poll也是I/O多路复用技术,但这2种技术受内部实现的限制,不支持高并发,如同时连入超过1000个客户端,性能就会明显下降。(epoll技术从linux内核2.6开始引入的)。
(2)epoll技术的性能,可以说非常惊艳,它是能够使单台计算机支撑数百万甚至数十万上百万并发的核心技术,远优于其他I/O模型或I/O函数(如select、poll函数),select和poll这类技术因为系统内部实现问题,当并发(客户端同时连入时)数量超过1000~2000时性能就开始急剧下降,但epoll技术完全没有这种问题(性能不会随着并发数量的提高而出现明显下降)。当然,并发数高,需要的内存也更大,所以,即便是并发数量的急剧提高对性能影响不大,但是内存总是有限的,换句话说,并发数也总是有限制的,不可能无限增加。
(3) 即使有10万个并发连接(同一时刻有10万个客户端保持和服务器的连接),这个10万个连接通常也不可能在同一时刻都在收发数据,一般在同一时刻通常只有其中几十个或几百个连接在收发数据,其他连接可能处于只在连接而没有收发数据的状态。如果以100ms为间隔判断一次,可能这100ms内只有100个活跃连接(有数据收发的连接),把这100个活跃连接的数据放在一个专门的地方,后续到这个专门的地方来,只需要处理100条数据。
处理起来是不是没有压力呀?这就是epoll处理方式。而select和poll是依次判断这10万个连接上有没有发来的数据(实际上有数据的只有100个连接),有数据则处理。不难想象,每次检查10万个连接与每次检查100个连接相比,是巨大的资源和时间浪费,所以并发数超过1000 ~ 2000的时候,select和poll技术(或者说这种函数、这种模型)的性能将急剧下降。
(4)很多处理网络通信的服务器程序都是多进程(每个进程对应一个客户端的连接)的,也有多线程(每个线程对应一个客户端的连接)的,但是进程或者线程增多,即使不计进程或者线程本身的消耗,进程或线程之间的时间片/上下文的频繁切换,也非常消耗性能的。而epoll技术是一种简单粗暴有效的技术,采用事件驱动机制,只在单独的进程或者线程里收集和处理事件,没有进程或线程的切换消耗。
当用户进程调用epoll_create时,内核会创建一个struct eventpoll的内核对象,并把它关联到当前进程的已打开文件列表中。
int epoll_create(int size);
源码中的找到该函数实现的源码:
struct eventpoll *ep = (struct eventpoll * )calloc(1, sizeof(struct eventpoll));
** 生成一个eventpoll对象**(想象系统生成一个结构体)
eventpoll对象中有很多成员,这里只关注其中的rbr
和rdlist
。000
① rbr。可以将该成员理解成一颗红黑树根节点(指针)。
使用红黑树,为了支持对海量连接的高效查找、插入、删除,eventpoll内部使用一颗红黑树,通过这棵树来管理用户进程下添加进来的所有socket连接。
红黑树是一种数据结构,用于保存数据,一般都是存"键 / 值(key / value)对"。红黑树的特点是能够极快快速地根据给的key(键)找到并取出value(值)。这里的key一般是个数字,value代表的可能是一批数据。如果value是一个数据结构,通过一个数字(key)在红黑树里查找,就可以快速找到value(一个结构体,里面有一批数据)。因为红黑树查找速度快,效率高,所以在epoll技术中引入了红黑树的。
② rdlist。可以将该成员理解成代表一个双向链表的表头指针
就绪的描述符链表。当有连接就绪的时候,内核会把就绪的连接放到rdlist链表里。这样应用进程只需要判断链表就能找出就绪连接,而不是去遍历整颗树。
双向链表也是一种数据结构,特点是顺序访问里面的节点速度非常快,沿着它的链往下走(遍历)就可以。与上面的红黑树相比,红黑树随机查找任意一个节点快,双向链表顺序往下访问每个节点,各有各的特点和用途。
③wq。等待队列链表。
软中断数据就绪的时候会通过wq来找到阻塞在epoll对象上的用户进程。
总结一下epoll_create函数:
(1)创建一个event_poll结构对象,被系统保存起来。
(2)对象中的rbr成员被初始化成指向一颗红黑树的根(有了这个根,就可以向红黑树中插入节点,或者说插入数据了)。
(3)对象中的rdlist成员被初始化成指向一个双向链表的根(有了这个根,就可以向双向链表中插入节点(数据))。
接下来,我们看下系统怎样使用eventpoll结构对象来处理高达百万的并发。
int epoll_ctl(int efpd, int op, int socketid, struct epoll_event *event);
把一个socket及socket相关的事件添加到epoll对象描述符中,已通过该epoll对象来监视该socket(也就是该TCP连接)上数据的来往情况,当有数据来往时,系统会通知程序。
我们会通过epoll_ctl函数把程序中需要关注(感兴趣)的事件(整个系统约有7 ~ 8个事件)添加到epoll对象描述符中,当这些事件到来时,系统会通知程序。
① 参数efpd。
从epoll_create返回的epoll对象描述符。
② 参数op。
一个操作类型(宏定义)
EPOLL_CTL_ADD:添加sockid上的关联事件。
EPOLL_CTL_MOD:修改sockid上的关联事件。
EPOLL_CTL_DEL:删除sockid上的关联事件。
添加事件之后,当这种事件到来,系统会通知程序去处理。所谓添加事件,就是在红黑树上添加一个节点。每个客户端连入服务器之后,服务器都会创建一个对应的socket(accept函数的返回值)用于与客户端通信,因为操作系统会保证每个连入服务器的socket值都不重复,所以系统就会以socket值为key,把节点添加到红黑树中(红黑树的key要求不能重复)。
修改事件就是修改红黑树节点中的一些值。所以想要修改事件,必须先调用EPOLL_CTL_ADD把事件添加到红黑树上。如原来添加epoll对象描述符中3个事件,现在想修改成只关注2个事件,这就需要调用EPOLL_CTL_MOD。
删除事件如原本关注3个事件,现在想减少1个事件,变成关注2个事件,就需要调用EPOLL_CTL_MOD而不是EPOLL_CTL_DEL。EPOLL_CTL_DEL的真是动作是从红黑树中删除节点(不是关闭这个TCP连接),这会导致程序无法收到所有该TCP连接上的事件通知,所以这一项只有在需要的时候才用。
③ 参数sockid。
一个TCP连接。添加事件(往红黑树中增加节点)时,就是用socketid作为key往红黑树中增加节点。
④ 参数event。
向epoll_ctl函数传递信息。如要增加一些事件,就可以通过event参数将具体事件传递进epoll_ctl函数。
事件类型:
EPOLLIN:需要读取数据的情况。
EPOKKOUT: 输出缓冲为空,可以立即发送数据的情况。
EPOLLPRI: 收到OOB数据的情况。
EPOLLRDHUP: 断开连接或半关闭的情况,这在边缘触发方式下非常有用。
EPOLLERR: 发生错误的情况。
EPOLLET: 以边缘触发方式得到事件通知。
EPOLLONESHOT: 发生一次事件后,相应文件描述符不再收到事件通知。因此需要向epoll_ctl函数的第二个参数传递EPOLL_CTL_MOD,再次设置事件。
① 我们看下源码实现。如果传递进来的是一个EPOLL_CTL_ADD,首先查找红黑树上是否已经有了该节点,如果有了,则直接返回,没有,程序往下走。确认红黑树没有该节点的情况下,此时来生成一个epitem对象,该epitem对象就是后续增加到红黑树中的一个节点。
图2_2_3就是即将向红黑树中插入一个节点,该节点的key保存在成员sockfd中,要增加的事件保存在成员event中,然后将该节点插入红黑树中。对于红黑树来讲,每个节点都要记录自己的左子树、右子树和父节点,图中rbn成员本身又是个结构类型,该结构中包含指向左子树、右子树、父节点的指针成员。如果将来多个用户连入服务器,需要向这颗红黑树中加入很多节点,这些节点彼此也要连接起来。
总之,对于红黑树的每个节点,通过rbn成员,做到有父节点的就指向父节点,有子节点的就指向子节点,父节点、子节点都有,就既指向父节点又指向子节点即可。
由谁向红黑树中增加节点呢?
实际上是epoll_ctl(EPOLL_CTL_ADD),每个红黑树节点其实就代表一个TCP连接。
② 如果传递进来的是一个EPOLL_CTL_MOD,找到已存在的红黑树节点,把该节点中的的一些数据(event)做一些修改。
③ 如果传递进来的是EPOLL_CTL_DEL,找到已存在的红黑树节点,从红黑树中删除该节点,释放对应的内存,把某个节点从红黑树上删除之后,该节点对应的TCP连接所发生的事件就没办法知道了。
总结:EPOLL_CTL_ADD,等价于往红黑树中增加节点;EPOLL_CTL_MOD,等价于修改红黑树的节点;EPOLL_CTL_DEL,等价于从红黑树中删除该节点。
所以,每一个连入的客户端都应该调用epoll_ctl向红黑树增加一个红黑树节点,如果有100万个并发连接,红黑树上就会有100万个节点。
现在,这100万个连接增加到红黑树中来了,相关的程序感兴趣的事件也一起增加到了红黑树的节点中,当某些TCP连接上发生这些事件(比如连入、断开、有数据收发等)时,操作系统就会通知程序。
程序如何接收到这些操作系统的通知呢?接下来,我们看下epoll_wait函数。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数efpd
从epoll_create返回的epoll对象对象描述符。参数events
一个数组,长度为maxevents,表示此次调用epoll_wait函数最多可以收集到maxevents个已经就绪(已经准备好)的读写事件。实际的读写事件由本函数返回值决定(换句话说,返回的是有事件发生的TCP连接的数目,但因为内存所限,可能100个TCP上有事件发生,但返回的数字却是80 —— 小于100)。参数timeout
阻塞等待的时长。LT:是水平触发,属于低速模式,如果该事件没有处理完,就会被一直触发。
ET:边缘触发,属于高速模式,该事件通知只会出现1次。
一般认为ET的效率很高,但是ET的编程难度很大。
客户端实例代码,方便下面运行结果演示:
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;
FILE *readfp;
FILE *writefp;
if(argc != 3){
printf("Usage: %s \n" , argv[0]);
exit(1);
}
sock = socket(PF_INET, SOCK_STREAM, 0);
if(sock == -1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
if(connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error!");
else
puts("Connected...........");
readfp = fdopen(sock, "r");
writefp = fdopen(sock, "w");
while(1)
{
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if(!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
fputs(message, writefp);
fflush(writefp);
fgets(message, BUF_SIZE, readfp);
printf("Message from server: %s", message);
}
fclose(writefp);
fclose(readfp);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
调用read函数后,输入缓冲区中仍有数据需要读取。而且会因此注册新的事件并从epoll_wait函数返回时将循环输出“return epoll_wait”字符串。
如果该事件没有处理完,就会被一直触发。
代码如下:
#include
#include
#include
#include
#include
#include
#include
#include
#include
//为了验证边缘触发的工作方式,将缓冲区设置为4字节
#define BUF_SIZE 4
#define EPOLL_SIZE 50
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;
if(argc != 2){
printf("Usage : %s \n" , argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
// --- epoll_create: 创建保存epoll文件描述符的空间,成功时返回epoll文件描述符,失败时返回-1
//参数:size(int size) 表示文件描述符保存空间的大小
epfd = epoll_create(EPOLL_SIZE);
// 表示保存发生事件的文件描述符集合的结构体地址
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
//发生需要读取数据情况(事件)时
event.events = EPOLLIN;
event.data.fd = serv_sock;
// --- epoll_ctl: 向epoll空间注册并销毁文件描述符,成功时返回0,失败时返回1
//参数:epfd(int epfd) 表示用于注册监视对象的epoll例程的文件描述符
//参数:op(int op)表示用于指定监视对象的添加、删除或更改等操作
//参数:fd(int fd)表示需要注册的监视对象文件描述符
//参数:event(epoll_event event)表示监视对象的事件类型
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while(1)
{
// --- epoll_wait: 等待文件描述符发生变化,成功时会返回文件描述符数,失败时返回-1 ----
//参数:epfd(int epfd) 表示事件发生监视范围的epoll例程的文件描述符
//参数:epevents(epoll_event events)表示指向缓冲区保存发生事件的文件描述符集合的结构体地址
//参数:EPOLL_SIZE(int maxevents)表示第二个参数中可以保存的最大事件数
//参数:-1(int timeout)表示以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生的事件
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if(event_cnt == -1)
{
puts("epoll_wait() error");
break;
}
//为观察事件发生数而添加的输出字符串的语句
puts("return epoll_wait");
for(i = 0; i < event_cnt; i++)
{
if(ep_events[i].data.fd == serv_sock)
{
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
event.events = EPOLLIN ;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connect client: %d \n", clnt_sock);
}else{
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if(str_len == 0) //close request!
{
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d \n", ep_events[i].data.fd);
break;
//read函数返回-1且errno值为EAGAIN时,意味着读取了输入缓冲区中的全部数据
}else{
write(ep_events[i].data.fd, buf, str_len); //echo!
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
从运行结果可以看出,每当收到客户端数据时,都会注册该事件,并因此多次调用epoll_wait函数。
边缘触发方式中,接收数据时仅注册1次该事件函数。
就是因为这种特点,一旦发生输入相关事件,就应该读取输入缓冲区的全部数据。因此需要验证输入缓冲区是否为空。
read函数返回-1时,变量errno中的值为EAGAIN时,说明没有数据可读。
既然如此,为何还需要将套接字变成非阻塞模式?边缘触发方式下,以阻塞方式工作的read & write函数有可能引起服务器端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞read & write函数有可能引起服务端的长时间停顿。因此,边缘触发方式中一定采用非阻塞read & write函数。
边缘触发必知的两点:
(1) 通过errno变量验证错误原因。
(2) 为了完成非阻塞(Non-blocking)I/O,更改套接字属性。
代码如下:
#include
#include
#include
#include
#include
#include
#include
#include
#include
//为了验证边缘触发的工作方式,将缓冲区设置为4字节
#define BUF_SIZE 4
#define EPOLL_SIZE 50
void setnonblockingmode(int fd);
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;
if(argc != 2){
printf("Usage : %s \n" , argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
// --- epoll_create: 创建保存epoll文件描述符的空间,成功时返回epoll文件描述符,失败时返回-1
//参数:size(int size) 表示文件描述符保存空间的大小
epfd = epoll_create(EPOLL_SIZE);
// 表示保存发生事件的文件描述符集合的结构体地址
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
//设置非阻塞模式
setnonblockingmode(serv_sock);
//发生需要读取数据情况(事件)时
event.events = EPOLLIN;
event.data.fd = serv_sock;
// --- epoll_ctl: 向epoll空间注册并销毁文件描述符,成功时返回0,失败时返回1
//参数:epfd(int epfd) 表示用于注册监视对象的epoll例程的文件描述符
//参数:op(int op)表示用于指定监视对象的添加、删除或更改等操作
//参数:fd(int fd)表示需要注册的监视对象文件描述符
//参数:event(epoll_event event)表示监视对象的事件类型
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while(1)
{
// --- epoll_wait: 等待文件描述符发生变化,成功时会返回文件描述符数,失败时返回-1 ----
//参数:epfd(int epfd) 表示事件发生监视范围的epoll例程的文件描述符
//参数:epevents(epoll_event events)表示指向缓冲区保存发生事件的文件描述符集合的结构体地址
//参数:EPOLL_SIZE(int maxevents)表示第二个参数中可以保存的最大事件数
//参数:-1(int timeout)表示以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生的事件
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if(event_cnt == -1)
{
puts("epoll_wait() error");
break;
}
//为观察事件发生数而添加的输出字符串的语句
puts("return epoll_wait");
for(i = 0; i < event_cnt; i++)
{
if(ep_events[i].data.fd == serv_sock)
{
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
//将accept函数创建的套接字改为非阻塞模式
setnonblockingmode(clnt_sock);
//向EPOLLIN添加EPOLLET标志,将套接字事件注册方式改为边缘触发
event.events = EPOLLIN|EPOLLET;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connect client: %d \n", clnt_sock);
}else{
while(1)
{
//边缘触发方式中,发生事件时需要读取输入缓冲区中的所有数据,因此需要循环调用read函数
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if(str_len == 0)
{
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d \n", ep_events[i].data.fd);
break;
//read函数返回-1且errno值为EAGAIN时,意味着读取了输入缓冲区中的全部数据
}else if(str_len < 0){
if(errno == EAGAIN)
break;
}else{
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
//设置非阻塞模式
void setnonblockingmode(int fd)
{
// --- int fcntl(int filedes, int cmd, . . .); ---
//fcntl成功时返回cmd参数相关值,失败时返回-1
//参数:int filedes 表示更改目标文件描述符
//参数:int cmd 表示函数调用的目的
//从上述声明中可以看到,fcntl具有可变参数形式。如果向第二个参数传递F_GETFL,可以获得第一个参数所指的文件描述符属性(int 型)。
//反之,如果传递F_SETFL,可以更改文件描述符属性,
//将文件(套接字)改为非阻塞模式
//获取之前设置的属性信息
int flag = fcntl(fd, F_GETFL, 0);
//添加非阻塞O_NONBLOCK标志
fcntl(fd, F_SETFL, flag|O_NONBLOCK);
//此时,调用read & write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
上述的运行结果需要注意的是,客户端发送消息次数和服务端epoll_wait函数调用次数。客户端从请求连接到断开连接共发送5次数据,服务端也相应产生了5个事件。
边缘触发的优点:
可以分离接受数据和处理数据的时间点。
运行流程如下:
为完成该过程,若能按如下流程运行程序,服务端的实现并不难。
但现实中可能频繁出现如下这些情况,换言之,如下情况更符合实际。
因此,即使输入缓冲区收到数据(注册相应的事件),服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来巨大的灵活性。
条件触发中无法区分数据接收和处理吗?
并非不可能,但在输入缓冲区收到数据的情况下,如果不读取(延迟处理),则每次调用epoll_wait函数时都会产生相应的事件。而事件也会累加,服务器端能承受吗?这在现实中不可能的(本省并不合理,因此是根本不想做的事)。