2 Java 原生 NIO API 从入门到放弃
复杂度高
- API复杂难懂,入门困。
- 粘包/半包问题费神。
- 需超强的并发/异步编程功底,否则很难写出高效稳定的实现。
稳定性差,坑多且深
- 调试困难,偶尔遭遇匪夷所思极难重现的bug,边哭边查是常有的事儿。
- linux 下 EPollArrayWrapper.epollWait 直接返回导致空轮训进而导致 100% cpu 的 bug 一直也没解决利索,Netty帮你 work around (通过rebuilding selector)。
NIO代码实现方面的一些缺点
1)Selector.selectedKeys() 产生太多垃圾
Netty 修改了 sun.nio.ch.SelectorImpl 的实现,使用双数组代替 HashSet 存储来 selectedKeys:
- 相比HashSet(迭代器,包装对象等)少了一些垃圾的产生(help GC)。
- 轻微的性能收益(1~2%)。
Nio 的代码到处是 synchronized (比如 allocate direct buffer 和 Selector.wakeup() ):
- 对于 allocate direct buffer,Netty 的 pooledBytebuf 有前置 TLAB(Thread-local allocation buffer)可有效的减少去竞争锁。
- wakeup 调用多了锁竞争严重并且开销非常大(开销大原因: 为了在 select 线程外跟 select 线程通信,linux 平台上用一对 pipe,windows 由于 pipe 句柄不能放入 fd_set,只能委曲求全用两个 TCP 连接模拟),wakeup 调用少了容易导致 select 时不必要的阻塞(如果懵逼了就直接用 Netty 吧,Netty中有对应的优化逻辑)。
- Netty Native Transport 中锁少了很多。
2)fdToKey 映射
- EPollSelectorImpl#fdToKey 维持着所有连接的 fd(描述符)对应 SelectionKey 的映射,是个 HashMap。
- 每个 worker 线程有一个 selector,也就是每个 worker 有一个 fdToKey,这些 fdToKey 大致均分了所有连接。
- 想象一下单机 hold 几十万的连接的场景,HashMap 从默认 size=16,一步一步 rehash...
3)Selector在linux 平台是 Epoll LT 实现
- Netty Native Transport支持Epoll ET。
4)Direct Buffers 事实上还是由 GC 管理
- DirectByteBuffer.cleaner 这个虚引用负责 free direct memory,DirectByteBuffer 只是个壳子,这个壳子如果坚强的活下去熬过新生代的年龄限制最终晋升到老年代将是一件让人伤心的事情…
- 无法申请到足够的 direct memory 会显式触发 GC,Bits.reservememory() -> { System.gc() },首先因为 GC 中断整个进程不说,代码中还 sleep 100 毫秒,醒了要是发现还不行就 OOM。
- 更糟的是如果你听信了个别<XX优化宝典>谗言设置了-XX: DisableExplicitGC 参数,悲剧会静悄悄的发生...
- Netty的UnpooledUnsafeNoCleanerDirectByteBuf 去掉了 cleaner,由 Netty 框架维护引用计数来实时的去释放。
1 Netty 中几个重要概念及其关系
EventLoop
- 一个 Selector。
- 一个任务队列(mpsc_queue: 多生产者单消费者 lock-free)。
- 一个延迟任务队列(delay_queue: 一个二叉堆结构的优先级队列,复杂度为O(log n))。
- EventLoop 绑定了一个 Thread,这直接避免了pipeline 中的线程竞争。
Boss: mainReactor 角色,Worker: subReactor 角色
- Boss 和 Worker 共用 EventLoop 的代码逻辑,Boss 处理 accept 事件,Worker 处理 read,write 等事件。
- Boss 监听并 accept 连接(channel)后以轮训的方式将 channel 交给 Worker,Worker 负责处理此 channel 后续的read/write 等 IO 事件。
- 在不 bind 多端口的情况下 BossEventLoopGroup 中只需要包含一个 EventLoop,也只能用上一个,多了没用。
- WorkerEventLoopGroup 中一般包含多个 EventLoop,经验值一般为 cpu cores * 2(根据场景测试找出最佳值才是王道)。
- Channel 分两大类 ServerChannel 和 Channel,ServerChannel 对应着监听套接字(ServerSocketChannel),Channel 对应着一个网络连接。
2 Netty4 Thread Model
3 ChannelPipeline
4 Pooling&reuse
PooledByteBufAllocator
- 基于 jemalloc paper (3.x)
- ThreadLocal caches for lock free:这个做法导致曾经有坑——申请(Bytebuf)线程与归还(Bytebuf)线程不是同一个导致内存泄漏,后来用一个mpsc_queue解决,代价就是牺牲了一点点性能。
- Different size classes。
Recycler
- ThreadLocal Stack。
- 曾经有坑,申请(元素)线程与归还(元素)线程不是同一个导致内存泄漏。
- 后来改进为不同线程归还元素的时候放入一个 WeakOrderQueue 中并关联到 stack 上,下次 pop 时如果 stack 为空则先扫描所有关联到当前 stack 上的 weakOrderQueue。
- WeakOrderQueue 是多个数组的链表,每个数组默认size=16。
- 存在的问题:思考一下老年代对象引用新生代对象对 GC 的影响?
5 Netty Native Transport
相比 Nio 创建更少的对象,更小的 GC 压力。
针对 linux 平台优化,一些 specific features:
- SO_REUSEPORT - 端口复用(允许多个 socket 监听同一个 IP 端口,与 RPS/RFS 协作,可进一步提升性能):可把 RPS/RFS 模糊的理解为在软件层面模拟多队列网卡,并提供负载均衡能力,避免网卡收包发包的中断集中的一个 CPU core 上而影响性能。
- TCP_FASTOPEN - 3次握手时也用来交换数据。
- EDGE_TRIGGERED (支持Epoll ET是重点)。
- Unix 域套接字(同一台机器上的进程间通信,比如Service Mesh)。
6 多路复用简介
select/poll
- 本身的实现机制上的限制(采用轮询方式检测就绪事件,时间复杂度: O(n),每次还要将臃肿的 fd_set 在用户空间和内核空间拷贝来拷贝去),并发连接越大,性能越差。
- poll 相比 select 没有很大差异,只是取消了最大文件描述符个数的限制。
- select/poll 都是 LT 模式。
epoll
- 采用回调方式检测就绪事件,时间复杂度: O(1),每次 epoll_wait 调用只返回已就绪的文件描述符。
- epoll 支持 LT 和 ET 模式。
7 稍微深入了解一点 Epoll
LT vs ET
概念:
- LT:level-triggered 水平触发
- ET:edge-triggered 边沿触发
可读:
- buffer 不为空的时候 fd 的 events 中对应的可读状态就被置为1,否则为0。
可写:
- buffer 中有空间可写的时候 fd 的 events 中对应的可写状态就被置为1,否则为0。
图解:
epoll 三个方法简介
1)主要代码:linux-2.6.11.12/fs/eventpoll.c
2)int epoll_create(int size)
创建 rb-tree(红黑树)和 ready-list (就绪链表):
- 红黑树O(logN),平衡效率和内存占用,在容量需求不能确定并可能量很大的情况下红黑树是最佳选择。
- size参数已经没什么意义,早期epoll实现是hash表,所以需要size参数。
3)int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event)
- 把epitem放入rb-tree并向内核中断处理程序注册ep_poll_callback,callback触发时把该epitem放进ready-list。
4)int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout)
- ready-list —> events[]。
epoll 的数据结构