RPC ------ 远程过程调用
回顾之前:之前学的是"一台机器里多个任务怎么协作",现在学的是"多台机器之间怎么通信"
本章核心目标:
-
解释本地调用与远程调用的核心区别
-
描述 RPC 的完整交互流程(请求 → 序列化 → 传输 → 反序列化 → 执行 → 返回)
-
在 Go 中使用 net/rpc 编写简单的 RPC server 和 client
-
说明 MapReduce 中 coordinator/worker 的 RPC 通信模式
一、回顾
| 概念 | 作用 |
|---|---|
sync.WaitGroup |
等一组 goroutine 全部完成(相当于计数器) |
Channel |
goroutine 之间传递数据,自带同步 |
select |
同时等多个 channel,谁先来处理谁 |
一台机器上:
- Goroutine 之间共享内存,直接读写变量;
- 函数调用:压栈 → 跳转 → 执行 → 返回,纳秒级完成
二、本章知识点
1、什么是分布式系统
定义:多台计算机通过网络相连,一起完成某件事

2、网络分层

| 层 | 作用 | 内容 |
|---|---|---|
| Applications 应用层 | 你自己的数据 | |
| Transport 层(端口/进程地址) | 在不同主机进程间提供端到端通信(TCP/UDP) | TCP/UDP网络协议 |
| Network 层(IP地址,路由) | 把数据包传输到其他网络的目的地(IP) | IP地址 |
| Link 层(相邻节点传输) | 在直接相连的两台机器间交换消息 | WiFi |
| Physical 层(物理介质) | 在物理链路上移动比特 | 网线,光纤 |
每一层只关心自己的事,上层的东西对下层来说是"黑盒"
1)层与层之间如何进行通信
每一层会附加上自己层的 Header
上层的 Header + Data 被下层当作 Data 封装
接收方从下往上逐层拆封,还原出应用层数据

2)Socket(操作系统提供的网络接口)
定义:Socket 是操作系统暴露给应用程序的网络 API(接口)
- 提供进程间显式消息交换
Go
sockfd = socket(...); // 创建一个信箱
connect(sockfd, 服务器地址); // 连到对方的信箱
send(sockfd, "Hello"); // 发信
recv(sockfd, buffer); // 收信
close(sockfd); // 关掉信箱

而RPC 就是在 Socket 上面再加一层,让你"像调用本地函数一样"调用远程函数
3、RPC
在 Application 和 Transport 之间插入 RPC 层
程序员只需要"调用函数",RPC 层负责网络通信

1)什么是RPC
定义:Remote Procedure Call(远程过程调用):让调用远程函数看起来像调用本地函数一样。
RPC 的设计目标:透明性
RPC带来的弊端:

2)RPC要解决的三大类问题
| 问题 | 说明 |
|---|---|
| 异构性 | 两端语言、平台、数据格式可能不同 |
| 故障 | 网络丢包、服务宕机、超时 |
| 性能 | 远程调用比本地调用慢 1000 倍以上 |
4、RPC的异构性问题
1)问题来源
远程调用中,两台机器可能出现:
| 差异类型 | 什么意思 | 举个例子 |
|---|---|---|
| 编程语言不同 | 两边用的语言不一样 | 客户端 Go,服务端 Java |
| 数据类型大小不同 | 同一个数据类型,占的字节数可能不同 | Go 的 int 可能是 8 字节,C 的 int 可能是 4 字节 |
| 字节序不同 | 多字节数字的存储顺序不同 | 大端 vs 小端 |
| 浮点数表示不同 | 浮点数的二进制格式可能不同 | IEEE 754 也不是所有平台都完全一样 |
| 内存对齐要求不同 | 结构体在内存里的排列方式不同 | C 语言里 struct 可能会为了性能而"填洞" |
异构性通俗理解就是:两台机器不一样
2)解决方法:Interface Description Language:接口描述语言(IDL)
什么时IDL?
- IDL 是一种中立的数据描述语言,不依赖任何编程语言
- 写完一个
.idl文件,然后运行 IDL 编译器 ,它会自动生成:- 客户端的代码(Go 版)
- 服务端的代码(C++ 版)
- 序列化/反序列化代码
IDL 编译器自动生成:
-
Marshal 代码:本地数据 → 字节流
-
Unmarshal 代码:字节流 → 本地数据
-
Client stub:函数调用 → 网络请求
-
Server stub:网络请求 → 函数调用
核心:Marshal(序列化)
结构化数据 → 字节流(把内存中的数据结构 变成平台无关的字节流 )
就是把程序里的复杂数据(比如结构体、对象),变成一段大家都看得懂的 "标准格式" 字节流
UnMarshal(反序列化)
把字节流变回内存中的数据结构
5、RPC整个流程(10步)(重点)
假设在Client machine客户端中写:
result := add(3, 5)
1)Client调用stub函数
客户端中调用client stub函数(代理函数)

2)Client stub 序列化参数
序列化(Marshal)
Client 把函数名和参数通过序列化(Marshal)打包为字节流,组装为一条网络消息:
proc: add | int: 3 | int: 5

3)Client machine发送的消息通过网络传输到Server machine中
Client machine的客户端操作系统通过网络把消息发送出去,发给Server machine
Client OS → 网络(TCP/IP) → Server OS

4)Server machine的Server OS接收到发来的消息,并传输到Server machine的Server stub:

5)Server machine的server stub反序列化
Server machine的服务端操作系统在收到字节流后,把字节流交给 server stub
Server stub做两件事:
-
反序列化(Unmarshal):把接收到的字节流 → 解析出函数名和参数
-
分发(Dispatch):根据函数名决定调用哪个函数

6、7)调用函数并返回
Server stub把参数传递给真的add函数

函数在服务器上执行并返回最终的结果值

8)Server machine的Server stub把返回的这个结果值通过序列化(Marshal)打包为字节流
并把这个字节流传递给Server的操作系统

9、10)返回值通过网络回传
返回值字节流通过网络从server os传给client os并给到client stub
Server OS → 网络 → Client OS → Client stub

Client stub通过反序列化(Unmarshal)解析字节流的返回值
最终client stub把这个解析出来的值赋予给变量k

总结:
10 个步骤,对调用者来说只有 1 行代码
result := add(3, 5)
这就是RPC的透明性,即:对程序员来说,远程调用和本地调用写起来一样:
Go
// 本地调用
k := add(3, 5)
// 远程调用(写法一模一样)
k := add(3, 5)
补充:
1)Server stub的内部结构
Server stub 实际上有两个部分:
- Dispatcher(分发器):收到请求后,确定调用哪个函数
- Skeleton(骨架):unmarshal 参数 → 调用函数 → marshal 返回值
2)RPC != 完美的本地调用
| 本地调用 | RPC 远程调用 |
|---|---|
| 纳秒级完成 | 毫秒级(慢 1000 倍以上) |
| 永远不会失败(除非程序崩溃) | 随时可能失败(网络、机器故障) |
| 可以传指针、传 channel | 只能传可序列化的数据 |
| 直接访问内存 | 要序列化/反序列化 |
RPC 提供的是"接口透明",不是"行为透明"(重要)
6、Go语言写RPC
1)GO的RPC方法签名规则(重要)
func (t *T) MethodName(args T1, reply *T2) error
| 部分 | 要求 | 为什么 |
|---|---|---|
func |
函数 | 就是函数 |
(t *T) |
接收者必须是指针 | RPC 需要知道是哪个对象的方法 |
MethodName |
方法名大写开头 | 导出(public),让 RPC 能访问到 |
args T1 |
第一个参数,请求数据 | 从客户端传过来的参数 |
reply *T2 |
第二个参数,指针 | 把结果填进去,返回给客户端 |
error |
返回值必须是 error | 告诉客户端调用成功还是失败 |
注意:T1 和 T2 必须是可序列化的(不能传 map、channel、函数等)
2)案例:词频统计RPC
step 1:定义数据结构
Go
type WordCountServer struct {
addr string // 服务器地址
}
type WordCountRequest struct {
Input string // 要统计的文字
}
type WordCountReply struct {
Counts map[string]int // 结果:单词 → 次数
}
注意 :所有字段名都大写开头 (Input、Counts),否则 RPC 看不到
step 2:Server服务端:Handler方法
Go
func (s *WordCountServer) Compute(
request WordCountRequest,
reply *WordCountReply,
) error {
counts := make(map[string]int)
tokens := strings.Fields(request.Input) // 按空格分词
for _, t := range tokens {
counts[t]++
}
reply.Counts = counts
return nil
}
| 代码 | 作用 |
|---|---|
(s *WordCountServer) |
这个方法属于 WordCountServer 这个服务 |
request WordCountRequest |
客户端传进来的文字**(输入)** |
reply *WordCountReply |
把统计结果填进去 |
strings.Fields |
按空格分词**(处理)** |
reply.Counts |
返回值**(输出)** |
return nil |
成功,没有错误 |
这个函数和本地函数几乎一样,唯一区别是签名格式固定
step 3:Server服务端:注册与监听
Go
func (s *WordCountServer) Listen() {
rpc.Register(s) // 1. 注册服务
listener, _ := net.Listen("tcp", s.addr) // 2. 监听端口
go func() {
rpc.Accept(listener) // 3. 接受连接并处理
}()
}
| 代码 | 人话 | 作用 |
|---|---|---|
rpc.Register(s) |
告诉 RPC:"这个对象的方法可以被远程调用" | 把 server 的所有导出方法注册为 RPC handler |
net.Listen("tcp", s.addr) |
在某个端口(比如 8888)开一个网络服务 | 在指定地址监听 TCP 连接 |
rpc.Accept(listener) |
无限循环:有人连上来就自动调用对应的方法 | 接受连接,自动分发到对应方法 |
注意:用 go 启动 rpc.Accept,不阻塞主线程
step 4:Client客户端连接与调用
Go
func makeRequest(input, addr string) (map[string]int, error) {
// 1. 连接服务器
client, err := rpc.Dial("tcp", addr)
if err != nil {
return nil, err
}
// 2. 准备请求和响应
args := WordCountRequest{Input: input}
reply := WordCountReply{Counts: make(map[string]int)}
// 3. 调用远程方法
err = client.Call("WordCountServer.Compute", args, &reply)
if err != nil {
return nil, err
}
// 4. 返回结果
return reply.Counts, nil
}
| 代码 | 人话 | 说明 |
|---|---|---|
rpc.Dial("tcp", addr) |
拨号连接服务器 | 建立 TCP 连接 |
client.Call("WordCountServer.Compute", args, &reply) |
调用远程方法,"类型名.方法名" |
同步调用,阻塞等待结果 |
&reply |
传指针,让 RPC 把结果填进来 | 准备请求和响应容器 |
step 5:完整运行
Go
func main() {
addr := "localhost:8888"
server := WordCountServer{addr: addr}
server.Listen() // 服务端启动(不阻塞)
input := "hello I am good hello bye bye bye bye good night hello"
result, _ := makeRequest(input, addr)
fmt.Printf("Result: %v\n", result)
}
预期输出:
Result: mapI:1 am:1 bye:4 good:2 hello:3 night:1
7、异步RPC
回顾RPC概念:本地代码调用另一台服务器上的函数 / 方法,像调用本地函数一样
上述说的是同步RPC,有以下特点:
- 调用方会阻塞:发完请求后,代码停住,必须等远端返回结果,才继续往下执行
- 缺点:如果远端处理慢、网络卡,当前线程 / 协程一直被占用,没法做别的事
如:
你打电话找人办事:
- 你拨通电话(发起 RPC 请求)
- 对方开始处理事情
- 你拿着电话一直等,啥也不干,直到对方说完结果(返回响应)
- 拿到结果,挂电话,继续做下一件事
即:发请求 → 阻塞等待 → 收到回复 → 继续
异步RPC则不一样:
- 调用方不阻塞:请求发出去立刻继续执行后续代码。
- 结果不会立刻拿到,一般通过回调函数、通道、事件通知接收返回值。
- 优点:高并发场景效率极高,线程 / 协程不会空等,资源利用率拉满。
- 缺点:代码逻辑变复杂,顺序感消失,异常、超时、回调嵌套需要额外处理。
如:
你发微信消息找人办事:
- 你发消息(发起 RPC 请求)
- 发完直接切去做别的事,不用盯着聊天框等
- 对方处理完,主动回消息(回调 / 通知结果)
- 你收到消息后,再处理返回的结果二者区别:
1)异步方式1:goroutine + channel
Go
func makeRequestAsync(input, addr string) chan Result {
ch := make(chan Result)
go func() {
client, _ := rpc.Dial("tcp", addr)
args := WordCountRequest{Input: input}
reply := WordCountReply{Counts: make(map[string]int)}
err := client.Call("WordCountServer.Compute", args, &reply)
ch <- Result{reply.Counts, err}
}()
return ch
}
使用:
Go
ch1 := makeRequestAsync("hello world", addr)
ch2 := makeRequestAsync("foo bar baz", addr)
result1 := <-ch1 // 等第一个完成
result2 := <-ch2 // 等第二个完成
特点:
- 两个goroutine并行发出
- 用channel来收集结果
2)异步方式2:client.Go
Go
call := client.Go("WordCountServer.Compute", args, &reply, nil)
// 做其他事情...
<-call.Done
fmt.Println(call.Error, reply.Counts)
注意:client.Go() 是 Go 自带的内置异步调用方式,和 goroutine+channel 原理一样
问题:
什么场景下你会更倾向于用异步 RPC?
| 选项 | 答案 |
|---|---|
| A. Master 同时向 10 个 worker 发任务 | ✅ 异步,并行发 |
| B. Client 发一个请求,等结果再发下一个 | ❌ 同步就够了 |
| C. 前端 App 同时请求用户信息 + 订单列表 + 推荐 | ✅ 异步,三个请求一起发 |
8、MapReduce中的RPC应用
1)MapReduce架构回顾:
- Coordinator / Master(协调者):负责分配任务,追踪任务进度
- Worker(工人):负责执行Map或者Reduce任务
- 通信方式:RPC
2)Worker主动向Coordinator请求任务
Go
args := RequestTaskArgs{WorkerID: myID}
reply := RequestTaskReply{}
call("Coordinator.RequestTask", &args, &reply)
3)Coordinator分配任务
Go
func (c *Coordinator) RequestTask(args *RequestTaskArgs, reply *RequestTaskReply) error {
task := c.getNextTask()
reply.TaskType = task.Type // Map 还是 Reduce
reply.InputFile = task.File
reply.TaskID = task.ID
return nil
}
4)Worker向Coordinator报告任务完成
Go
args := ReportTaskArgs{
TaskID: taskID,
TaskType: MapTask,
Files: outputFiles,
}
call("Coordinator.ReportTask", &args, &reply)
5)MapReduce的RPC总结
|-------------|----------------------|--------|
| RPC 调用 | 方向 | 作用 |
| RequestTask | Worker → Coordinator | 请求分配任务 |
| ReportTask | Worker → Coordinator | 报告任务完成 |
-
Worker 是 RPC client (主动要任务、主动报告)
-
Coordinator 是 RPC server (分配任务、收报告)
9、GO语言RPC部分总结
Go RPC 方法必须长成 func (t *T) Method(req T1, reply *T2) error 这个形状。
服务端 Register + Accept,客户端 Dial + Call。
同步用 Call,异步用 goroutine+channel 或 client.Go。
10、RPC可能出错的环节
Q:远程调用 Add(a, b) 时,哪些环节可能出问题?
| 编号 | 问题 | 通俗解释 |
|---|---|---|
| 1 | Client 发出的请求丢了 | 你发的消息根本没到对方 |
| 2 | Server 收到了请求,但执行过程中挂了 | 对方收到了,但正在干活时电脑蓝屏了 |
| 3 | Server 执行成功了,但回复丢了 | 对方算完了 3+5=8,但回复的消息在路上丢了 |
| 4 | 一切正常,但太慢了 | 对方算完了也回复了,但等了 5 秒才到 |
注意:从 Client 角度看,这四种情况全部表现为"没收到回复",无法区分到底是"没收到"还是"没执行"
1)最简单的解决这个问题的策略:At-Least-Once
策略内容:Client 等待一段时间,没收到回复就重发请求。重复几次还没回复就返回错误。
策略缺点:
Client 发送:"从银行账户扣 10 元"
-
第一次请求成功执行,扣了 10 元,但回复丢了
-
Client 没收到回复,重发
-
Server 又扣了 10 元
结果:扣了 20 元!
那么什么时候可以用At-Least-Once策略呢?
| 场景 | 例子 | 为什么安全 |
|---|---|---|
| 操作是只读的 | 查询余额、读文件 | 读多少次结果都一样 |
| 操作是幂等的 | "把值设为 5"、"删除这个文件" | 重复执行效果一样 |
| 应用层自己能处理重复 | 自己做了去重 | 业务层面兜底 |
2)另一种更好的策略:At-Most-Once
策略内容:Client 给每个请求附带一个唯一 ID(xid)。Server 记录已经处理过的 xid。收到重复的 xid → 返回缓存的结果,不重新执行。
if seenxid {
retval = oldxid // 返回之前缓存的结果
} else {
retval = handler() // 执行真正的函数
oldxid = retval
seenxid = true
}
return retval
通俗理解:
每个请求都有一个唯一的"订单号"。服务器接到订单后,先查一下这个订单号有没有处理过:
-
处理过 → 直接返回上次的结果
-
没处理过 → 正常处理,并记下这个订单号
这样就算客户端重复发送,也不会重复执行
At-Most-Once的工程问题:
| 问题 | 说明 |
|---|---|
| 记录表会无限增长 | 处理过的请求越来越多,内存装不下 → 需要清理机制 |
| xid 如何生成? | 一般用(client ID + 序列号),但需要保证不重复 |
| 原始请求还在执行,又收到重复请求怎么办? | 需要加锁或标记"正在处理" |
| Server 重启了,记录表丢了怎么办? | 重启后可能重复执行(需要持久化存储) |
11、Go RPC的故障行为
| 特点 | 说明 |
|---|---|
| 底层用 TCP | TCP 自己会处理丢包、重传、重复包过滤 |
| 不会自动重试 | 请求失败就返回 error,不会帮你再试一次 |
| error 不告诉你原因 | 你不知道是"请求没到"还是"执行了但回复丢了" |
三、本章知识点总结
| 问题 | 答案 |
|---|---|
| 远程调用时哪些环节可能出问题? | 请求丢、执行挂、回复丢、太慢 |
| RPC 怎么解决异构问题? | 用序列化(Marshal/Unmarshal) |
| RPC 的透明性是什么意思? | 代码写起来像本地调用,但背后走了网络 |
| 透明性的代价是什么? | 延迟高、可能失败、不能传指针/channel |
RPC 通过 stub + marshal 让远程调用看起来像本地调用
但网络传输引入了延迟、丢失、重复、异构
| 概念 | 一句话解释 |
|---|---|
| RPC | 让远程调用看起来像本地调用 |
| stub | Client/server 端代理,负责 marshal/unmarshal |
| IDL | 接口描述语言,解决异构性 |
| 透明性 | API 一样,但行为不一样 |
| 序列化(Marshal) | 结构化数据 → 字节流 |
| 异构性 | 两台机器不一样,需要序列化来解决 |
| At-Least-Once | 没收到回复就重试(可能重复执行) |
| At-Most-Once | 用唯一 ID 去重,不重复执行 |
| Go net/rpc 局限 | 不自动重试,不告诉你失败原因 |
RPC位于:Application和Transport之间
RPC完整10步流程,对于程序员只有1行代码
Go Net/RPC:func (t *T) MethodName(args T1, reply *T2) error
MapReduce 用 RPC 实现 Coordinator-Worker通信
RPC 的透明性是有限的------故障语义是核心难题