Go语言分布式计算(RPC入门)

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  // 结果:单词 → 次数
}

注意 :所有字段名都大写开头InputCounts),否则 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,有以下特点:

  • 调用方会阻塞:发完请求后,代码停住,必须等远端返回结果,才继续往下执行
  • 缺点:如果远端处理慢、网络卡,当前线程 / 协程一直被占用,没法做别的事

如:

打电话找人办事

  1. 你拨通电话(发起 RPC 请求)
  2. 对方开始处理事情
  3. 你拿着电话一直等,啥也不干,直到对方说完结果(返回响应)
  4. 拿到结果,挂电话,继续做下一件事

即:发请求 → 阻塞等待 → 收到回复 → 继续

异步RPC则不一样:

  • 调用方不阻塞:请求发出去立刻继续执行后续代码。
  • 结果不会立刻拿到,一般通过回调函数、通道、事件通知接收返回值。
  • 优点:高并发场景效率极高,线程 / 协程不会空等,资源利用率拉满。
  • 缺点:代码逻辑变复杂,顺序感消失,异常、超时、回调嵌套需要额外处理。

如:

发微信消息找人办事

  1. 你发消息(发起 RPC 请求)
  2. 发完直接切去做别的事,不用盯着聊天框等
  3. 对方处理完,主动回消息(回调 / 通知结果)
  4. 你收到消息后,再处理返回的结果二者区别:
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+channelclient.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 的透明性是有限的------故障语义是核心难题

相关推荐
2401_868534782 小时前
2025下半年网络规划设计师真题(选择题、案例分析)
运维·服务器·网络
TechWayfarer2 小时前
查IP归属地接入实战:保险理赔如何做动态风险监控与预警
网络·python·tcp/ip·安全·flask
米丘2 小时前
SSE (server-sent events)
javascript·网络协议
Resurgence_zc2 小时前
openGauss 资源池化主备页面交互流程梳理
网络·交互·数据库开发
Dlrb12113 小时前
Linux网络编程-网络基础概念(IP, UDP协议)
linux·服务器·网络·网络基础·端口号·ip协议·udp协议
shushangyun_3 小时前
汽车服务行业B2B平台+AI解决方案哪家专业:2026年最新测评
java·运维·网络·数据库·人工智能·汽车
一RTOS一3 小时前
东土科技:智能制造系统高性能工业网络解决方案揭榜挂帅项目正式验收达标
网络·科技·制造
森G3 小时前
64、完善聊天室程序(TLV拓展)---------网络编程
网络·c++·tcp/ip