Linux 网络通信epoll详解( 10 ) -【Linux通信架构系列 】

系列文章目录

C++技能系列
Linux通信架构系列
C++高性能优化编程系列
深入理解软件架构设计系列
高级C++并发线程编程

期待你的关注哦!!!
在这里插入图片描述

现在的一切都是为将来的梦想编织翅膀,让梦想在现实中展翅高飞。
Now everything is for the future of dream weaving wings, let the dream fly in reality.

一、epoll技术简介

(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工作原理

2.1 epoll_create函数 - [ 创建一个epoll对象 ]

当用户进程调用epoll_create时,内核会创建一个struct eventpoll的内核对象,并把它关联到当前进程的已打开文件列表中。

2.1.1 epoll_create格式

  • epoll_create函数格式如下
    int epoll_create(int size);

2.1.2 epoll_create功能

  • 创建一个epoll对象,返回一个对象(文件)描述符来标识该epoll对象,后续要通过操作该描述符来进行数据的收发;
    该对象最终要close关闭,因为它是个描述符,或者说是个句柄,总是要关闭的;
    格式中的size,要保证值大于0,以免出现不可预料的问题;

2.1.3 epoll_create原理

在这里插入图片描述

图2_1 epoll结构

  • 源码中的找到该函数实现的源码:
    struct eventpoll *ep = (struct eventpoll * )calloc(1, sizeof(struct eventpoll));
    ** 生成一个eventpoll对象**(想象系统生成一个结构体)
    eventpoll对象中有很多成员,这里只关注其中的rbrrdlist。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结构对象来处理高达百万的并发。

2.2 epoll_ctl函数 - [ 向epoll对象添加/删除、修改一个(socket)管理的链接 ]

2.2.1 epoll_ctl格式

  • epoll_create函数格式如下
    int epoll_ctl(int efpd, int op, int socketid, struct epoll_event *event);

2.2.2 epoll_ctl功能

  • 把一个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,再次设置事件。

2.2.3 epoll_ctl原理

  • ① 我们看下源码实现。如果传递进来的是一个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函数。

2.3 epoll_wait函数 - [ 等待其管理连接上的I/O事件 ]

2.2.1 epoll_wait格式

  • epoll_create函数格式如下
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

2.2.2 epoll_wait功能

  • 阻塞一小段时间并等待事件发生,返回事件集合,既获取内核的时间通知。换句话说就是遍历双向链表,把双向链表中节点相关的数据复制出去,并从双向链表中删除该节点。因为所有有数据的socket(TCP连接)都在双向链表里记录着。
    参数efpd 从epoll_create返回的epoll对象对象描述符。
    参数events 一个数组,长度为maxevents,表示此次调用epoll_wait函数最多可以收集到maxevents个已经就绪(已经准备好)的读写事件。实际的读写事件由本函数返回值决定(换句话说,返回的是有事件发生的TCP连接的数目,但因为内存所限,可能100个TCP上有事件发生,但返回的数字却是80 —— 小于100)。
    参数timeout 阻塞等待的时长。
    总体来说,该函数就是到双向链表中去,把此刻同时连入的连接中有事件发生的连接拿过来,后续用read、write或send、recv之类的函数收发数据。某个socket只要在双向链表中,该socket一定发生了某个/某些事件,换句话说,只有发生了某个/某些事件的socket,才会在双向链表中出现。
    这就是epoll高效的原因,因为epoll每次只遍历发生事件的一小部分socket连接(这些socket都在这个双向链表中),而不用到全部socket连接中去逐个遍历以判断事件是否到来。
    epitem是一个红黑树节点,同时也是一个双向链表节点,所以这个epitem节点设计得非常巧妙,很通用,既能做为红黑树的一个节点加到红黑树中,也能作为双向链表的一个节点加到双向链表中,所以,通过epoll_wait函数到双向链表中取节点时,取出来的依旧是epitem节点。
    rdlink成员,有2个指针,这样就能够把epitem节点插入双向链表当中。
    假如有3个TCP连接上都收到了事件,那么这3个TCP连接肯定都待在双向链表里了(当然它们同时也待在红黑树里)。

2.2.3 epoll_wait原理

  • 源码中的找到该函数实现的源码,
    ① while循环,用于等待一小段时间(如100ms)。这一小段时间内发生的事件的节点(socket连接),就会被操作系统放到双向链表中。
    ② 等待的时间到达后,确定本次返回给调用本函数(epoll_wait)的调用者程序的事件数量
    用一个while循环把这一批事件的信息返回给调用者程序。注意从双向链表中移除返回给调用者程序的节点(节点始终在红黑树中存着,但是否在双向链表中取决于该节点是否收到了事件)。另外,epitem结构中的rdy成员用于标记该节点是否存在于双向链表中,所以,当从双向链表中移除时,rdy成员被设置为0。

2.4 内核向双向链表增加节点

  • epoll_wait函数实际上是去双向链表中取节点,那么,是谁把这些节点插入双向链表中的呢?虽然是操作系统(内核)。操作系统什么时候向双向链表插入节点呢?显然是某个TCP连接上有事件到来时(这些事件是程序员用epoll_ctl登记到红黑树里面的),操作系统就会向双向链表中插入节点。
    写代码时,哪些事件会使操作系统把节点插入双向链表去?一般分4种情况。
    (1)客户端完成三次握手时,操作系统会向双向链表插入节点,这时服务器往往要调用accept函数把该连接从已完成连接队列中取走。
    (2)当客户端关闭连接时,操作系统会向双向链表插入节点,这时服务器也要调用close关闭对应的socket。
    (3)当客户端发送来数据时,操作系统会向双向链表插入节点,这时服务器可以调用send或者recv来收数据。
    (4)当可以发送数据时,操作系统会向双向链表插入节点,这时服务器可以调用send或者write向客户端发送数据。(如果客户端接收数据慢,服务器端发送数据块,那么服务器就得等客户端收完一批数据后才能再发下一批,以免客户端"噎死")

三、ET(边缘触发)、LT(水平触发)模式深入

  • LT:是水平触发,属于低速模式,如果该事件没有处理完,就会被一直触发。
  • ET:边缘触发,属于高速模式,该事件通知只会出现1次。

一般认为ET的效率很高,但是ET的编程难度很大。

客户端实例代码,方便下面运行结果演示:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<sys/socket.h>

#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 <IP> <port>\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);
}

3.1 epoll实例 - 水平触发

调用read函数后,输入缓冲区中仍有数据需要读取。而且会因此注册新的事件并从epoll_wait函数返回时将循环输出“return epoll_wait”字符串。
如果该事件没有处理完,就会被一直触发。

代码如下:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/epoll.h>
#include<fcntl.h>
#include<errno.h>
//为了验证边缘触发的工作方式,将缓冲区设置为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 <port> \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);
}

在这里插入图片描述

图3_1 epoll水平触发运行结果

从运行结果可以看出,每当收到客户端数据时,都会注册该事件,并因此多次调用epoll_wait函数。

3.2 epoll实例 - 边缘触发

边缘触发方式中,接收数据时仅注册1次该事件函数。
就是因为这种特点,一旦发生输入相关事件,就应该读取输入缓冲区的全部数据。因此需要验证输入缓冲区是否为空。

read函数返回-1时,变量errno中的值为EAGAIN时,说明没有数据可读。
既然如此,为何还需要将套接字变成非阻塞模式?边缘触发方式下,以阻塞方式工作的read & write函数有可能引起服务器端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞read & write函数有可能引起服务端的长时间停顿。因此,边缘触发方式中一定采用非阻塞read & write函数。

边缘触发必知的两点:
(1) 通过errno变量验证错误原因。
(2) 为了完成非阻塞(Non-blocking)I/O,更改套接字属性。

代码如下:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/epoll.h>
#include<fcntl.h>
#include<errno.h>
//为了验证边缘触发的工作方式,将缓冲区设置为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 <port> \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);
}

运行客户端、服务端的边缘触发方式,结果如下:
在这里插入图片描述

图3_2 epoll边缘触发运行结果

上述的运行结果需要注意的是,客户端发送消息次数和服务端epoll_wait函数调用次数。客户端从请求连接到断开连接共发送5次数据,服务端也相应产生了5个事件。

3.3 水平触发和边缘触发孰优孰劣

边缘触发的优点:
可以分离接受数据和处理数据的时间点。

在这里插入图片描述

图3_3 理解边缘触发

运行流程如下:

  • 服务端分别从客户端A、B、C接收数据。
  • 服务端按照A、B、C的顺序重新组合收到的数据。
  • 组合的数据将发送任意主机。

为完成该过程,若能按如下流程运行程序,服务端的实现并不难。

  • 客户端按照A、B、C的顺序连接服务器端,并依序向服务器端发送数据。
  • 需要接收数据的客户端应在客户端A、B、C之前连接到服务器端并等待。

但现实中可能频繁出现如下这些情况,换言之,如下情况更符合实际。

  • 客户端C和B正向服务器端发送数据,但A尚未连接到服务器端。
  • 客户端A、B、C乱序发送数据。
  • 服务端已收到数据,但要接收数据的目标客户端还未连接到服务器端。

因此,即使输入缓冲区收到数据(注册相应的事件),服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来巨大的灵活性。

条件触发中无法区分数据接收和处理吗?
并非不可能,但在输入缓冲区收到数据的情况下,如果不读取(延迟处理),则每次调用epoll_wait函数时都会产生相应的事件。而事件也会累加,服务器端能承受吗?这在现实中不可能的(本省并不合理,因此是根本不想做的事)。

猜你喜欢

转载自blog.csdn.net/weixin_30197685/article/details/131465487