IO 多路复用机制

153 阅读16分钟

前言

本文首先介绍了跟 IO 多路复用机制有关的基础概念,然后简单介绍了BIO、NIO,然后引出了 IO 多路复用机制的概念。最后详细介绍了 IO 多路复用机制三种实现select、poll、epoll。

1、概念说明

1.1、用户空间&内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。 操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

1.2、进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

1.3、进程阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

1.4、文件描述符(fd)

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

1.5、缓存IO

缓存I/O又称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 IO 的缺点:

数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

2、什么是IO多路复用

  • IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;
  • 一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
  • 没有文件句柄就绪就会阻塞应用程序,交出CPU。

多路是指网络连接,复用指的是同一个线程

3、IO多路复用机制优势

没有IO多路复用机制时,有BIO、NIO两种实现方式,但它们都有一些问题

4、同步阻塞(BIO)

4.1、单线程模式

4.1.1、过程演示

1、服务端启动

图片.png

启动服务端,等待socket连接,accept()方法阻塞

2、客户端连接,未发送数据

图片.png

连接客户端,accept() 方法执行,未收到client1发送的数据,read()方法阻塞

3、另一个客户端连接

图片.png 由于read()方法阻塞,无法执行到accept()方法,所以这样cpu一次只能处理一个socket

4.1.2、存在的问题

上面的模型存在很大的问题,如果客户端与服务端建立了连接,客户端迟迟不发数据,进程就会一直堵塞在read()方法上,这样其他客户端也不能进行连接,也就是一次只能处理一个客户端,对客户很不友好

4.1.3、如何解决

其实要解决这个问题很简单,利用多线程就可以,只要连接了一个socket,操作系统分配一个线程来处理,这样read()方法堵塞在每个线程上,不堵塞主线程,就能操作多个socket了,有哪个线程中的socket有数据,就读哪个socket

4.2、多线程模式

4.2.1、过程演示

图片.png

  • 程序服务端只负责监听是否有客户端连接,使用 accept() 阻塞
  • 客户端1连接服务端,就开辟一个线程(thread1)来执行 read() 方法,程序服务端继续监听
  • 客户端2连接服务端,也开辟一个线程,执行read()方法
  • 任何一个线程上的socket有数据发送过来,read()就能立马读到,cpu就能进行处理

4.2.2、存在的问题

上面这个多线程模型,看似已经十分的完美,其实也有很大的问题。每来一个客户端,就要开辟一个线程,如果来1万个客户端,那就要开辟1万个线程。在操作系统中,用户态不能直接开辟线程,需要调用cpu的80软中断,让内核来创建的一个线程,这其中还涉及到用户状态的切换(上下文的切换),十分耗资源。

4.2.3、如何解决

第一个办法:使用线程池,这个在客户端连接少的情况下可以使用,但是用户量大的情况下,你不知道线程池要多大,太大了内存可能不够,也不可行

第二个办法:因为read()方法堵塞了,所有要开辟多个线程,如果什么方法能使read()方法不堵塞,这样就不用开辟多个线程了,这就用到了另一个IO模型,NIO(非阻塞式IO)

5、NIO(非阻塞式IO)

5.1、过程演示

1、服务端刚创建,没有客户端连接

图片.png

在NIO中,accept()方法也是非阻塞的,它在一个while死循环中

2、当有一个客户端进行连接时

图片.png

3、当有第二个客户端进行连接时

图片.png

5.2、总结

在NIO模式中,一切都是非阻塞的:

  • accept()方法是非阻塞的,如果没有客户端连接,就返回error

  • read()方法是非阻塞的,如果read()方法读取不到数据就返回error,如果读取到数据时只阻塞read()方法读数据的时间

在NIO模式中,只有一个线程:

  • 当一个客户端与服务端进行连接,这个socket就会加入到一个数组中,隔一段时间遍历一次,看这个socket的read()方法能否读到数据

  • 这样一个线程就能处理多个客户端的连接和读取了

5.3、存在的问题

NIO成功的解决了BIO需要开启多线程的问题,NIO中一个线程就能解决多个socket,看似已经 perfect,但是还存在问题。

这个模型在客户端少的时候十分好用,但是客户端如果很多,比如有1万个客户端进行连接,那么每次循环就要遍历1万个socket,如果一万个socket中只有10个socket有数据,也会变量一万个socket,就会做很多无用功。而且这个遍历过程是在用户态进行的,用户态判断socket是否有数据还是调用内核的read()方法实现的,这就涉及到用户态和内核态的切换,每遍历一个就要切换一次,开销很大

因为这些问题的存在,IO多路复用应运而生

6、IO Multiplexing(IO多路复用)

IO多路复用有三种实现方式,select、poll、epoll,现在让我们来看看这三种实现的真面目吧

6.1、select

图片.png

这里还有select代码实现的代码例子

图片.png

6.1.1 优点

select 其实就是把NIO中用户态要遍历的 fd 数组拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了

从代码中可以看出,select系统调用后,返回了一个置位后的&rset,这样用户态只需进行很简单的二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率

6.1.2 存在的问题

1、bitmap最大1024位,一个进程最多只能处理1024个客户端
2、&rset不可重用,每次socket有数据就相应的位会被置位
3、文件描述符数组拷贝到了内核态,仍然有开销
4、select并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历

6.2、poll

6.2.1 代码例子

图片.png

在poll中,文件描述符有一份独立的数据结构pollfd,传入poll中的是pollfd的数组,其他的实现逻辑和select一样

6.2.2 优点

1、poll使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client
2、当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回0,实现了pollfd数组的重用

6.2.3 缺点

poll 解决了select缺点中的前两条,其本质原理还是select的方法,还存在select中原来的问题

1、pollfds数组拷贝到了内核态,仍然有开销
2、poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历

6.3、epoll

6.3.1 代码例子

图片.png

6.3.2 事件通知机制

1、当有网卡上有数据到达了,首先会放到DMA(内存中的一个buffer,网卡可以直接访问这个数据区域)中
2、网卡向cpu发起中断,让cpu先处理网卡的事
3、中断号在内存中会绑定一个回调,哪个socket中有数据,回调函数就把哪个socket放入就绪链表中

6.3.3 详细过程

首先epoll_create创建epoll实例,它会创建所需要的红黑树,以及就绪链表,以及代表epoll实例的文件句柄,其实就是在内核开辟一块内存空间,所有与服务器连接的socket都会放到这块空间中,这些socket以红黑树的形式存在,同时还会有一块空间存放就绪链表;红黑树存储所监控的文件描述符的节点数据,就绪链表存储就绪的文件描述符的节点数据;
epoll_ctl添加新的描述符,首先判断是红黑树上是否有此文件描述符节点,如果有,则立即返回。如果没有, 则在树干上插入新的节点,并且告知内核注册回调函数。当接收到某个文件描述符过来数据时,那么内核将该节点插入到就绪链表里面。
epoll_wait将会接收到消息,并且将数据拷贝到用户空间,清空链表。

6.3.4 水平触发和边沿触发

EPOLL事件有两种模型:

  • Level Triggered (LT) 水平触发只要有数据都会触发。
  • Edge Triggered (ET) 边缘触发只有数据到来,才触发,不管缓存区中是否还有数据。

Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你!!!如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!!!

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

6.3.5 优点

epoll是现在最先进的IO多路复用器,Redis、Nginx,linux中的Java NIO都使用的是epoll

1、一个socket的生命周期中只有一次从用户态拷贝到内核态的过程,开销小
2、使用event事件通知机制,每次socket中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的socket

7、其它

7.1、Nginx的IO模型

Nginx 支持多种并发模型,并发模型的具体实现根据系统平台而有所不同。

在支持多种并发模型的平台上,nginx 自动选择最高效的模型。但我们也可以使用 use 指令在配置文件中显式地定义某个并发模型。

NGINX中支持的并发模型:

7.1.1、select

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

7.1.2、poll

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

7.1.3、epoll

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

7.1.4、kqueue

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

7.1.5、/dev/poll

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

7.1.6、eventport

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

7.2、Redis IO多路复用技术

Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用 就是为了解决这个问题而出现的。

redis的io模型主要是基于epoll实现的,不过它也提供了select和kqueue的实现,默认采用epoll。

总结

select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。  

epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现

selectpollepoll
操作方式遍历遍历回调
数据结构bitmap数组红黑树
最大连接数1024(x86)或 2048(x64)无上限无上限
最大支持文件描述符数一般有最大值限制6553565535
fd拷贝每次调用select,都需要把fd集合从用户态拷贝到内核态每次调用poll,都需要把fd集合从用户态拷贝到内核态fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝
工作模式LTLT支持ET高效模式
工作效率每次调用都进行线性遍历,时间复杂度为O(n)每次调用都进行线性遍历,时间复杂度为O(n)事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)

epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。