BIO / NIO

  1. Tomcat 8.5之前,默认使用BIO线程模型,在高并发的场景下,可以设置为NIO线程模型,来提供系统的网络通信性能
  2. 页面请求用于模拟多IO读写操作的请求,Tomcat在IO读写请求比较多的情况下,使用NIO线程模型有明显的优势

网络IO模型优化

网络通信中,最底层的是操作系统内核中的网络IO模型,分别为阻塞式IO非阻塞式IOIO复用信号驱动式IO异步IO

TCP工作流程

  1. 首先,应用程序通过系统调用socket,创建一个套接字,它是系统分配给应用程序的一个文件描述符
  2. 其次,应用程序通过系统调用bind,绑定地址和的端口号,给套接字命名一个名称
  3. 然后,系统调用listen,创建一个队列用于存放客户端进来的连接
  4. 最后,应用程序通过系统调用accept监听客户端的连接请求
  5. 当有一个客户端连接到服务端后,服务端会通过系统调用fork,创建一个子进程
    • 通过系统调用read监听客户端发来的消息,通过系统调用write向客户端返回消息

阻塞式IO

每一个连接创建时,都需要一个用户线程来处理,并且在IO操作没有就绪或者结束时,线程会被挂起,进入阻塞等待状态

connect阻塞

  1. 客户端通过系统调用connect发起TCP连接请求,TCP连接的建立需要完成三次握手
  2. 客户端需要阻塞等待服务端返回的ACK和SYN,服务端需要阻塞等待客户端的ACK

accept阻塞

服务端通过系统调用accept接收客户端请求,如果没有新的客户端连接到达,服务端进程将被挂起,进入阻塞状态

read、write阻塞

Socket连接创建成功后,服务端调用fork创建子进程,调用read等待客户端写入数据,如果没有,子进程被挂起,进入阻塞状态

非阻塞式IO

  1. 使用fcntl把上面的操作都设置为非阻塞,如果没有数据返回,直接返回EWOULDBLOCKEAGAIN错误,进程不会被阻塞
  2. 最传统的非阻塞IO模型:设置一个用户线程对上面的操作进行轮询检查

IO复用

  1. 传统的非阻塞IO模型使用用户线程轮询检查一个IO操作的状态,无法应对大量请求的情况
  2. Linux提供了IO复用函数selectpollepoll
    • 进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上,_系统内核去侦测多个读操作是否处于就绪状态_

select

  1. 在超时时间内,监听用户感兴趣的文件描述符上的可读可写异常事件的发生
  2. Linux内核将所有外部设备看做文件,对文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd)
  3. select函数监听的文件描述符分为三类:readsetwritesetexceptset(异常事件)
  4. 调用select函数后会阻塞,直到有文件描述符就绪超时,函数返回
  5. 当select函数返回后,可以通过FD_ISSET函数遍历fdset(readset/writeset/exceptset),来找到就绪的文件描述符
1
2
3
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写

poll

  1. 每次调用select函数之前,系统需要把fd从用户态拷贝到内核态(交由内核侦测),带来一定的性能开销
  2. 单个进程监视的fd数量默认为1024(可以修改宏定义或者重新编译内核
  3. 另外fd_set是基于数组实现的,在新增删除fd时,时间复杂度为O(n)(因此fd_set不宜过大)
  4. poll的机制与select类似,本质上差别不大(轮询),只是poll没有最大文件描述符数量的限制
  5. poll和select存在相同的缺点
    • 包含大量文件描述符的数组整体复制用户态内核态的地址空间,无论这些文件描述符是否就绪
    • 系统开销会随着文件描述符的增加而线性增大

epoll

  1. select/poll是顺序扫描fd是否就绪,而且支持的fd数量不宜过大
  2. Linux 2.6提供了epoll调用,epoll使用事件驱动的方式代替轮询扫描fd
  3. epoll事先通过epoll_ctl注册一个文件描述符,将文件描述符存放在内核的一个事件表
    • 该事件表是基于红黑树实现的,在大量IO请求的场景下,其插入和删除的性能比select/poll的数组fd_set要好
    • 因此epoll的性能更好,而且没有fd数量的限制
  4. epoll_ctl函数的参数解析
    • epfd:由epoll_create函数生成的一个epoll专用文件描述符
    • op:操作事件类型
    • fd:关联的文件描述符
    • event:监听的事件类型
  5. 一旦某个文件描述符就绪,操作系统内核会采用类似Callback的回调机制,迅速激活该文件描述符
    • 当进程调用epoll_wait时便得到通知,之后进程将完成相关的IO操作
1
2
3
int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)

int epoll_wait(int epfd, struct epoll_event events,int maxevents,int timeout)

信号驱动式IO

  1. 信号驱动式IO类似于观察者模式内核是观察者信号回调是通知
  2. 用户进程发起一个IO请求操作,通过系统调用sigaction,给对应的Socket注册一个信号回调
    • 此时不阻塞用户进程,用户进行继续工作
  3. 内核数据就绪时,操作系统内核该进程生成一个SIGIO信号,通过信号回调通知进程进行相关IO操作
  4. 相比于前三种IO模型,在等待数据就绪时进程不被阻塞,主循环可以继续工作,性能更佳
  5. 但对于TCP来说,信号驱动式IO几乎没有被使用
    • 因为SIGIO信号是一种UNIX信号没有附加信息
    • 如果一个信号源有多种产生信号的原因,信号接收者无法确定实际发生了什么
    • 而TCP Socket生产的信号事件有七种之多,进程收到SIGIO信号后也根本没法处理
  6. 而对于UDP来说,信号驱动式IO已经有所应用,例如NTP服务器
    • 因为UDP只有一个数据请求事件
    • 正常情况下,UDP进程只要捕获到SIGIO信号,就调用recvfrom读取到达的数据报
    • 异常情况下,就返回一个异常错误

异步IO

  1. 虽然信号驱动式IO在等待数据就绪时,不会阻塞进程,但在被通知后进行的IO操作还是阻塞的
    • 进程会_等待数据从内核空间复制到用户空间_
  2. 异步IO实现了真正的非阻塞IO
    • 用户进程发起一个IO请求操作,系统会告知内核启动某个操作,并让内核在整个操作完成后通知用户进程
    • 整个操作包括:等待数据就绪、_数据从内核空间复制到用户空间_
  3. Linux不支持异步IO,Windows支持异步IO,因此生产环境中很少用到异步IO模型

Java NIO

Selector

Java NIO使用了IO复用器Selector实现非阻塞IO,Selector使用的是IO复用模型Selector是select/poll/epoll的外包类

SelectionKey

Socket通信中的connect、accept、read/write是阻塞操作,分别对应SelectionKey的四个监听事件

服务端编程

  1. 首先,创建ServerSocketChannel,用于监听客户端连接
  2. 然后,创建Selector,将ServerSocketChannel注册到Selector,应用程序会通过Selector来轮询注册在其上的Channel
    • 当发现有一个或多个Channel处于就绪状态,返回就绪的监听事件,最后进行相关的IO操作
  3. 在创建Selector时,应用程序会根据操作系统版本选择使用哪种IO复用函数
    • JDK 1.5 + Linux 2.6 -> epoll
    • 由于信号驱动式IO对TCP通信不支持,以及Linux不支持异步IO,因此大部分框架还是基于IO复用模型实现网络通信
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 功能:向每个接入的客户端发送Hello字符串

// 创建ServerSocketChannel,配置为非阻塞模式
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
// 绑定监听
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
// 创建单独的IO线程,用于轮询多路复用器Selector
Selector selector = Selector.open();
// 创建Selector,将之前创建的serverSocketChannel注册到Selector上,监听OP_ACCEPT
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 轮询就绪的Channel
while (true) {
try {
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
for (Iterator<SelectionKey> it = keys.iterator(); it.hasNext(); ) {
SelectionKey key = it.next();
it.remove();
try {
if (key.isAcceptable()) {
// 新的客户端接入
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
// 将客户端的Channel注册到Selector上,监听OP_WRITE
client.register(selector, SelectionKey.OP_WRITE);
} else if (key.isWritable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.wrap("Hello".getBytes());
client.write(buffer);
key.cancel();
}
} catch (IOException e) {
key.cancel();
try {
key.channel().close();
} catch (IOException ignored) {
}
}
}
} catch (IOException e) {
break;
}
}

零拷贝

  1. IO复用模型中,执行读写IO操作依然是阻塞的,并且存在多次内存拷贝上下文切换,增加性能开销
  2. 零拷贝是一种避免多次内存复制的技术,用来优化读写IO操作
  3. 在网络编程中,通常由read、write来完成一次IO读写操作,每次IO读写操作都需要完成4次内存拷贝
    • 路径:_IO设备 -> 内核空间 -> 用户空间 -> 内核空间 -> 其他IO设备_

Linux mmap

  1. Linux内核中的mmap函数可以代替read、write的IO读写操作,实现用户空间和内核空间共享一个缓存数据
  2. mmap将用户空间的一块地址和内核空间的一块地址_同时映射到相同的一块物理内存地址_
    • 不管是用户空间还是内核空间都是虚拟地址,最终都要映射到物理内存地址
  3. 这种方式避免了内核空间与用户空间的数据交换
  4. IO复用的epoll函数也是利用了mmap函数减少了内存拷贝

Java NIO

  1. Java NIO可以使用Direct Buffer来实现内存的零拷贝
  2. Java直接在JVM内存之外开辟一个物理内存空间,这样内核用户进程都能共享一份缓存数据

线程模型优化

  1. 一方面内核网络IO模型做了优化,另一方面NIO用户层也做了优化
  2. NIO是基于事件驱动模型来实现IO操作
  3. Reactor模型是同步IO事件处理的一种常见模型
    • 将IO事件注册到多路复用器上,一旦有IO事件触发,多路复用器会将事件分发事件处理器中,执行就绪的IO事件操作

Reactor模型的组件

  1. 事件接收器Acceptor
    • 主要负责接收请求连接
  2. 事件分离器Reactor
    • 接收请求后,会将建立的连接注册到分离器中,依赖于循环监听多路复用器Selector
    • 一旦监听到事件,就会将事件分发到事件处理器
  3. 事件处理器Handler
    • 事件处理器主要完成相关的事件处理,比如读写IO操作

单线程Reactor

  1. 最开始NIO是基于单线程实现的,所有的IO操作都在一个NIO线程上完成
  2. 由于NIO是非阻塞IO,理论上一个线程可以完成所有IO操作
  3. 但NIO并没有真正实现非阻塞IO,因为读写IO操作时用户进程还是处于阻塞状态
  4. 在高并发场景下会存在性能瓶颈,一个NIO线程也无法支撑C10K

多线程Reactor

  1. 为了解决单线程Reactor在高并发场景下的性能瓶颈,后来采用了线程池
  2. TomcatNetty中都使用了一个Acceptor线程来监听连接请求事件
    • 当连接成功后,会将建立的连接注册到多路复用器中,一旦监听到事件,将交给Worker线程池来负责处理
    • 在大多数情况下,这种线程模型可以满足性能要求,但如果连接的客户端很多,一个Acceptor线程也会存在性能瓶颈

主从Reactor

  1. 现在主流通信框架中的NIO通信框架都是基于主从Reactor线程模型来实现的
  2. 主从Reactor:Acceptor不再是一个单独的NIO线程,而是一个线程池
    • Acceptor接收到客户端的TCP连接请求,建立连接后,后续的IO操作将交给Worker线程处理

Tomcat

原理

  1. 在Tomcat中,BIO和NIO是基于主从Reactor线程模型实现的
  2. BIO中,Tomcat中的Acceptor只负责监听新的连接,一旦连接建立,监听到IO操作,就会交给Worker线程处理
  3. NIO中,Tomcat新增一个Poller线程池
    • Acceptor监听到连接后,不是直接使用Worker线程处理请求,而是先将请求发送给Poller缓冲队列
    • 在Poller中,维护了一个Selector对象,当Poller从缓冲队列中取出连接后,注册到该Selector中
    • 然后,通过遍历Selector,找出其中就绪的IO操作,并交给Worker线程处理

配置参数

  1. acceptorThreadCount
    • Acceptor的线程数量,默认1
  2. maxThreads
    • 专门处理IO操作的Worker线程数量,默认200(不一定越大越好)
  3. acceptCount
    • Acceptor线程负责从accept队列中取出连接,然后交给Worker线程处理
    • acceptCount指的是accept队列的大小
    • 当HTTP关闭Keep Alive,并发量会增大,可以适当调大该值
    • 当HTTP开启Keep Alive,而Worker线程数量有限,并且有可能被长时间占用,连接在accept队列中等待超时
      • 如果accept队列过大,很容易造成连接浪费
  4. maxConnections
    • 表示可以有多少个Socket连接到Tomcat上,默认10000
    • BIO模式中,一个线程只能处理一个连接,一般maxThreads与maxConnections的值相同
    • NIO模式中,一个线程可以同时处理多个连接,maxThreads应该比maxConnections大很多

参考资料

Java性能调优实战