1. 2.1 网络编程基础回顾
本章从常见写法及其局限出发,再说明 zhenyi-base 网络栈里采用的默认路径与可选优化(如 Reactor)。本节只用最小例子复习 net.Conn 模式,为后文固定头、RingBuffer、多协议等铺垫。
1.1. 2.1.1 标准做法:用 net.Conn 直接读写
Go 标准库提供了 net.Conn,直接用它读写很简单:
ln, _ := net.Listen("tcp", ":8001")
for {
conn, _ := ln.Accept()
go func() {
buf := make([]byte, 1024)
for {
n, _ := conn.Read(buf)
if n == 0 {
return
}
conn.Write(buf[:n]) // echo
}
}()
}
篇幅很短,足以完成 echo 类演示。
1.2. 2.1.2 这样做有什么问题?
用 net.Conn 直接写服务器,有三个实际问题:
1.2.1. 问题一:粘包
TCP 是流式协议,没有消息边界:
客户端发送: 服务端 Read() 收到:
[ABC] [AB]
[DEFG] → [CDEF]
[HI] [GHI]
一次 Read() 可能返回:
- 半个包(
[AB]+[C]) - 多个包(
[AB]+[CDEFG]+[HI])
业务层必须自己解析消息边界。
1.2.2. 问题二:内存分配
每次 Read() 都分配新 buffer:
buf := make([]byte, 1024)
n, _ := conn.Read(buf) // 这次分配的 buf,用完就扔
如果每秒处理 10 万条消息,就是 10 万次内存分配,GC 压力很大。
1.2.3. 问题三:没有会话管理
连接和业务逻辑混在一起,不好扩展。后续要加心跳、加认证、加路由,全部堆在一起。
1.3. 2.1.3 改善方案一:自定义协议(固定头 + 长度)
为了解决粘包,最常见的方案是自定义协议:
┌──────────┬──────────────┐
│ Header │ Body │
│ 12 bytes │ variable │
├──────────┼──────────────┤
│ msgId(4) │ │
│ seqId(4) │ data length │
│ len(4) │ (max 1MB) │
└──────────┴──────────────┘
- 前 12 字节是固定头,包含
msgId、seqId、body长度 - 读协议头 → 知道 body 长度 → 读 body
这样每次 Read() 都知道消息边界,粘包解决了。
1.3.1. 这个方案有什么好处?
- 解决粘包:通过固定头中的长度字段,精确定位消息边界
- 实现简单:比二进制协议(protobuf)轻量,比文本协议(JSON)高效
1.4. 2.1.4 改善方案二:RingBuffer 零拷贝
固定头协议解决了粘包,但内存分配问题还在。
每次 Read() 都分配新 buffer → GC 压力大 → RingBuffer
RingBuffer 是一个环形 buffer,复用一块固定大小的内存:
┌─────────────────────────────────────┐
│ [已读] [未读数据] [可用空间] │
└─────────────────────────────────────┘
读数据:移动读指针,不拷贝
写数据:追加到写指针位置
满了:覆盖旧数据或等待
zhenyi 的 RingBuffer 还支持零拷贝:
概念上:未读数据在环上连续时,解析路径可直接引用环内切片,避免额外拷贝;若一段逻辑跨环尾与环头,则常需拼接或单次拷贝到临时缓冲(具体以 zhenyi-base/znet 实现为准)。
1.4.1. 这个方案有什么好处?
- 减少内存分配:复用固定大小的 RingBuffer,避免每次分配
- 零拷贝:数据连续时直接引用,避免不必要的拷贝
- 降低 GC 压力:复用的内存不需要 GC 回收
1.4.2. 改善方案有什么问题?
- 实现复杂度增加:RingBuffer 的读写指针、边界处理需要仔细实现
- 跨边界时往往仍需拷贝或分段处理:出现频率与负载与缓冲尺寸有关,属实现与参数折中
1.5. 2.1.5 为什么还需要 Reactor 模式?
有了 RingBuffer,内存问题解决了。但还有一个问题:goroutine 数量。
前面我们说"每连接一个 goroutine"够用。但在极高并发下(十万级连接):
| 连接数 | goroutine 数 | 内存占用(每 goroutine 最小 2KB) |
|---|---|---|
| 1 万 | 2 万(读+写) | ~40 MB |
| 10 万 | 20 万 | ~400 MB |
单协程成本不高,但连接数乘到十万量级仍会累积调度、栈与 M:N 映射上的开销。
Reactor 模式用 epoll(Linux)或 kqueue(macOS)实现单线程处理多连接:
// 单线程监听所有连接的事件
for {
n, _ := syscall.EpollWait(epfd, events, -1)
for i := 0; i < n; i++ {
if events[i].Events&syscall.EPOLLIN != 0 {
// 有可读的连接,处理它
}
}
}
1.5.1. Reactor 有什么好处?
- goroutine 数量大幅减少:单线程处理所有连接的可读事件
- CPU 利用率高:没有线程切换开销
1.5.2. Reactor 有什么问题?
- 实现复杂:epoll 的边缘触发、水平触发、EPOLLONESHOT 等概念容易出错
- 调试困难:单线程模式,出问题不好排查
- 跨平台不一致:Linux 用 epoll,macOS 用 kqueue,Windows 用 IOCP
1.6. 2.1.6 zhenyi 的选择
zhenyi 提供了 Reactor 模式,但默认不接入:
默认模式:每连接 2 个 goroutine
- goroutine: 读循环(Start)
- goroutine: 发送队列(StartSend)
可选模式:Reactor(Linux only)
- 单线程 epoll 驱动(`zhenyi-base/ztcp.ServerReactor`)
- goroutine 数量大幅减少
- 当前实现要求:**TCP、未启用 TLS** 时才走 Reactor;非 Linux 或启用 TLS 时回退普通模式(见 `zhenyi-base/zserver/server.go`)
为什么不默认用 Reactor?
| 考量 | 说明 |
|---|---|
| 够用 | goroutine 已经足够轻量,大多数场景 2 万连接毫无压力 |
| 简单 | 每连接 2 个 goroutine 的模式,代码直观,调试容易 |
| 跨平台 / 加密 | Reactor 依赖 Linux epoll;且与 TLS 读路径未合并为默认一条 |
默认路径优先保证实现清晰、跨平台行为稳定;在连接规模与 CPU 剖面确有需求时,再在满足约束的前提下启用 Reactor(见上表)。
1.7. 2.1.7 本节要点
| 方案 | 解决的问题 | 仍有课题 |
|---|---|---|
net.Conn 直读直写 |
- | 粘包、频繁分配、会话边界模糊 |
| 固定头 + 长度 | 消息定界 | 仍可优化分配与拷贝 |
| RingBuffer | 复用缓冲、减少分配 | 实现与边界情况 |
| Reactor(可选) | 减少每连 goroutine | 平台与 TLS 等约束、复杂度 |
zhenyi-base 默认组合为:固定头线协议 + RingBuffer + 每连接读/写路径上的 goroutine 模型;Reactor 在 TCP、未启用 TLS、Linux 等条件下可选接入(见 zhenyi-base/zserver/server.go)。
2.2 节起展开固定头与解析状态机。