线程与 IO 模型
{Back to Index}
Table of Contents
部分图和内容引用了《bin的技术小屋》,做回伸手党,表示感谢
1 EventLoop
EventLoop 本质是一个单线程执行器,内部维护一个 Selector 对象,通过 run 方法处理 Channel 上源源不断的 IO 事件。
EventLoopGroup 则是一组 EventLoop 的集合,Channel 会通过 EventLoopGroup::register
绑定至其中一个 EventLoop ,后续所有关于此 Channel 的 IO 事件都由该 EventLoop 负责处理, 这样保证了 IO 处理的线程安全性。
常见的 EventLoopGroup 实现有:
- NioEventLoopGroup
- 处理 IO 事件,普通任务,定时任务
- DefaultEventLoopGroup
- 仅处理普通任务与定时任务
2 Channel/Unsafe
Figure 1: Channel 类继承关系
Netty 中的 IO 操作最终都是在 channel pipeline 中由 Unsafe 完成的,pipeline 与 channel 和 usafe 的关系是一对一的,即 pipeline 中包含 channel 引用,channel 中初始化 unsafe 。
3 Future/Promise
io.netty.concurrent.Future
继承自 java.util.concurrent.Future
, io.netty.util.concurrent.Promise
又对 io.netty.concurrent.Future
进行了扩展:
java.util.concurrent.Future
只能同步等待任务结束才能得到结果。
io.netty.concurrent.Future
可以同步等待任务结束从而得到结果【sync()】,也可以异步方式得到结果【addListerner()】,两者都是要以任务结束为前提。Future 一般是由框架代码生成并返回给应用程序使用的。
io.netty.util.concurrent.Promise
可以当做
io.netty.concurrent.Future
使用,也可以作为通用的异步对象使用(即支持主动设置结果),即作为线程间传递结果的载体(想象成线程间的约定)。 应用程序可以先创建 Promise 对象,再将其交给执行线程(或框架代码)处理。
4 Reactor 线程模型
Figure 2: 宏观模型
Figure 3: Reactor 大致流程
Figure 4: 线程模型
- event loop group 负责给每个 channel 分配 其需要注册的 event loop
- executor 用于创建 一个 内部执行 IO 轮询的线程,线程对象保存在 thread 变量中
- 所有的即时 task 存放在 MPSC 队列中,调度与延时 task 存放在 PQ 中
- 每个 NioEventLoop 代表一个 Reactor 模型
5 IO 模型
5.1 selector
Figure 5: select系统调用流程
select
不会告诉用户线程具体哪些fd上有IO数据到来,只是在IO活跃的fd上打上标记,将打好标记的完整fd数组返回给用户线程, 所以用户线程还需要遍历fd数组找出具体哪些fd上有IO数据到来。
另外,由于内核在遍历的过程中已经修改了fd数组,所以在用户线程遍历完fd数组后获取到IO就绪的Socket后,就需要 重置fd数组 ,并重新调用select传入重置后的fd数组,让内核发起新的一轮遍历轮询。
select 缺点:
- 在发起select系统调用以及返回时,用户线程各发生了一次用户态到内核态以及内核态到用户态的上下文切换开销。 发生2次上下文切换
- 在发起select系统调用以及返回时,用户线程在内核态需要将文件描述符集合从用户空间拷贝到内核空间。以及在内核修改完文件描述符集合后,又要将它从内核空间拷贝到用户空间。 发生2次文件描述符集合的拷贝
- 虽然由原来在用户空间发起轮询优化成了在内核空间发起轮询但select不会告诉用户线程到底是哪些Socket上发生了IO就绪事件,只是对IO就绪的Socket作了标记,用户线程依然要遍历文件描述符集合去查找具体IO就绪的Socket。 时间复杂度依然为O(n)。
- 内核会对原始的文件描述符集合进行修改。导致 每次在用户空间重新发起select调用时,都需要对文件描述符集合进行重置。
- BitMap结构的文件描述符集合(长度为固定的1024的 数组), 所以 只能监听0~1023的文件描述符。
- select系统调用 不是线程安全的。
select 只适用于1000个左右的并发连接场景。
Figure 6: Java Selector 对象内存使用
Figure 7: channel与SelectionKey对应关系
5.2 poll
poll只是改进了select只能监听1024个文件描述符的数量限制(换成了一个pollfd结构没有固定长度的 链表),但是并没有在性能方面做出改进。
和select上本质并没有多大差别。
5.3 epoll
- epoll在内核中通过红黑树管理海量的连接,所以在调用epoll_wait获取IO就绪的socket时,不需要传入监听的socket文件描述符。从而避免了海量的文件描述符集合在用户空间和内核空间中来回复制。
- epoll仅会通知IO就绪的socket。避免了在用户空间遍历的开销。
- epoll通过在socket的等待队列上注册回调函数ep_poll_callback通知用户程序IO就绪的socket。避免了在内核中轮询的开销。
大部分情况下socket上并不总是IO活跃的,在面对海量连接的情况下,select,poll采用内核轮询的方式获取IO活跃的socket, 无疑是性能低下的核心原因。
6 启动流程
Figure 8: Reactor 启动流程图
Figure 9: 为了简化逻辑,中间函数调用过程有所忽略,但描述了大致的处理流程
NioSocketChannel 通常只关心 OP_READ 事件,但是如果在写操作时发现暂时无法写入,则会添加 OP_WRITE 感兴趣事件,这样当 selector 轮询到该事件时,说明系统缓冲区空出来了,可以写入了。
6.1 服务端启动总体流程
- 创建服务端NioServerSocketChannel并初始化
- 将服务端NioServerSocketChannel注册到主Reactor线程组中
- 注册成功后 ,开始初始化NioServerSocketChannel中的pipeline,然后在pipeline中触发channelRegister事件
- 随后由NioServerSocketChannel绑定端口地址
- 绑定端口地址成功后,向NioServerSocketChannel对应的Pipeline中触发传播ChannelActive事件
- 在ChannelActive事件回调中向Main Reactor注册OP_ACCEPT事件,开始等待客户端连接。服务端启动完成
Figure 10: NioServerSocketChannel 布局
6.2 客户端启动总体流程
Figure 11: NioSocketChannel 布局