IO模型及NIO优化实现

网络通信中,最底层的就是操作系统内核中的网络IO模型了。
随着技术的发展,网络模型衍生出了五种IO: 阻塞式IO、非阻塞式IO、IO复用、信号驱动式IO和异步IO
每一种IO模型的出现,都是基于前一种IO模型的优化升级。

时隔一年,我又回来了。

一些概念

  • 内核态和用户态
    用户空间是用户进程所在的内存区域,内核空间是操作系统所在的内存区域。

  • read/write系统调用
    把数据从内核缓冲区复制到进程缓冲区 / 把数据从进程缓冲区复制到内核缓冲区。
    缓冲区的目的,是为了减少频繁的系统IO调用(上下文切换,进程数据和状态等信息)。缓冲区达到一定数量才会进行IO的调用,提升性能。至于什么时候读取和存储则由内核来决定,用户程序无需关心。

  • TCP 服务端的工作流程
    当有一个客户端连接到服务端之后,服务端就会调用 fork 创建一个子进程,通过系统调用 read 监听客户端发来的消息,再通过 write 向客户端返回信息。
    simpleTCP

IO模型

阻塞式IO (BIO)

读写数据的过程中发生阻塞,用户线程发出io读写请求后(read/write系统调用),内核开始检查数据是否就绪:

  • 没有就绪:用户线程阻塞,并且交出CPU (优点,不占用CPU)
  • 已经就绪:内核会将数据复制到进程缓冲区,并返回结果给用户线程

socket通信过程中,可能存在的阻塞有三种:

  1. connect 阻塞:TCP建立连接时,等待ACK信号…
  2. accept 阻塞:阻塞等待外来的连接
  3. read/write 阻塞:fork子进程,写入/返回数据

非阻塞式IO (NIO)

当系统调用时,数据没有就绪,那么线程会立即返回 (优点:不阻塞),但是需要进行轮询检查 (缺点,占用大量CPU时间,CPU利用率低),直到数据就绪,用户线程阻塞,内核开始从内核缓冲区复制数据到用户进程缓冲区,然后内核返回结果。

IO多路复用 (Java的NIO)

Linux 提供了IO复用函数 select/poll/epoll,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。这样,系统内核就可以帮我们侦测多个读操作是否处于就绪状态。一旦有就绪的,内核能够通知程序进行相应的IO系统调用。

  1. select() 函数:它的用途是,在超时时间内,监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。Linux 操作系统的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd)。
    调用后 select() 函数会阻塞,直到有描述符就绪或者超时,函数返回。当 select 函数返回后,可以通过函数 FD_ISSET 遍历 fdset,来找到就绪的描述符。
    缺点:每次调用 select() 函数前,系统需要把一个fd从用户态拷贝到内核态,需要一定的性能开销。再有单个进程监视的 fd 数量默认是 1024,我们可以通过修改宏定义甚至重新编译内核的方式打破这一限制。但由于 fd_set 是基于数组实现的,在新增和删除 fd 时,数量过大会导致效率降低。

  2. poll() 函数:poll()管理多个描述符也是通过轮询,根据描述符的状态进行处理,但 poll() 没有最大文件描述符数量的限制。
    缺点:包含大量文件描述符的数组被整体复制到用户态和内核的地址空间之间,而无论这些文件描述符是否就绪,开销很大。

  3. epoll() 函数:Linux 在 2.6 内核版本中提供了一个 epoll 调用,epoll 使用事件驱动的方式代替轮询扫描 fd。
    epoll 事先通过 epoll_ctl() 来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,这个事件表是基于红黑树实现的,所以在大量IO请求的场景下,插入和删除的性能比 select/poll 的数组 fd_set 要好,而且不会受到 fd 数量的限制。
    epoll

信号驱动式IO

信号驱动式 I/O 类似观察者模式,内核为观察者,信号回调则是通知。用户进程发起一个IO请求,会通过系统调用 sigaction 函数,给对应的套接字注册一个信号回调,此时不阻塞用户进程。当内核数据就绪时,内核就为该进程生成一个 SIGIO 信号,通过信号回调通知进程进行相关IO操作。

信号驱动式IO相比于前三种模式,实现了在等待数据就绪时,进程不被阻塞可以继续工作,所以性能更佳
TCP中几乎没有使用该模式,因为 SIGIO 信号是一种 Unix 信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么,而 TCP socket 生产的信号事件有七种之多
可以使用在UDP 通信上,因为 UDP 只有一个数据请求事件,如 NTP 服务器的应用。

异步IO (AIO)

信号驱动式IO虽然在等待数据就绪时,没有阻塞进程,但在被通知后进行的IO操作还是阻塞的,进程会等待数据从内核空间复制到用户空间中。而异步IO则是实现了真正的非阻塞IO
当用户进程发起一个IO请求操作,系统会告知内核启动某个操作,并让内核在整个操作(包括数据就绪和将数据复制到用户空间)完成后通知进程。
由于程序的代码复杂度高,调试难度大,且支持异步IO操作系统比较少见,因此使用较少。

NIO优化实现

零拷贝

零拷贝是一种避免多次内存复制的技术,用来优化读写IO操作。
在网络编程中,通常由read/write来完成一次IO读写操作。每一次IO读写都需要完成四次内存拷贝: I/O 设备 -> 内核空间 -> 用户空间 -> 内核空间 -> 其它 I/O 设备。

Linux 内核中的 mmap 函数可以代替read/write的IO读写操作,实现用户空间和内核空间共享一个缓存数据。mmap 将用户空间的一块虚拟地址和内核空间的一块虚拟地址同时映射到相同的一块物理内存地址,最终通过地址映射映射到物理内存地址。这种方式避免了内核空间与用户空间的数据交换,epoll函数中就使用了 mmap 减少了内存拷贝。

在 Java 的 NIO 编程中,则是使用到了 Direct Buffer 来实现内存的零拷贝。Java 直接在 JVM 内存空间之外开辟了一个物理内存空间,这样内核和用户进程都能共享一份缓存数据。

线程模型优化

NIO除了在内核层做了优化,同时在用户层也有优化升级。NIO 是基于事件驱动模型来实现的IO操作。Reactor 模型是同步IO事件处理的一种常见模型,其核心思想是将IO事件注册到多路复用器上,一旦有IO事件触发,多路复用器就会将事件分发到事件处理器中,执行就绪的IO事件操作。

模型的三个主要组件:

  • 事件接收器 Acceptor:主要负责接收请求连接;
  • 事件分离器 Reactor:接收请求后,会将建立的连接注册到分离器中,依赖于循环监听多路复用器Selector,将事件 dispatch 到事件处理器;
  • 事件处理器 Handlers:事件处理器主要是完成相关的事件处理,比如读写 I/O 操作。

线程模型的优化:

  1. 单线程 Reactor 线程模型:
    NIO 其实不算真正地实现了非阻塞IO操作,因为读写IO操作时用户进程还是处于阻塞状态。一个 NIO 线程如果同时处理上万连接的 I/O 操作,依旧无法支撑。
  2. 多线程 Reactor 线程模型:
    在 Tomcat 和 Netty 中都使用了一个 Acceptor 线程来监听连接请求事件,当连接成功之后,会将建立的连接注册到多路复用器中,一旦监听到事件,将交给 Worker 线程池来负责处理。
  3. 主从 Reactor 线程模型:
    现在主流通信框架中的 NIO 通信框架都是基于主从 Reactor 线程模型来实现的。在这个模型中,Acceptor 不再是一个单独的 NIO 线程,而是一个线程池。Acceptor 接收到客户端的 TCP 连接请求,建立连接之后,后续IO操作将交给 Worker IO线程。
    MasterSlaveReactor

Tomcat参数调优

  • 在 BIO 中,Tomcat 中的 Acceptor 只负责监听新的连接,一旦连接建立监听到IO操作,将会交给 Worker 线程中,Worker 线程专门负责 I/O 读写操作。
  • 在 NIO 中,Tomcat 新增了一个 Poller 线程池,Acceptor 监听到连接后,是先将请求发送给了 Poller 缓冲队列。在 Poller 中维护了一个 Selector 对象,当 Poller 从队列中取出连接后,注册到该 Selector 中;然后通过遍历 Selector,找出其中就绪的 I/O 操作,并使用 Worker 中的线程处理相应的请求。

Tomcat相关参数:

  1. acceptorThreadCount:该参数代表 Acceptor 的线程数量,在请求客户端的数据量非常巨大情况下,可以适当地调大该线程数量来提高处理请求连接的能力,默认值为 1。
  2. maxThreads:专门处理IO操作的 Worker 线程数量,默认是 200,可以根据实际的环境来调整该参数,但不一定越大越好。
  3. acceptCount:Tomcat 的 Acceptor 线程是负责从 accept 队列中取出该 connection,然后交给工作线程去执行相关操作,这里的 acceptCount 指的是 accept 队列的大小。
    关闭http的keep alive时,当并发量较大,可以适当地调大这个值。开启http的keep alive时,因为 Worker 线程数量有限,Worker 线程就可能因长时间被占用,而连接在 accept 队列中等待超时。如果 accept 队列过大,就容易浪费连接。
  4. maxConnections:表示有多少个 socket 连接到 Tomcat 上。

参考

  1. NIO的优化实现原理了解吗?图文结合教你如何正确避坑
  2. Java NIO 底层原理
分享到:
Disqus 加载中...

如果长时间无法加载,请针对 disq.us | disquscdn.com | disqus.com 启用代理