5种IO模型以及常见服务IO模型设计

本文是转载于知乎100%弄明白5种IO模型,原作者勤劳的小手,在原博客基础上整理了其他
与IO模型相关的优质内容。

1、从TCP发送数据的流程说起

所有的系统I/O都分为两个阶段:等待就绪和操作。举例来说,读函数,分为等待系统可读和真正的读;同理,写函数分为等待网卡可以写和真正的写。

需要说明的是等待就绪的阻塞是不使用CPU的,是在“空等”;而真正的读写操作的阻塞是使用CPU的,真正在”干活”,而且这个过程非常快,属于memory copy,带宽通常在1GB/s级别以上,可以理解为基本不耗时。

要深入的理解各种IO模型,那么必须先了解下产生各种IO的原因是什么,要知道这其中的本质问题那么我们就必须要知道一条消息是如何从一个人发送到另外一个人的;

以两个应用程序通讯为例,我们来了解一下当“A”向"B" 发送一条消息,简单来说会经过如下流程:

第一步:应用A把消息发送到 TCP发送缓冲区。

第二步: TCP发送缓冲区再把消息发送出去,经过网络传递后,消息会发送到B服务器的TCP接收缓冲区。

第三步:B再从TCP接收缓冲区去读取属于自己的数据。

20221227100621

根据上图我们基本上了解消息发送要经过 应用A、应用A对应服务器的TCP发送缓冲区、经过网络传输后消息发送到了应用B对应服务器TCP接收缓冲区、然后最终B应用读取到消息。

如果理解了上面的消息发送流程,那么我们下面开始进入文章的主题;

2、阻塞IO |非阻塞IO

们把视角切换到上面图中的第三步, 也就是应用B从TCP缓冲区中读取数据。

20221227100706

思考一个问题:

因为应用之间发送消息是间断性的,也就是说在上图中TCP缓冲区还没有接收到属于应用B该读取的消息时,那么此时应用B向TCP缓冲区发起读取申请,TCP接收缓冲区是应该马上告诉应用B 现在没有你的数据,还是说让应用B在这里等着,直到有数据再把数据交给应用B。

把这个问题应用到第一个步骤也是一样,应用A在向TCP发送缓冲区发送数据时,如果TCP发送缓冲区已经满了,那么是告诉应用A现在没空间了,还是让应用A等待着,等TCP发送缓冲区有空间了再把应用A的数据访拷贝到发送缓冲区。

2.1 什么是阻塞IO

如果上面的问题你已经思考过了,那么其实你已经明白了什么是阻塞IO了,所谓阻塞IO就是当应用B发起读取数据申请时,在内核数据没有准备好之前,应用B会一直处于等待数据状态,直到内核把数据准备好了交给应用B才结束。

术语描述:在应用调用recvfrom读取数据时,其系统调用直到数据包到达且被复制到应用缓冲区中或者发送错误时才返回,在此期间一直会等待,进程从调用到返回这段时间内都是被阻塞的称为阻塞IO;

流程:

1、应用进程向内核发起recfrom读取数据。

2、准备数据报(应用进程阻塞)。

3、将数据从内核负责到应用空间。

4、复制完成后,返回成功提示。

20221227100729

2.1.1 阻塞IO存在的问题

如果IO阻塞了,用户线程会变成阻塞态,此时会让出CPU的使用权限,即使让出CPU使用权,也需要在寄存器中记录用户线程当前的状态(也就是上下文),
因为要保证,当用户线程恢复执行时能够正常运行(这里就是常说的线程上下文切换),那么问题就产生了。

线程是很”贵”的资源,主要表现在:

  1. 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数
  2. 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半
  3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态
  4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大

2.2 什么是非阻塞IO

我敢保证如果你已经理解了阻塞IO,那么必定已经知道了什么是非阻塞IO。按照上面的思路,所谓非阻塞IO就是当应用B发起读取数据申请时,如果内核数据没有准备好会即刻告诉应用B,不会让B在这里等待。

术语:非阻塞IO是在应用调用recvfrom读取数据时,如果该缓冲区没有数据的话,就会直接返回一个EWOULDBLOCK错误,不会让应用一直等待中。在没有数据的时候会即刻返回错误标识,那也意味着如果应用要读取数据就需要不断的调用recvfrom请求,直到读取到它数据要的数据为止。

流程:

1、应用进程向内核发起recvfrom读取数据。

2、没有数据报准备好,即刻返回EWOULDBLOCK错误码。

3、应用进程向内核发起recvfrom读取数据。

4、已有数据包准备好就进行一下 步骤,否则还是返回错误码。

5、将数据从内核拷贝到用户空间。

6、完成后,返回成功提示。

3、IO复用模型

如果你已经明白了非阻塞IO的工作模式,那么接下来我们继续了解IO复用模型的产生原因和思路。

思考一个问题:

我们还是把视角放到应用B从TCP缓冲区中读取数据这个环节来。如果在并发的环境下,可能会N个人向应用B发送消息,这种情况下我们的应用就必须创建多个线程去读取数据,每个线程都会自己调用recvfrom 去读取数据。那么此时情况可能如下图:

20221227100808

如上图一样,并发情况下服务器很可能一瞬间会收到几十上百万的请求,这种情况下应用B就需要创建几十上百万的线程去读取数据,同时又因为应用线程是不知道什么时候会有数据读取,为了保证消息能及时读取到,那么这些线程自己必须不断的向内核发送recvfrom 请求来读取数据;

那么问题来了,这么多的线程不断调用recvfrom 请求数据,先不说服务器能不能扛得住这么多线程,就算扛得住那么很明显这种方式是不是太浪费资源了,线程是我们操作系统的宝贵资源,大量的线程用来去读取数据了,那么就意味着能做其它事情的线程就会少。

所以,有人就提出了一个思路,能不能提供一种方式,可以由一个线程监控多个网络请求(我们后面将称为fd文件描述符,linux系统把所有网络请求以一个fd来标识),这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据,这么做就可以节省出大量的线程资源出来,这个就是IO复用模型的思路。

20221227100820

正如上图,IO复用模型的思路就是系统提供了一种函数可以同时监控多个fd的操作,这个函数就是我们常说到的select、poll、epoll函数,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,select函数监控的fd中只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时询问线程再去通知处理数据的线程,对应线程此时再发起recvfrom请求去读取数据。

术语描述:进程通过将一个或多个fd传递给select,阻塞在select操作上,select帮我们侦测多个fd是否准备就绪,当有fd准备就绪时,select返回数据可读状态,应用程序再调用recvfrom读取数据。

20221227100834

总结:复用IO的基本思路就是通过slect或poll、epoll 来监控多fd ,来达到不必为每个fd创建一个对应的监控线程,从而减少线程资源创建的目的。

3.1 IO多路复用模型扩展

IO多路复用是利用了系统内核提供的select、poll、epoll等函数来实现的。

3.1.1 select

select会将TCP中的全连接队列中的Socket对应生成的文件描述符放入到一个集合中,然后复制到内核中,让内核不断去轮询是否有读写事件的产生,一旦有,就把对应的Socket标记为可读/可写,再将全部的文件描述符集合拷贝到用户空间,select函数返回,应用程序需要再一次对文件描述符集合进行遍历,检查是否为可读/可写,对其进行处理。

具体过程

其实这里说细一点,就涉及到了操作系统调度和中断知识了~
当应用进程调用select函数时会陷入内核态,内核程序会去轮询有无产生读写事件的socket,如果没有的话,会将当前应用进程停靠在需要检查的socket的等待队列中(补充:socket的结构有三块:写缓存,读缓存,等待队列),也就是挂起该进程了,CPU切换其他进程运行。
一旦任意一个socket有事件产生,也就是网络数据包到达时,会触发网络数据传输完毕对应的中断,CPU转而执行中断处理程序,分析出该数据包是属于哪个socket,将数据包(根据TCP首部的端口号)放入对应的socket的读缓存中,然后去检查socket的等待队列是否有等待进程,有的话把等待进程移回工作队列中,中断结束。CPU的使用权交还给用户态。刚刚挂起的进程又回到工作队列中,又有机会获得CPU的运行时间片了,然后再次执行select函数,检查是否有读写事件发生的socket,有的话标记为可读,就接下去上面说的步骤啦~

几个缺点:

使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
将文件描述符集合从用户态到内核态,有拷贝的开销
当有数据时select就会返回,但是select函数并不知道哪个文件描述符有数据,后面还需要再次对文件描述符进行遍历,效率比较低。

3.1.2 poll

poll是对select的增强。它采用链表的形式来存储文件描述符,突破了select对文件描述符的限制,只受内核内存大小的限制。

但还是需要经历内核、应用进程对文件描述符集合的遍历检查,内核到应用进程的拷贝开销。

3.1.3 epoll

它使用了两种红黑树和就绪链表两种数据结构解决了select/poll的缺点。在Linux2.5.44版本中就使用了这种I/O复用机制。
主要有三个系统调用API:
// 内核创建epoll实例,包括红黑树和就绪链表
int epoll_create(int size);

// 对红黑树进行修改、删除、增加一个socket节点
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 内核利用红黑树,快速查找活跃的socket,放入就绪链表
// 再将就绪链表中一定数量的内容拷贝到events
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
复制代码
首先应用进程调用epoll_create创建epoll实例,同时在内核中建立红黑树和就绪链表;
调用epoll_ctl将会对红黑树增删改一个socket节点:

ADD 会检查红黑树有无这个socket,有的话加入就绪链表中,没有就会插入该红黑树中维护。
DEL 从epoll实例的各个资源删除。
MOD 会修改对应socket的状态,并再次检查红黑树,有活跃的socket会加入就绪链表中,没有就注册事件回调函数,每当有事件发生时就通过回调函数把这些socket放入就绪链表中。

epoll_wait会去检查就绪链表有无已经就绪的socket,没有就等待唤醒,有的话就拷贝回用户空间。
由于epoll从内核态仅需要拷贝活跃的socket到用户态,就解决了select/poll的大量socket拷贝开销和无效遍历的缺点。

适用场景

并不是说epoll就一定比select/poll好,每种技术都有适合的场景。如果是并发量比较低且socket都比较活跃的情况下,无需创建红黑树和就绪链表的开销,两次遍历的时间开销不会很大并且充分利用了每个遍历节点,所以select/poll会更适合。而如果是高并发且任一时间只有少数socket是活跃的,那epoll会更适合,因为它每次只拷贝活跃的socket到用户态。

4、信号驱动IO模型

复用IO模型解决了一个线程可以监控多个fd的问题,但是select是采用轮询的方式来监控多个fd的,通过不断的轮询fd的可读状态来知道是否有可读的数据,而无脑的轮询就显得有点暴力,因为大部分情况下的轮询都是无效的,所以有人就想,能不能不要我总是去问你是否数据准备就绪,能不能我发出请求后等你数据准备好了就通知我,所以就衍生了信号驱动IO模型。

于是信号驱动IO不是用循环请求询问的方式去监控数据就绪状态,而是在调用sigaction时候建立一个SIGIO的信号联系,当内核数据准备好之后再通过SIGIO信号通知线程数据准备好后的可读状态,当线程收到可读状态的信号后,此时再向内核发起recvfrom读取数据的请求,因为信号驱动IO的模型下应用线程在发出信号监控后即可返回,不会阻塞,所以这样的方式下,一个应用线程也可以同时监控多个fd。

类似于下图描述:
20221227100856

术语描述:首先开启套接口信号驱动IO功能,并通过系统调用sigaction执行一个信号处理函数,此时请求即刻返回,当数据准备就绪时,就生成对应进程的SIGIO信号,通过信号回调通知应用线程调用recvfrom来读取数据。

20221227100909

总结: IO复用模型里面的select虽然可以监控多个fd了,但select其实现的本质上还是通过不断的轮询fd来监控数据状态, 因为大部分轮询请求其实都是无效的,所以信号驱动IO意在通过这种建立信号关联的方式,实现了发出请求后只需要等待数据就绪的通知即可,这样就可以避免大量无效的数据状态轮询操作。

5、异步IO

其实经过了上面两个模型的优化,我们的效率有了很大的提升,但是我们当然不会就这样满足了,有没有更好的办法,通过观察我们发现,不管是IO复用还是信号驱动,我们要读取一个数据总是要发起两阶段的请求,第一次发送select请求,询问数据状态是否准备好,第二次发送recevform请求读取数据。

思考一个问题:

也许你一开始就有一个疑问,为什么我们明明是想读取数据,而却非得要先发起一个select询问数据状态的请求,然后再发起真正的读取数据请求,能不能有一种一劳永逸的方式,我只要发送一个请求我告诉内核我要读取数据,然后我就什么都不管了,然后内核去帮我去完成剩下的所有事情?

当然既然你想得出来,那么就会有人做得到,有人设计了一种方案,应用只需要向内核发送一个read 请求,告诉内核它要读取数据后即刻返回;内核收到请求后会建立一个信号联系,当数据准备就绪,内核会主动把数据从内核复制到用户空间,等所有操作都完成之后,内核会发起一个通知告诉应用,我们称这种一劳永逸的模式为异步IO模型。

20221227100942

术语描述: 应用告知内核启动某个操作,并让内核在整个操作完成之后,通知应用,这种模型与信号驱动模型的主要区别在于,信号驱动IO只是由内核通知我们合适可以开始下一个IO操作,而异步IO模型是由内核通知我们操作什么时候完成。

20221227100952

总结:异步IO的优化思路是解决了应用程序需要先后发送询问请求、发送接收数据请求两个阶段的模式,在异步IO的模式下,只需要向内核发送一次请求就可以完成状态询问和数拷贝的所有操作。

6、再谈IO模型里面的同步异步

我们通常会说到同步阻塞IO、同步非阻塞IO,异步IO几种术语,通过上面的内容,那么我想你现在肯定已经理解了什么是阻塞什么是非阻塞了,所谓阻塞就是发起读取数据请求的时,当数据还没准备就绪的时候,这时请求是即刻返回,还是在这里等待数据的就绪,如果需要等待的话就是阻塞,反之如果即刻返回就是非阻塞。

我们区分了阻塞和非阻塞后再来分别下同步和异步,在IO模型里面如果请求方从发起请求到数据最后完成的这一段过程中都需要自己参与,那么这种我们就称为同步请求;反之,如果应用发送完指令后就不再参与过程了,只需要等待最终完成结果的通知,那么这就属于异步。

我们再看同步阻塞、同步非阻塞,他们不同的只是发起读取请求的时候一个请求阻塞,一个请求不阻塞,但是相同的是,他们都需要应用自己监控整个数据完成的过程。而为什么只有异步非阻塞 而没有异步阻塞呢,因为异步模型下请求指定发送完后就即刻返回了,没有任何后续流程了,所以它注定不会阻塞,所以也就只会有异步非阻塞模型了。

7、tomcat中的IO模型

模型描述
BIO阻塞式IO,即Tomcat使用传统的java.io进行操作。该模式下每个请求都会创建一个线程,对性能开销大,不适合高并发场景。优点是稳定,适合连接数目小且固定架构。
NIO非阻塞式IO,jdk1.4 之后实现的新IO。该模式基于多路复用选择器监测连接状态在通知线程处理,从而达到非阻塞的目的。比传统BIO能更好的支持并发性能。Tomcat 8.0之后默认采用该模式
APR全称是 Apache Portable Runtime/Apache可移植运行库),是Apache HTTP服务器的支持库。可以简单地理解为,Tomcat将以JNI的形式调用Apache HTTP服务器的核心动态链接库来处理文件读取或网络传输操作。使用需要编译安装APR 库
AIO异步非阻塞式IO,jdk1.7后之支持 。与nio不同在于不需要多路复用选择器,而是请求处理线程执行完程进行回调调知,已继续执行后续操作。Tomcat 8之后支持。

8、Redis中的IO模型

9、Nginx中的IO模型

select:IO多路复用、标准并发模型。在编译 nginx 时,如果所使用的系统平台没有更高效的并发模型,select 模块将被自动编译。configure 脚本的选项:–with-select_module 和 --without-select_module 可被用来强制性地开启或禁止 select 模块的编译

poll:IO多路复用、标准并发模型。与 select 类似,在编译 nginx 时,如果所使用的系统平台没有更高效的并发模型,poll 模块将被自动编译。configure 脚本的选项:–with-poll_module 和 --without-poll_module 可用于强制性地开启或禁止 poll 模块的编译

epoll:IO多路复用、高效并发模型,可在 Linux 2.6+ 及以上内核可以使用

kqueue:IO多路复用、高效并发模型,可在 FreeBSD 4.1+, OpenBSD 2.9+, NetBSD 2.0, and Mac OS X 平台中使用

/dev/poll:高效并发模型,可在 Solaris 7 11/99+, HP/UX 11.22+ (eventport), IRIX 6.5.15+, and Tru64 UNIX 5.1A+ 平台使用

eventport:高效并发模型,可用于 Solaris 10 平台,PS:由于一些已知的问题,建议 使用/dev/poll替代。

为什么epoll快?
比较一下Apache常用的select和Nginx常用的epoll

select:
1、最大并发数限制,因为一个进程所打开的 FD (文件描述符)是有限制的,由 FD_SETSIZE 设置,默认值是 1024/2048
,因此 Select 模型的最大并发数就被相应限制了。自己改改这个 FD_SETSIZE ?想法虽好,可是先看看下面吧。
2、效率问题, select 每次调用都会线性扫描全部的 FD 集合,这样效率就会呈现线性下降,把 FD_SETSIZE
改大的后果就是,大家都慢慢来,什么?都超时了。
3、内核 / 用户空间 内存拷贝问题,如何让内核把 FD 消息通知给用户空间呢?在这个问题上 select
采取了内存拷贝方法,在FD非常多的时候,非常的耗费时间。
总结为:1、连接数受限 2、查找配对速度慢 3、数据由内核拷贝到用户态消耗时间

epoll:
1、Epoll 没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于 2048, 一般来说这个数目和系统内存关系很大 ,具体数目可以 cat /proc/sys/fs/file-max 查看。
2、效率提升, Epoll 最大的优点就在于它只管你“活跃”的连接 ,而跟连接总数无关,因此在实际的网络环境中, Epoll的效率就会远远高于 select 和 poll 。
3、内存共享, Epoll 在这点上使用了“共享内存 ”,这个内存拷贝也省略了。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值