《游戏服务端编程实践》2.4.3 实战:实现简易 Echo Server

从零实现一个带长度前缀的 TCP Echo Server,覆盖监听、accept、每连接读循环、粘包处理、回写、读超时、单包上限与优雅退出,并给出使用 127.0.0.1:0 的真实监听测试、超长包拒绝、并发客户端与压测方法,说明本机数字只代表本机。

一、引言:为什么从 Echo 开始

Echo Server 是网络编程的“最小闭环”:客户端连上来、发一段数据、服务端原样发回去、连接关闭。它足够小,可以把注意力放在连接生命周期、帧边界、超时与退出上,而不是业务逻辑。

但“能回显”不等于“能上线”。一个可用的 Echo Server 至少要回答四个问题:两个包粘在一起时怎么知道一帧到哪里结束;客户端只发半个包就断开,服务端会不会永久阻塞;客户端发一个声称 4 GB 的包,服务端会不会直接分配内存;进程收到退出信号时,正在处理的连接怎么办。本文的目标是实现一个能启动、能收包、能回包、能优雅退出的 TCP Echo Server,并用真实监听端口验证它。

1.1 目标与非目标

类型内容
目标长度前缀组帧、单连接读循环、原样回显、读超时、优雅关闭、并发连接
目标用 127.0.0.1:0 起真实监听,测试连接、回显、超长包拒绝与关闭
非目标业务协议、鉴权、心跳、断线重连、房间路由、背压策略
非目标TLS、WebSocket、Protobuf 编解码、分布式部署

明确非目标很重要:Echo 是地基,不是游戏服。后面几节会列出从 Echo 到游戏服还缺什么。


二、协议:为什么需要长度前缀

TCP 是字节流,不保留消息边界。客户端连续发送 [长度=5][hello][长度=5][world] 时,服务端一次 Read 可能读到 [长度=5][hel,也可能一次读到两帧的全部字节。这就是“粘包”和“拆包”。解决方式通常有两类:

方案做法优点缺点
分隔符用 \n 或特殊字节分隔简单、可读负载不能包含分隔符,需转义
长度前缀包头写明负载长度二进制安全、解析确定需要限制最大长度

游戏协议多用长度前缀,因为消息是二进制、长度变化大。本文的帧格式就是 4 字节大端无符号整数 N,后面紧跟 N 字节负载。解析步骤:

  1. 读满 4 字节,得到 N;
  2. 校验 0 < N <= MaxFrame;
  3. 再读满 N 字节;
  4. 处理或回写;
  5. 回到步骤 1,继续处理下一帧。

任何一步失败,都关闭当前连接,而不是让半帧留在缓冲区里。


三、实现步骤

3.1 监听与 accept

net.Listen("tcp", addr) 创建监听器;传入 127.0.0.1:0 时由内核分配随机端口,测试可以从 Addr() 取到实际地址。Accept 返回连接后交给独立处理函数;监听器关闭后 Accept 返回错误,如果已经进入关闭流程,就把它当作正常退出。

3.2 每连接读循环

每个连接一个 goroutine,循环执行“读头—读体—回写”。这样写简单,但要配合三个约束:读超时,空闲连接不能永久占住 goroutine;单包上限,长度前缀不可信,必须先校验再分配;连接跟踪,关闭时要能找到所有活跃连接。

3.3 回写与关闭

回写时把原始 4 字节头和负载一起写回,避免重新计算长度。写操作也要设置超时,否则慢客户端会让 goroutine 卡在 Write 上。连接结束时统一 Close 并从跟踪表删除;任何协议错误、读超时、写失败都走同一条退出路径。

3.4 信号处理与优雅退出

在 main 中可以用 signal.NotifyContext 监听 SIGINT 与 SIGTERM,收到信号后调用 Shutdown(ctx):关闭监听器、在预算内等待已有连接、预算耗尽时强制关闭剩余连接,然后退出。下面代码里的 Shutdown 就是这套语义,测试会分别验证“无连接时正常关闭”和“超时后强制关闭空闲连接”。


四、完整实现

// file: ch02/echo/server.go
// tier: L1
// verify: go test ./ch02/echo/

package echo

import (
	"context"
	"encoding/binary"
	"io"
	"net"
	"sync"
	"time"
)

const (
	// HeaderSize 是长度前缀的字节数,使用大端序。
	HeaderSize = 4
	// DefaultMaxFrame 是单包负载上限:64 KiB。
	DefaultMaxFrame = 64 << 10
	// DefaultReadTimeout 是单次读操作的超时,防止空闲连接永久占用 goroutine。
	DefaultReadTimeout = 5 * time.Second
)

// Config 是服务器参数。零值字段会被替换为默认值。
type Config struct {
	MaxFrame    uint32
	ReadTimeout time.Duration
}

// Server 是一个带长度前缀的 TCP Echo 服务器。
// 它只负责“收一整包、原样回一整包”,不解析业务协议。
type Server struct {
	ln          net.Listener
	maxFrame    uint32
	readTimeout time.Duration

	wg      sync.WaitGroup
	mu      sync.Mutex
	conns   map[net.Conn]struct{}
	stopped bool

	quit      chan struct{}
	closeOnce sync.Once
	closeErr  error
}

// New 在 addr 上监听并返回服务器。此时还没有开始 Accept。
func New(addr string, cfg Config) (*Server, error) {
	if cfg.MaxFrame == 0 {
		cfg.MaxFrame = DefaultMaxFrame
	}
	if cfg.ReadTimeout <= 0 {
		cfg.ReadTimeout = DefaultReadTimeout
	}
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return nil, err
	}
	return &Server{
		ln:          ln,
		maxFrame:    cfg.MaxFrame,
		readTimeout: cfg.ReadTimeout,
		conns:       make(map[net.Conn]struct{}),
		quit:        make(chan struct{}),
	}, nil
}

// Addr 返回实际监听地址;测试传入 127.0.0.1:0 时用它取得随机端口。
func (s *Server) Addr() net.Addr { return s.ln.Addr() }

// Serve 接受连接直到 Shutdown 关闭监听器,正常关闭时返回 nil。
func (s *Server) Serve() error {
	for {
		conn, err := s.ln.Accept()
		if err != nil {
			select {
			case <-s.quit:
				return nil
			default:
				return err
			}
		}
		if !s.track(conn) {
			_ = conn.Close()
			return nil
		}
		go func() {
			defer s.wg.Done()
			defer s.forget(conn)
			defer conn.Close()
			s.handle(conn)
		}()
	}
}

// track 在停止接收后拒绝新连接,保证 wg.Add 不会晚于 Shutdown 的 wg.Wait。
func (s *Server) track(conn net.Conn) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.stopped {
		return false
	}
	s.conns[conn] = struct{}{}
	s.wg.Add(1)
	return true
}

func (s *Server) forget(conn net.Conn) {
	s.mu.Lock()
	delete(s.conns, conn)
	s.mu.Unlock()
}

func (s *Server) closeConns() {
	s.mu.Lock()
	for conn := range s.conns {
		_ = conn.Close()
	}
	s.mu.Unlock()
}

// handle 在一个连接上循环:读 4 字节长度、读负载、原样回写。
// 任何协议错误或 I/O 错误都会结束该连接,不影响其他连接。
func (s *Server) handle(conn net.Conn) {
	var head [HeaderSize]byte
	for {
		_ = conn.SetReadDeadline(time.Now().Add(s.readTimeout))
		if _, err := io.ReadFull(conn, head[:]); err != nil {
			return
		}
		n := binary.BigEndian.Uint32(head[:])
		if n == 0 || n > s.maxFrame {
			return
		}
		payload := make([]byte, n)
		if _, err := io.ReadFull(conn, payload); err != nil {
			return
		}
		frame := make([]byte, HeaderSize+len(payload))
		copy(frame, head[:])
		copy(frame[HeaderSize:], payload)
		_ = conn.SetWriteDeadline(time.Now().Add(s.readTimeout))
		if _, err := conn.Write(frame); err != nil {
			return
		}
	}
}

// Shutdown 先停止接受新连接,再在 ctx 预算内等待已有连接处理完。
// 预算耗尽时强制关闭剩余连接,并返回 ctx.Err()。重复调用是安全的。
func (s *Server) Shutdown(ctx context.Context) error {
	s.closeOnce.Do(func() {
		close(s.quit)
		s.closeErr = s.ln.Close()
		s.mu.Lock()
		s.stopped = true
		s.mu.Unlock()
	})
	done := make(chan struct{})
	go func() {
		s.wg.Wait()
		close(done)
	}()
	select {
	case <-done:
		return s.closeErr
	case <-ctx.Done():
		s.closeConns()
		<-done
		return ctx.Err()
	}
}

4.1 几个容易忽略的细节

细节原因
io.ReadFull一次 Read 不保证读满,必须循环读满 4 字节或 N 字节
n == 0 拒绝空帧通常意味着协议错误或攻击,直接断开更安全
先校验再分配防止 make([]byte, n) 被超大长度拖垮内存
读超时空闲连接不能无限占用 goroutine
写超时慢客户端不能无限阻塞回写
track 内 wg.Add与 Shutdown 的 wg.Wait 形成同步屏障,避免漏等连接
关闭后再 Accept用 quit 区分“正常关闭”与“真正的监听错误”

五、测试设计:用真实端口而不是 mock

测试全部通过 127.0.0.1:0 启动真实监听,客户端用 net.Dial 连接,能覆盖真实的内核缓冲区、端口分配与关闭行为,而不是只测一个假的 net.Conn。

测试验证点
TestEchoRoundTrip连接、发送多组负载、逐字节回显一致
TestOversizeFrameClosesConnection长度超过 MaxFrame 时连接被关闭,不发生大内存分配
TestZeroLengthFrameRejected0 长度帧被拒绝
TestConcurrentClients16 个客户端并发、每个 20 轮,回显不串包
TestShutdownStopsAcceptAndDrains关闭后不再接受新连接,Serve 返回 nil
TestShutdownDeadlineClosesIdleConn超时预算耗尽后强制关闭空闲连接,返回 context.DeadlineExceeded

运行命令:

cd .local/checks/gs-practice-lab
gofmt -l ch02/echo
go vet ./ch02/echo/
go test -race -v ./ch02/echo/

-race 用来检查连接跟踪表与关闭流程是否有数据竞争;-v 用来确认每个用例都真的执行了。


六、压测方法与结果口径

Echo 的压测可以用两种方式:一是在测试里起服务端,用固定数量的 goroutine 反复发送固定大小帧,统计每轮耗时;二是用独立进程建立 N 条连接,按目标速率发送,记录吞吐、p50、p99 与错误数。无论哪种方式,都必须写明机器型号与核数、Go 版本与编译参数、连接数、消息大小、在途请求数、压测持续时间与是否包含建连,以及统计的是服务端处理时间还是客户端往返时间。

本机运行测试的命令与摘要:

go test -race ./ch02/...
go test -bench=. -benchmem ./ch02/langbench/

本机(Apple M1 Pro、Go 1.26.0)的 10 个用例通过。Echo 包本身没有 benchmark,组帧开销可复用 2.4.1 的 langbench 基线;并发回显则用 TestConcurrentClients 与外部压测客户端观察。benchmark 的绝对数字会随机器、负载与 Go 版本变化,只应作为你自己环境里的对照基线,不能直接当作线上容量。压测时优先看 p99、错误率与内存增长,而不是单个 QPS 数字。


七、从 Echo 到游戏服还缺什么

能力Echo 的现状游戏服需要补什么
协议只有长度前缀命令号、序列化格式、版本协商、错误码
会话连接即会话登录态、角色绑定、断线重连、顶号
路由无按命令号分发、按 PlayerID/RoomID 路由
心跳读超时应用层心跳、超时踢人、弱网判定
鉴权无Token 校验、防重放、权限控制
背压无有界队列、限流、降级、慢客户端处理
可观测性无连接数、帧数、错误率、延迟分布、日志追踪
部署单进程多实例、服务发现、灰度、优雅发布

Echo 帮你验证的是“字节能不能正确进出”,而游戏服的大部分工作发生在字节之外。


八、失败场景

8.1 不设读超时导致 goroutine 泄漏

客户端建立连接后不再发送数据,服务端如果阻塞在 ReadFull 且没有超时,这个 goroutine 会一直存在。攻击者可以反复建连,最终耗尽内存与文件描述符。本文用 SetReadDeadline 给每次读设置上限,连接空闲超过预算就退出。

8.2 不限制单包大小导致内存被打爆

如果直接 make([]byte, n),客户端发一个长度为 0xFFFFFFFF 的包头,服务端就会尝试分配约 4 GB。必须在分配前校验 n <= MaxFrame,并对超限连接立即关闭。

8.3 关闭时只关监听器

只调用 listener.Close() 而不处理已有连接,会导致已连接客户端继续占用资源、进程退出时数据没有回写,测试或部署工具还可能误以为服务已经停止。本文的 Shutdown 先停止接受,再在预算内等待,最后强制关闭剩余连接,保证退出行为可预测。


小结

结论说明
长度前缀解决粘包先读长度、校验、再读负载
每个连接一个 goroutine 可行但必须有读超时、写超时与单包上限
关闭是协议的一部分停止接受、等待排空、超时强制关闭
测试要用真实端口127.0.0.1:0 能覆盖真实连接行为
压测数字只代表本机标明机器、版本、负载与统计口径

一句话总结:Echo Server 的价值不在“回显”,而在于把连接、帧、超时、关闭这四件事做对;这四件事做错,后面的业务代码再漂亮也会被线上流量打回原形。


常见错误与边界

  • 不设读超时:空闲连接永久占用 goroutine,最终泄漏。
  • 不限制单包大小:长度前缀不可信,必须先校验再分配。
  • 用一次 Read 当一帧:TCP 不保证边界,必须用 io.ReadFull 或自己的缓冲区。
  • 忽略写超时:慢客户端会让处理 goroutine 卡在 Write。
  • 关闭时只关监听器:已有连接没有排空,退出行为不可预测。
  • 在 handler 里 panic 不恢复:一个连接的错误会带崩整个进程;生产环境应在连接级别加 recover 与日志。
  • 边界:本文只实现 TCP 与长度前缀,不包含 TLS、WebSocket、Protobuf、鉴权、心跳与背压;这些能力需要在上线前逐项补齐并单独压测。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist