• select、poll、epoll源码分析


    0 事件

    • 所有正规tcp和所有udp数据被认为是普通数据

    • tcp外带数据是优先级带数据

    • tcp的对端写半关闭(也就是对端发来FIN,写半关闭),认为是普通数据,但是 读操作返回0

    • tcp连接存在错误可认为是普通数据,也可以是POLLERR。但是随后读操作返回-1

    • 监听套接字上的已完成队列不为空的时候,认为是普通数据或是优先级带数据。

    • 非阻塞式connect连接完成,被认为是套接字可写。

    1 select

    #include <sys/select.h>
    #include <sys/time.h>
    
    int select(int maxfd_add_1, fd_set *readset, fd_set *write_set, fd_set * exceptset, 
                const struct timeval *timeout);
    struct timeval{
        long tv_sec;
        long tv_usec;
    };
    
    
    /*设置fd_set,下面的四个函数都是宏,不能取地址*/
    
    //清空
    void FD_ZERO(fd_set *fdset);
    
    //设置一个位
    void FD_SET(int fd, fd_set *fdset);
    
    //清除一个位
    void FD_CLR(int fd, fd_set *fdset);
    
    /*检查某个位是否被设置,可用于函数返回时判断那个文件描述符就绪*/
    int FD_ISSET(int fd,fd_set *fdset);
    

    maxfd_add_1后面三个集中设置了的文件描述符的最大值+1,因为这个值表示的是个数。这个参数存在的意义就是内核在每次唤醒的时候需要遍历的文件描述符个数,因而不用全部遍历所有1024个文件描述符的状态

    存在这个参数的原因纯粹是为了效率。因为fd_set的最大值典型的是1024.为了避免所有的都被检查,因此使用这个。

    最大值为宏FD_SETSIZE

    readsetwrite_setexceptset这三个fd_set类型是一个位图。返回的时候,如果对应的位被设置,那么表示文件描述符就绪,因此需要用户再次手动遍历依次。

    1.2 源码

    首相将需要的信息拷贝到内核:文件描述符,超时时间等。

    每个文件描述符,调用其poll方法.当被唤醒的时候,再次调用文件描述符对应的poll()方法,通过返回的值来判断是否就绪。并且这里注册的回调回调函数与epoll的无关。

    然后设置对应的文件描述符的位,再拷贝到用户空间。

    1.3 弊端

    • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

    • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

    • select支持的文件描述符数量太小了,默认是1024

    2 poll

    2.1 调用函数

    #include <poll.h>
    int poll(struct pollfd *fdarray, unsigned long n, int timeout);
    //成功个数,错误-1,超时0
    
    struct pollfd{
      int fd;
      short events;
      short revents;
    };
    • fdarray是一个pollfd类型的数组(首指针),n表示这个数组的个数。

    • pollfdevents成员是要测试的条件,而revents是内核要填充的,表示文件描述符当前的读写状态。

    2.2 源码

    poll方法不再使用位图的方式传入文件描述符符,而是采用一个结构体的方式,因而在遍历每个文件描述符的poll方法的时候,就从结构体的数组中依次遍历,因此突破了select文件描述符的限制。

    但是,仍然采用对每个文件描述符poll的方法获取就绪状态。

    因此仍然低效。

    3 epoll

    3.1 调用函数

    #include <sys/epoll.h>
    
    // 创建 epollfd 的函数
    int epoll_create(int size);
    int epoll_create1(int flags);
    
    // 对要监听的文件描述符的的增加修改删除
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    
    // 开始阻塞
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, 
        int timeout);
    int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, 
        int timeout, const sigset_t *sigmask);
    
    // epoll_wait 返回的时候,events表示发生的、
    //时间 data则表示与文件描述符相关的信息,可以指向一个结构体,
    //也可以是直接的文件描述符
    typedef union epoll_data {
        void *ptr; // 常用这个
        int fd;
        uint32_t u32;
        uint64_t u64;
    } epoll_data_t;
    
    struct epoll_event
    {
     //感兴趣的事件如EPOLLIN,EPOLLOUT,EPOLLPRI等
        uint32_t events;  
     //一般使用data的fd成员表示感兴趣的socket
        epoll_data_t data;
    };
    

      

      

      

     epoll_ctl() 这个函数是修改epoll关注描述符的。

    1. epfdepoll_create创建的文件描述符
    2. op表示要修改的方式:
      1. POLL_CTL_ADD:添加

      2. EPOLL_CTL_MOD:修改

      3. EPOLL_CTL_DEL:删除

    3. fd参数是要修改关注的文件描述符
    4. event则是要关注的事件了。

     epoll_wait() 就是开始阻塞的函数了。

    1. events是已经分配好的epoll_event结构体。这个是一个空的,内核负责帮我们填充.events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存
    2. maxevents是传入要填充的数组的大小。
    常量说明
    EPOLLIN 可读
    EPOLLOUT 可写
    EPOLLPRI 优先级带可读
    EPOLLERR 错误
    EPOLLHUP 挂断
    EPOLLET 将EPOLL设为边缘触发模式,这是相对于水平触发(Level Triggered)来说的
    EPOLLONESHOT 只监听一次事件,当监听完这次事件之后,就从关注的数组中移除。

    3.2 源码分析

    epoll_create()函数在内核中申请了一块内存区域,存储eventpoll等结构体。eventpoll中有一个满足事件的链表,就绪队列,用来存储以满足事件的文件描述符的信息。一个红黑数用来维护需要监听的文件描述符

    epoll_ctl()采用增量式的修改方法,对关注的文件描述符进行更改,也就是说,每次只能修改一个。已经传入的则保存在eventpoll结构体中的红黑树中。epoll_ctl()插入的或是修改文件描述符的时候,会直接将包含文件描述符的结构体挂在到相应设备的等待队列上,并注册回调函数。加到红黑树中。这个回调函数是epoll性能优于select和poll的关键,当文件描述符就绪释,会调用该回调函数,回调函数会将本结构体挂到eventpoll的就绪队列上。如果一个线程在添加文件描述符,并且在添加到相应的等待队列的时候返回值指示该文件描述符已经就绪,而另一个线程阻塞在epoll_wait(),那么epoll_wait()的线程会被立即唤醒。(poll()方法会将相应的文件描述符添加到对应的等待队列,返回值标志当前文件描述符发生的事件,如果已经就绪,那么后面需要判断该文件描述符是否已经挂在到eventpoll的已就绪队列中,如果是,那么不操作。如果不是,那么就挂载到等待队列,并唤醒进程)。

    epoll_wait()函数的主要工作是先判断已就绪队列中是否已经为空,如果为空,那么就阻塞等待,回调函数会唤醒本进程。当进程被唤醒的时候,就将eventpoll中已就绪队列中数据,填充到用户传入的内存空间中。并返回。如果有多线程的存在,那么可能被唤醒,但是别的epoll_wait()已经返回,那么在不超时的情况下,会继续阻塞。

    其中填充函数会再次调用poll去获取最新的文件描述符的状态。

    3.3 为什么高效

    1. 每次调用不需要传入所有的文件描述符

    select/poll每次调用都要传递所要监控的所有fd给select/poll系统调用(这意味着每次调用都要将fd列表从用户态拷贝到内核态,当fd数目很多时,这会造成低效)。而每次调用epoll_wait时(作用相当于调用select/poll),不需要再传递fd列表给内核,因为已经在epoll_ctl中将需要监控的fd告诉了内核(epoll_ctl不需要每次都拷贝所有的fd,只需要进行增量式操作)。所以,在调用epoll_create之后,内核已经在内核态开始准备数据结构存放要监控的fd了。每次epoll_ctl只是对这个数据结构进行简单的维护。

    2. 每次epoll_wait()被唤醒,不需要去遍历所有文件描述符

    3. epoll没有文件描述符数量上的限制。

    3.4 epoll两种工作方式LT和ET

    水平触发(LT)是epoll缺省的工作方式,支持阻塞和非阻塞文件描述符。当数据可读写的时候就唤醒epoll_wait如果不对这个文件描述符作任何操作,内核还是会继续通知,所以,这种模式编程出错误可能性要小一点。传统的select/poll都使用这种模型。

    边缘触发(ET)是高速工作方式,只支持非阻塞模式。当一个新的事件到来时,ET模式下当然可以从epoll_wait调用中获取到这个事件,可是如果这次没有把这个事件对应的套接字缓冲区处理完,在这个套接字中没有新的事件再次到来时,在ET模式下是无法再次从epoll_wait调用中获取这个事件的。

    LT模式下开发基于epoll的应用要简单些,不太容易出错。而在ET模式下事件发生时,如果没有彻底地将缓冲区数据处理完,则会导致缓冲区中的用户请求得不到响应。

    ngnix采用ET模式。

    ET只支持非阻塞模式的原因在于ET当事件触发的时候,比如写操作,那么就需要一直写,直到阻塞位置。因此只能使用非阻塞,如果使用了阻塞。那么在最后一次就会阻塞住。非阻塞socket,read/write立即返回-1, 同时errno设置为EAGAIN。

    而LT模式可以使用非阻塞也可以使用阻塞,是因为LT模式下,没有读写一定要到底的要求。因此是,一般是不用阻塞模式的。

    下面是ET模式下,正确的读写模式。

    n = 0;
    while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {
        n += nread;
    }
    if (nread == -1 && errno != EAGAIN) {
        perror("read error");
    }
    
    int nwrite, data_size = strlen(buf);
    n = data_size;
    while (n > 0) {
        nwrite = write(fd, buf + data_size - n, n);
        if (nwrite < n) {
            if (nwrite == -1 && errno != EAGAIN) {
                perror("write error");
            }
            break;
        }
        n -= nwrite;
    }
    

      

    ET模式下accept

    多个连接同时到达,服务器的TCP就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll只会通知一次,accept只处理一个连接,导致TCP就绪队列中剩下的连接都得不到处理。解决办法是用while循环抱住accept调用,处理完TCP就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢?accept返回-1并且errno设置为EAGAIN就表示所有连接都处理完。

    使用Linux epoll模型,水平触发模式;当socket可写时,会不停的触发socket可写的事件,因此需要向socket写数据的时候才把socket加入epoll,等待可写事件。接受到可写事件后,调用write或者send发送数据。当所有数据都写完后,把socket移出epoll。缺点在于很少的数据也需要加入epoll

    开始不把socket加入epoll,需要向socket写数据的时候,直接调用write或者send发送数据。如果返回EAGAIN,把socket加入epoll,在epoll的驱动下写数据,全部数据发送完毕后,再移出epoll。

    源码级别差异:LT和ET的模式在于,在填充函数中,对每一个在已就绪队列中的文件描述符,都会再次调用poll函数,获取最新的文件描述符状态。而LT模式就在于当从已就绪队列中取出文件描述符,获取最新状态以后,会重新添加到已就绪队列中。因此在下一次epoll_wait()的时候会立即唤醒,然后获取最新状态。然后会判断数据是否可读,如果可读并且是ET模式,那么该文件描述符会被重新添加到等待队列,如果没有数据,那么就不添加了。因此可能会空转一次。

    epoll 惊群

    文章链接

    当某个等待在epoll实例上的进程被唤醒后,最终会进入到ep_scan_ready_list() 这个函数中,ep_scan_ready_list()会以回调方式调用ep_send_events_proc()来将数据复制到用户空间。而ep_scan_ready_list()函数在返回之前会再次判断epoll的就绪链表rdllist是否为空,如果不为空的话,就会再唤醒其他进程!

    epoll 多线程

    man中说epoll的后两个函数都是线程安全的,但是一般实现上,还是在epoll所在线程调用epoll_ctl()

  • 相关阅读:
    bzoj4044/luoguP4762 [Cerc2014]Virus synthesis(回文自动机+dp)
    bzoj4032/luoguP4112 [HEOI2015]最短不公共子串(后缀自动机+序列自动机上dp)
    bzoj3926/luoguP3346 [Zjoi2015]诸神眷顾的幻想乡(trie上构建广义后缀自动机)
    bzoj3144 [HNOI2013]切糕(最小割)
    知识点简单总结——原根和指标
    uoj86 mx的组合数 (lucas定理+数位dp+原根与指标+NTT)
    rest_framework 学习笔记(一)
    Django 数据库操作
    02-Kubenetes资源
    10-Helm
  • 原文地址:https://www.cnblogs.com/perfy576/p/8554734.html
Copyright © 2020-2023  润新知