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

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那样进行初始化设置。
c 复制代码
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

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

c 复制代码
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() 将感兴趣的文件描述符注册进实例空间。

c 复制代码
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会无限期等下去)

c 复制代码
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

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 测试本地即可。

相关推荐
五味香几秒前
Linux学习,ip 命令
linux·服务器·c语言·开发语言·git·学习·tcp/ip
落落落sss25 分钟前
MQ集群
java·服务器·开发语言·后端·elasticsearch·adb·ruby
我救我自己25 分钟前
UE5运行时创建slate窗口
java·服务器·ue5
大风吹PP凉1 小时前
38配置管理工具(如Ansible、Puppet、Chef)
linux·运维·服务器·ansible·puppet
康熙38bdc2 小时前
Linux 进程间通信——共享内存
linux·运维·服务器
刘艳兵的学习博客2 小时前
刘艳兵-DBA033-如下那种应用场景符合Oracle ROWID存储规则?
服务器·数据库·oracle·面试·刘艳兵
运维小文3 小时前
服务器硬件介绍
运维·服务器·计算机网络·缓存·硬件架构
小周不摆烂3 小时前
丹摩征文活动 | 丹摩智算平台:服务器虚拟化的璀璨明珠与实战秘籍
大数据·服务器
中云DDoS CC防护蔡蔡3 小时前
为什么海外服务器IP会被封
服务器·经验分享
是安迪吖3 小时前
nfs服务器
运维·服务器