MIT_65840测试网络环境的搭建与实现

MIT_65840测试网络环境的搭建与实现

由于 MIT_65840 实际上实现的大部分是分布式算法相关的内容, 或者更加关注 Client 和 Server 的操作, 因此透明化了 Client 和 Server 之间的通信, 分布式系统中 Client 和 Server 通常使用 RPC 的方式通信. 本实验中使用一些代码模拟了这一过程, 从而让我们更加关注算法实现的细节. 但是这背后的网络的实现也很值得学习与研究, 也方便后续在实现算法的时候中途进行调试的步骤.

序列化与反序列化

数据传输的基础是是网络之间数据传输时的序列化与反序列化, 这部分主要在代码中的 labgob 中, 这个封装主要是基于 Go 标准库 encoding/gob. 但是 Go 标准库中的 encoding/gob 有一些陷阱, 特别是在 MIT 6.5840 这样的分布式系统实验中:

问题 1: Encode 阶段小写字段无法序列化

type BadStruct struct {
    PublicField  int  // ✅ 大写开头, 可以序列化
    privateField int  // ❌ 小写开头, 无法序列化, 但不报错!
}

Go 的 RPC 和 gob 只能传输大写开头的字段(exported fields), 如果不小心用了小写字段, 会导致神秘的错误或崩溃, labgob 会检测并警告小写字段.

问题2: 解码阶段解码到非默认值
在 Raft 或者其他使用 RPC 的算法中, 实现的过程中, 有些实现可能会将数据解码到一个已存在的变量, 此时如果解码目标变量已经包含非默认值, 而解码的数据是默认值, gob 不会覆盖目标变量的值.
我们可以使用下面这个例子来说明:

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
)

type Example struct {
    Field1 int
    Field2 string
}

func main() {
    // 1. 创建一个默认值的结构体
    original := Example{
        Field1: 0,       // 默认值
        Field2: "",      // 默认值
    }

    // 2. 将默认值的结构体编码为 gob 数据
    var buffer bytes.Buffer
    encoder := gob.NewEncoder(&buffer)
    err := encoder.Encode(original)
    if err != nil {
        fmt.Println("Encoding error:", err)
        return
    }

    // 3. 创建一个目标变量, 并赋予非默认值
    target := Example{
        Field1: 42,      // 非默认值
        Field2: "hello", // 非默认值
    }

    // 4. 解码到目标变量
    decoder := gob.NewDecoder(&buffer)
    err = decoder.Decode(&target)
    if err != nil {
        fmt.Println("Decoding error:", err)
        return
    }

    // 5. 打印解码后的目标变量
    fmt.Printf("Decoded target: %+v\n", target)
}
  1. 编码阶段: original 是一个默认值的结构体(Field1=0, Field2=""). 它被编码为 gob 数据.
  2. 解码阶段: target 是一个已经包含非默认值的结构体(Field1=42, Field2="hello"). 当 gob 解码器尝试将 original 的数据解码到 target 时: Field1 和 Field2 的值是默认值(0 和 ""). gob 不会覆盖 target 中的非默认值.
  3. 结果: target 的值保持不变(Field1=42, Field2="hello"), 而不是被解码数据覆盖.

为什么这样的使用会提示错误呢, 这是因为分布式系统中, 这种行为可能会导致以下问题:

  1. RPC 调用: 如果一个 RPC 的响应被解码到一个复用的变量中. 旧的值可能会干扰新的逻辑.
  2. 状态恢复: 如果从持久化存储中恢复状态时. 旧的值没有被覆盖. 可能会导致状态不一致.

RPC 基础实现

labrpc 中定义了 MIT 65840 的网络部分, 实际主要包含三个部分, 客户端, 服务端, 以及客户端与服务端之间的网络通信. 这部分代码就是模拟了一个可以丢失请求, 丢失应答, 延迟应答, 或者完全丢失与特定主机连接的网络与 RPC 调用功能. 它的实现如下:

客户端

src/labrpc/labrpc.go 使用下面的结构体代表一个客户端终端节点的抽象, 如下:

// ClientEnd is an abstraction of a single client end-point. multiple ClientEnds may talk to the same server.
// each ClientEnd has a unique name (usually a string or int).
type ClientEnd struct {
	endname interface{}   // this end-point's name
	ch      chan reqMsg   // 在具体使用的时候, 使用 copy of Network.endCh
	done    chan struct{} // closed when Network is cleaned up
}

单个 Client 在进行远程调用或者 Call() 的时候, 函数如下:

// ClientEnd sends an RPC, wait for the reply.
// the return value indicates success; false means that
// no reply was received from the server.
func (e *ClientEnd) Call(svcMeth string, args interface{}, reply interface{}) bool {
	req := reqMsg{}
	req.endname = e.endname
	req.svcMeth = svcMeth
	req.argsType = reflect.TypeOf(args)
	req.replyCh = make(chan replyMsg)
  // args 是远程调用的 Function 调用的输入参数, 需要序列化通过客户端发送到服务端
	qb := new(bytes.Buffer)
	qe := labgob.NewEncoder(qb)
	if err := qe.Encode(args); err != nil {
		panic(err)
	}
	req.args = qb.Bytes()

	// send the request.
	// 当实例化一个 ClientEnd 的时候, e.ch 是网络层的 rn.endCh Channel 的一个拷贝
	select {
	case e.ch <- req:
		// the request has been sent.
	case <-e.done:
		// entire Network has been destroyed.
		return false
	}

	// wait for the reply.
	rep := <-req.replyCh
	if rep.ok {
		rb := bytes.NewBuffer(rep.reply)
		rd := labgob.NewDecoder(rb)
		if err := rd.Decode(reply); err != nil {
			log.Fatalf("ClientEnd.Call(): decode reply: %v\n", err)
		}
		return true
	} else {
		return false
	}
}

网络连接

我们可以定义下面的一个结构体用来模拟一个客户端与服务端连接的网络连接.

type Network struct {
	mu             sync.Mutex
	reliable       bool
	longDelays     bool                        // pause a long time on send on disabled connection
	longReordering bool                        // sometimes delay replies a long time
	ends           map[interface{}]*ClientEnd  // ends, by name
	enabled        map[interface{}]bool        // by end name
	servers        map[interface{}]*Server     // servers, by name
	connections    map[interface{}]interface{} // endname -> servername
	endCh          chan reqMsg
	done           chan struct{} // closed when Network is cleaned up
	count          int32         // total RPC count, for statistics
	bytes          int64         // total bytes send, for statistics
}

上述的网络结构体模拟的网络连接可以支持多个客户端与服务端节点的连接, 客户端发送请求时的延时, 服务端返回应答时的延时, 客户端与服务端的映射关系等.
初始化一个网络连接的部分如下:

func MakeNetwork() *Network {
	rn := &Network{}
	rn.reliable = true
	rn.ends = map[interface{}]*ClientEnd{}
	rn.enabled = map[interface{}]bool{}
	rn.servers = map[interface{}]*Server{}
	rn.connections = map[interface{}](interface{}){}
	rn.endCh = make(chan reqMsg)
	rn.done = make(chan struct{})

	// single goroutine to handle all ClientEnd.Call()s
	go func() {
		for {
			select {
			case xreq := <-rn.endCh:
				atomic.AddInt32(&rn.count, 1)
				atomic.AddInt64(&rn.bytes, int64(len(xreq.args)))
				go rn.processReq(xreq)
			case <-rn.done:
				return
			}
		}
	}()

	return rn
}

在网络连接中, 是客户端与服务端之间的通信, 建立一个新的客户端的代码如下:

// MakeEnd creates a client end-point.
// start the thread that listens and delivers.
func (rn *Network) MakeEnd(endname interface{}) *ClientEnd {
	rn.mu.Lock()
	defer rn.mu.Unlock()

	if _, ok := rn.ends[endname]; ok {
		log.Fatalf("MakeEnd: %v already exists\n", endname)
	}

	e := &ClientEnd{}
	e.endname = endname
  // 在 MakeEnd 函数中, 每个 ClientEnd 的 ch 字段被初始化为网络的 rn.endCh
  // 每个新建的客户端都会使用这个端口, 而网络连接处理请求的时候监听的时候也是这个 Channel
	e.ch = rn.endCh
	e.done = rn.done
  // 设置一个端点
	rn.ends[endname] = e
	rn.enabled[endname] = false
	rn.connections[endname] = nil

	return e
}

初始化一个网络连接的时候, 会初始化一个 go routine 用来监听 rn.endCh 端口, 当个端口接收到客户端发送来的请求的时候(来自前面客户端的 Call 函数), 就会触发请求处理函数 processReq. 这个函数如下:

// process a single request message.
func (rn *Network) processReq(req reqMsg) {
	// read the network configuration Information for this request.
	enabled, servername, server, reliable, longreordering := rn.readEndnameInfo(req.endname)

	if enabled && servername != nil && server != nil {
		if reliable == false {
			// short delay for unreliable networks, delay for random time
			ms := (rand.Int() % SHORTDELAY)
			time.Sleep(time.Duration(ms) * time.Millisecond)
		}
		// sometimes drop the request
		if reliable == false && (rand.Int()%1000) < 100 {
			// drop the request, return as if timeout
			req.replyCh <- replyMsg{false, nil}
			return
		}

		// execute the request (call the RPC handler). in a separate thread so that we can periodically check
		// if the server has been killed and the RPC should get a failure reply.
		ech := make(chan replyMsg)
		go func() {
			r := server.dispatch(req)
			ech <- r
		}()

		// wait for handler to return,
		// but stop waiting if DeleteServer() has been called,
		// and return an error.
		var reply replyMsg
		replyOK := false
		serverDead := false
		for replyOK == false && serverDead == false {
			select {
			case reply = <-ech:
				replyOK = true
			// check periodically if server is dead
			case <-time.After(100 * time.Millisecond):
				serverDead = rn.isServerDead(req.endname, servername, server)
				if serverDead {
					go func() {
						<-ech // drain channel to let the goroutine created earlier terminate
					}()
				}
			}
		}
		// do not reply if DeleteServer() has been called, i.e.
		// the server has been killed. this is needed to avoid situation in which a client gets a positive reply
		// to an Append, but the server persisted the update into the old Persister. config.go is careful to call
		// DeleteServer() before superseding the Persister. 如果服务器在应答之后被删除, 则不回复
		// 这样做是为了避免客户端收到一个成功的应答, 但服务器实际上已经被删除并且数据被写入了旧的持久化存储中
		serverDead = rn.isServerDead(req.endname, servername, server)

		if !replyOK || serverDead {
			// server was killed while we were waiting; return error.
			req.replyCh <- replyMsg{false, nil}
		} else if !reliable && (rand.Int()%1000) < 100 {
			// drop the reply, return as if timeout
			req.replyCh <- replyMsg{false, nil}
		} else if longreordering && rand.Intn(900) < 600 {
			// delay the response for a while
			ms := 200 + rand.Intn(1+rand.Intn(2000))
			// Russ points out that this timer arrangement will decrease
			// the number of goroutines, so that the race
			// detector is less likely to get upset.
			time.AfterFunc(time.Duration(ms)*time.Millisecond, func() {
				atomic.AddInt64(&rn.bytes, int64(len(reply.reply)))
				req.replyCh <- reply
			})
		} else {
			atomic.AddInt64(&rn.bytes, int64(len(reply.reply)))
			req.replyCh <- reply
		}
	} else {
		// simulate no reply and eventual timeout.
		ms := 0
		if rn.IsLongDelays() {
			// let Raft tests check that leader doesn't send RPCs synchronously.
			ms = (rand.Int() % LONGDELAY)
		} else {
			// many kv tests require the client to try each server in fairly rapid succession.
			ms = (rand.Int() % 100)
		}
		time.AfterFunc(time.Duration(ms)*time.Millisecond, func() {
			req.replyCh <- replyMsg{false, nil}
		})
	}
}

上面主要是网络之间的通信, 也就是客户端发送请求后, 服务端并不是直接收到请求, 然后处理请求的, 而是在网络连接中, RPC 调用的服务端的函数是下面的 dispatch 函数:

func (rs *Server) dispatch(req reqMsg) replyMsg {
	rs.mu.Lock()

	rs.count += 1

	// split Raft.AppendEntries into service and method
	dot := strings.LastIndex(req.svcMeth, ".")
	// 获取 Service 的服务名和方法名
	serviceName := req.svcMeth[:dot]
	methodName := req.svcMeth[dot+1:]
	// check that the service is known
	service, ok := rs.services[serviceName]

	rs.mu.Unlock()

	if ok {
		return service.dispatch(methodName, req)
	} else {
		choices := []string{}
		for k, _ := range rs.services {
			choices = append(choices, k)
		}
		log.Fatalf("labrpc.Server.dispatch(): unknown service %v in %v.%v; expecting one of %v\n",
			serviceName, serviceName, methodName, choices)
		return replyMsg{false, nil}
	}
}

Server 的 Dispatch 函数在收到客户端的请求后, 会根据请求的服务名选择 Service.
在服务器与客户端通信时, 服务器中提供服务的最小单元是 Service, 服务调用, 一个 Server 中可能存在多个 Service. 这个结构体如下:

// an object with methods that can be called via RPC.
// a single server may have more than one Service.
type Service struct {
	name    string
	rcvr    reflect.Value
	typ     reflect.Type
	methods map[string]reflect.Method
}

在 Service 中基本都使用了反射的方法, 这是为了获取注册到 Service 中的方法与参数, 服务名等信息, 我们可以看到新建一个 Service 部分的代码如下:

func MakeService(rcvr interface{}) *Service {
	svc := &Service{}
	svc.typ = reflect.TypeOf(rcvr)
	svc.rcvr = reflect.ValueOf(rcvr)
	svc.name = reflect.Indirect(svc.rcvr).Type().Name()
	svc.methods = map[string]reflect.Method{}

	for m := 0; m < svc.typ.NumMethod(); m++ {
		method := svc.typ.Method(m) // 获取方法
		mtype := method.Type        // 获取方法类型
		mname := method.Name        // 获取方法名

		//fmt.Printf("%v pp %v ni %v 1k %v 2k %v no %v\n",
		//	mname, method.PkgPath, mtype.NumIn(), mtype.In(1).Kind(), mtype.In(2).Kind(), mtype.NumOut())
		// select only suitable methods
		if method.PkgPath != "" || // capitalized?
			mtype.NumIn() != 3 ||
			//mtype.In(1).Kind() != reflect.Ptr ||
			mtype.In(2).Kind() != reflect.Ptr ||
			mtype.NumOut() != 0 {
			// the method is not suitable for a handler
			//fmt.Printf("bad method: %v\n", mname)
		} else {
			// the method looks like a handler
			svc.methods[mname] = method
		}
	}
	return svc
}

在这里注册 Service 的时候必须满足下面的条件:

  1. 方法是导出的(大写开头)
  2. 方法有 3 个参数(接收者、args、reply)
  3. 第三个参数是指针类型
  4. 方法没有返回值

为什么要使用反射呢? 因为 MakeService 函数的参数通常是一个结构体, 结构体的定义与数据类型往往都是不同的, 而且我们还需要获取这个结构体的名称, 用来注册 Service, 获取这个服务的方法名等, 因此需要使用到反射. 还有一点需要注意的是, Go 语言同样是编译型语言, 编译型语言的特点是, 我们在写代码的时候知道我们定义的变量与方法名的数据类型和返回值等, 但是, 在运行的时候却不知道, 因为运行时, 这些内容就是内存中的数据, 因此当运行时想获取原来定义的结构体的信息等, 就需要用到反射. 而解释型语言则不需要, 这是因为解释型语言在运行时所有对象都自带类型信息, 例如 python 中我们可以通过 type() 获取数据类型等.

我们从下面的 Servicedispatch 函数来分析具体是如何调用 Service 中对应的方法的, 也就是我们写的服务函数.

// dispatch the RPC to the appropriate method.
func (svc *Service) dispatch(methname string, req reqMsg) replyMsg {
	if method, ok := svc.methods[methname]; ok {
		// prepare space into which to read the argument.
		// the Value's type will be a pointer to req.argsType.
		args := reflect.New(req.argsType)

		// decode the argument.
		ab := bytes.NewBuffer(req.args)
		ad := labgob.NewDecoder(ab)
		ad.Decode(args.Interface())

		// allocate space for the reply.
		replyType := method.Type.In(2)
		replyType = replyType.Elem()
		replyv := reflect.New(replyType)

		// call the method.
		function := method.Func
		function.Call([]reflect.Value{svc.rcvr, args.Elem(), replyv})

		// encode the reply.
		rb := new(bytes.Buffer)
		re := labgob.NewEncoder(rb)
		re.EncodeValue(replyv)

		return replyMsg{true, rb.Bytes()}
	} else {
		choices := []string{}
		for k, _ := range svc.methods {
			choices = append(choices, k)
		}
		log.Fatalf("labrpc.Service.dispatch(): unknown method %v in %v; expecting one of %v\n",
			methname, req.svcMeth, choices)
		return replyMsg{false, nil}
	}
}

我们可以分析一下 dispatch 函数中, reflect 的使用, 可以得到整个调用流程的方法.

  1. 动态调用方法, 在 Service.dispatch 中, reflect 被用来动态调用方法:
function := method.Func  // 获取方法的函数句柄, reflect.Value.
function.Call([]reflect.Value{svc.rcvr, args.Elem(), replyv})  // 调用方法, 参数是一个 []reflect.Value, 依次传入接收者、参数和返回值.
  1. 动态创建参数和返回值
    Service.dispatch 中, reflect 被用来动态创建参数和返回值:
args := reflect.New(req.argsType)  // 创建参数, 例如, reflect.New(reflect.TypeOf(Args{})) 返回 *Args.
replyType := method.Type.In(2).Elem()  // 获取返回值的类型
replyv := reflect.New(replyType)  // 创建返回值

具体的使用

客户端的管理

具体使用上述的网络连接的部分我们可以首先看一下 src/tester1 中的代码, 其中在 src/tester1/clnts.go 中定义了客户端的一些行为, 这部分代码模拟了在测试过程中, 在测试分布式系统中客户端的一些行为.
我们从一个客户端的端点开始, 定义一个客户端的端点如下:

// a per-clerk ClientEnd to a server
type end struct {
	name string								// 客户端的名称
	end  *labrpc.ClientEnd		// 一个客户端端点的抽象
}

然后我们使用下面的结构体表示一个客户端的所有链接, 因为一个客户端可能会连接到多个 Servers,

// Servers are named by ServerName() and clerks lazily make a per-clerk ClientEnd to a server.
// Each clerk has a Clnt with a map of the allocated ends for this clerk.
type Clnt struct {
	mu   sync.Mutex
	net  *labrpc.Network
	ends map[string]end // map from server name to per-clerk ClientEnd

	// if nil client can connect to all servers
	// if len(srvs) = 0, client cannot connect to any servers
	srvs []string
}

我们解释一下上面的结构体, net 是使用的网络连接, 这个我们在前面以及说过了, 是用来建立客户端端点与服务器的连接. 在一个网络上可以建立多个连接, 有多个端点, 多个服务器. 而这里的 Clnt 是抽象一个客户端与多个服务器之前的连接. 但是一个客户端可以包含多个客户端端点, 也就是 ends. 同时, 这些 ends 会在网络 net 上与服务器建立多个连接, 这链接实际上也写在了 net 中. srvs 的作用是记录一个客户端连接的所有服务器, 然后我们可以使用 ends 这个映射, 得到每个服务器连接时使用的客户端的端点.

Clnt 的典型的使用函数是下面的 Call() 函数

func (clnt *Clnt) Call(server, method string, args interface{}, reply interface{}) bool {
	// we call makeEnd to get (or create) the per-clerk ClientEnd, because the fucntion makeEnd is a Lazy Initialization
	end := clnt.makeEnd(server)
	ok := end.end.Call(method, args, reply)
	// log.Printf("%p: Call done e %v m %v %v %v ok %v", clnt, end.name, method, args, reply, ok)
	return ok
}

Clnt 就是所谓一个客户端的 Clerk, 然后我们使用下面的结构体管理多个客户端节点, 如下:

type Clnts struct {
	mu     sync.Mutex
	net    *labrpc.Network
	clerks map[*Clnt]struct{}
}

Clnts 用来管理多个客户端节点与服务器的连接. 也就是封装了多个 clerks, 这部分比较好理解. 目前在 KV Server 这部分中, 仅用到了客户端的部分, src/tester1 的其他部分在后续 Raft 实现的时候我们还会再具体分析.

posted @ 2025-11-03 11:13  虾野百鹤  阅读(7)  评论(0)    收藏  举报