netty 图解_Jupiter/high_performance_rpc_with_netty.md at master · fengjiachun/Jupiter · GitHub

提纲

什么是Netty? 能做什么?

贴近日常生活, 先设计一个服务框架

RPC的一些Features&好的实践

如何压榨性能

Why Netty? (延伸: Netty --> NIO --> Linux Epoll一些实现细节)

什么是Netty? 能做什么?

Netty是一个致力于创建高性能网络应用程序的成熟的IO框架

相比较与直接使用底层的Java IO API, 你不需要先成为网络专家就可以基于Netty去构建复杂的网络应用

业界常见的涉及到网络通信的相关中间件大部分基于Netty实现网络层

设计一个分布式服务框架

Architecture

远程调用的流程

启动服务端(服务提供者)并发布服务到注册中心

启动客户端(服务消费者)并去注册中心订阅感兴趣的服务

客户端收到注册中心推送的服务地址列表

调用者发起调用, Proxy从服务地址列表中选择一个地址并将请求信息, methodName, args[]等信息序列化为字节数组并通过网络发送到该地址上

服务端收到收到并反序列化请求信息, 根据从本地服务字典里查找到对应providerObject, 再根据通过反射调用指定方法, 并将方法返回值序列化为字节数组返回给客户端

客户端收到响应信息再反序列化为Java对象后由Proxy返回给方法调用者

以上流程对方法调用者是透明的, 一切看起来就像本地调用一样,

重要概念: RPC三元组

远程调用客户端图解

若是netty4.x的线程模型, IO Thread(worker) —> Map代替全局Map能更好的避免线程竞争

远程调用服务端图解

重要概念: RPC三元组

远程调用传输层图解

左图为客户端, 右图为服务端

设计传输层协议栈

协议头

协议体

metadata:

methodName

parameterTypes[] 真的需要?

有什么问题?

反序列化时ClassLoader.loadClass()潜在锁竞争

协议体码流大小

泛化调用多了参数类型

能解决吗?

Java方法静态分派规则参考JLS $15.12.2.5 Choosing the Most Specific Method 章节

args[]

其他: traceId, appName…

一些Features&好的实践&压榨性能

创建客户端代理对象

Proxy做什么?

集群容错 —> 负载均衡 —> 网络

有哪些创建Proxy的方式?

jdk proxy/javassist/cglib/asm/bytebuddy

要注意的:

注意拦截toString, equals, hashCode等方法避免远程调用

推荐的(bytebuddy):

优雅的同步/异步调用

往上翻再看看'远程调用客户端图解'

思考下如何拿到future?

单播/组播

消息派发器

FutureGroup

泛化调用

Object $invoke(String methodName, Object... args)

parameterTypes[]

序列化/反序列化(协议header标记serializer type, 同时支持多种)

可扩展性

Java SPI

-java.util.ServiceLoader

-META-INF/services/com.xxx.Xxx

服务级别线程池隔离

要挂你先挂, 别拉着我

责任链模式的拦截器

太多扩展需要从这里起步

指标度量(Metrics)

注册中心

流控(应用级别/服务级别)

要有能方便接入第三方流控中间件的扩展能力

Provider线程池满了怎么办?

软负载均衡

加权随机

加权轮训(最大公约数)

最小负载

一致性hash(有状态服务场景)

其他

要有预热逻辑

集群容错

Fail-fast

Failover

异步调用怎么处理?

Bad 😥

Better 😀

Fail-safe

Fail-back

Forking

其他

如何压榨性能(Don’t trust it, Test it)

ASM写个FastMethodAccessor来代替服务端那个反射调用

序列化/反序列化

在业务线程中序列化/反序列化, 避免占用IO线程

序列化/反序列化占用数量极少的IO线程时间片

反序列化常常会涉及到Class的加载, loadClass有一把锁竞争严重(可通过JMC观察一下)

选择高效的序列化/反序列化框架(kryo/protobuf/protostuff/hessian/fastjson/…)

选择只是第一步, 它(序列化框架)做的不好的, 去扩展和优化之

传统的序列化/反序列化+写入/读取网络的流程

java对象--> byte[] -->堆外内存 / 堆外内存--> byte[] -->java对象

新社会主义优化

省去byte[]环节, 直接读/写 堆外内存, 这需要扩展对应的序列化框架

String编码/解码优化

Varint优化

多次writeByte合并为writeShort/writeInt/writeLong

同步阻塞调用的客户端和容易成为瓶颈, 客户端协程?

Java层面可选的并不多, 暂时也都不完美 😥

类型

特点

kilim

编译期间字节码增强

quasar

agent动态字节码增强

ali_wisp

ali_jvm在底层直接实现

Netty Native Transport & PooledByteBufAllocator

减小GC带来的波动

尽快释放IO线程去做他该做的事情, 尽量减少线程上下文切换

Why Netty?

BIO vs NIO

Java原生NIO API从入门到放弃

复杂度高

API复杂难懂, 入门困难

粘包/半包问题费神

需超强的并发/异步编程功底, 否则很难写出高效稳定的实现

稳定性差, 坑多且深

调试困难, 偶尔遭遇匪夷所思极难重现的bug, 边哭边查是常有的事儿

linux下EPollArrayWrapper.epollWait直接返回导致空轮训进而导致100% cpu的bug一直也没解决利索, Netty帮你work around(通过rebuilding selector)

NIO代码实现方面的一些缺点

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中锁少了很多

fdToKey映射

EPollSelectorImpl#fdToKey维持着所有连接的fd(描述符)对应SelectionKey的映射, 是个HashMap

每个worker线程有一个selector, 也就是每个worker有一个fdToKey, 这些fdToKey大致均分了所有连接

想象一下单机hold几十万的连接的场景, HashMap从默认size=16, 一步一步rehash...

Selector在linux平台是Epoll LT实现

Netty Native Transport支持Epoll ET

Direct Buffers事实上还是由GC管理

DirectByteBuffer.cleaner这个虚引用负责free direct memory, DirectByteBuffer只是个壳子, 这个壳子如果坚强的活下去熬过新生代的年龄限制最终晋升到老年代将是一件让人伤心的事情…

无法申请到足够的direct memory会显式触发GC, Bits.reserveMemory() -> { System.gc() }, 首先因为GC中断整个进程不说, 代码中还sleep 100毫秒, 醒了要是发现还不行就OOM

更糟的是如果你听信了个别谗言设置了-XX:+DisableExplicitGC参数, 悲剧会静悄悄的发生...

Netty的UnpooledUnsafeNoCleanerDirectByteBuf去掉了cleaner, 由Netty框架维护引用计数来实时的去释放

Netty的真实面目

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对应着一个网络连接

Netty4 Thread Model

ChannelPipeline

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的影响

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域套接字

多路复用简介

select/poll

本身的实现机制上的限制(采用轮询方式检测就绪事件, 时间复杂度: O(n), 每次还要将臃肿的fd_set在用户空间和内核空间拷贝来拷贝去), 并发连接越大, 性能越差

poll相比select没有很大差异, 只是取消了最大文件描述符个数的限制

select/poll都是LT模式

epoll

采用回调方式检测就绪事件, 时间复杂度: O(1), 每次epoll_wait调用只返回已就绪的文件描述符

epoll支持LT和ET模式

稍微深入了解一点Epoll

LT vs ET

概念

LT:  level-triggered 水平触发

ET: edge-triggered 边沿触发

可读

buffer不为空的时候fd的events中对应的可读状态就被置为1, 否则为0

可写

buffer中有空间可写的时候fd的events中对应的可写状态就被置为1, 否则为0

图解

epoll三个方法简介

主要代码: linux-2.6.11.12/fs/eventpoll.c

int epoll_create(int size)

创建rb-tree(红黑树)和ready-list(就绪链表)

红黑树O(logN), 平衡效率和内存占用, 在容量需求不能确定并可能量很大的情况下红黑树是最佳选择

size参数已经没什么意义, 早期epoll实现是hash表, 所以需要size参数

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

把epitem放入rb-tree并向内核中断处理程序注册ep_poll_callback, callback触发时把该epitem放进ready-list

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

ready-list —> events[]

epoll的数据结构

epoll_wait工作流程概述

epoll_wait调用ep_poll

当rdlist(ready-list)为空(无就绪fd)时挂起当前线程, 直到rdlist不为空时线程才被唤醒

文件描述符fd的events状态改变

buffer由不可读变为可读或由不可写变为可写, 导致相应fd上的回调函数ep_poll_callback被触发

ep_poll_callback被触发

将相应fd对应epitem加入rdlist, 导致rdlist不空, 线程被唤醒, epoll_wait得以继续执行

执行ep_events_transfer函数

将rdlist中的epitem拷贝到txlist中, 并将rdlist清空

如果是epoll LT, 并且fd.events状态没有改变(比如buffer中数据没读完并不会改变状态), 会再重新将epitem放回rdlist

执行ep_send_events函数

扫描txlist中的每个epitem, 调用其关联fd对应的poll方法取得较新的events

将取得的events和相应的fd发送到用户空间

Netty的最佳实践

业务线程池必要性

业务逻辑尤其是阻塞时间较长的逻辑, 不要占用netty的IO线程, dispatch到业务线程池中去

WriteBufferWaterMark, 注意默认的高低水位线设置(32K~64K), 根据场景适当调整

重写MessageSizeEstimator来反应真实的高低水位线

默认实现不能计算对象size, 由于write时还没路过任何一个outboundHandler就已经开始计算message size, 此时对象还没有被encode成Bytebuf, 所以size计算肯定是不准确的(偏低)

注意EventLoop#ioRatio的设置(默认50), 这是EventLoop执行IO任务和非IO任务的一个时间比例上的控制

空闲链路检测用谁调度?

Netty4.x默认使用IO线程调度, 使用eventLoop的delayQueue, 一个二叉堆实现的优先级队列, 复杂度为O(log N), 每个worker处理自己的链路监测, 有助于减少上下文切换, 但是网络IO操作与idle会相互影响

如果总的连接数小, 比如几万以内, 上面的实现并没什么问题, 连接数大建议用HashedWheelTimer实现一个IdleStateHandler, HashedWheelTimer复杂度为 O(1), 同时可以让网络IO操作和idle互不影响, 但有上下文切换开销

使用ctx.writeAndFlush还是channel.writeAndFlush?

ctx.write直接走到下一个outbound handler, 注意别让它违背你的初衷绕过了空闲链路检测

channel.write从末尾开始倒着向前挨个路过pipeline中的所有outbound handlers

使用Bytebuf.forEachByte() 来代替循环 ByteBuf.readByte()的遍历操作, 避免rangeCheck()

使用CompositeByteBuf来避免不必要的内存拷贝

缺点是索引计算时间复杂度高, 请根据自己场景衡量

如果要读一个int, 用Bytebuf.readInt(), 不要Bytebuf.readBytes(buf, 0, 4)

这能避免一次memory copy (long, short等同理)

配置UnpooledUnsafeNoCleanerDirectByteBuf来代替jdk的DirectByteBuf, 让netty框架基于引用计数来释放堆外内存

io.netty.maxDirectMemory

< 0:  不使用cleaner, netty方面直接继承jdk设置的最大direct memory size, (jdk的direct memory size是独立的, 这将导致总的direct memory size将是jdk配置的2倍)

== 0: 使用cleaner, netty方面不设置最大direct memory size

> 0:  不使用cleaner, 并且这个参数将直接限制netty的最大direct memory size, (jdk的direct memory size是独立的, 不受此参数限制)

最佳连接数

一条连接有瓶颈, 无法有效利用cpu, 连接太多也白扯, 最佳实践是根据自己场景测试

使用PooledBytebuf时要善于利用 -Dio.netty.leakDetection.level 参数

四种级别: DISABLED(禁用), SIMPLE(简单), ADVANCED(高级), PARANOID(偏执)

SIMPLE, ADVANCED采样率相同, 不到1%(按位与操作 mask ==128 - 1)

默认是SIMPLE级别, 开销不大

出现泄漏时日志会出现”LEAK: ”字样, 请时不时grep下日志, 一旦出现”LEAK: ”立刻改为ADVANCED级别再跑, 可以报告泄漏对象在哪被访问的

PARANOID: 测试的时候建议使用这个级别, 100%采样

Channel.attr(), 将自己的对象attach到channel上

拉链法实现的线程安全的hash表, 也是分段锁(只锁链表头), 只有hash冲突的情况下才有锁竞争(类似ConcurrentHashMapV8版本)

默认hash表只有4个桶, 使用不要太任性

从Netty源码中学到的代码技巧

海量对象场景中 AtomicIntegerFieldUpdater --> AtomicInteger

Java中对象头12 bytes(开启压缩指针的情况下), 又因为Java对象按照8字节对齐, 所以对象最小16 bytes, AtomicInteger大小为16 bytes, AtomicLong大小为 24 bytes

AtomicIntegerFieldUpdater作为static field去操作volatile int

FastThreadLocal, 相比jdk的实现更快

线性探测的Hash表 —> index原子自增的裸数组存储

IntObjectHashMap / LongObjectHashMap …

Integer—> int

Node[] —> 裸数组

哈希冲突: 拉链法 —> 线性探测

RecyclableArrayList, 基于前面说的Recycler, 频繁new ArrayList的场景可考虑

JCTools: 一些jdk没有的 SPSC/MPSC/SPMC/MPMC 无锁并发队以及NonblockingHashMap(可以对比ConcurrentHashMapV6/V8)

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值