IO多路复用

作者: SparkOnly | 来源:发表于2021-08-12 14:48 被阅读0次

    IO模型

    阻塞IO只能阻塞一个IO操作,IO复用模型能阻塞多个IO操作,所以才叫多路复用
    读数据

    1. 等待数据到达
    2. 将到达的数据拷贝到kernel的buffer,再从kernel buffer拷贝到User Space的buffer

    Blocking IO

    阻塞IO

    直到数据全拷贝至User Space后才返回

    Non-Blocking IO

    非阻塞IO

    不断去Kernel做Polling,询问比如读操作是否完成,没完成则read()操作会返回EWOUDBLOCK,需要过一会再尝试执行一次read()。该模式会消耗大量CPU

    IO Multiplexing

    IO多路复用

    之前等待时间主要消耗在等数据到达上。IO Multiplexing则是将等待数据到来和读取实际数据两个事情分开,好处是通过select()等IO Multiplexing的接口一次可以等待在多个Socket上。select()返回后,处于Ready状态的Socket执行读操作时候也会阻塞,只是只阻塞将数据从Kernel拷贝到User的时间

    Signal-Driven I/O

    信号驱动IO

    首先注册处理函数到SIGIO信号上,在等待数据到来过程结束后,系统触发SGIO信号,之后可以在信号处理函数中执行读数据操作,再唤醒Main Thread或直接唤醒Main Thread让它完成数据读取。整个过程没有一次阻塞。
    问题:TCP下,连接断开/可读/可写等都会产生Signal,并且Signal没有提供好的方法去区分这些Signal到底为什么被触发

    Asynchronous I/O

    异步IO

    AIO是注册一个读任务,直到读任务完全完成后才会通知应用层。AIO是由内核通知IO操作什么时候完成,信号驱动IO是由内核告知何时启动IO操作
    也存在挺多问题,比如如何去cancel一个读任务

    IO模型比较

    模型比较

    除了AIO是异步IO,其他全是同步IO

    IO多路复用接口

    select

    int select(int nfds, fd_set *readfds, fd_set *writefds,
               fd_set *exceptfds, struct timeval *timeout);
    
    • nfds是readfds、writefds、exceptfds中编号最大的那个文件描述符加1。
    • readfds 监听读操作的文件描述符列表,当被监听的文件描述符有可以不阻塞就读取的数据时,select会返回并将读就绪的描述符放在readfds指向的数组内
    • writefds 监听写操作,当被监听的文件描述符中能可以不阻塞就写数据时,select会返回
    • exceptfds 监听出现异常的文件描述符类别
    • timeout select最大阻塞时间,精度为毫秒
      select返回条件:
    • 有文件描述符就绪,可读/可写/异常
    • 线程被interrupt;
    • timeout

    fd_set: 一个long类型的数组,每一位可以表示一个文件描述符

    # 简化结构  32=1024/8/4
    # unsigned long int “无符号长整型”数据
    typedef struct{
        unsigned long int fds_bits[32];
    }fd_set;
    

    问题

    • 监听的文件描述符有上限FD_SETSIZE,一般是1024。因为fd_set是个bitmap,它为最多nfds个描述符都用一个bit去表示是否监听
    • 用户侧,select返回传入的所有的描述符列表集合,包括ready和非ready的描述符,用户侧需要去遍历所有readfds、writefds、exceptfds去看哪个描述符是ready状态,再做接下来的处理。还要清理这个ready状态,做完IO操作后再塞给select准备执行下一轮IO操作
    • Kernel侧,select执行后每次都要陷入内核遍历三个描述符集合数组为fd注册监听,即在描述符指向的Socket或文件等上面设置处理函数,从而在文件ready时能调用处理函数。等有文件描述符ready后,在select返回退出之前,kernel还需要再次遍历描述符集合,将设置的这些处理函数拆除再返回
    • 惊群问题。假设一个fd被多个进程或线程注册在自己的select描述符集合内,当这个文件描述符ready后会将所有监听它的进程或线程全部唤醒
    • 无法动态添加描述符,比如一个线程已经在执行select,突然想写数据到某个新描述符上,就只能等前一个select返回后重新设置fd set重新执行select

    poll

    int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    

    返回条件与select一样。
    fds还是关注的描述符列表。poll将events和reevents分开了,所以如果关注的events没有发生变化就可以重用fds,poll只修改rents不会动events。fds是个数组,不是fds_set,没有了上限。
    相对于select,poll解决了fds长度上限问题,解决了监听描述符无法复用问题,但仍需在poll返回后遍历fds去找ready的描述符,也要清理ready描述符对应的revents,Kernel也同样是每次poll调用需要去遍历fds注册监听,poll返回时拆除监听,也仍有惊群问题,无法动态修改描述符的问题。

    epoll

    int epoll_create(int size);
    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);
    
    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 */
    };
    

    使用步骤:

    1. 用epoll_create创建epoll的描述符;
    2. 用epoll_ctl将一个个需要监听的描述符以及监听的事件类型用epoll_ctl注册在epoll描述符上;
    3. 执行epoll_wait等着被监听的描述符Ready,epoll_wait返回后遍历Ready的描述符,根据Ready的事件类型处理事件
    4. 如果某个被监听的描述符不再需要了,需要用epoll_ctl将它与epoll的描述符解绑
    5. 当epoll描述符不再需要时需要主动close,像关闭文件一样释放资源

    优点

    • 监听的描述符没有上限
    • epoll_wait每次只会返回Ready的描述符,不用完整遍历所有被监听的描述符
    • 监听的描述符被注册到epoll后会与epoll的描述符绑定,维护在内核,不主动通过epoll_ctl执行删除不会自动被清理,所以每次执行epoll_wait后用户侧不用重新配置监听,Kernel侧在epoll_wait调用前后也不会反复注册和拆除描述符的监听
    • 可以通过epoll_ctl动态增减监听的描述符,即使有另一个线程已经在执行epoll_wait
    • epoll_ctl在注册监听的时候还能传递自定义的event_data,一般是传描述符
    • 即使没线程等在epoll_wait上,Kernel因为知道所有被监听的描述符,所以在这些描述符Ready时候就能做处理,等下次有线程调用epoll_wait时候直接返回。这也帮助epoll去实现IO Edge Trigger,即IO Ready时候Kernel就标记描述符为Ready,之后在描述符被读空或写空前不再去监听它
    • 多个不同的线程能同时调用epoll_wait等在同一个epoll描述符上,有描述符Ready后它们就去执行

    缺点

    • epoll_ctl是个系统调用,每次修改监听事件,增加监听描述符的时候都是一次系统调用,没有批量操作的方法
    • 对于服务器上大量连上又断开的连接处理效率低,即accept()执行后生成一个新的描述符需要执行epoll_ctl去注册新Socket的监听,之后epoll_wait又是一次系统调用,如果Socket立即断开了epoll_wait会立即返回,又需要再用epoll_ctl把它删掉
    • 依然有惊群问题,需配合使用方式避免

    kqueue

    int kqueue(void);
    int kevent(int kq, const struct kevent *changelist, int nchanges, struct kevent *eventlist, int nevents, 
               const struct timespec *timeout);
    
    struct kevent {
             uintptr_t  ident;       /* identifier for this event */
             short     filter;       /* filter for event */
             u_short   flags;        /* action flags for kqueue */
             u_int     fflags;       /* filter flag value */
             int64_t   data;         /* filter data value */
             void      *udata;       /* opaque user data identifier */
             uint64_t  ext[4];       /* extensions */
    };
    

    changelist用于传递关心的event
    nchanges用于传递changelist的大小
    eventlist用于当有事件产生后,将产生的事件放在这里
    nevents用于传递eventlist大小
    timeout 超时时间
    kqueue高级的地方在于,它监听的不一定非要是Socket,不一定非要是文件,可以是一系列事件,所以struct kevent内参数叫filter,用于过滤出关心的事件。
    kqueue有epoll所有优点,还能通过changelist一次注册多个关心的event,不需要像epoll那样每次调用epoll_ctl去配置

    更多Epoll

    epoll综合的执行过程

    当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里。
    如此,一棵红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。


    对比

    Edge-Trigger, Level-Trigger

    Epoll有两种触发模式,一种Edge Trigger简称ET,一种Level Trigger简称LT。每个使用epoll_ctl注册在epoll描述符上的被监听的描述符都能单独配置自己的触发模式。
    从使用角度的区别:ET模式下当一个文件描述符Ready后,需要以Non-Blocking方式一直操作这个FD直到操作返回EAGAIN错误位置,期间Ready这个事件只会触发epoll_wait一次返回。LT模式,如果FD上的事件一直处在Ready状态没处理完,则每次调用epoll_wait都会立即返回
    场景:

    1. 一个Socket注册在epoll fd上,监听它的读事件
    2. Socket另一端发送了2KB数据到这个Socket
    3. epoll_wait返回,并带着这个Socket的fd说它读Ready
    4. Socket的reader只从Socket读了1KB的数据
    5. 再次执行epoll_wait
      如果这个Socket注册在epoll fd上时待着EPOLLET标志,即ET模式,及时Socket还有1KB数据没读,第5步epoll_wait执行时也不会立即返回,会一直阻塞直到再有新数据到达这个Socket。因为这个Socket上的数据一直没有读完,Ready状态在上一次触发epoll_wait返回后一直没被清理。需要等这个Socket上所有可读的数据全部被读干净,read()操作返回EAGAIN后,再次执行epoll_wait,如果再有新数据到达Socket,epoll_wait才会立即因为Socket读Ready而返回。
      如果LT模式,Socket还剩1KB数据没读,第5步执行epoll_wait后它也会带着这个Socket的fd立即返回,event列表会记录这个Socket读Ready。
      ET模式下如果数据分好几个部分到来,则即使处于读Ready状态且Socket还未读空情况下,每个新到达的数据部分都会触发一次epoll_wait返回,除非Socket的fd在注册到epoll fd的时候设置EPOLLONESHOT标志,这个Socket只要触发过一次epoll_wait返回后,不管再有多少数据到来,Socket有没有读空,都不会再触发epoll_wait返回,必须主动带着EPOLL_CTL_MOD再执行一次epoll_ctl把Socket的fd重新设置到epoll的fd上

    Java的Selector

    Java的NIO提供了Selector类,用于跨平台的实现Socket Polling,即IO多路复用。BSD系统上对应的是Kqueue,Window上对应的是Select,Linux上对应的是LT的Epoll(为了跨平台统一,Windows上背后是Select,是LT的)
    Selector的使用:

    1. 先通过Selector.open()创建出来Selector;
    2. 创建出来SelectableChannel(可以理解为Socket),配置Channel为Non-Blocking
    3. 通过Channel下的register接口注册Channel到Selector,注册时可以带上关系的事件比如OP_READ, OP_ACCEPT, OP_WRITE等
    4. 调用Selector上的select()等待有Channel上有Event产生
    5. select()返回后说明有Channel有Event产生,通过Selector获取SelectionKey,即哪些Channel有什么事件产生了
    6. 遍历所有获取的SelectionKey检查产生了什么事件,是OP_READ还是OP_WRITE等,之后处理Channel上的事件
    7. 从select()返回的Iterator中移除处理完的SelectionKey

    相关文章

      网友评论

        本文标题:IO多路复用

        本文链接:https://www.haomeiwen.com/subject/llhvrltx.html