select, poll, epoll 学习笔记
摘要
在学习 Redis 的过程中,发现 Redis 底层是复用了现成的 I/O多路复用模型(evport, epoll, kqueue, select),本篇博客就总结一下 Linux 内核中提供的三种模型。
select
fd_set
select()函数主要是建立在fd_set类型的基础上的。fd_set 是一组文件描述字(fd)的集合,它用一位来表示一个fd(下面会仔细介绍),对于fd_set类型通过下面四个宏来操作:
void FD_CLR(int fd, fd_set *set) //置 fd_set 中 fd 对应的位为 0 int FD_ISSET(int fd, fd_set *set) //判断 fd 在 fd_set 对应的位是否为 1 void FD_SET(int fd, fd_set *set) //将 fd_set 中 fd 对应的位置 1 void FD_ZERO(fd_set *set) //将 fd_set 中的所有位全部置 0
首先先看一下 fd_set 的结构体:
// from linux-3.10, win 下定义可能不同,一般为 4096(FD_SETSIZE = 64, long long int 类型数组,数组大小为 64, 所以 64 * 64 = 4096) // posix_types.h #undef __FD_SETSIZE #define __FD_SETSIZE 1024 typedef struct { unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))]; } __kernel_fd_set;
可以看出 fd_set 是一个结构体,该结构体包含了一个 unsigned long 类型的一个数组,数组大小为:1024/(8 * sizeof(long)), 在我的系统上(Ubuntu 16.04),sizeof(long) == sizeof(unsigned long) = 8。所以数组的大小为:1024/(8 * 8) = 16; 由于 fd_set 是以位来标识一个 fd 的,所以我们来计算这个数组表示的位数,数组中的每一个元素都是 long 类型,数组元素个数为 16, 所以得:bits = 8 * 8 * 16 = 1024(bit), 故可以标识 1024 个 fd。
可以通过下面的代码在自己的 Linux 系统上跑一下,参考代码:
#include <sys/time.h> #include <sys/time.h> #include <sys/types.h> #include <unistd.h> #include <stdio.h> int main() { printf("fd_set size = %d\n", sizeof(fd_set)); printf("long type size: %d\n", sizeof(long)); printf("unsigned long type size: %d\n", sizeof(unsigned long)); return 0; }
原型
先看一下 select 原型:
/*
* nfds: 监听的最大描述符序号 + 1
* readfds: 监听的读描述符集合
* writefds: 监听的写描述符集合
* exceptfds: 监听的发生异常的描述符集合
* *timeout: 时间结构体指针,表示阻塞时间
* returnVal: 满足监听事件的 fd 总个数
* */
int select(int nfds, fd_set *readfds, fd_set *writefd, fd_set *exceptfds, struct timeval *timeout);
示例代码
// from man 2 select #include <stdio.h> #include <stdlib.h> #include <sys/time.h> #include <sys/types.h> #include <unistd.h> int main(void) { fd_set rfds; struct timeval tv; int retval; /* Watch stdin (fd 0) to see when it has input. */ FD_ZERO(&rfds); FD_SET(0, &rfds); /* Wait up to five seconds. */ tv.tv_sec = 5; tv.tv_usec = 0; retval = select(1, &rfds, NULL, NULL, &tv); /* Don't rely on the value of tv now! */ if (retval == -1) perror("select()"); else if (retval) printf("Data is available now.\n"); /* FD_ISSET(0, &rfds) will be true. */ else printf("No data within five seconds.\n"); exit(EXIT_SUCCESS); }
执行原理
理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
(1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000 (2)若fd=5,执行FD_SET(fd,&set);后set变为0010,0000(下标为 5 的位被 置 1) //参考博客中说是:0001,0000 (3)若再加入fd=2,fd=1,则set变为0010,0110 //参考博客中说是:0001,0011 (4)执行select(6,&set,0,0,0)阻塞等待 (5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0110。注意:没有事件发生的fd=5被清空 //参考博客中说:set 变为 0000,0011
总结
(1) 可监控的文件描述符个数取决于 sizeof(fd_set) 的值,我 Ubuntu 16.04 系统上 sizeof(fd_set)=128,所以可监控的描述符个数为:128*8=1024 个;即可监控的文件描述符的个数是有限的; (2) select 在内核执行时会修改传入的 fds 的值,所以每次执行 select() 都要重新传入,可以提前将原 dfs 保存下来,但是每次都需要将 fds 从用户态复制到内核态; (3) 因为linux系统对select()的实现中会修改参数tv为剩余时间,所以对于select函数中的最后一个参数,需要在循环中设置,每次循环要重新设置;
Poll
原型
struct pollfd { int fd; /* file descriptor */ short events; /* requested events */ short revents; /* returned events */ }; /* * *fds: 监听的 pollfd 数组 * nfds: 监听的 pollfd 个数 * timeout: 监听的阻塞时间
* returnVal: 返回满足监听事件的 fd 总个数,同 select 的返回值 * */ int poll(struct pollfd *fds, nfds_t nfds, int timeout);
示例代码
for(int i=0; i<5; i++) { memset(&client, 0, sizeof(client)); addrlen = sizeof(client); pollfds[i].fd = accept(sockfd, (struct sockaddr*)&client, &addrlen); pollfds[i].events = POLLIN; sleep(1); while(1) { puts("round again"); poll(pollfds, 5, 50000); for(int i=0; i<5; i++) { if(pollfds[i] & POLLIN) { pollfds[i].revents = 0; // 恢复 revents ,使之可重用 memset(buffer, 0, MAXBUF); read(pollfds[i].fd, buffer, MAXBUF); puts(buffer); } } } }
总结
(1) 监听的描述符个数不仅限于 1024 个了(可以通过 ulimit -a 查看最大可打开的文件个数, 也可进行修改); (2) 传入的 pollfds 是可重用的,但是仍然需要用户态到内核态的拷贝,因为我们置位了 revents; (3) 仍然需要遍历所以的 pollfds, 来确定是哪个 fd 满足条件,只是根据 revents 来判断而已
epoll
原型
epoll 函数主要包括三个函数 epoll_create, epoll_ctl, epoll_wait
epoll_create
/* * size: 建议内核监听的事件个数,Since Linux 2.6.8, the size argument is ignored * returnVal: 成功:非负值,失败:-1 * 作用:在内核中创建一个红黑树,返回值指向这个红黑树的树根(其实返回值是整数,通过这个索引找到数值中存在的指向树根节点的指针) * */ int epoll_create(int size);
epoll_ctl
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 */ }; /* * epfd: epoll_create() 返回的 fd (指向红黑树的根节点的句柄) * op: 操作类型:EPOLL_CTL_ADD/MOD/DEL * fd: 将要进行操作的 fd * *event: 结构体的地址,event->events 的取值: * EPOLLIN:读事件 * EPOLLOUT:写事件 * EPOLLERR:异常事件 * EPOLLET: 边缘触发(Edge Triggered)模式, 默认为水平触发(Level Triggered)模式 * event->data.fd 的取值:同 fd 参数 * 为了内核执行 epoll_wait() 的时候,把该结构复制进 出参 events 中,进行返回 * returnVal: 成功:0, 失败:-1 * */ int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_wait
/* * epfd: epoll_create() 返回的 fd (指向红黑树的根节点的句柄) * *events: 出参:满足监听条件的 poll_event 类型的数组 * maxevents: 返回满足监听条件的最大 events 个数 * timeout: 阻塞时间 * returnVal: 满足监听条件的文件描述符 fd 的个数, 出错返回 -1 * */ int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
触发方式
水平触发(Level Trigger):默认的触发方式,如果缓冲区中的数据一次没有全部读取完,则 epoll_wait() 会再次触发,直到所有数据全部被取走完成。
用管道来模拟水平触发方式,参考代码如下:
#include <stdio.h> #include <stdlib.h> #include <sys/epoll.h> #include <errno.h> #include <unistd.h> #define MAXLINE 10 int main(int argc, char *argv[]) { int efd, i; int pfd[2]; pid_t pid; char buf[MAXLINE], ch = 'a'; pipe(pfd); pid = fork(); if(pid == 0) { //子进程 写操作 close(pfd[0]); //关闭管道读端 while(1) { //aaaa\n for(i=0; i<MAXLINE/2; i++) buf[i] = ch; buf[i-1] = '\n'; ch++; //bbbb\n for(; i<MAXLINE; i++) buf[i] = ch; buf[i-1] = '\n'; ch++; //aaaa\nbbbb\n write(pfd[1], buf, sizeof(buf)); //把 buff 写入管道 sleep(5); //sleep 5s } close(pfd[1]); }else if(pid > 0) { //父进程 读操作 struct epoll_event event; struct epoll_event revents[10]; //epoll_wait 返回的 event 数组 int res, len; close(pfd[1]); //关闭管道写端 efd = epoll_create(10); //event.events = EPOLLIN | EPOLLET; //ET 边缘触发 event.events = EPOLLIN; //LT 水平触发(默认) event.data.fd = pfd[0]; //监控管道读操作 epoll_ctl(efd, EPOLL_CTL_ADD, pfd[0], &event); while(1) { res = epoll_wait(efd, revents, 10, -1); printf("res: %d\n", res); if(revents[0].data.fd == pfd[0]) { len = read(pfd[0], buf, MAXLINE/2); //只读取管道中的一半内容 write(STDOUT_FILENO, buf, len); } } close(pfd[0]); } return 0; } /* * 输出结果为: * aaaa * bbbb * sleep(5s) * cccc * dddd * sleep(5s) * .... * 结果解析:水平触发会持续触发 epoll_wait 函数返回,直到所监视的文件描述符中的所有内容读完为止 * */
边缘触发(Edge Trigger):默认只触发一次,直到有新的触发事件产生才会再次触发。这种方式可以很大程度上减少 epoll_wait() 的触发次数,提高 epoll 的效率。
用管道来模拟边缘触发方式,参考代码如下:
#include <stdio.h> #include <stdlib.h> #include <sys/epoll.h> #include <errno.h> #include <unistd.h> #define MAXLINE 10 int main(int argc, char *argv[]) { int efd, i; int pfd[2]; pid_t pid; char buf[MAXLINE], ch = 'a'; pipe(pfd); pid = fork(); if(pid == 0) { //子进程 写操作 close(pfd[0]); //关闭管道读端 while(1) { //aaaa\n for(i=0; i<MAXLINE/2; i++) buf[i] = ch; buf[i-1] = '\n'; ch++; //bbbb\n for(; i<MAXLINE; i++) buf[i] = ch; buf[i-1] = '\n'; ch++; //aaaa\nbbbb\n write(pfd[1], buf, sizeof(buf)); //把 buff 写入管道 sleep(5); //sleep 5s } close(pfd[1]); }else if(pid > 0) { //父进程 读操作 struct epoll_event event; struct epoll_event revents[10]; //epoll_wait 返回的 event 数组 int res, len; close(pfd[1]); //关闭管道写端 efd = epoll_create(10); event.events = EPOLLIN | EPOLLET; //ET 边缘触发 //event.events = EPOLLIN; //LT 水平触发(默认) event.data.fd = pfd[0]; //监控管道读操作 epoll_ctl(efd, EPOLL_CTL_ADD, pfd[0], &event); while(1) { res = epoll_wait(efd, revents, 10, -1); printf("res: %d\n", res); if(revents[0].data.fd == pfd[0]) { len = read(pfd[0], buf, MAXLINE/2); //只读取管道中的一半内容 write(STDOUT_FILENO, buf, len); } } close(pfd[0]); } return 0; } /* * 输出结果为: * aaaa * sleep(5s) * bbbb * sleep(5s) * cccc * sleep(5s) * dddd * sleep(5s) * .... * 结果解析: * step1: buf = aaaa\nbbbb\n 触发 epoll_wait(), 读出 aaaa\n, buf 中还有 bbbb\n, 但没有新的触发事件发生 * sleep(5s) * * step2: buf = bbbb\ncccc\ndddd\n 触发 epoll_wait(), 读出 bbbb\n, buf 中还有 cccc\ndddd\n, 没有新的触发事件发生 * sleep(5s) * ... * */
示例代码
struct epoll_event events[5]; int epfd = epoll_create(10); for(int i=0; i<5; i++) { static struct epoll_event ev; memset(&client, 0, sizeof(client)); addrlen = sizeof(client); ev.data.fd = accept(sockfd, (struct sockaddr*)&client, &addrlen); ev.events = EPOLLIN; epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); } while(1) { puts("round again"); nfds = epoll_wait(epfd, events, 5, 10000); for(int i=0; i<nfds; i++) { memset(buffer, 0, MAXBUF); read(events[i].data.fd, buffer, MAXBUF); puts(buffer); } }
总结
(1) 与 poll 一样,没有描述符 1024 的限制; (2) 只需要从用户态拷贝一次监控的描述符到内核态; (3) 返回了就绪的描述符(events 数组),而不是只返回就绪的描述符总个数,不需要遍历全部描述符
参考资料: