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那样进行初始化设置。
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的对比,三者主要在以下三个方面差异:
- 监视的文件描述符数量:有没有限制
- 时间复杂度:是否存在性能的线性降低
- 内核空间:是否需要频繁地拷贝,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 测试本地即可。