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 字节是固定头,包含 msgIdseqIdbody长度
  • 读协议头 → 知道 body 长度 → 读 body

这样每次 Read() 都知道消息边界,粘包解决了。

1.3.1. 这个方案有什么好处?

  1. 解决粘包:通过固定头中的长度字段,精确定位消息边界
  2. 实现简单:比二进制协议(protobuf)轻量,比文本协议(JSON)高效

1.4. 2.1.4 改善方案二:RingBuffer 零拷贝

固定头协议解决了粘包,但内存分配问题还在。

每次 Read() 都分配新 buffer → GC 压力大 → RingBuffer

RingBuffer 是一个环形 buffer,复用一块固定大小的内存:

┌─────────────────────────────────────┐
│  [已读] [未读数据] [可用空间]         │
└─────────────────────────────────────┘
读数据:移动读指针,不拷贝
写数据:追加到写指针位置
满了:覆盖旧数据或等待

zhenyi 的 RingBuffer 还支持零拷贝

概念上:未读数据在环上连续时,解析路径可直接引用环内切片,避免额外拷贝;若一段逻辑跨环尾与环头,则常需拼接或单次拷贝到临时缓冲(具体以 zhenyi-base/znet 实现为准)。

1.4.1. 这个方案有什么好处?

  1. 减少内存分配:复用固定大小的 RingBuffer,避免每次分配
  2. 零拷贝:数据连续时直接引用,避免不必要的拷贝
  3. 降低 GC 压力:复用的内存不需要 GC 回收

1.4.2. 改善方案有什么问题?

  1. 实现复杂度增加:RingBuffer 的读写指针、边界处理需要仔细实现
  2. 跨边界时往往仍需拷贝或分段处理:出现频率与负载与缓冲尺寸有关,属实现与参数折中

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 有什么好处?

  1. goroutine 数量大幅减少:单线程处理所有连接的可读事件
  2. CPU 利用率高:没有线程切换开销

1.5.2. Reactor 有什么问题?

  1. 实现复杂:epoll 的边缘触发、水平触发、EPOLLONESHOT 等概念容易出错
  2. 调试困难:单线程模式,出问题不好排查
  3. 跨平台不一致: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 模型ReactorTCP、未启用 TLS、Linux 等条件下可选接入(见 zhenyi-base/zserver/server.go)。

2.2 节起展开固定头与解析状态机。

results matching ""

    No results matching ""