要解决的问题:Tomcat如何实现非阻塞I/O?
核心就是Poller/Selector。但在异步IO里不需要Poller/Selector。

IO模型解决了什么问题?

I/O 就是计算机内存与外部设备之间拷贝数据的过程。

CPU 访问内存的速度远远高于外部设备,CPU 先把外部设备的数据读到内存里,然后再进行处理。

I/O 模型要解决的问题,在发生 IO 时,如何充分利用 CPU 的资源?
当程序通过 CPU 向外部设备发出一个读指令时,数据从外部设备拷贝到内存往往需要一段时间,这个时候 CPU 没事干了。程序是主动把 CPU 让给别人?还是让 CPU 不停地查:数据到了吗,数据到了吗……

I/O 的两个核心步骤

I/O 就是计算机内存与外部设备之间拷贝数据的过程。

对于网络 I/O,会涉及两个对象和两个动作:
两个对象:用户线程(发起 I/O 操作)、操作系统内核(管理 I/O 细节)
两个动作:I/O 事件查询、读取数据。
两个拷贝:
第一次:用户线程等待内核将数据从网卡拷贝到内核空间。
第二次:内核将数据从内核空间拷贝到用户空间。

背景知识

用户进程和绝大多数内核进程都不能直接访问物理内存。操作系统提供了虚拟地址空间。进程的虚拟地址空间分为用户空间和内核空间。

用户线程不能直接访问内核空间,除非通过特殊调用共享内存,才能访问到内核虚拟地址对应的物理地址。因此必须拷贝到用户空间才能使用户线程访问数据。

一定是内核进程把数据从内核空间拷贝到用户空间。

NioEndpoint 的 I/O 多路复用

I/O多路复用模型也用于redis等。

LimitLatch 限制最大连接数。
Acceptor监听连接,创建 fd,然后Poller关注加入的 fd。
如果 fd 有就绪事件,Poller 会生成任务对象 sockerProcessor,丢到线程池。
sockerProcessor 会进一步调用 http11Processor 来读写 fd 。

注意:对于TCP协议,拆包是应用层协议的任务。image.png

LimitLatch 的实现

AQS 是骨架抽象类,它帮我们搭了个架子,用来控制线程的阻塞和唤醒。具体什么时候阻塞、什么时候唤醒由你来决定。

当前线程数被定义成原子变量 AtomicLong,而 limit 变量用 volatile 关键字来修饰。

AQS、原子类、并发容器,线程池等都值得回味。

Acceptor 的实现

accept() 方法返回获得 SocketChannel 对象,将其封装在一个 PollerEvent 对象中,并将 PollerEvent 对象压入 Poller 的 Queue 里,这是个典型的“生产者 - 消费者”模式。Acceptor 与 Poller 线程之间通过 Queue 通信。

TCP 三次握手的底层细节

TCP三次握手建立连接的过程中,内核通常会为每一个LISTEN状态的Socket维护两个队列:
SYN队列(半连接队列):这些连接已经接到客户端SYN;
ACCEPT队列(全连接队列):这些连接已经接到客户端的ACK,完成了三次握手,等待被accept系统调用取走。可以同时有多个Acceptor调用accept方法,accept是线程安全的。 Acceptor负责从ACCEPT队列中取出连接,当Acceptor处理不过来时,连接就堆积在ACCEPT队列中,这个队列长度acceptCount 参数配置

Poller 的实现

Poller 本质是一个 Selector,选择活跃的 fd 。每个 Poller 线程都有自己的 Queue。每个 Poller 线程可能同时被多个 Acceptor 线程调用来注册 PollerEvent。

SocketProcessor 的实现

Http11Processor 并不是直接读取 Channel 的。因为 Tomcat 支持同步非阻塞 I/O 模型和异步 I/O 模型,在 Java API 中,相应的 Channel 类也是不一样的,比如有 AsynchronousSocketChannel 和 SocketChannel。

为了对 Http11Processor 屏蔽这些差异,Tomcat 设计了一个包装类叫作 SocketWrapper,Http11Processor 只调用 SocketWrapper 的方法去读写数据。

Executor 的介绍

Executor 是 Tomcat 定制版的线程池,它负责创建真正干活的工作线程,干什么活呢?就是执行 SocketProcessor 的 run 方法,也就是解析请求并通过容器来处理请求,最终会调用到我们的 Servlet。

如何实现高并发?

高并发就是能快速地处理大量的请求,需要合理设计线程模型让 CPU 忙起来,尽量不要让线程阻塞。

NioEndpoint 要完成三件事情:接收连接、检测 I/O 事件以及处理请求。那么最核心的就是把这三件事情分开,用不同规模的线程数去处理。比如:
用专门的线程组去跑 Acceptor,Acceptor 的个数可以配置;
用专门的线程组去跑 Poller,Poller 的个数也可以配置;
具体任务的执行也由专门的线程池来处理,线程池的大小也可以配置。

阻塞与进程调度

I/O 模型的区别

各种 I/O 模型的区别就是:它们实现两个拷贝步骤的方式是不一样的。
更确切地讲,就是 同步和异步 的区别。
同步模型之间的区别在于如何查询事件的状态。

分析如下:

第二次拷贝,都是通过系统调用完成,这次拷贝必然是阻塞的。
因此,第二次拷贝不是关注的重点。

阻塞/非阻塞,关注的是第一次拷贝(从网卡拷贝数据到内核,直到数据就绪)。
第一次拷贝时,如果用户进程hang住了,就是阻塞模型,否则就是非阻塞模型。

为什么要hang住?因为用户进程不知道第一次拷贝结束所产生IO就绪事件什么时候发生的。因此存在多次read,或者IO多路复用的情况。
因此有几种策略:
1 一直问
2 隔一段时间问一次
3 复用一个线程,来查询 IO 就绪事件。

同步/异步,关注的是 IO 事件的通知机制。
同步/异步是指应用和内核的事件通知方式。
IO 事件就是指 数据是否准备好,或者缓冲区可写。事件就绪后内核进程开始第二次拷贝。
到底是用户进程自己查询,还是内核主动通知?是前者就是同步,是后者就是异步。
异步意味着用户进程发起了调用之后不会阻塞,只需等内核拷贝两次,把数据搬运到用户空间。第二次拷贝的过程中,也可以干别的事。