NIO优化原理和Tomcat线程模型

1、I/O阻塞

书上说BIO、NIO等都属于I/O模型,但是I/O模型这个范围有点含糊,我为此走了不少弯路。我们日常开发过程中涉及到NIO模型应用,如Tomcat、Netty中等线程模型,可以直接将其视为网络I/O模型。本文还是在基础篇章中介绍几种I/O模型方式,后面就默认只讲解网络I/O模型了。

1.1、I/O分类

BIO、NIO、AIO等都属于I/O模型,所以它们优化的都是系统I/O的性能,因此首先,我们要清楚常见的I/O有哪些分类:

I/O种类 场景 java中到应用
内存I/O 从内存中读取数据,将数据写入内存 线程从内存中将数据读取到工作空间,将值在工作空间完成更改后,将值由工作空间刷新到内存中(jmm)
磁盘I/O 读取磁盘文件,写文件到磁盘 线程从内存中将数据读取到工作空间,将值在工作空间完成更改后,将值由工作空间刷新到内存中(jmm)
网络I/O 网络数据的读写和传输 tcp/udp的抽象api即socket 通信 (java.net)

1.2、I/O过程和性能

I/O(Input/Output)即数据的输入/输出,为什么大家很关心I/O的性能呢?因为I/O存在的范围很广,在高并发的场景下,这部分性能会被无限放大。而且与业务无关,是可以有统一解决方案的。

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

  1. 等待就绪:等待数据就绪,一般是将数据加载到内核缓存区。无论是从磁盘、网络读取数据,程序能处理的都是进入内核态之后的数据,在这之前,cpu会阻塞住,等待数据进入内核态。
  2. 数据操作:数据就绪后,一般是将内核缓存中的数据加载到用户缓存区

需要说明的是等待就绪的阻塞是不使用CPU的,是在“空等”;而真正的读写操作的阻塞是使用CPU的,真正在”干活”,而且这个过程非常快,属于memory copy,带宽通常在1GB/s级别以上,可以理解为基本不耗时。这就出现一个奇怪的现象 -- 不使用CPU的“等待就绪”,却比实际使用CPU的“数据操作”,占用CPU时间更多

传统阻塞I/O模型,即在读写数据过程中会发生阻塞现象。当用户线程发出I/O请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才会解除block状态。

明确的是,让当前工作线程阻塞,等待数据就绪,是很浪费线程资源的事情,上述三种I/O都有一定的优化方案:

  • 磁盘I/O:现代电脑中都有一个DMA(Direct Memory Access 直接内存访问) 的外设组件,可以将I/O数据直接传送到主存储器中并且传输不需要CPU的参与,以此将CPU解放出来去完成其他的事情。
  • 网络I/O:NIO、AIO等I/O模型,通过向事件选择器注册I/O事件,基于就绪的事情来驱动执行I/O操作,避免的等待过程。
  • 内存I/O:内存部分没涉及到太多阻塞,优化点在于减少用户态和内核态之间的数据拷贝。nio中的零拷贝就有mmap和sendfile等实现方案。

1.3、网络I/O阻塞

这里仔细的讲讲网络I/O模型中的阻塞,即socket的阻塞。在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式,是在tcp/ip协议上,抽象出来的一层网络通讯协议。

同上面I/O的过程一样,网络I/O也同样分成两个部分:

  1. 等待网络数据到达网卡,读取到内核缓冲区。
  2. 从内核缓冲区复制数据到用户态空间。

每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区:

  • 输入缓冲区:当使用 read()/recv() 读取数据时,(1)首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。(2)如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 read()/recv() 函数再次读取。(3)直到读取到数据后 read()/recv() 函数才会返回,否则就一直被阻塞。
  • 输出缓冲区:当使用 write()/send() 发送数据时,(1)首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么 write()/send() 会被阻塞(暂停执行),直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒 write()/send() 函数继续写入数据。(2) 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send() 也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。(3)如果要写入的数据大于缓冲区的最大长度,那么将分批写入。(4)直到所有数据被写入缓冲区 write()/send() 才能返回。

由此可见在网络I/O中,会有很多的因素导致数据的读取和写入过程出现阻塞,创建socket连接也一样。socket.accept()、socket.read()、socket.write()这类函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的,该线程当前的cpu时间片就浪费了。

2、阻塞优化

2.1、BIO、NIO、AIO

BIO、NIO、AIO对比

以socket.read()为例子:

  • 传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据。
  • 对于NIO,如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不会阻塞。
  • 最新的AIO(Async I/O)里面会更进一步:不但等待就绪是非阻塞的,就连数据从网卡到内存的过程也是异步的。

换句话说,BIO里用户最关心“我要读”,NIO里用户最关心”我可以读了”,在AIO模型里用户更需要关注的是“读完了”。

NIO

NIO的优化体现在两个方面:

  1. 网络I/O模式的优化,通过非阻塞的模式,提高了CPU的使用性能。
  2. 内存I/O的优化,零拷贝等方式,让数据在内核态和用户态之前的传输消耗降低了。

NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)

NIO的主要事件有几个:读就绪、写就绪、有新连接到来。

我们首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时机告诉事件选择器:我对这个事件感兴趣。对于写操作,就是写不出去的时候对写事件感兴趣;对于读操作,就是完成连接和系统没有办法承载新读入的数据的时;对于accept,一般是服务器刚启动的时候;而对于connect,一般是connect失败需要重连或者直接异步调用connect的时候。

其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。

2.2、Reactor模式

Reactor模式称之为响应器模式,通常用于NIO非阻塞IO的网络通信框架中。Reactor设计模式用于处理由一个或多个客户端并发传递给应用程序的的服务请求,可以理解成,Reactor模式是用来实现网络NIO的方式

Reactor是一种事件驱动机制,是处理并发I/O常见的一种模式,用于同步I/O,其中心思想是将所有要处理的I/O事件注册到一个中心I/O多路复用器上,同时主线程阻塞在多路复用器上,一旦有I/O事件到来或是准备就绪,多路复用器将返回并将相应I/O事件分发到对应的处理器中。

Reactor模式主要分为下面三个部分:

  1. 事件接收器Acceptor:主要负责接收请求连接,接收请求后,会将建立的连接注册到分离器中。
  2. 事件分离器Reactor:依赖于循环监听多路复用器Selector,是阻塞的,一旦监听到事件,就会将事件分发到事件处理器。(例如:监听读事件,等到内核态数据就绪后,将事件分发到Handler,Handler将数据读到用户态再做处理)
  3. 事件处理器Handler:事件处理器主要完成相关的事件处理,比如读写I/O操作。

2.3、三种Reactor模式

单线程Reactor模式

一个线程:

  • 单线程:建立连接(Acceptor)、监听accept、read、write事件(Reactor)、处理事件(Handler)都只用一个单线程。
多线程Reactor模式

一个线程 + 一个线程池:

  • 单线程:建立连接(Acceptor)和 监听accept、read、write事件(Reactor),复用一个线程。
  • 工作线程池:处理事件(Handler),由一个工作线程池来执行业务逻辑,包括数据就绪后,用户态的数据读写。
主从Reactor模式

三个线程池:

  • 主线程池:建立连接(Acceptor),并且将accept事件注册到从线程池。
  • 从线程池:监听accept、read、write事件(Reactor),包括等待数据就绪时,内核态的数据I读写。
  • 工作线程池:处理事件(Handler),由一个工作线程池来执行业务逻辑,包括数据就绪后,用户态的数据读写。

3、Tomcat线程模型

3.1、Api网络请求过程

我们先补一下基础知识,讲解后端接口的响应过程。一个http连接里,完整的网络处理过程一般分为accept、read、decode、process、encode、send这几步:

  1. accept:接收客户端的连接请求,创建socket连接(tcp三次握手,创建连接)。
  2. read:从socket读取数据,包括等待读就绪,和实际读数据。
  3. decode:解码,因为网络上的数据都是以byte的形式进行传输的,要想获取真正的请求,必定需要解码。
  4. process:业务处理,即服务端程序的业务逻辑实现。
  5. encode:编码,同理,因为网络上的数据都是以byte的形式进行传输的,也就是socket只接收byte,所以必定需要编码。
  6. send:往网络socket写回数据,包括实际写数据,和等待写就绪。

3.2、各个线程模型

在tomcat的各个版本中,所支持的线程模型也发生了一步步演变。一方面,直接将默认线程模型,从BIO变成了NIO。另一方面,在后续几个版本中,加入了对AIO和APR线程模型的支持,这里要注意,仅仅是支持,而非默认线程模型。

  • BIO:阻塞式IO,tomcat7之前默认,采用传统的java IO进行操作,该模式下每个请求都会创建一个线程,适用于并发量小的场景。
  • NIO:同步非阻塞,比传统BIO能更好的支持大并发,tomcat 8.0 后默认采用该模式。
  • AIO:异步非阻塞 (NIO2),tomcat8.0后支持。多用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂。
  • APR:tomcat 以JNI形式调用http服务器的核心动态链接库来处理文件读取或网络传输操作,需要编译安装APR库(也就是说IO操作的部分直接调用native代码实现)。

各个线程模型中,NIO是作为目前最实用的线程模型,因此也是目前Tomcat默认的线程模型,因此本文对此着重讲解。

3.3、BIO和NIO

BIO模型

在BIO模型中,主要参与的角色有:AcceptorHandler工作线程池。对应于前文中Api的请求过程,它们的分工如下:

  • Acceptor:Accepter线程专门负责建立网络连接(accept)。新连接创建后,交给Handler工作线程池处理请求。
  • Handlers:针对每个请求的连接,Handler工作线程池都会分配一个线程,执行后面的所有步骤(read、decode、process、encode、send)。

前文的知识点有铺垫,readsend是面向网络I/O的,在等待读写就绪过程中,其实是CPU阻塞的。因此Handler工作线程池中的每个线程,都会因为I/O阻塞而“空等待”,造成浪费。

NIO模型

tomcat的NIO模型,相比较于BIO模型,多了个Poller角色:AcceptorPollerHandler工作线程池。这三个角色是不是很熟悉,如果将Poller换成Reactor,是不是就是Reactor模型。没错,tomcat的nio模型,的确就是基于主从Reactor模型,只不过将Reactor换了个名字而已。

  • Acceptor:Accepter线程专门负责建立网络连接(accept)。新连接创建后,不是直接使用Worker线程处理请求,而是先将请求发送给Poller缓冲队列。
  • Poller:在Poller中,维护了一个Selector对象,当Poller从缓冲队列中取出连接后,注册到该Selector中,阻塞等待读写就绪(read等待就绪、send等待就绪)。
  • Handlers:遍历Selector,找出其中就绪的IO操作,并交给Worker线程处理(read内存读、decode、process、encode、send内存写)。
对比
  • BIO模型中,一个线程对应一个请求连接的完整过程,因此tomcat服务能处理的最大连接数,和最大线程数一致。
  • NIO模型中,在一个请求连接中,对应的一个工作线程,只处理I/O读写就绪后的非阻塞过程。因此tomcat服务能处理的最大连接数,要远大于最大线程数量。

3.4、参数设置

针对于tomcat的nio模型,可以做一些参数设置。因为springboot是内嵌tomcat的,这些参数设置同样可以在properties配置文件中定义:

  • 最大线程数(server.tomcat.threads.max):工作线程池的最大线程数,默认200。注意不是越大越好,如果线程数过大,那么CPU会花费大量的时间用于线程的切换,整体效率会降低。
  • 最小线程数(server.tomcat.threads.min-spare):工作线程池的最小线程数,默认10。
  • 最大等待数(server.tomcat.accept-count):当调用HTTP请求数达到tomcat的最大线程数时,还有新的HTTP请求到来,这时tomcat会将该请求放在等待队列中,这个acceptCount就是指能够接受的最大等待数,默认100。如果等待队列也被放满了,这个时候再来新的请求就会被tomcat拒绝。
  • 最大连接数(server.tomcat.max-connections):在同一时间,tomcat能够接受的最大连接数,默认8192。

4、常见问题

1、tomcat运行后,出现 nio-8080-exec-前缀的线程作用是什么?

是工作线程池中的线程。你们可以观察某个springboot运行项目的线程模型,由于基本都是基于nio模型的tomcat应用,因此都包括这些线程:

  • 1个名称中包含Accepter的线程。
  • 2个名称中包含Poller的线程。
  • 10个工作线程,名称从 nio-8080-exec-1 到 nio-8080-exec-10。如果并发交高,默认最多有200个线程,名称到 nio-8080-exec-200。
2、tomcat中nio模型中,存在poller单线程读取多个请求线程的数据,会不会出现线程安全问题?因为通过会使用ThreadLocal存储请求用户身份信息。

不会。因为poller只是处理等待读就绪的环节,一旦读就绪事件触发后,真正的读取数据和处理业务逻辑,都是由工作线程池中的某个线程跟到底,可以放心大胆使用ThreadLocal。

3、为什么我自己对比测试nio和bio,性能提升不大?

nio线程模型优化的是线程利用率,为了在高并发场景下,基于有限的线程资源,处理更多的请求连接。

例如:tomcat使用默认最大线程数200,但你的并发请求数量连200都不到,就算是BIO模型,线程池中200个线程都没利用完。这时候你用NIO还是BIO,区别不大,甚至BIO模型处理还更快一些。但如果你的并发请求数到了2000、20000,BIO模型就会出现性能瓶颈了,超过200的请求都会阻塞住,而NIO模型就能大展身手。

你可能感兴趣的