epoll模型理论(从select到epoll)
select
select的算法时间复杂度略高,存在线性的性能下降问题(需要遍历访问文件描述符)。并且,受限于早期的内核资源的限制,select能够监视的文件描述符的数量不超过1024个。这个是他的缺陷。
但是他的创新之处在于:他把多线程多进程的机制改成为一个线程就可以实现并发管理
早期的时候:Apache(网页服务器,http服务器),用户如果想访问,Apache会开一个进程跟用户去沟通,并由这个进程给用户提供服务,用户下去之后,这个进程就被销毁了。
这种一个用户一个进程的模式存在一个很大的问题,进程消耗资源比较多:PCB,进程空间,进程之间的交互也很难。虽然后来有了线程,成本代价远低于进程,但也需要一定的代价。
select支持1024个并发度,假如用select写一个Apache,就意味着,如果有1024个客户端接到Apache服务器,Apache完全可以用select监视他们,谁给我发个request请求,我就给他发一个response,如果他没有数据请求,我就等着直到他下线。
poll
通过查看poll的文档,我们可以得出两个结论:
- poll的文件描述符不再受到1024的限制(因为poll底层使用了链表这种数据结构,而select底层是用了类似数组这样的结构)
- 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的对比,三者主要在以下三个方面差异:
- 监视的文件描述符数量:有没有限制
- 时间复杂度:是否存在性能的线性降低
- 内核空间:是否需要频繁地拷贝,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 测试本地即可。