【高并发服务器 03】——epoll模型

avatar
作者
猴君
阅读量:4

epoll模型理论(从select到epoll)

select

select的算法时间复杂度略高,存在线性的性能下降问题(需要遍历访问文件描述符)。并且,受限于早期的内核资源的限制,select能够监视的文件描述符的数量不超过1024个。这个是他的缺陷

但是他的创新之处在于:他把多线程多进程的机制改成为一个线程就可以实现并发管理

  • 早期的时候:Apache(网页服务器,http服务器),用户如果想访问,Apache会开一个进程跟用户去沟通,并由这个进程给用户提供服务,用户下去之后,这个进程就被销毁了。

    这种一个用户一个进程的模式存在一个很大的问题,进程消耗资源比较多:PCB,进程空间,进程之间的交互也很难。虽然后来有了线程,成本代价远低于进程,但也需要一定的代价。

select支持1024个并发度,假如用select写一个Apache,就意味着,如果有1024个客户端接到Apache服务器,Apache完全可以用select监视他们,谁给我发个request请求,我就给他发一个response,如果他没有数据请求,我就等着直到他下线。


poll

通过查看poll的文档,我们可以得出两个结论:

  1. poll的文件描述符不再受到1024的限制(因为poll底层使用了链表这种数据结构,而select底层是用了类似数组这样的结构)
  2. poll引入了事件的概念,将文件描述符和感兴趣的事件比特掩码的形式)绑定到一起,把返回的事件放到revents中,因此poll没有对出事提交的“表单”进行任何修改,我至少没有必要在每一次循环的时候都像select那样进行初始化设置。
int select(int nfds, fd_set *readfds, fd_set *writefds,            fd_set *exceptfds, struct timeval *timeout); 

我们可以看到,select管理了三个集合,分别标记三个你所感兴趣的事件。每次都要一开始写好,并收回来查看这三张纸,哪个可以存钱,哪个可以取钱,哪个异常。而poll就不需要修改fd和events的信息了,返回消息都存放在revents里面。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);  struct pollfd {     int   fd;         /* file descriptor */     short events;     /* requested events */     short revents;    /* returned events */ }; 

epoll

linux2.6之后引入了epoll,并且只有linux平台有,epoll被设计成一个API(比函数高级一些,有一套组合的调用函数)。

  • epoll对于相应文件描述符可以采用**边缘触发(edge-triggered)水平触发(level-triggered)**两种模式,边缘触发表示,如果你在我发生的时候没有响应我,你就别再响应我了,或者只处理了我发过来的部分数据,后续数据也就不管了(更适合高并发的场景)。水平触发表示,如果事件发生了,我就必须响应你,如果在发生的那一刻我没来得及响应你,那么之后我也必须响应你(更适合安全场景)。

  • epoll中还使用了mmap技术,节省了内核态到用户态的拷贝

    注意:mmap只用在epoll实例里面:epoll_create创建并返回一个epoll实例,这个实例文件在自己的进程空间里有一片存储空间(里面存储了文件描述符集合),为了避免将实例中的数据,在每次传送给内核让内核去修改的时候,内核需要首先对数据进行拷贝,然后在这份拷贝的基础上对数据进行修改,最终将这份拷贝传送给用户态。mmap节省的是这个过程中的拷贝。

  • 而且epoll也没有文件描述符数量上的限制。也不存在性能的线性降低的问题(用户可以直接获取到就绪的events而不需要挨个问文件描述符)

epoll_ctl() 将感兴趣的文件描述符注册进实例空间。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *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 */ }; 

event里的data采取了共用体union的形式,丰富了传入参数的多样性选择

epoll_wait() 等待IO事件,如果没有事件就绪就阻塞当前线程,直到时间超时(timeout=-1会无限期等下去)

int epoll_wait(int epfd, struct epoll_event *events,                int maxevents, int timeout); 

epoll_wait功能就是在epfd这个epoll实例当中监听事件的发生,并将这些就绪的事件按顺序存储在events指针所指的连续数据当中,最多maxevents个,返回int发生就绪事件的个数。


总结一下select和epoll的对比,三者主要在以下三个方面差异:

  1. 监视的文件描述符数量:有没有限制
  2. 时间复杂度:是否存在性能的线性降低
  3. 内核空间:是否需要频繁地拷贝,mmap的引入

epoll小作业:TCP 100并发服务器的实现

1.task1.c

/************************************************************************* 	> File Name: 1.task1.c 	> Author: jby 	> Mail:  	> Created Time: Sun 24 Mar 2024 09:02:20 AM CST  ************************************************************************/  #include "head.h" #define MAXUSER 2000   // 2000并发 #define MAXEVENTS 10   // epoll支持的并发事件数 #define INS 4 		   // 线程池中线程的数量 #define QUEUESIZE 100  // 任务队列长度  int clients[MAXUSER];  // 全局的用户fd管理区 char *data[MAXUSER];   // 全局的数据存放区 int epollfd, total;    // 全局的epollfd; total:并发数 pthread_mutex_t mutex[MAXUSER]; // 每个客户端配一个互斥锁,为什么不是给线程配而是给用户配,因为用户的数据存放的区域已经按照不同的用户分隔开了,对于不同线程取到同一用户的不同数据,需要加锁处理。  void logout(int sig) { 	DBG("total = %d\n", total); 	exit(1); } void freeAll() {     for (int i = 0; i < MAXUSER; i++) {         free(data[i]);     }     return ; } int main (int argc, char **argv) { 	if (argc != 2) { 		fprintf(stderr, "Usage : %s port.\n", argv[0]); // 因为写的是服务端,所以要设置端口 		exit(1); 	} 	int server_listen, port, sockfd; 	port = atoi(argv[1]); // 将字符串串转换为整数 	if ((server_listen = socket_create(port)) < 0) { 		perror("socket_create"); 		exit(1); 	} 	DBG(YELLOW"<Init> : server_listen %d start on port %d .\n"NONE, server_listen, port); // 打印在哪个端口监听,监听套接字  	// 下面考虑用线程池 	// 开启线程池之前需要首先创建一个任务队列 	struct task_queue *taskQueue = (struct task_queue *)calloc(1, sizeof(struct task_queue)); // 用指针的形式 	task_queue_init(taskQueue, QUEUESIZE); // 任务队列的初始化 	DBG(YELLOW"<Init> : task_queue init.\n"NONE);  	// 下面创建几个线程 	// pthread_t tid[INS];  	pthread_t *tid = (pthread_t *)calloc(INS, sizeof(pthread_t)); // 等价的写法,但是空间申请在堆区 	// 启动每一个线程 	for (int i = 0; i < INS; i++) { 		pthread_create(&tid[i], NULL, thread_work, (void *)taskQueue); // NULL:这个参数是指向线程属性对象的指针。在这个例子中,通过传递 NULL,我们指定使用默认的线程属性。 	} 	DBG(YELLOW"<Init> : work threads create.\n");  	// 锁需要初始化才能用 	for (int i = 0; i < MAXUSER; i++) { 		pthread_mutex_init(&mutex[i], NULL); 	} 	DBG(YELLOW"<Init> : pthread mutex init.\n"NONE);  	// 初始化全局数据区 	for (int i = 0; i < 2000; i++) { 		data[i] = (char *)calloc(4096, sizeof(char)); 	}  	// 创建epoll实例 	if ((epollfd = epoll_create(1)) < 0) { // epoll_create(size):size只需要大于0即可,作用可以忽略 		perror("epoll_create"); 		exit(1); 	}  	// 注册epoll事件 	struct epoll_event ev, events[MAXEVENTS]; 	// 为了让server_listen能够监听客户端,第一个事件便是把server_listen的fd注册进epoll实例 	ev.data.fd = server_listen; 	ev.events = EPOLLIN;	// EPOLLIN:这是一个宏,表示对应的文件描述符可读。具体来说,它表示文件描述符上有新的输入数据可读,或者监听的 socket 上有新的连接尝试,或者是一个管道的写端已经关闭,使得读操作不会再被阻塞。 	if (epoll_ctl(epollfd, EPOLL_CTL_ADD, server_listen, &ev) < 0) { 		perror("epoll_ctl"); 		exit(1); 	} 	DBG(YELLOW"<Init> : Epoll instance created and add server_listen.\n"NONE);  	signal(SIGINT, logout); // SIGINT是一个宏定义,代表“中断信号”。这是一个特定的信号,通常由用户按下Ctrl+C产生,用于请求中断一个程序。当SIGINT信号被触发时,系统将调用这个logout函数。  	// 等待客户端链接到达。 	for (;;) { 		int nfds = epoll_wait(epollfd, events, MAXEVENTS, -1); // -1: 这个参数指定了 epoll_wait 函数的超时时间,以毫秒为单位。在这个例子中,值为 -1 表示 epoll_wait 函数将无限期地等待,直到至少有一个监视的文件描述符上发生了事件。如果这个参数设置为非负值,epoll_wait 将在指定的时间后返回,即使没有事件发生。如果设置为 0,epoll_wait 将立即返回,这种情况通常用于非阻塞轮询。 		// 返回响应事件的fd个数,并且将事件挨个填写到events数组当中 		if (nfds <= 0) { 			perror("epoll_wait"); 			exit(1); 		} 		total += nfds; 		for (int i = 0; i < nfds; i++) { 			int fd = events[i].data.fd; // 提取出事件的fd 			if (fd == server_listen && (events[i].events & EPOLLIN)) { 				// 这意味着有新的客户端的TCP请求到来,客户端需要先accept接收,用一个新的sockfd代表客户端 				if ((sockfd = accept(server_listen, NULL, NULL)) < 0) { // NULL, NULL: 这两个 NULL 参数分别对应于 accept 函数的第二个和第三个参数。第二个参数(这里是第一个 NULL)如果不是 NULL,它应该是指向 sockaddr 结构的指针,该结构用于接收连接方的协议地址(例如,IP 地址和端口号)。第三个参数(这里是第二个 NULL)如果不是 NULL,它应该是指向 socklen_t 类型的变量的指针,该变量在输入时表示地址结构的长度,在输出时表示实际存储在地址结构中的字节数。在这个例子中,因为这两个参数都是 NULL,所以我们不关心连接方的地址信息。 					perror("accept"); 					exit(1); 				} 				// 再把客户端fd注册进epoll实例 				ev.data.fd = sockfd; 				ev.events = EPOLLIN | EPOLLET; // 设为边缘触发模式,这样就只会断开一次 				clients[sockfd] = sockfd; 				make_nonblock(sockfd); 				DBG(CYAN"make_nonblock fd = %d\n"NONE, sockfd); 				if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev) < 0) { 					perror("epoll_ctl"); 					exit(1); 				} 			} else { 				if (events[i].events & EPOLLIN) { 					// 如果是客户端发来数据,那么添加进任务队列中 					task_queue_push(taskQueue, (void *)&clients[fd]); // 现在是把fd传进任务队列,而不是把数据传进任务队列 				} 				if (events[i].events & EPOLLHUP) {  					// 异常 					epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, NULL); 					close(fd); 				} 			} 		} 	} 	freeAll(); 	free(taskQueue); } 

用telnet 测试本地即可。
在这里插入图片描述

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!