基本概念

IO模型就是说用什么样的通道进行数据的发送和接收,Java 共支持3种网络编程 IO 模式:BIO、NIO、AIO。Java 中的 BIO、NIO 和 AIO 理解为是 Java 语言对操作系统的各种 IO 模型的封装。我们在使用这些 API 的时候,不需要关心操作系统层面的知识,也不需要根据不同操作系统编写不同的代码。
在讲 BIO、NIO、AIO 之前先回顾几个概念:同步与异步、阻塞与非阻塞、I/O模型。

同步与异步

同步与异步取决于被调用者

  • 同步:同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回
  • 异步:异步就是发一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时可以处理其他的请求,被调用者通常依靠事件、回调等机制来通知调用者其返回结果。

    阻塞与非阻塞

    阻塞与非阻塞取决于调用者

  • 阻塞:阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。

  • 非阻塞:非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他的事情。

同步异步与阻塞非阻塞(段子)

老张烧开水的故事(故事来源网络)
老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

  1. 老张把水壶放到火上,立等水开。(同步阻塞)

    老张觉得自己有点傻

  2. 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)

    老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~的噪音。

  3. 老张把响水壶放到火上,立等水开。(异步阻塞)

    老张觉得这样傻等意义不大

  4. 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)

    老张觉得自己聪明了。

几种常见的 I/O 模型举例

所有的系统 I/O 都分为两个阶段:等待数据到达 和 数据拷贝。
需要说明的是等待数据到达阶段的阻塞是不使用 CPU 的,是在“空等”;而真正的读操作的阻塞是使用 CPU 的,真正在“干活”,而且这个过程非常快,属于 memory copy,带宽通常在 1GB/s 级别以上,可以理解为基本不耗时。
各种IO模型其实是根据它在上述两个阶段的不同表现(用户进程或者线程是否阻塞)来区分的,根据在第一个阶段用户进程是否阻塞用来区分阻塞/非阻塞,在第二个阶段用户是否阻塞用来区分同步/异步。

阻塞IO - Blocking IO

最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。
当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出 CPU 使用权,进入 block 状态。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除 block 状态。BIO、NIO、AIO 总结 - 图1
特点:

  • 进程阻塞挂起不消耗 CPU 资源,不占用用户的 CPU 资源;
  • 实现难度低、开发应用较容易;
  • 适用并发量小的网络应用开发,并发量高时,服务器会为每一个 socket 创建线程,造成极大的资源浪费;
  • 单线程环境下某个socket 阻塞会影响到其他 socket,无法处理并发问题;
  • 多线程环境下,服务端会为每个 socket 创建一个线程,并且准备就绪的 socket 其实占全部 socket 的一小部分(等待数据到达的时间远远大于数据等待时间),很浪费资源 ;

    非阻塞IO - NoneBlocking IO

    当用户线程发起一个 IO 操作后,并不需要等待,而是马上就得到一个结果。如果结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 IO 操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。
    在非阻塞IO 模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,而会一直占用CPU。
    整个 I/O 请求中,虽然结果立即返回,但是因为是同步的,为了真正实现IO,需要不断的轮询、重复请求,消耗了大量的 CPU 资源。因此,这种模型很少使用,实际用处不大。
    上面的文字是根据其他人的博客总结出来的,但是总感觉云里雾里,一些关键性的问题没有理解透彻,后来看了很多其他人的总结和视频,自己也总结了一些东西:
  1. 服务端监听 socket
  2. 客户端发起 IO 请求,服务端接收到对应的 fd,并添加到 fds (fd_set)
  3. 客户端每收到一次IO 请求就轮询一遍 fds,如果客户端请求的 IO 对应的fd 已经准备就绪,那么接下来的流程就是将资源通过网卡发送给客户端,客户端接受,并写入内存(假设是 read 请求);如果对应的 fd 没有准备就绪,那么服务端发送一个错误码返回给客户端,告知该资源没有就绪
  4. 客户端接收到服务器返回的信息后,如果已经成功,那么皆大欢喜,客户端进入数据拷贝阶段,真正的 IO 开始;如果失败,那么客户端重新发起请求,直到超时或者返回正确结果

BIO、NIO、AIO 总结 - 图2
特点:

  • 进程轮询(重复)调用,消耗CPU的资源;
  • 实现难度低、开发应用相对阻塞IO模式较难;
  • 适用并发量较小、且不需要及时响应的网络应用开发;
  • 优点是解决了阻塞IO 单个socket 阻塞会影响其他 socket 的问题;
  • 缺点是每次用户空间向内核空间发起 IO 请求时都会轮询 fd_set,有一定的开销浪费;

    I/O 多路复用 - IO multiplexing

    不管是同步阻塞还是同步非阻塞,对系统性能的提升都是很小的。同步阻塞单线程无法处理并发,多线程则连接数高了服务质量堪忧(线程上下文切换和线程资源浪费);同步非阻塞可以解决并发问题,但是如果存在大量的 I/O 请求,每个都要进行轮询很浪费 CPU 资源。
    由于前两种 I/O 存在的问题,人们想出了一种能够使用单线程或一组线程来处理多个 I/O 请求的模式——IO多路复用。IO 多路复通有以下三种实现:

  • select

  • poll
  • epoll

    select

    ```shell /**
  • 获取就绪事件
  • max_fd 为以下三个集合中最大fd + 1,存在的意义是告诉内核空间我需求的fd 小于这个 数,大于等于这个数的你就可以忽略了
  • readset 要监听的可读文件描述符集合
  • writeset 要监听的可写文件描述符集合
  • exceptset 要监听的异常文件描述符集合
  • timeout 超时时间[>0:正常时间;0:不等待,立即返回;-1:永久等待] / int select( int max_fd, fd_set readset, fd_set writeset, fd_set exceptset, struct timeval *timeout ) ``` select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
    这么一看的话这个文件描述符集合是不是跟非阻塞IO 的fds 有点相似?但是实际上他们还是不同的,非阻塞IO 会为每一个新的连接分配一个线程,而select 是由一个或者一组线程专门维护一个文件描述符集合,并且当事件为准备就绪时,非阻塞IO 是由内核空间返回一个负数,有用户空间发起下一轮请求,而IO多路复用则是由内核空间阻塞用户进程,直到数据准备就绪时才唤醒用户进程。
    image.png
    IO 多路复用流程:
  1. 服务器通过线程 T1 监听应用进程绑定的 socket a
  2. 客户端通过 a 连接应用进程产生传输数据的已连接 socket b
  3. 客户端发起IO 请求
  4. 服务器收到IO 请求,用户空间将收到的socket 整理成一个 fd_set,通过 select 函数传递给内核空间
    1. fd_set 是一个 bitsmap,操作系统中 FD_SETSIZE 参数限制了该数据结构长度为 1024,入参时表示对哪些 fd 发起事件,回参时表示哪些 fd 事件已经准备就绪
  5. 内核空间收到 fd_set, 遍历
    1. 如果存在事件已经准备就绪的 fd,那么返回其数量
    2. 如果所有fd_set 中所有fd 都未准备就绪,那么挂起该进程,当fd_set 中有 fd 准备就绪时,唤醒该进程,内核空间拷贝IO数据到用户空间,内核空间将已就绪事件的数量返回,修改回参 fd_set
  6. 用户空间遍历 fd_set,读取对应的事件,本次IO 结束

特点:

  • 存在大小限制,虽然可以修改,但是会影响IO 效率;
  • select 传参和回参时需要将 fd_set 在内核空间和用户空间之间拷贝;
  • select 返回的结果是以就绪事件的数量,具体是哪个 fd 准备就绪了还需要用户空间自行遍历获取

    poll

    与select基本相同,不同点在于将 fd_set 的数据结构由 bitmap 修改为 动态链表,突破了 fd_set 的长度限制。
    poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

    epoll

    上文讲到的 select 和 poll 已经很好的解决了并发瓶颈问题,可以实现一个或一组线程监听多个 socket 连接的问题,但是还是有一些问题尚未解决,比如fd_set 的两次拷贝问题和两次遍历问题,epoll 就诞生于这个背景。
    Q:epoll 如何解决两次拷贝问题?
    A:epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
    Q:epoll 如何解决两次遍历问题?
    A: epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
    image.png

信号驱动IO - signal driven IO

在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。这个一般用于UDP中,对TCP套接字几乎没用,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么请求。
BIO、NIO、AIO 总结 - 图5

异步IO - asynchronous IO

前面四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。
BIO、NIO、AIO 总结 - 图6
异步IO实际上是用户进程发起read操作之后,就会立刻收到一个返回,所以用户进程就可以去完成其他的工作,而不需要阻塞;直到数据准备就绪并且完成了从内核空间向用户空间拷贝的工作,这时用户进程会收到一个通知,告诉他read操作已完成整个过程中用户进程不会被阻塞。