Linux-IO全整理:BIO/NIO/IO多路复用解析

BIO 同步阻塞

BIO是Blocking IO的意思。在类似于网络中进行readwriteconnect一类的系统调用时会被卡住。

举个例子,当用read去读取网络的数据时,是无法预知对方是否已经发送数据的。因此在收到数据之前,能做的只有等待,直到对方把数据发过来,或者等到网络超时。

对于单线程的网络服务,这样做就会有卡死的问题。因为当等待时,整个线程会被挂起,无法执行,也无法做其他的工作。

于是,网络服务为了同时响应多个并发的网络请求,必须实现为多线程的。每个线程处理一个网络请求。线程数随着并发连接数线性增长。这的确能奏效。实际上2000年之前很多网络服务器就是这么实现的。但这带来两个问题:

  • 线程越多,Context Switch就越多,而Context Switch是一个比较重的操作,会无谓浪费大量的CPU。
  • 每个线程会占用一定的内存作为线程的栈。比如有1000个线程同时运行,每个占用1MB内存,就占用了1个G的内存。

NIO 同步非阻塞

在BIO模式下,调用read,如果发现没数据已经到达,就会Block住。

在NIO模式下,调用read,如果发现没数据已经到达,就会立刻返回-1, 并且errno被设为EAGAIN

NIO就是轮询,不断的尝试有没有数据到达,有了就处理,没有(得到EWOULDBLOCK或者EAGAIN)就等一小会再试。这比之前BIO好多了,起码程序不会被卡死了。

但这样会带来两个新问题:

  • 如果有大量文件描述符都要等,那么就得一个一个的read。这会带来大量的Context Switch(read是系统调用,每调用一次就得在用户态和核心态切换一次)
  • 休息一会的时间不好把握。这里是要猜多久之后数据才能到。等待时间设的太长,程序响应延迟就过大;设的太短,就会造成过于频繁的重试,干耗CPU而已。

AIO 异步非阻塞

AIO和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,AIO可以把拷贝这一步也帮我们完成之后才通知应用程序。我们使用 aio_read 来读,aio_write 写。

IO多路复用

IO多路复用在Linux下包括了三种,selectpollepoll,抽象来看,他们功能是类似的,但具体细节各有不同:首先都会对一组文件描述符进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时。更多细节详见下面的 "具体怎么用"。IO多路复用都可以关注多个文件描述符,但对于这三种机制而言,不同数量级文件描述符对性能的影响是不同的

select

/* According to POSIX.1-2001, POSIX.1-2008 */
    #include <sys/select.h>

    /* According to earlier standards */
    #include <sys/time.h>
    #include <sys/types.h>
    #include <unistd.h>

    int select(int nfds, fd_set *readfds, fd_set *writefds,
                fd_set *exceptfds, struct timeval *timeout);

    int pselect(int nfds, fd_set *readfds, fd_set *writefds,
                fd_set *exceptfds, const struct timespec *timeout,
                const sigset_t *sigmask);

    void FD_CLR(int fd, fd_set *set);
    int  FD_ISSET(int fd, fd_set *set);
    void FD_SET(int fd, fd_set *set);
    void FD_ZERO(fd_set *set);

select的调用会阻塞到有文件描述符可以进行IO操作或被信号打断或者超时才会返回。

select将监听的文件描述符分为三组,每一组监听不同的需要进行的IO操作。readfds是需要进行读操作的文件描述符,writefds是需要进行写操作的文件描述符,exceptfds是需要进行异常事件处理的文件描述符。这三个参数可以用NULL来表示对应的事件不需要监听。当select返回时,每组文件描述符会被select过滤,只留下可以进行对应IO操作的文件描述符

每次循环调用时,都需要将描述符和文件拷贝到内核空间

poll

#include <poll.h>

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

    #include <signal.h>
    #include <poll.h>

    int ppoll(struct pollfd *fds, nfds_t nfds,
            const struct timespec *tmo_p, const sigset_t *sigmask);

    struct pollfd {
        int fd; /* file descriptor */
        short events; /* requested events to watch */
        short revents; /* returned events witnessed */
    };

和select用三组文件描述符不同的是,poll只有一个pollfd数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。events参数是我们需要关心的事件,revents是所有内核监测到的事件

每次循环调用时,都需要将描述符和文件拷贝到内核空间

epoll

#include <sys/epoll.h>

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

利用sys_epoll_creat()创建内核时间表,在sys_epoll_creat()里面创建了struct eventpoll结构体,其中包括两个成员:
就绪队列 struct list_head relist, 用来存放有就绪事件的描述符;
红黑树 struct rb_root rbr,作为内核时间表用来收集描述符;
每一个epoll对象都有一个独立的eventpoll结构体,用于存放epoll_ctl 向epoll对象中添加事件,这些事件都会通过ep_insert挂载到红黑树上,这样重复添加的事件就可以通过红黑树而高效的识别出来;

而所有的添加到epoll中都会与驱动程序建立回调关系,当相应的事件发生后, 会通过ep_poll_callback这个回调方法,它会将发生的事件添加到rdlist;

select、poll、epoll 区别

  1. select poll每次循环调用时,都需要将描述符和文件拷贝到内核空间;epoll 只需要拷贝一次;
    对于这种情况对于描述符数量不大还可以,但是当描述符的数量达到十几万甚至上百万的时候,效率就会急速降低,因为每一次轮询的时候都需要将这些所有的socket文件从用户态拷贝到内核态,会造成大量的浪费和资源开销;
  2. select每次返回后,都需要遍历所有的描述文件才能找到就绪的,时间复杂度为O(n),而epoll则需要O(1);
  3. select、poll是通过内核方式来完成的,时间复杂度O(n);epoll在每个描述文件上设置回调函数,时间复杂度为O(1);

猜你喜欢

转载自blog.csdn.net/wmq880204/article/details/115129800