深入浅出netty - ByteBuf 和 ByteBufPool
ByteBuf
首先 java 本身就有 ByteBuffer, java的 nio 中用的就是他, 然后他有2个实现, 分别是 HeapByteBuffer
和 DirectByteBuffer
前者是基于 java 的堆实现, 后者则使用了unsafe的API进行了堆外的实现。
而 ByteBuf 其实也有类似的2个实现, 至于为什么要额外设计一个 ByteBuf 则需要我们看一下他们在使用上区别。
ByteBuffer 内部使用4个变量表示读写缓冲区。
1 | position 当前读取位置 |
ByteBuf 内部则使用更多的变量, 主要是将 position
, mark
变成 reader, write
index 和 mark
1 | readerIndex |
这样的好处则如下图所示:
由于 ByteBuffer 只有一个 position 表示当前位置,所以在进行 读
写
切换的时候需要手工调用 flip
而 ByteBuf 由于有 read write 2个 index, 所以不需要, 写代码体验更好。
零拷贝
在 linux 中传统的 IO操作是 一种 缓冲IO
传输数据时需要在缓冲区中进行多次的拷贝操作。
数据在内核态(硬件,比如硬盘) 到用户态(内存) 各有自己的缓冲。
而零拷贝可以直接将数据从 内核态(硬盘) 发给 内核态(网卡) 中间不经过用户态(ps, 当然也不能够修改数据)
linux 提供2种 零拷贝的支持
- 硬盘到网卡 (LinuxAPI: sendfile) (static file server)
- 网卡到网卡 (LinuxAPI: splice) (proxy server)
不过 java jdk 目前只支持 1的官方支持, 2 不支持
而 netty 提供了一种 CompositeByteBuf 的 ByteBuf 实现, 官方把它称之为 netty 的零拷贝,
其实它不是零拷贝, 主要解决了如下的问题
比如 http 协议的 1.0 版本中并没有要求 response 需要带上 content-length
那么普通情况下 读取数据的话需要做很多次memory copy, 以便上层使用者可以使用合并后的结果
而 netty 则不省略allocate 的部分,而仅省了 recopy 的过程, 用 CompositeByteBuf 内部的指针合并多个 ByteBuffer
如图
同样对上层来说仅仅是一个 合并的 ByteBuf, 但是其实内部可以减少 reallocate 和 copy 的过程,减少系统调用。
ByteBufPool
ByteBufPool 是 netty4 引入的新 feature。 目的在于减少内存的分配次数 (heap/direct)
至于使用之后带来的性能上的提升可以看 twitter 做的评测, 这里不重复展开。
netty 的 ByteBufPool 是 jemelloc 的精简版,大体流程是类似的。
都有一个 threadlocal 的 cache, 以及公有的 Arena。
从cache 取内存无锁,从 Arena取内存有锁。所以一般建议将 Arena 的数量设置为 EventLoop 数量,以达到无竞争。
关系图如下:
关于 参数的设定参看 io.netty.buffer.PooledByteBufAllocator
这个类.
如果先不管 Arena 是什么的话,Cache 是如何缓存的?
- 给每个 申请的内存块的大小 都分配一个Queue (一样大小的都recycle 都放回一个 Queue)
- 如果给每个大小都定义个Queue 反而浪费内存了,所以 可申请的内存块大小是指定规格的。
- 对申请的内存根据预定义的规格向上取整
- 所有的规格都放在一个数组里面,寻找起来 O1 的速度。
根据默认参数的话规格如下:
1 | tiny: [16 32 48 64 80 ... 496] |
举个例子: 当申请的内存为 10个byte 则向上取整 16 , 分配16byte, 取 tiny[0]
里取内存即可
Arena 为分配器,是预申请内存块的管理者。 jemelloc 的初衷为了减少内存碎片,那么如何减少?
当申请比如 1 byte 的话就会预申请一大块内存,然后分配其中一部分给 1byte。
为什么是分配 一部分? 不然 内存管理花费的内存开销将大于内存实际的开销。。
Arena 是一个分配器,内部管理的内存块 为 Chunk
, 申请大于 Chunk
大小的内存将直接向系统申请。
Chunk
内部由 n 个 page
组成, page
是一个虚拟的概念,因为 page
还可以切分为 n 个 SubPage
他们的关系图如下:
ps: 左侧为默认参数
这里说一下Arena 需要做的事情
首先, 一个 Arena 用 ChunkList 管理着 n 个 Chunk, 一个Chunk 默认 16mb, 当一个 Chunk 分配不了时,新建一个新的 Chunk
Chunk & ChunkList
Arena 内部还对 Chunk 做了 使用量的 监测。
Arena 新建了 6个 ChunkList 分别为 qInit
, q000
, q025
, q050
, q075
, q100
她们依次组成一个双向链表,分别又一个 最小使用量 和 最大使用量 的阀值
1 | // PoolChunkList<T> nextList, int minUsage, int maxUsage, int chunkSize |
然后在每次 allocate 和 deallocate 时判断 Chunk 是否触及 阀值,然后分别 上移/下移 到合适的 ChunkList
为了减小内存碎片, 分配的优先级如下:
q050 > q025 > q000 > qInit > q075
代码如下
1 | private synchronized void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) { |
为什么这么弄?
首先, 可以分配的内存时这样的, 回到之前的规格。 对于 8192x2^11
byte 相当于 100% 的 chunk 了。
而 比他小的分别为 50%, 25%, 12.5%, 6.25%, 3.125% …
所以如果将优先级 的第一第二换个位置则可能造成 大量的 75% 。。。
Page & SubPage
Chunk 由 2048 个 Page 组成。
一个 Page 默认 8k。
Chunk 里面将这 2048 个 page 用其 2 倍的空间 byte[4096] memoryMap
以完全二叉树管理。
深度为 11 的共 2048 个元素表示该 page 有没有被分配
深度 1-10 的节点都是索引
每个节点的 value 默认为该节点在树的深度。
当 page 被分配掉时, 则将自己的 value + 1 也就是变成12
每次被分配后都要同时更新自己的 parent 的 value, parent 的 value 取 2 个 child 中小的一个。
1 | private void updateParentsAlloc(int id) { |
比如当要分配 8k 的大小时直接找 第 11 层找 第一个 val 为 11 的(unused), 找到后更新自己以及parent节点。
这样当比如需要分配 8MB 内存是,立即找 depth 为 1 的2个节点, 只要她的 value 不等于 depth 就知道该节点的 child 被分配过了。
然后 Subpage 的作用是用来标示分配小于一个 page 的内存块。
但是 Subpage 不再是用二叉树管理了。而是用更方便的方式。
一个 Subpage 只能分配一种 size 大小, 然后用 3个long 共 8x8x8
个bit 标示是否被使用。
比如 如果 page 为 8k , 由于共 512 个标示可用,所以最小分配单元 为 16 byte.
但是 如果 最小分配单元为 32 byte, 则 共只需要 256 个标示即可。(3个 long 不需要全部用完)
Subpage 也是一个链表, 相同规格的subpage 链在一起。
然后 PoolArena 初始化了 n 个size 的 Subpage 的链表头, 并用来维护。
1 | private final PoolSubpage<T>[] tinySubpagePools; |
每次新创建 Subpage 会吧 subpage 链到 相应的 idx 的 PoolSubpage 内。
1 | private long allocateSubpage(int normCapacity) { |
图如下
内存开销
最后算一下开销。
如果 一个 netty server 配置了 1 个 acceptor Thread,32个 IO Thread, 我们这里仅仅考虑默认配置。
为了让 arena 不被锁影响,则需要33 个 arena, 那么就会需要 33个 chunk, 共计 33 * 16mb 共计 528MB (默认配置)
由于 ByteBufPool 默认是使用堆外内存的, 而 jdk 默认堆外内存 64 mb, 所以需要手动修改。
不然 netty 会使用 堆内存。 不经计算,贸然使用可能会造成full gc。
内存分配需要根据实际情况自己计算参数:
举一个例子
算一台服务 2k qps的服务,每个包 2k 的话 预计需要
1 | In [13]: 2000 * 2000 / 1024/1024.0 |
ps 这是没有算上 请求包的,就算 * 2 8mb 因该够了。
可以将 pageSize 调为一半,节省内存 io.netty.allocator.pageSize = 4096
于是一个 chunk 8mb 可以少分配一半的内存。
最佳实践
建议不使用 heap 的bytebuf pool,减小堆内gc 压力
堆外的size 量力而为,有时候 并不一定需要分配时并行,毕竟只有第一次分配时需要锁。
但要注意, 如果当 iosize 远大于 arena size的话, 当大流量进来的时候, 锁的影响还是很大的.
关于锁的影响, 完全可以通过预申请 buffer 解决,
reference
https://my.oschina.net/flashsword/blog/159613
http://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy1/
http://www.jianshu.com/p/c4bd37a3555b
http://www.jianshu.com/p/d91060311437