关于游戏服务端TCP连接中不使用bufio获取数据的原因
下面是面试官当时问到的问题、场景以及对应的解决方法:
与游戏后端面试官聊到过,实际游戏服务端开发中不会使用 bufio 这个包进行TCP数据获取。
具体场景
每个包长度是50,当读取完数据包后,还没处理完当前数据包,下一个数据包就接收进来了,这样的场景不使用 bufio。
因为当这个使用 bufio 建立了 TCP 连接之后进行数据缓存,通过 TCP 协议传输过来的数据,有可能上一次数据传过来的数据会被下一次传过来的数据覆盖。
解决方法
很简单,就是传过来之后,不在 bufio 的缓冲区里面进行操作,而是将数据内容先深拷贝出来,再对其进行操作。这样即便缓冲区的数据被覆盖了,我们依然有深拷贝出来的数据内容作为备用辅助。
源码查询原因
下面就是我自己查询的原因了,会出现数据覆盖的主要原因在于 bufio.ReadSlice 这个方法,官方源码的注释其实已经给出明确的提示:
// ReadSlice reads until the first occurrence of delim in the input,
// returning a slice pointing at the bytes in the buffer.
// The bytes stop being valid at the next read.
// If ReadSlice encounters an error before finding a delimiter,
// it returns all the data in the buffer and the error itself (often io.EOF).
// ReadSlice fails with error [ErrBufferFull] if the buffer fills without a delim.
// Because the data returned from ReadSlice will be overwritten
// by the next I/O operation, most clients should use
// [Reader.ReadBytes] or ReadString instead.
// ReadSlice returns err != nil if and only if line does not end in delim.
func (b *Reader) ReadSlice(delim byte) (line []byte, err error) {
s := 0 // search start index
for {
// Search buffer.
if i := bytes.IndexByte(b.buf[b.r+s:b.w], delim); i >= 0 {
i += s
line = b.buf[b.r : b.r+i+1]
b.r += i + 1
break
}
// Pending error?
if b.err != nil {
line = b.buf[b.r:b.w]
b.r = b.w
err = b.readErr()
break
}
// Buffer full?
if b.Buffered() >= len(b.buf) {
b.r = b.w
line = b.buf
err = ErrBufferFull
break
}
s = b.w - b.r // do not rescan area we scanned before
b.fill() // buffer is not full
}
// Handle last byte, if any.
if i := len(line) - 1; i >= 0 {
b.lastByte = int(line[i])
b.lastRuneSize = -1
}
return
}注释里明确提示到(看 *** 的注释):
// ReadSlice reads until the first occurrence of delim in the input,
// returning a slice pointing at the bytes in the buffer.
// *** The bytes stop being valid at the next read.
// If ReadSlice encounters an error before finding a delimiter,
// it returns all the data in the buffer and the error itself (often io.EOF).
// ReadSlice fails with error [ErrBufferFull] if the buffer fills without a delim.
// Because the data returned from ReadSlice will be overwritten
// *** by the next I/O operation, most clients should use
// *** [Reader.ReadBytes] or ReadString instead.
// ReadSlice returns err != nil if and only if line does not end in delim.
// 译文:
// ReadSlice 从输入中读取直到第一次遇到分隔符 delim,
// 返回一个指向内部缓冲区字节的切片。
// *** 这些字节在下一次读操作后就不再有效。
// 如果在找到分隔符前遇到错误,它会返回缓冲区中已有的全部数据以及该错误(通常是 io.EOF)。
// 如果缓冲区被填满仍未找到分隔符,ReadSlice 将返回错误 ErrBufferFull。
// *** 由于 ReadSlice 返回的数据会被下一次 I/O 操作覆盖,
// *** 大多数客户端应使用 Reader.ReadBytes 或 ReadString 代替。
// 当且仅当返回的行不以 delim 结尾时,ReadSlice 会返回 err != nil从源代码我们可以看到:
for {
// Search buffer.
if i := bytes.IndexByte(b.buf[b.r+s:b.w], delim); i >= 0 {
i += s
line = b.buf[b.r : b.r+i+1] // ←line 只是对这块内存的子切片,零拷贝,无新内存
b.r += i + 1
break
}
// Pending error?
if b.err != nil {
line = b.buf[b.r:b.w]
b.r = b.w
err = b.readErr()
break
}
// Buffer full?
if b.Buffered() >= len(b.buf) {
b.r = b.w
line = b.buf
err = ErrBufferFull
break
}
s = b.w - b.r // do not rescan area we scanned before
b.fill() // buffer is not full
}我们可以看到源码 line 只是原 buf 的一个子切片,我们都知道 Golang 的切片只是一个指向底层数组的指针,所以这里并没有像深拷贝那样赋予新的内存空间,自然有新的数据进来就会被覆盖掉。
源码之后执行也可以看到,如果 buf 没有被填满,则会执行 b.fill 保证传输的数据量足够达到阈值,尽可能的用一定量的资源传输更多的数据。这里设想是好的,但是我们看 b.fill 的源码:
// fill reads a new chunk into the buffer.
func (b *Reader) fill() {
// Slide existing data to beginning.
if b.r > 0 {
copy(b.buf, b.buf[b.r:b.w])
b.w -= b.r
b.r = 0
}
if b.w >= len(b.buf) {
panic("bufio: tried to fill full buffer")
}
// Read new data: try a limited number of times.
for i := maxConsecutiveEmptyReads; i > 0; i-- {
n, err := b.rd.Read(b.buf[b.w:])
if n < 0 {
panic(errNegativeRead)
}
b.w += n
if err != nil {
b.err = err
return
}
if n > 0 {
return
}
}
b.err = io.ErrNoProgress
}通过源码的这里:
func (b *Reader) fill() {
// 1. 把残留数据搬到头部
copy(b.buf, b.buf[b.r:b.w])
b.w -= b.r
b.r = 0
// 2. 从 TCP 连接读,直接写到 b.buf[b.w:]
n, _ := b.rd.Read(b.buf[b.w:])
b.w += n
}- 第一步
copy就把老内容原地平移(可能覆盖掉line的前缀)。 - 第二步
rd.Read又把新字节直接盖在b.buf后面。 - 只要
line还指向这片数组,里面的字节立即失效。
我们来举个例子:
假设初始状态(buf 大小 = 16 字节)
下标 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬──┬──┬──┬──┬──┬──┐
│A|B|C|D|E|F|G|H|I|J| | | | | | |
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴──┴──┴──┴──┴──┴──┘
^r=0 ^w=10上一次 ReadSlice('\n') 返回的是 A B C D E(假设 \n 在 offset=4),
上层拿到 line = b.buf[0:5]——只是视图,没拷贝。
我们开始进入到 fill() 进行填充。
第一步 copy(b.buf, b.buf[b.r:b.w]) 把剩余数据搬到头部
下标 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬──┬──┬──┬──┬──┬──┐
│F|G|H|I|J| | | | | | | | | | | |
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴──┴──┴──┴──┴──┴──┘
^r=0 ^w=5- 老数据
A B C D E已经被copy的dst覆盖掉(F写进了 slot 0)。 - 上层手里的
line现在指向失效区域,内容变成F G H I J的前缀。
第二步 rd.Read(b.buf[w:]) 把新 TCP 数据写进来
假设内核又送来 8 字节 K L M N O P Q R:
下标 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬──┬──┬──┐
│F|G|H|I|J|K|L|M|N|O|P|Q|R| | | |
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴──┴──┴──┘
^r=0 ^w=13- 新字节从 slot 5 开始写,完全合法,但整片
b.buf已被刷新。 - 只要
line还指向b.buf的任何位置,内容就不再可靠。
总结
我们从上面的源码分析和实际例子就可以看出,传输过来的数据进入 Reader.buf 之后,是如何因为调用 bufio.ReadSlice 导致上一次传入数据被下一次传入数据所覆盖消失的。
解决方法有如下四种:
不用 bufio 当协议解析层
直接
rawConn.Read()到一个 自己管理的 ring-buffer / slice,然后在这块内存上做长度前缀解析(或\n分隔解析)。这样预读多少自己说了算,不会出现“多读的字节塞不回去”的尴尬。
游戏服务端、网关、RPC 框架几乎都是这条路。
用
bufio.Reader但只让它“偷看”Peek(n)+Discard(n)组合:先Peek出足够长的字节,确认已经拿到一条完整帧,再把这一帧深拷贝走,最后Discard掉已消费的长度。这样bufio 的缓冲区依旧存在,但不会把解析状态机绑死,也解决了“读超界”问题。
深拷贝
buf内每次传入的数据,这样就不会覆盖了收到数据后,先把 bufio.Reader 里当前这一帧能解析出的完整字节 copy 出来,再做业务处理;
这样即使后面又把同一条连接交给别的 goroutine/新的解析器,也不会把已经预读但尚未消费的字节弄丢。事实上源码里面
bufio.ReadBytes就是这样做的,但相对来说因为放弃了ReadSlice的 “零拷贝” 做法,所以对数据包读取的长度有提前使用Peak知道的需求。一条连接永远只用一个 goroutine 做 IO
如果业务层必须多 goroutine 消费,再用无锁队列把完整帧抛出去,绝不允许第二个 goroutine 再去碰同一个
bufio.Reader。但这种方法显然不考虑,因为游戏服务端必须是长连接、高并发的,限制 goroutine 必然会影响并发的效率。
ReadSlice 确实 “零拷贝” 很诱人,但线上大部分网络库 / 游戏网关恰恰因为它的 “视图(源码的 line)随时失效” 而不敢直接用它来做协议解析;更常见的做法是:
- 先用
Peek只把长度字段读出来(一次系统调用也不拷贝)。 - 一旦知道整条报文长度,就一次性
make一块独立缓冲区,然后把整包读进来(io.ReadFull或ReadBytes)。 - 把这份独立副本抛给逻辑线程、消息队列,后续再怎样
Read都不会踩坏它。
换句话说:
ReadSlice只在你当场、立即、本帧就把数据消费掉时才安全;- 只要需要缓存、跨帧、异步投递,就必须拷贝,于是干脆直接用
ReadBytes/ 手写make+copy,省得踩坑。
所以“大部分时候用 ReadSlice”并不成立——零拷贝机会很少,安全拷贝才是主流。
RoLingG | 博客
评论(0)