MIT6.824 rpc 相关知识整理

RPC 基本概念

RPC 全称为 Remote Procedure Call,即 远程过程调用,他可以让用户在不知道底层网络协议的情况下,在本地的计算机就可以调用远端服务器的一些处理方法,然后服务器会把处理的结果返回到用户本地,就像这个方法写在用户本地空间中一样。

Client 端(由上到下):

  • Application:应用层,表示和用户直接交互的部分,用户在该层确定自己想要调用的函数,并且输入参数
  • stub:原意是烟蒂,很形象的表示这只是个函数空壳,表示用户想要调用的函数,这里可以是函数名
  • RPC lib:RPC 库,包括一系列的编码解码工作,对于用户端来说,是编码用户的函数调用请求进入网络层,或者是解码服务端的结果成为上层结构可以理解的语言。
  • Network Layer:网络层传输协议,这里不再赘述,它可以是 TCP 或者 UDP。

Server 端(由下到上):

  • Network Layer:网络层传输协议,同上
  • RPC lib:RPC 库,对服务端来说,用于解码用户的调用函数请求,或者是函数结果的封装。
  • Dispatcher:用户的请求到达该层后,服务端需要通过 Dispatcher,根据请求的函数,找到服务器中对应的函数,并把任务交到这个函数上
  • Handler:具体的函数逻辑

RPC 的操作流程为:

  • 用户端写好函数调用,向下传递到网络层并发送到服务端
  • 服务端上行用户请求,通过 Dispatcher 找到具体的 Handler,由 Handler 处理具体的逻辑
  • 服务端处理完毕后,通过 RPC lib 编码发还到客户端
  • 客户端经过解码后获得结果,用户获得 Application 层获得结果返回。

RPC 处理失败

失败原因

可能导致请求失败的原因有:

  • 服务端没有收到这个请求
  • 服务端执行了请求,但响应之前宕机了
  • 服务端执行了请求并发送了响应,但在响应之前网络故障了

解决方案

方案1: At Least Once

客户端会不断重试请求,直到收到请求被执行的肯定确认。适用于只读和幂等操作。

At least once 会造成资源浪费,服务端出现不一致的情况,不是一个很好的方法。

方案2: At Most Once

客户端不会自动重试请求。

如果没有针对失败请求的显式重试机制,请求可能会丢失并且永远不会被执行。

如果重试请求,服务器负责检测重复请求并确保只有一个请求成功。

Go RPC 实现了 At-Most-Once 语义,如果没有得到响应,只会返回一个错误。客户端可以选择重试一个失败请求,但服务端要自己处理重复的请求。

labrpc 代码解析

labrpc模拟网络的通信,可以模仿网络的延迟、丢包等故障,方便测试lab2的稳定性。顺便学习一下大佬是如何使用golang进行编程的。这里实现的RPC框架虽然只是用于模拟,相对简单,但麻雀虽小五脏俱全。其中用到了go语言的反射机制,gRPC的go语言实现也是利用这个反射机制。

1. 从end.Call()开始

end.Call("Raft.AppendEntries", &args, &reply)表示发起一个RPC调用,其中Raft是服务端的结构体名称,AppendEntries是需要调用的方法名。args是调用方法时传给服务端的参数,reply是服务端返回的响应。这两个参数都需要以指针的形式传递。

并发调用的场景下:同一ClientEnd上可以同时进行多个Call()调用。并发调用Call()时,它们可能会被服务器以不同的顺序处理,因为网络可能会重新排序消息。

使用Go的channelselect语句,方法能够优雅地处理正常情况和异常情况(如网络层销毁)。此外,它使用了labgob作为序列化工具,这是MIT 6.824课程中的一个自定义序列化库,用于在RPC调用中编码和解码数据。

1
2
3
4
5
6
7
select {
case e.ch <- req:
// the request has been sent.
case <-e.done:
// entire Network has been destroyed.
return false
}

该方法下向 e.ch 管道发送请求。

那这个管道是从哪里来的呢?

1
2
3
4
5
6
7
8
9
10
11
type Network struct {
mu sync.Mutex
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
}

在项目测试代码的网络构建之初,就会构建这样一个管道,随后每个终端end创建之后都会共用这个管道。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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
}
}
}()

另外,当网络构建之初,开启一个goroutine,监听来自ClientEnd的消息。收到消息后,就会开启一个协程go rn.processReq(xreq),对请求进行处理。

2. 处理请求 processReq(req)

这个方法非常的冗长,因为为了模拟网络的不同的状态,进行了不同的处理。接下来我们只讨论网络正常的情况下,网络是如何处理请求的。

首先通过Network.servers这个servername到具体server的映射,找到对应的服务器,也就是下面代码里的server

1
2
3
4
5
ech := make(chan replyMsg)
go func() {
r := server.dispatch(req)
ech <- r
}()

网络创建一个管道,然后将 server.dispatch(req)的结果放入其中。在server.dispatch(req)这个方法里,首先解析出服务名和方法名,随后通过service, ok := rs.services[serviceName]找到对应的服务,再调用这个服务的service.dispatch(methodName, req),即将具体的请求发送给该服务器提供服务的相应方法。

发送给相应服务后,会执行以下两行代码

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

method是一个reflect.Method类型的变量,通过查找方法名称查找得到(method, ok := svc.methods[methname])。

method.Funcreflect.Value类型,它包含了对应方法的实际函数。这里,function变量被赋予了这个函数的反射值。

function.Call是一个通过反射调用函数的方法。[]reflect.Value{svc.rcvr, args.Elem(), replyv}是调用函数所需参数的反射值组成的切片:

  • svc.rcvr是服务对象的接收者,即服务实例本身。这是因为在Go中,方法是绑定在接收者上的,调用一个方法实际上是在特定的接收者实例上调用该方法。
  • args.Elem()是请求参数的反射值。args是一个通过reflect.New创建的指针类型的反射值,指向实际的参数值。Elem方法返回指针指向的实际值的反射值。
  • replyv是用于存放方法调用结果的变量的反射值。它先通过方法签名确定返回值类型,之后allocate动态创建的,以便方法可以将返回值写入其中。

到这RPC调用就基本完成了,将reply结果放入req.replyCh中即可。

最后再到end.Call() 结束:rep := <-req.replyCh

当代RPC框架 Dubbo

Dubbo 是一个分布式服务框架,致力于提供高性能和透明化的 RPC 远程服务调用方案,以及 SOA 服务治理方案。简单的说,Dubbo 就是个服务框架,说白了就是个远程服务调用的分布式框架。

Dubbo特点

  • 远程通讯: 提供对多种基于长连接的 NIO 框架抽象封装(非阻塞 I/O 的通信方式,Mina/Netty/Grizzly),包括多种线程模型,序列化(Hessian2/ProtoBuf),以及“请求-响应”模式的信息交换方式。
  • 集群容错: 提供基于接口方法的透明远程过程调用(RPC),包括多协议支持(自定义 RPC 协议),以及软负载均衡(Random/RoundRobin),失败容错(Failover/Failback),地址路由,动态配置等集群支持。
  • 自动发现: 基于注册中心目录服务,使服务消费方能动态的查找服务提供方,使地址透明,使服务提供方可以平滑增加或减少机器。

微服务的分布式特性,使得应用间的依赖、网络交互、数据传输变得更频繁,因此不同的应用需要定义、暴露或调用 RPC 服务,那么这些 RPC 服务如何定义、如何与应用开发框架结合、服务调用行为如何控制?这就是 Dubbo 服务开发框架的意义,Dubbo 在微服务应用开发框架之上抽象了一套 RPC 服务定义、暴露、调用与治理的编程范式。

Dubbo 与 gRPC

Dubbo 与 gRPC 最大的差异在于两者的定位上:

  • gRPC 定位为一款 RPC 框架,Google 推出它的核心目标是定义云原生时代的 rpc 通信规范与标准实现;
  • Dubbo 定位是一款微服务开发框架,它侧重解决微服务实践从服务定义、开发、通信到治理的问题,因此 Dubbo 同时提供了 RPC 通信、与应用开发框架的适配、服务治理等能力。

MIT6.824 rpc 相关知识整理
http://example.com/2024/03/01/6.824-rpc/
作者
Melrose Wei
发布于
2024年3月1日
许可协议