MIT_65840 测试网络环境的搭建与实现
MIT 6.5840 的实验代码把真实网络通信细节封装了起来, 让我们把注意力集中在分布式算法本身. 但从调试和理解整个测试框架的角度看, 测试环境是如何搭起来的同样非常重要.
一开始我只关注了 src/labrpc 中的 RPC、Network、Server 和 Service, 但如果想真正理解“一个测试是怎么把 client、server 和网络跑起来的”, 就必须把 src/tester1 也一起看进去. 实际上, 完整的测试环境是由下面几层共同组成的:
labgob: 负责 RPC 参数与返回值的序列化/反序列化.labrpc: 提供一个可控的、可模拟故障的 RPC 网络.tester1: 在labrpc之上搭建测试环境, 负责启动 server、创建 client、连接网络、注入故障、收尾清理.- 各个实验自己的
test.go/*_test.go: 描述本实验想怎么使用 tester, 比如启动几个 server, server 的类型、创建什么 clerk、检查什么性质.
本文会以 kvsrv1/lock/lock_test.go 这个“分布式锁测试”为例, 串起整个测试网络搭建流程.
一、整体分层
从职责上看, 这套测试框架可以理解成四层:
- 业务层:
kvsrv1/server.go、kvsrv1/client.go、kvsrv1/lock/lock.go - 测试封装层:
kvsrv1/test.go、kvtest1/kvtest.go - 测试环境层:
tester1/config.go、tester1/group.go、tester1/srv.go、tester1/clnts.go - RPC 网络层:
labrpc/labrpc.go
最容易混淆的一点是:
kvsrv1.KVServer是你实现的业务服务对象.labrpc.Server是一个RPC 分发器, 它并不关心具体业务.tester1.Server是 tester 用来包装单台测试节点的测试时 server 容器.
也就是说, 这里其实有三个不同层次的 “server” 概念, 它们分工不同:
KVServer负责实现具体服务的Get/Put函数.labrpc.Server负责把 RPC 请求路由给某个Servicetester1.Server负责持有测试时的一台节点、它的端点、persister 和 service 列表.
后续我们可以看到这三层 Server 是如何协作的.
二、序列化与反序列化:labgob
RPC 传输的基础是序列化与反序列化. 这里使用的是对 Go 标准库 encoding/gob 的封装 labgob.
encoding/gob 在实验里有两个常见坑:
1. 小写字段不会被编码
type BadStruct struct {PublicField intprivateField int
}
只有导出字段(大写开头)才能参与 gob 编码. RPC 参数或 reply 如果用了小写字段, 会导致非常隐蔽的问题.
2. 解码到已有非默认值变量时, 默认值可能不会覆盖旧值
这会影响:
- RPC reply 解码
- 持久化状态恢复
- 复用变量时的正确性
因此在这套框架里, 实验非常依赖严格的编码/解码约定.
三、labrpc:可控的 RPC 网络
labrpc 不是简单地“帮你调函数”, 而是实现了一个可模拟故障的网络层. 它支持:
- 丢失请求
- 丢失回复
- 延迟回复
- 长时间重排回复
- 断开某些 client/server 的连接
- 删除 server
1. ClientEnd:客户端端点
在 labrpc 里, 一个 RPC 发送端抽象成 ClientEnd. 它最关键的工作是:
- 把
args编码成字节流 - 把请求投递到
Network.endCh - 等待 reply
- 把 reply 解码回结构体
可以把它理解成“一个 RPC socket 的抽象”.
2. Network:整个测试中的共享网络
Network 是整个 RPC 环境的核心, 它内部维护:
- 所有端点
ends - 哪些端点当前可用
enabled - 端点连向哪个 server
connections - 当前网络上注册了哪些 server
servers - 用于接收请求的总入口
endCh
MakeNetwork() 创建网络时, 会起一个 goroutine 持续监听 endCh, 收到请求后交给 processReq() 处理. 这里的设计很关键:
- 同一个测试用例内部, 多个 client 和 server 共享同一个
Network - 不同测试用例之间, 每次都会重新
MakeNetwork(), 彼此隔离
这也是为什么不同测试不会互相污染, 而同一个测试里的所有 client/server 又确实工作在同一张“虚拟网络”上.
3. processReq():网络如何处理 RPC
processReq() 是整个网络层最重要的函数之一. 它负责:
- 检查这个
ClientEnd当前是否可用 - 检查它连接到哪个 server
- 根据网络配置决定是否丢包或延迟
- 调用 server 的 RPC dispatcher
- 决定是否正常返回 reply、延迟 reply、或者伪装成超时
这一步不是直接调用业务逻辑, 而是先调 labrpc.Server.dispatch().
4. labrpc.Server 和 Service
labrpc.Server 是一个通用的 RPC 分发器:
type Server struct {mu sync.Mutexservices map[string]*Servicecount int
}
这里的 Server 并不是 KVServer 或 Raft, 而是一个“RPC 容器”. 一个 labrpc.Server 可以挂多个 Service, 例如:
- 一个
Raftservice - 一个
KVServerservice
当请求形如 "KVServer.Put" 到来时:
Server.dispatch()先按.切成服务名和方法名- 在
services里找到KVServer - 再由
Service.dispatch()调用具体方法Put
5. 为什么这里用了反射
MakeService(rcvr interface{}) 会扫描传入对象的导出方法, 把形如下面签名的方法注册为 RPC handler:
- 导出方法
- 参数个数为 3(含接收者)
- 第三个参数是指针
- 没有返回值
之所以要用反射, 是因为 labrpc 作为一个通用框架, 在编译时并不知道你会注册的是 KVServer、Raft、还是 ShardCtrler. 它只能在运行时读取对象的类型信息、方法名和方法签名.
四、tester1:真正把测试环境搭起来
如果只看 labrpc, 我们只能理解“RPC 是怎么发出去的”;但还不知道:
- 测试开始时谁创建了网络
- 谁创建了 server
- 谁把 server 注册到网络上
- 谁创建了 client clerk
- 谁负责清理这些资源
这些工作都在 tester1 里完成.
五、config.go:测试环境的总控制器
tester1.Config 可以看作一次测试的总控对象. 它里面最核心的成员是:
net *labrpc.Network*Groups*Clnts
它把“网络、server 组、client 集合”组织到一起.
初始化入口是:
func MakeConfig(t *testing.T, n int, reliable bool, mks FstartServer) *Config
这个函数会完成几件事:
- 创建一个全新的
labrpc.Network - 创建
Groups - 创建默认 group, 并启动其中的 servers
- 创建
Clnts - 根据参数设置网络是否可靠
这里最值得注意的是最后一个参数 mks FstartServer. 这个参数是一个“服务工厂函数”, tester 自己并不知道你到底要起的是 KVServer 还是 Raft, 所以它只负责搭环境, 真正如何构造业务 service, 由调用方通过 FstartServer 传进来.
六、group.go:server 组的生命周期管理
group.go 负责把一组 server 管起来. 对于后面的 Raft 或 shardkv, 这一层尤其重要;不过即使是 lock_test.go 这种单机 KV 测试, 也一样会走这套流程.
1. ServerGrp
ServerGrp 表示一个 server group, 它维护:
- group 的 id
- 这组里的所有 tester server
- 每个 server 的名字
- 当前连接状态
- 如何启动 server 的回调
mks
2. FstartServer
group.go 里定义了:
type FstartServer func(ends []*labrpc.ClientEnd, grp Tgid, srv int, persister *Persister) []IService
这个函数类型的意义是:
- tester 准备好一台测试节点的基础设施
- 然后把这些基础设施传给你
- 由你返回真正要注册到 RPC server 上的业务 service 列表
对于 kvsrv1 来说, 传进去的是 StartKVServer;对于 kvraft1 来说, 传进去的是另一个版本的 StartKVServer.
3. StartServer() 的关键作用
ServerGrp.StartServer(i) 做了整条链路里最关键的桥接:
srv.svcs = sg.mks(srv.clntEnds, sg.gid, i, srv.saved)
labsrv := labrpc.MakeServer()
for _, svc := range srv.svcs {s := labrpc.MakeService(svc)labsrv.AddService(s)
}
sg.net.AddServer(ServerName(sg.gid, i), labsrv)
这几行代码把三层东西接起来了:
- 通过
sg.mks(...)创建业务 service, 例如KVServer中的mks函数就是StartKVServer. - 用
labrpc.MakeService(...)把业务对象包装成 RPC service. - 用
labrpc.MakeServer()把这些 service 装进通用 RPC server. - 再调用
net.AddServer(...)挂到网络上
所以真正的“服务启动”不是一行完成的, 而是 tester1 和 labrpc 协作完成的.
4. 连接、断开、分区
group.go 还负责:
ConnectAll()DisconnectAll()ShutdownServer()Partition()
这就是为什么后面可以很方便地写各种故障测试. tester 不需要改业务代码, 只要改网络连通性即可.
七、srv.go: tester 对单台 server 的包装
tester1.Server 不是业务 server, 而是 tester 对“一个测试节点”的封装. 它主要保存:
- 这台节点使用的
net - 它的
Persister - 返回出来的业务 service 列表
svcs - 这台节点到其它节点的
ClientEnd
其中有一个非常关键的函数:
func makeServer(net *labrpc.Network, gid Tgid, nsrv int) *Server
它会为这台节点预先创建一组 ClientEnd, 这些端点主要给多副本 server 之间相互通信使用. 对当前 kvsrv1 这个 lab 来说, 这些 ends 基本可以忽略;但对于后面的 raft1、kvraft1 就非常重要了.
另外, startServer() 和 shutdownServer() 会处理 persister 复制和 Kill(), 从而支持重启与清理.
八、clnts.go: 测试中的 client 是怎么创建的
clnts.go 负责管理测试过程中的客户端集合.
1. Clnt: 一个 clerk 对应的连接集合
一个 Clnt 不是单个 ClientEnd, 而是一组“按 server 名字索引的端点”. 它内部的 makeEnd(server) 是懒创建的:
- 如果还没连过这个 server, 就创建一个新的
ClientEnd - 然后调用
net.Connect(...)把它挂到对应 server 名字上 - 最后按连通性策略决定是否
Enable
这意味着:
- 每个 clerk 都有自己独立的一组端点
- 不同 clerk 不会共用同一个
ClientEnd - 但它们都共享同一个
Network
2. Clnts: 所有 clerk 的集合
Clnts 负责:
MakeClient(): 创建测试用的 clerk 连接器DeleteClient(): 删除这个 clerk 的所有端点cleanup(): 测试结束时统一删除所有 clerk
九、persister.go 与 annotation.go
这两个文件在当前 kvsrv1/lock_test.go 里不是主角, 但它们属于 tester 框架的重要配套:
persister.go:提供持久化状态抽象, 主要服务于 Raft / kvraftannotation.go:记录测试过程中的故障、检查器信息、可视化标注
对于简单 KV 测试, 可以把它们看作“为更复杂实验预留的基础设施”.
十、以 lock_test.go 为例看完整启动流程
下面用 src/kvsrv1/lock/lock_test.go 把整个流程串起来.
1. 测试入口
测试入口类似:
TestOneClientReliableTestManyClientsReliableTestOneClientUnreliableTestManyClientsUnreliable
这些测试都会调用 runClients().
2. runClients() 创建测试环境
runClients() 先调用 kvsrv.MakeTestKV(t, reliable).
MakeTestKV() 的关键是:
cfg := tester.MakeConfig(t, 1, reliable, StartKVServer)
这表示:
- 创建一个 tester 配置
- 启动 1 台 server
- 网络是否可靠由参数决定
- 用
StartKVServer来生成业务 service
3. MakeConfig() 创建网络和 group
MakeConfig() 会:
cfg.net = labrpc.MakeNetwork()cfg.Groups = newGroups(cfg.net)cfg.MakeGroupStart(GRP0, 1, StartKVServer)cfg.Clnts = makeClnts(cfg.net)
注意顺序:先有网络, 再有 group/server, 最后再有 client 集合.
4. MakeGroupStart() 启动 server
这一步会继续走到:
MakeGroup(...)StartServers()StartServer(0)
5. StartServer(0) 创建真正的 KV 服务
在这里, tester 调用你自己的:
func StartKVServer(...) []tester.IService {kv := MakeKVServer()return []tester.IService{kv}
}
可以看到:
- 真正的业务对象是
KVServer - 但返回给 tester 的是
[]IService - tester 再把它包装成
labrpc.Service并注册到labrpc.Server
所以这里的关系是:
KVServer -> Service -> labrpc.Server -> Network
6. 测试并发创建 clerk
runClients() 后面调用 SpawnClientsAndWait(...). 这个函数会起多个 goroutine, 每个 goroutine 都会执行:
ts.MakeClerk()- 调用
oneClient(...) - 测试结束后删除该 clerk
TestKV.MakeClerk() 里会:
clnt := ts.Config.MakeClient()ck := MakeClerk(clnt, tester.ServerName(tester.GRP0, 0))
这里的 clnt 是 tester 层的连接器, ck 才是 kvsrv1 里真正的 KV clerk.
7. clerk 发 RPC 的完整路径
当 lock.go 里的 Acquire() 调用 ck.Get() / ck.Put() 时, 请求路径是:
kvsrv1.Clerk.Get/Puttester.Clnt.Call(server, method, args, reply)labrpc.ClientEnd.Call(...)Network.processReq(...)labrpc.Server.dispatch(...)Service.dispatch(...)KVServer.Get/Put(...)
这条链路就是整个实验里最重要的调用路径.
8. 锁测试到底在测什么
这里测试的并不是一个单独的“锁服务器”, 而是:
- 用
KVServer提供的版本化Get/Put - 在客户端
lock.go里实现锁逻辑 - 多个 client 通过 RPC 并发争抢同一个 key
也就是说, 锁本身是构建在 KV 服务之上的客户端协议, 而不是 server 端内建的一个专门对象.
十一、几个关键技术细节
1. 每个测试都有自己的网络
不同测试用例之间不会共享同一个 Network. 每次调用 MakeConfig(), 都会新建一个 Network.
2. 同一个测试内, 所有 client 和 server 共享同一个网络
这是测试能够模拟统一网络环境的前提.
3. 每个 clerk 有自己独立的 ClientEnd
虽然共享同一个 Network, 但每个 clerk 的端点是独立创建的. 这让 tester 可以细粒度控制不同 client 的连接状态.
4. FstartServer 是解耦的关键
tester 不知道怎么构造 KVServer 或 Raft, 它只知道“我要起一台 server, 你给我一个 service 列表”.
5. labrpc.Server 是通用 RPC 容器, 不是业务 server
这也是为什么不同实验都能复用同一套 labrpc.
6. processReq() 中的 goroutine 很关键
它一方面异步等待业务 handler 执行, 另一方面允许网络层持续检测 server 是否已被删除, 从而正确模拟“处理中宕机”的场景.
十二、总结
如果只看 src/labrpc, 我们看到的是“RPC 怎么发送与分发”;但如果把 src/tester1 一起看进去, 才能真正理解整个测试环境是怎么搭起来的.
以 lock_test.go 为例, 完整链路可以概括为:
- 测试代码调用
MakeTestKV() MakeTestKV()调用tester.MakeConfig()MakeConfig()创建Network、Groups、ClntsGroups.StartServer()通过FstartServer构造KVServerKVServer被包装成Service, 注册到labrpc.Server- client 通过
tester.Clnt创建自己的端点 - clerk 发起
Get/Put, 请求经Network和dispatch路由到KVServer - 锁逻辑在 client 侧完成, KV server 提供原子读写基础
从这个角度看, MIT 6.5840 的测试框架并不是“简单封装了一个 RPC”, 而是搭了一整套可控、可扩展、可模拟故障的分布式实验环境. 理解这一层之后, 后续分析 raft1、kvraft1 和 shardkv1 的测试代码会清晰很多.
十三、一张简化的调用链
lock_test.go-> MakeTestKV()-> tester.MakeConfig()-> labrpc.MakeNetwork()-> MakeGroupStart()-> StartServers()-> StartServer()-> StartKVServer()-> labrpc.MakeService()-> labrpc.MakeServer()-> net.AddServer()-> makeClnts()-> SpawnClientsAndWait()-> MakeClerk()-> tester.MakeClient()-> kvsrv1.MakeClerk()-> Clerk.Get/Put()-> tester.Clnt.Call()-> labrpc.ClientEnd.Call()-> Network.processReq()-> Server.dispatch()-> Service.dispatch()-> KVServer.Get/Put()
这张图基本就是整个测试网络环境搭建与执行的核心脉络.
十四、一个常见误区
很多时候会把 kvsrv1.KVServer、tester1.Server 和 labrpc.Server 混在一起. 实际上三者并不是一回事:
kvsrv1.KVServer: 你写的业务逻辑tester1.Server: tester 中的一台测试节点包装labrpc.Server: RPC 服务分发器
只要把这三层区分清楚, 整个框架的结构就会非常清晰.
总结成一句话
labrpc 负责“模拟网络和 RPC”, tester1 负责“搭测试环境和管理节点”, 而具体 lab 的 test.go 和 server.go 负责“把业务逻辑接入这套环境并接受测试”.
这三层合起来, 才构成了 MIT 6.5840 实验里的完整测试网络环境.
