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 用法示例

执行原理

理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。

1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,00002)若fd=5,执行FD_SET(fd,&set);后set变为0010,0000(下标为 5 的位被 置 1)  //参考博客中说是:0001,00003)若再加入fd=2,fd=1,则set变为0010,0110                              //参考博客中说是:0001,00114)执行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);
            }
        }
    }
}
poll 使用示例

总结

(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_create

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_ctl

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);
epoll_wait

触发方式

    水平触发(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 函数返回,直到所监视的文件描述符中的所有内容读完为止
 * */
epoll-LT.c

    边缘触发(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)
 * ...
 * */
epoll-ET.c

示例代码

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);
    }
}
epoll 使用示例

总结

(1) 与 poll 一样,没有描述符 1024 的限制;
(2) 只需要从用户态拷贝一次监控的描述符到内核态;
(3) 返回了就绪的描述符(events 数组),而不是只返回就绪的描述符总个数,不需要遍历全部描述符

 

参考资料:

https://www.cnblogs.com/anker/p/3265058.html

https://blog.csdn.net/starflame/article/details/7860091

posted on 2020-06-15 19:01  爱笑的张飞  阅读(291)  评论(0编辑  收藏  举报

导航