1. 2.3 RingBuffer 与「零拷贝」读路径
2.2 的定界与解析依赖可读字节驻留在固定容量环中;本节说明 zhenyi-base/znet 里 RingBuffer 的职责、API 形态及与单连接读循环的配合(实现与注释以 ringbuffer.go 为准)。
1.1. 2.3.1 为什么需要 RingBuffer?
标准做法是从连接读取数据时,每次分配新 buffer:
buf := make([]byte, 1024)
n, _ := conn.Read(buf)
问题在于:
| 问题 | 影响 |
|---|---|
| 每次分配新内存 | GC 压力大 |
| 数据拷贝到新 buffer | CPU 浪费 |
| buffer 大小固定 | 大包浪费,小包不够 |
RingBuffer 的思路不同:预分配一块固定大小的内存,循环复用。
┌─────────────────────────────────────┐
│ [已读数据] [未读数据] [可用空间] │
└─────────────────────────────────────┘
↑ ↑
read write
读数据:移动 read 指针,不分配内存
写数据:追加到 write 位置,不分配内存
1.2. 2.3.2 环形结构
为什么叫"环形"?因为当 write 到达末尾后,会绕回到开头继续写:
初始状态: 写了一圈后:
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ │ │ [写] │
│ │ │ [写] [读] │
│ [读] → [写] │ │ [写] [读] │
│ │ └─────────────────────────────┘
└─────────────────────────────┘
write 到达末尾后回到开头,
read 跟着走,两者之间就是未读数据。
为了快速计算位置,缓冲区大小必须是 2 的幂,这样可以用位运算代替取模:
pos := rb.read & rb.mask // 等价于 rb.read % rb.size,但更快
1.3. 2.3.3 零拷贝读取
"零拷贝"是 RingBuffer 最核心的特性。
普通方式:读数据时拷贝到新 buffer
buf := make([]byte, 1024) // 分配新内存
copy(buf, sourceData) // 拷贝数据
零拷贝方式:直接返回底层内存的引用
data := rb.Peek(n) // 返回 []byte,直接指向 RingBuffer 内部
// 不需要 copy,不需要分配新内存
但有一个问题:数据可能跨越环形边界。
┌─────────────────────────────┐
│[可读..] [可读..]│
└─────────────────────────────┘
read write
数据从 read 一直写到末尾,又从开头写了一段。
zhenyi 的处理方式:
// PeekTwoSlices 返回两个切片,覆盖跨边界的数据
first, second, _ := rb.PeekTwoSlices(offset, length)
// first: 从 read 到末尾的数据
// second: 从开头到 write 的数据
多数时候未读区间在物理上连续(second 为空);仅当写指针已绕回而读指针仍在环的后半段时,逻辑区间才会落在两段物理区间上,频率与负载、缓冲尺寸有关。
1.4. 2.3.4 写入优化:WriteFromReader
普通的做法是先读到临时 buffer,再拷贝到 RingBuffer:
tmp := make([]byte, 4096)
n, _ := conn.Read(tmp) // 读到临时 buffer
rb.Write(tmp[:n]) // 再拷贝到 RingBuffer(2次拷贝)
zhenyi 的 WriteFromReader 直接把 conn 的数据读入 RingBuffer:
// 直接读入 RingBuffer 内部内存,省掉中间 buffer
n, err := rb.WriteFromReader(conn, 4096)
实现原理:
func (rb *RingBuffer) WriteFromReader(r io.Reader, maxRead int) (int, error) {
writePos := rb.write & rb.mask
if endPos <= rb.size {
// 空间连续,直接读入
n, err = r.Read(rb.buf[writePos : writePos+free])
} else {
// 空间跨边界,分两次读
n, _ = r.Read(rb.buf[writePos:rb.size]) // 第一段
n2, _ := r.Read(rb.buf[:free-firstPart]) // 第二段
n += n2
}
}
效果:从 2 次拷贝变成 0 次拷贝。
1.5. 2.3.5 对象池
每个连接一个 RingBuffer,如果每次创建新对象,连接多了内存压力大。
zhenyi 用对象池复用:
// 从池获取(默认 4KB)
rb := GetRingBuffer()
defer PutRingBuffer(rb) // 归还到池
// 池默认 4KB;若与「直接 New 默认 64KB」混用,
// 多连接场景下总占用差一个数量级(源码注释中有数量级估算)。
归还时只回收标准大小的 buffer,非标准大小的直接丢弃让 GC 回收,避免池膨胀。
1.6. 2.3.6 扩容机制
默认 4KB 不够用怎么办?RingBuffer 支持自动扩容:
func (rb *RingBuffer) Grow(n int) bool {
needed := rb.Len() + n
newSize := rb.size * 2 // 每次翻倍
for newSize < needed {
newSize *= 2
}
// 最大不超过 1MB
if rb.maxSize > 0 && newSize > rb.maxSize {
return false
}
// 分配新 buffer,搬运数据
newBuf := make([]byte, newSize)
first, second := rb.PeekAll()
copy(newBuf, first)
if len(second) > 0 {
copy(newBuf[len(first):], second)
}
}
| 配置 | 大小 | 说明 |
|---|---|---|
| 池默认 | 4KB | 控制内存占用 |
| 直接创建默认 | 64KB | 适合单缓冲场景 |
| 最大扩容 | 1MB | 防止恶意大包撑爆内存 |
1.7. 2.3.7 线程安全
RingBuffer 是非线程安全的,设计上就不加锁:
// ⚠️ 同一 RingBuffer 禁止并发调用
// 适用场景:每连接一个 RingBuffer,单 goroutine 驱动读循环
这在 API 上是约束,在「每连接单读协程」模型下却省去锁:
- 每个连接有自己的 RingBuffer
- 读循环在单个 goroutine 中运行
- 不需要加锁,自然就没有锁竞争
这也解释了为什么 zhenyi 的默认模式是每连接 2 个 goroutine:读和写各一个,各自操作各自的逻辑,RingBuffer 只在读 goroutine 中使用,不需要锁。
1.8. 2.3.8 本节要点
- RingBuffer:预分配容量、
read/write与掩码寻址;读路径与解析在同一连接上串行使用。 - 零拷贝语义:
Peek*返回底层切片视图;Discard/后续写入前不得继续使用旧切片(见源码「Peek 与生命周期」)。 - 跨段:
PeekTwoSlices暴露逻辑连续、物理可能两段的事实。 - 池化:
GetRingBuffer/PutRingBuffer默认 4KB;非标准尺寸归还时丢弃以免池膨胀。 - 扩容:
Grow在MaxSize(如默认 1MB)内尝试扩大;具体策略以ringbuffer.go为准。 - 线程安全:非线程安全;设计假设为每连接单消费者驱动缓冲。
2.4 节说明 TCP / WebSocket / KCP 如何汇入同一套 net.Conn 抽象。