深度剖析5种IO模型

遇到的问题

  • 为啥子Nginx能同时支撑百万并发和数十万连接?
  • 为啥子Redis单线程的性能比多线程的Memcached还要强?
  • 为啥子Dubbo的的通信效率非常高?

其实上面的场景回归到具体应用上就是一种超强的IO能力,谈到IO我们可以先了解有哪些IO模型

追女神的五种技能

当前我们计算机常见的五种IO模型包括:同步阻塞IO、同步非阻塞IO、IO多路复用、信号驱动IO和异步IO,在介绍5种模型之前,请允许我引用不知哪位高手的比喻:

同步阻塞IO – 到女神宿舍楼下给女神发一条微信:“宝贝,我来找你了,一起去吃饭看电影吧”, 然后你就默默的一直等着女神下楼, 这个期间除了等待也做不了其他事情。

同步非阻塞IO – 给女神发微信, 如果她不回, 你就每隔一段时间继续发, 一直发到女神下楼, 这个期间你除了发短信等待不会做其他事情。

IO多路复用 – 是找一个楼管阿姨来帮你监视下楼的女生, 这个期间你可以些其他的事情. 例如可以顺便去买点水果,或者打打游戏, 上个厕所等等。IO复用又包括 select, poll, epoll 模式. 那么它们的区别是什么?

  • select阿姨问每一个下楼的女生, 她不知道这个是不是你的女神, 她需要一个一个询问,并且select阿姨能力还有限, 最多一次帮你监视1024个妹子。
  • poll阿姨不限制盯着女生的数量, 只要是经过宿舍楼门口的女生, 都会帮你去问是不是你女神。
  • epoll阿姨不限制盯着女生的数量, 并且也不需要一个一个去问. 那么如何做呢? epoll阿姨会为每个进宿舍楼的女生包包贴上一个大字条,上面写上女生自己的名字, 只要女生下楼了,epoll阿姨就知道这个是不是你女神了, 然后大妈再通知你。

上面这些同步IO有一个共同点就是, 当女神走出宿舍门口的时候,你已经站在宿舍门口等着女神的, 此时你属于阻塞状态

信号驱动IO:给女神送一款专属智能手机,设置了12个铃声提示。当你约女神吃饭的时候,你就对应的拨打吃饭的铃声,女神就下楼去吃饭;如果你约女神看电影,就拨打看电影的铃声,女神就直接去电影院了……

异步IO:你告诉女神我来了, 然后你就去打游戏了, 一直到女神下楼了, 发现找不见你了, 女神再给你打电话通知你, 说我下楼了, 你在哪呢? 这时候你才来到宿舍门口。

上面5种追女神的方式你get到了吗?下面我们在对应的看看操作系统是如何处理这5类IO的。

计算机的IO模型

同步阻塞IO, 看一段基于Socket通信的动画

图片[1]-深度剖析5种IO模型-不念博客
于Socket通信

在Linux中,默认情况下所有socket都是阻塞模式的。当用户线程调用系统函数read(),内核开始准备数据(从网络接收数据),内核准备数据完成后,数据从内核拷贝到用户空间的应用程序缓冲区,数据拷贝完成后,请求才返回。从发起read请求到最终完成内核到应用程序的拷贝,整个过程都是阻塞的。为了提高性能,可以为每个连接都分配一个线程。因此,在大量连接的场景下就需要大量的线程,会造成巨大的性能损耗,这也是传统阻塞IO的最大缺陷。

图片[2]-深度剖析5种IO模型-不念博客
同步阻塞

非阻塞IO:用户线程在发起Read请求后立即返回,不用等待内核准备数据的过程。如果Read请求没读取到数据,用户线程会不断轮询发起Read请求,直到数据到达(内核准备好数据)后才停止轮询。非阻塞IO模型虽然避免了由于线程阻塞问题带来的大量线程消耗,但是频繁地重复轮询大大增加了请求次数,对CPU消耗也比较明显。这种模型在实际应用中很少使用。

图片[3]-深度剖析5种IO模型-不念博客
同步非阻塞

不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。

所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read 函数

这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。

操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

这样,就需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。

多路复用IO

上述的处理方式其实已经可以了,我们用了一个主线程进行监听,每次有一个新的连接进来,我们就新启动一个线程去发起非阻塞的read()。但是这里有一个很大的问题,就是相当于我们为每一个客户端都建立了一个线程,服务端的线程资源很容易就被阻塞了,而且创建线程也是很大的系统开销。

图片[4]-深度剖析5种IO模型-不念博客
多路复用IO

相比于阻塞IO模型,多路复用只是多了一个select/poll/epoll函数。select函数会不断地轮询自己所负责的文件描述符/套接字的到达状态,当某个套接字就绪时,就对这个套接字进行处理。select负责轮询等待,recvfrom负责拷贝。当用户进程调用该select,select会监听所有注册好的IO,如果所有IO都没注册好,调用进程就阻塞。

对于客户端来说,一般感受不到阻塞,因为请求来了,可以用放到线程池里执行;但对于执行select的操作系统而言,是阻塞的,需要阻塞地等待某个套接字变为可读

IO多路复用其实是阻塞在select,poll,epoll这类系统调用上的,复用的是执行select,poll,epoll的线程。

信号驱动IO模型

信号驱动IO模型,应用进程使用sigaction函数,内核会立即返回,也就是说内核准备数据的阶段应用进程是非阻塞的。内核准备好数据后向应用进程发送SIGIO信号,接到信号后数据被复制到应用程序进程。

采用这种方式,CPU的利用率很高。不过这种模式下,在大量IO操作的情况下可能造成信号队列溢出导致信号丢失,造成灾难性后果。

异步IO模型

异步IO模型的基本机制是,应用进程告诉内核启动某个操作,内核操作完成后再通知应用进程。在多路复用IO模型中,socket状态事件到达,得到通知后,应用进程才开始自行读取并处理数据。在异步IO模型中,应用进程得到通知时,内核已经读取完数据并把数据放到了应用进程的缓冲区中,此时应用进程直接使用数据即可。归纳其特点如下:

  • 异步I/O执行的两个阶段都不会阻塞读写操作,由内核完成。
  • 完成后内核将数据放到指定的缓冲区,通知应用程序来取。

很明显,异步IO模型性能很高。不过到目前为止,异步IO和信号驱动IO模型应用并不多见,传统阻塞IO和多路复用IO模型还是目前应用的主流。Linux2.6版本后才引入异步IO模型,目前很多系统对异步IO模型支持尚不成熟。很多应用场景采用多路复用IO替代异步IO模型。

IO多路复用的目的

提高系统的吞吐能力:与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必频繁的创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

图片[5]-深度剖析5种IO模型-不念博客
IO多路复用

如上图所示,管道的2边分布着不同的请求和处理服务,大家共用一个沟通通道。左边的服务等待相关的事件发生时才需要使用管道进行数据传输,那么如何保证这个管道最大程度地为多个服务使用呢?其实就是一个贪心的想法,这个方案就是本文要探讨的另一个内容。

IO多路复用的设计理念

I/O多路复用(I/O multiplexing) 指的其实是在单个线程通过记录跟踪每一个Socket(I/O流)的状态来同时管理多个I/O流。本质上都是同步I/O。简单的说:I/O多路复用就一个线程通过一种机制可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

IO多路复用的发展和实现

最有名的场景就是nginx的LB服务。nginx使用epoll(一种实现)接收请求,在高并发(很多请求同时打进来)场景中时, epoll会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的处理函数处理。

常用的IO多路复用实现有三种:select / poll / epoll

  • select是第一个实现 (1983 左右在BSD里面实现的)。select:http://www.cnblogs.com/Anker/archive/2013/08/14/3258674.html
  • poll 在14年以后(1997年)实现,它修复了select的很多问题。poll:http://www.cnblogs.com/Anker/archive/2013/08/15/3261006.html
  • 5年以后, 在2002, 大神 Davide Libenzi 实现了epoll。epoll:http://www.cnblogs.com/Anker/archive/2013/08/17/3263780.html

select

select系统调用,其实就是轮询一组 socket,看这组socket 是否有可读的、可写的、异常的socket。当timeout设置为0时,表示阻塞操作,此时最少有一个socket准备好,才返回,否则进程阻塞自己,让出CPU资源;当timeout设置为非0时,表示非阻塞操作,timeout 或者 找到符合条件的socket ,就能返回。

socket的函数声明:

int select(
    int nfds, //nfds:监控的文件描述符集里最大文件描述符加1
    fd_set *readfds,// readfds:监控有读数据到达文件描述符集合,传入传出参数
    fd_set *writefds,// writefds:监控写数据到达文件描述符集合,传入传出参数
    fd_set *exceptfds,// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
    struct timeval *timeout);// timeout:定时阻塞监控时间,3种情况
//  1.NULL,永远等下去
//  2.设置timeval,等待固定时间
//  3.设置timeval里时间均为0,检查描述字后立即返回,轮询

通过了解select的实现原理,可以推断出,select存在以下问题:

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
  4. 传入的文件描述符的数量有1024的限制。为什么有这个限制呢?是因为在glibc中,FD_SET结构体是一个数组,整个数组的长度是1024位,每一位表示一个socket句柄。虽然在 linux系统,文件描述符是 从 0 开始,默认最大值是1024,但是可以通过命令调整。因此,1024 并不是内核的限制。(而是glibc的限制)如果想突破1024的限制,那么不要使用FD_SET read_set,直接 read_set = (fd_set *)malloc(8000/8). //1000字节,也就是8000位,允许8000个句柄。

poll

poll 也是操作系统提供的系统调用函数。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制,因为传入的变成了结构体。不在原来的fds上修改,而是将结果放在返回值,因此能精确的得知哪些socket有变化,且不需要重置fds。

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*文件描述符*/
  shortevents; /*监控的事件*/
  shortrevents; /*监控事件中满足条件返回的事件*/
};

epoll

epoll 是优化了select和poll的方案,它解决了 select 和 poll 的一些问题,但是并不代表所以得场景都需要用epoll

还记得上面说的 select 的三个细节么?

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

所以 epoll 主要就是针对这三点进行了改进。

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

具体,操作系统提供了这三个函数。

第一步,创建一个 epoll 句柄

int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符。

int epoll_ctl(
    int epfd, int op, int fd, struct epoll_event *event);

第三步,类似发起了 select() 调用

int epoll_wait(
    int epfd, struct epoll_event *events, int max events, int timeout);

epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。

epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。

epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式(水平触发),ET是“高速”模式(边缘触发)。LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,而在ET模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。

所以在ET模式下,read一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或者 遇到EAGAIN错误。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

epoll为什么要有EPOLLET触发模式?

如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLLET这种边沿触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。

如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

epoll 使用问题

  1. epoll 惊群:多个进程等待在 ep->wq 上,事件触发后所有进程都被唤醒,但只有其中 1 个进程能够成功继续执行的现象。其他被白白唤起的进程等于做了无用功,可能会造成系统负载过高的问题。为了解决 epoll 惊群,内核后续的高版本又提供了 EPOLLEXCLUSIVE 选项和 SO_REUSEPORT 选项,我个人理解两种解决方案思路上的不同点在于:EPOLLEXCLUSIVE 是在唤起进程阶段起作用,只唤起排在队列最前面的 1 个进程;而 SO_REUSEPORT 是在分配连接时起作用,相当于每个进程自己都有一个独立的 epoll 实例,内核来决策把连接分配给哪个 epoll
  2. epmutex、ep->mtx、ep->lock 3 把锁的区别。锁的粒度和使用目的不同。
  • epmutex 是一个全局互斥锁,epoll 中一共只有 3 个地方用到这把锁。分别是 ep_free() 销毁一个 epoll 实例时、eventpoll_release_file() 清理从 epoll 中已经关闭的文件时、epoll_ctl() 时避免 epoll 间嵌套调用时形成死锁。我的理解是 epmutex 的锁粒度最大,用来处理跨 epoll 实例级别的同步操作。
  • ep->mtx 是一个 epoll 内部的互斥锁,在 ep_scan_ready_list() 扫描就绪列表、eventpoll_release_file() 中执行 ep_remove()删除一个被监视文件、ep_loop_check_proc()检查 epoll 是否有循环嵌套或过深嵌套、还有 epoll_ctl() 操作被监视文件增删改等处有使用。可以看出上述的函数里都会涉及对 epoll 实例中 rdllist 或红黑树的访问,因此我的理解是 ep->mtx 是一个 epoll 实例内的互斥锁,用来保护 epoll 实例内部的数据结构的线程安全。
  • ep->lock 是一个 epoll 实例内部的自旋锁,用来保护 ep->rdllist 的线程安全。自旋锁的特点是得不到锁时不会引起进程休眠,所以在 ep_poll_callback 中只能使用 ep->lock,否则就会丢事件。

总结

一切一切的开始都起源于这个 read 系统调用,它是操作系统提供的并且是阻塞的,称之为阻塞IO。为了破这个局,程序员在用户态通过多线程来防止主线程卡死。后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是非阻塞 IO

但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。

后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用。多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。

© 版权声明
THE END
喜欢就支持一下吧
点赞130赞赏 分享
评论 抢沙发
头像
欢迎光临不念博客,留下您的想法和建议,祝您有愉快的一天~
提交
头像

昵称

取消
昵称代码图片

    暂无评论内容