首页 热点资讯 义务教育 高等教育 出国留学 考研考公
您的当前位置:首页正文

Tomcat 源码分析 NIO (基于8.0.5)

2024-12-20 来源:化拓教育网
1. Tomcat NIO 概述

Tomcat 8.x.x 默认的请求处理都是 NIO, 据说以前处理都是 BIO (PS: 两者的区别: 一个是从IO设备读取数据到内核内存, 再从内核内存copy到用户内存, 另一个是 通过 select 来轮训注册是事件, 而且数据已经从 IO 设备读取到了 内核内存中), 而NIO对比BIO最大的好处就是少一步从IO设备读取数据 + 程序可以用更少的线程处理更多的请求, 尤其是在开启 KeepAlive 的情况下

2. Tomcat NIO 组件

我们先来看一下在 NIO 模型下, Connector 的组件:

1. Acceptor             : 监听指定的端口, 将接收到的 socket 封装成 NioChannel 丢给 Poller 线程来进行处理
2. NioChannel           : SocketChannel 的一个包装类, 给 SocketChannel 增加了一个属性, 并且代理其做了一个方法
3. PollerEvent          : 其的作用就是在 Poller.Selector 上异步注册 OP_READ 事件
4. SynchronizedQueue    : Poller 每次运行都会执行里面的 PollerEvent 事件, 进行SocketChannel注册 selector
5. Poller               : 从 SynchronizedQueue 里面 poll 出PollerEvent(在 Selector 上注册读数据的时间)事件, 并进行通过 selector.select 来轮训注册的读写事件
6. SocketProcessor      : Tomcat 执行 Socket 请求处理的执行单元

本质上就是一个线程在端口上等待接收请求, 开启几个 Poller 线程(每个Poller线程有一个归属的 Selector), 通过 round robin 的方式来分派 SocketChannel , 并且注册对应的 OP_READ 事件到其 Selector 上, 并且进行 SocketChannel 后续的读写处理

3. Tomcat NIO 请求

先来看一下下面一张 UML 图:


Tomcat_nio.png

整个流程就是开启一个Acceptor线程来接收请求, 2个Poller线程(PS: 每个线程管理一个 Selector) 来处理读写事件, 最终真正的逻辑处理交给 Executor 来处理

4. Tomcat NIO 读取数据

先来看一张HTTP协议的结构图


image.png

在Http头部中, 每行都是通过 /r/n 字符来进行分割, 而header 与 body 之间也是通过一个单独的一行(这一行里面只有 /r/n 字符)来进行分割;

Tomcat NIO 读取数据主要在 InternalNioInputBuffer.parseRequestLine() 与 InternalNioInputBuffer.parseHeaders(); 这里对 HTTP header 数据的解析也是通过判断是否有单独一行数据是 /r/n 来进行判断, 不然就进行调用 InternalNioInputBuffer.fill(boolean timeout, boolean block) 来进行再次读取(为什么要再次读取呢? 主要还是因为TCP底层在发送数据包时 不一定一下子将数据发送过来), 其实这一步也就是 IM 服务中的粘包

5. Tomcat NIO 写数据
image.png
(PS: )

其中指明了 在NIO模式下, Response 的写入是 Blocking 的, 而我们在通过 SocketChannel 进行写数据时有可能一次不能完全写完, 那Tomcat是这么做的呢? 直接看 NioBlockingSelector.write 方法

try {
    while ( (!timedout) && buf.hasRemaining()) {                                    // 1. 检查数据是否写完, 写操作是否超时
        if (keycount > 0) { //only write if we were registered for a write
            int cnt = socket.write(buf); //write the data                           // 2. 进行写操作
            if (cnt == -1)                                                          // 3. 写操作失败, 直接报异常 (有可能对方已经关闭 socket)
                throw new EOFException();
            written += cnt;                                                         // 4. 累加 已经写的数据总和
            if (cnt > 0) {                                                          // 5. 写数据成功, continue 再次写数据
                time = System.currentTimeMillis(); //reset our timeout timer
                continue; //we successfully wrote, try again without a selector
            }
        }
        try {                                                                       // 6. 写入不成功 (cnt == 0)
            if ( att.getWriteLatch()==null || att.getWriteLatch().getCount()==0) att.startWriteLatch(1);
            poller.add(att,SelectionKey.OP_WRITE,reference);                        // 7. 通过 BlockPoller 线程将 SocketChannel 的 OP_WRITE 事件 注册到 NioSelectorPool 中的 selector 上
            if (writeTimeout < 0) {                                                 // 8. CountDownLatch 进行不限时的等到 OP_WRITE 事件
                att.awaitWriteLatch(Long.MAX_VALUE,TimeUnit.MILLISECONDS);
            } else {
                att.awaitWriteLatch(writeTimeout,TimeUnit.MILLISECONDS);            // 9. CountDownLatch 进行限时的等到 OP_WRITE 事件
            }
        } catch (InterruptedException ignore) {
            // Ignore
        }
        if ( att.getWriteLatch()!=null && att.getWriteLatch().getCount()> 0) {      // 10. 若  CountDownLatch 是被线程 interrupt 唤醒的, 将 keycount 置为 0 (CountDownLatch被  Interrupt 的标识就是程序能继续向下执行, 但里面的 statue > 0)
            //we got interrupted, but we haven't received notification from the poller.
            keycount = 0;                                                          // 11. keycount 变成 0, 则在第一次进入 loop 时不会接着写数据, 因为这时还没有真正的 OP_WRITE 事件过来
        }else {
            //latch countdown has happened
            keycount = 1;
            att.resetWriteLatch();                                                 // 12. OP_WRITE 事件过来了, 重置 CountDownLatch 里面的技术支持
        }

        if (writeTimeout > 0 && (keycount == 0))
            timedout = (System.currentTimeMillis() - time) >= writeTimeout;        // 13. 判断是否写超时
    } //while
    if (timedout)
        throw new SocketTimeoutException();                                        // 14. 若是写超时的话, 则直接抛异常
} finally {
    poller.remove(att,SelectionKey.OP_WRITE);                                      // 15. Tomcat 写数据到客户端成功, 移除 SocketChannel 对应的 OP_WRITE 事件
    if (timedout && reference.key!=null) {
        poller.cancelKey(reference.key);
    }
    reference.key = null;
    keyReferenceStack.push(reference);
}

通过代码我们知道, 其实就是在 SocketChannel.write 数据的个数是0, 则将 SocketChannel的OP_WRITE事件注册到 Selector 上(这里的 selector 是通过 NioSelectorPool 获取的, 有单例, 也有对象池), 再通过一个 CountDownLatch 来进行阻塞, 直到 NioSelectorPool.selector 通知其有对应的 OP_WRITE 事件;
问题来了, 这里怎么又有个 NioSelectorPool, 我们明明可以注册到 Poller 中的 selector 上, 干嘛还要注册到 NioSelectorPool.selector 上?
补充知识:

selector 内部有3 个SelectionKeys 集合
1. publicKeys               : 所有注册的 SelectionKeys (PS: 包括部分取消的SelectionKeys)        
    (通过 selector.keys() 来获取)
2. publicSelectedKeys       :通过底层select获取到的有触发的 SelectionKeys 的集合 
    (通过 selector.selectedKeys() 来获取)
3. cancelledKeys            : SelectionKey.cancel 来触发加入这个集合中, 或调用 SocketChannel.close() 也行

4. 调用 selector.select 或 selector.register 都会阻塞 publicKeys (通过 Synchronized 关键字, 见代码 

上面的第4点其实已经说明 select 或 register 都会通过 Synchronized 来进行阻塞操作, 所以这里也就是增加 selector 来减少 对 selector 操作的并发度

6. Tomcat NIO 与 BIO 对比之 KeepAlive
开启 KeepAlive 功能下

NIOEndPoint.SocketProcessor.doRun(SelectionKey key, KeyAttachment ka) 方法下的 finally 中
    getExecutor().execute(new SocketProcessor(socket, SocketStatus.OPEN_READ));
    在处理好请求后, 只要再将 SocketChannel 再次注册到Selector上就可以

JioEndPoint.SocketProcessor.doRun 方法下的 finally 中
    getExecutor().execute(new SocketProcessor(socket, SocketStatus.OPEN_READ));
    在处理好请求后, 将 socket 封装出 SocketProcessor 来接着处理请求

可以看出, BIO对比NIO 在KeepAlive 的情况下, 需要开启更多的线程处理 socket, 从而使得系统的压力更大(PS: Http 的 KeepAlive 是默认开启的, 就因为这个 KeepAlive 的开启从而使得 NIO 相对于 BIO 在同等硬件资源下 更能并发处理请求)

7. 总结:

Reactor 的线程模型其实大多数开源项目都是差不多的(主要区别在是否开启多个 Selector ), 而这里只是对 NIO 与 BIO 做了一个简单的对比, 随着代码的深入, 越发觉得 要正真掌握 NIO 是需要 深入理解 TCP/IP + Unix 网络编程(PS: 至于粘包拆包 + NIO CPU 100% 的bug 好像 Tomcat 没做对应的修复, 这两步其实在 Netty 里面已经做了, 可以参照 Netty 的代码获知)

8. 参考
显示全文