RPC 异步调用基本使用方法:基于 官方helloworld-async 示例
一、从同步调用过渡到异步调用
1. 为什么还要看异步调用
上一篇 ## RPC 同步调用基本使用方法:基于官方 RouteGuide 示例 已经介绍过 RPC、gRPC、Protobuf、HTTP/2、Channel、Stub、Service、ServerBuilder 这些基础概念,所以这里不再重复展开
简单回顾一句:gRPC 的基本开发流程就是先写 .proto 文件定义服务和消息,再用 protoc 生成 C++ 代码,服务端实现服务,客户端通过 Stub 发起远程调用
同步调用的特点是写法直观,客户端调用 RPC 后阻塞等待结果,服务端像普通函数一样重写接口、填充响应、返回 Status;异步调用的重点就不在"远程函数怎么定义"上,而在"请求和响应完成后,程序怎么被通知、怎么找到对应请求、怎么继续处理"
同步调用比较像普通函数调用,调用语句返回时,响应对象和状态码已经拿到了;异步调用更像事件驱动模型,程序先注册一个异步操作,操作真正完成后,gRPC 把事件放进 CompletionQueue,我们再从队列里取出事件,通过 tag 找到对应的请求对象,然后继续推进状态。因此异步 gRPC 的核心可以先记成三个词:CompletionQueue、tag、状态机
2. 同步和异步的代码风格差异
以最简单的 SayHello 为例,同步服务端一般继承 Greeter::Service,然后重写 SayHello():
cpp
class GreeterServiceImpl final : public Greeter::Service {
public:
Status SayHello(ServerContext* context,
const HelloRequest* request,
HelloReply* reply) override {
reply->set_message("Hello " + request->name());
return Status::OK;
}
};
这段代码很像本地函数:请求进来,框架调用函数,函数写响应,最后返回状态。异步服务端不是这种写法,它使用的是 Greeter::AsyncService,不是重写 SayHello(),而是主动调用 RequestSayHello() 注册一次"我准备好接收 SayHello 请求了"。当请求到达、响应发送完成时,gRPC 会把事件放入 CompletionQueue,服务端线程再通过 cq_->Next(&tag, &ok) 取事件。也就是说,同步代码更像"函数调用",异步代码更像"注册事件 + 等待事件 + 推进状态"
3. helloworld-async 示例要解决什么问题
helloworld-async 的业务本身非常简单:客户端发送一个 name,服务端返回 Hello name。它真正有价值的地方不是业务逻辑,而是展示了 gRPC C++ 异步 API 的基本套路。客户端会用到 CompletionQueue、ClientAsyncResponseReader、PrepareAsyncSayHello()、StartCall()、Finish();服务端会用到 ServerCompletionQueue、Greeter::AsyncService、ServerAsyncResponseWriter、RequestSayHello(),以及一个手写的 CallData 状态机。学这个例子时不要被 Hello world 分散注意力,重点看一次 RPC 是如何被创建、等待、处理、响应和释放的
二、异步 gRPC 的核心模型
1. CompletionQueue:异步事件队列
CompletionQueue 可以理解为 gRPC 的异步事件队列。不管是客户端还是服务端,只要使用异步 API,很多操作都不会直接给出最终结果,而是先把操作提交给 gRPC,等操作完成后,gRPC 再向 CompletionQueue 投递一个事件。程序通过 cq_->Next(&tag, &ok) 等待事件到来,其中 tag 用来标识这个事件属于哪个异步操作,ok 表示这个异步操作是否正常完成
客户端里一般是本地创建一个 CompletionQueue cq,用于等待某次或多次客户端 RPC 的完成事件;服务端里一般通过 builder.AddCompletionQueue() 创建 ServerCompletionQueue,用于接收"请求到达""响应发送完成"等服务端事件。这里要注意,客户端和服务端各有自己的完成队列,它们不是同一个对象,只是模型类似
2. tag:用来找回对应请求
tag 本质上是一个 void*,可以传入任意指针。异步操作完成后,CompletionQueue::Next() 会把当初传进去的 tag 原样返回,所以我们就能知道"这次完成的到底是哪一个操作"。helloworld-async 客户端比较简单,只发起一个 RPC,所以直接把 (void*)1 当 tag;服务端要同时管理很多请求,因此把 this 作为 tag,也就是把当前 CallData 对象地址传给 gRPC,后面事件返回时再通过 static_cast<CallData*>(tag) 找回这个对象
cpp
// 客户端:用一个简单 tag 标识这次 Finish 操作
rpc->Finish(&reply, &status, (void*)1);
// 服务端:用当前 CallData 对象地址作为 tag
service_->RequestSayHello(&ctx_, &request_, &responder_, cq_, cq_, this);
所以可以把 tag 理解成"异步事件携带的用户数据"。没有 tag,cq.Next() 只能告诉你有事件完成了,却不知道这个事件属于哪个 RPC;有了 tag,就可以把事件重新映射回对应的请求上下文
3. 状态机:保存一次 RPC 的生命周期
同步服务端一个函数从头执行到尾就结束了,而异步服务端的一次 RPC 会被拆成多个阶段:刚开始要注册接收请求,请求来了要处理业务,响应准备好后要异步发送,响应发送完成后还要释放对象。这几个阶段不是一次连续函数调用完成的,而是随着 CompletionQueue 中事件的到来一步步推进,所以需要一个状态机记录当前 RPC 走到哪里了
helloworld-async 中的 CallData 定义了三个状态:CREATE 表示对象刚创建,准备调用 RequestSayHello() 注册请求;PROCESS 表示请求已经到达,开始创建下一个接收对象并处理当前请求;FINISH 表示响应已经发送完成,可以 delete this 清理自己
cpp
enum CallStatus { CREATE, PROCESS, FINISH };
CallStatus status_;
4. 和 epoll/Reactor 模型的对比
如果学过 epoll 或 Reactor,会发现 gRPC 异步模型和事件驱动服务器很像。epoll 中,内核把就绪 fd 返回给用户层,用户根据 fd 找到对应连接对象并处理;gRPC 中,CompletionQueue 把完成事件返回给用户层,用户根据 tag 找到对应的 RPC 状态对象并处理
| Reactor 模型 | gRPC 异步模型 |
|---|---|
epoll |
CompletionQueue |
epoll_wait() |
cq_->Next() |
fd 或 event.data.ptr |
tag |
http_conn(一个用户连接) |
CallData |
| 读事件、写事件、连接事件 | 请求到达事件、响应完成事件 |
process() / 回调函数 |
Proceed() 状态机 |
| 一个连接对象维护连接状态 | 一个 CallData 维护一次 RPC 状态 |
当然它们底层实现不是一回事,但编程模型上可以这样类比:CompletionQueue 像 gRPC 层面的事件队列,tag 像事件携带的用户数据,CallData 像一次 RPC 的上下文对象,Proceed() 就是这个上下文对象的状态推进函数
三、helloworld-async 客户端分析
1. 客户端整体结构
客户端主要封装了一个 GreeterClient 类,里面保存 std::unique_ptr<Greeter::Stub> stub_。Channel 和 Stub 的概念上一篇已经讲过,这里只看异步相关部分:客户端通过 Stub 创建异步 RPC 对象,然后用本地的 CompletionQueue 等待结果。虽然这个例子使用了异步 API,但它只发一个请求,并且马上调用 cq.Next() 阻塞等待,所以它更像"异步 API 的最小演示",不是一个真正高并发异步客户端
cpp
class GreeterClient {
public:
explicit GreeterClient(std::shared_ptr<Channel> channel)
: stub_(Greeter::NewStub(channel)) {}
private:
std::unique_ptr<Greeter::Stub> stub_;
};
2. 构造请求、响应和上下文
SayHello() 一开始先创建请求对象 HelloRequest request,通过 request.set_name(user) 设置请求参数;再创建 HelloReply reply 接收服务端返回;ClientContext context 表示这次客户端 RPC 的上下文,以后可以设置超时、metadata、认证信息等;CompletionQueue cq 用来等待异步操作完成;Status status 用来保存 RPC 最终状态
cpp
HelloRequest request;
request.set_name(user);
HelloReply reply;
ClientContext context;
CompletionQueue cq;
Status status;
这里要区分 ok 和 status:ok 是完成队列里某个异步操作是否正常完成,status 才是这次 RPC 的最终状态。比如网络失败、服务端返回错误、超时取消,通常要看 status.ok()
3. PrepareAsyncSayHello 和 StartCall
客户端调用 stub_->PrepareAsyncSayHello(&context, request, &cq) 创建一个异步 RPC 对象,类型是 ClientAsyncResponseReader<HelloReply>。这里的 Prepare 只是准备,并没有真正把请求发出去;真正发起调用的是 rpc->StartCall()
cpp
std::unique_ptr<ClientAsyncResponseReader<HelloReply>> rpc(
stub_->PrepareAsyncSayHello(&context, request, &cq));
rpc->StartCall();
可以这样理解:PrepareAsyncSayHello() 创建了一个 RPC 句柄,并且把这个 RPC 和 CompletionQueue 关联起来;StartCall() 才是真正启动远程调用。对于一元 RPC 来说,请求是一个 HelloRequest,响应是一个 HelloReply,所以客户端使用的是 ClientAsyncResponseReader<HelloReply>
4. Finish 和 cq.Next
调用 rpc->Finish(&reply, &status, (void*)1) 表示:当这个 RPC 完成时,把服务端响应写入 reply,把最终状态写入 status,并向 CompletionQueue 投递一个 tag 为 (void*)1 的完成事件。随后客户端调用 cq.Next(&got_tag, &ok) 等待事件返回
cpp
rpc->Finish(&reply, &status, (void*)1);
void* got_tag;
bool ok = false;
GPR_ASSERT(cq.Next(&got_tag, &ok));
GPR_ASSERT(got_tag == (void*)1);
GPR_ASSERT(ok);
if (status.ok()) {
return reply.message();
} else {
return "RPC failed";
}
这段代码虽然使用异步 API,但由于马上阻塞等待,所以执行效果和同步调用很接近。真正的异步客户端一般会同时创建多个请求对象,给每个请求分配不同 tag,然后统一从一个或多个 CompletionQueue 中取完成事件,谁先完成就先处理谁。官方另一个异步客户端示例就是一个线程批量发送请求,另一个线程不断通过 cq_.Next() 处理返回
四、helloworld-async 服务端分析
1. 服务端整体结构
异步服务端主要由 ServerImpl 和内部类 CallData 组成。ServerImpl 负责创建服务器、注册异步服务、创建完成队列并启动事件循环;CallData 负责保存并推进一次 RPC 的状态。可以简单记成:ServerImpl 是服务器本体,CallData 是每个请求对应的状态对象
cpp
std::unique_ptr<ServerCompletionQueue> cq_;
Greeter::AsyncService service_;
std::unique_ptr<Server> server_;
这里 service_ 是异步服务对象,cq_ 是服务端完成队列,server_ 是真正启动起来的 gRPC Server。同步服务端注册的是 Greeter::Service 的子类,异步服务端注册的是 Greeter::AsyncService
2. Run:启动异步服务端
Run() 的前半部分和同步服务端很像:设置监听地址,创建 ServerBuilder,添加监听端口,注册服务,构建并启动 Server。不同点是异步服务端还要通过 builder.AddCompletionQueue() 创建完成队列,然后进入 HandleRpcs() 事件循环
cpp
void Run() {
std::string server_address("0.0.0.0:50051");
ServerBuilder builder;
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
builder.RegisterService(&service_);
cq_ = builder.AddCompletionQueue();
server_ = builder.BuildAndStart();
std::cout << "Server listening on " << server_address << std::endl;
HandleRpcs();
}
0.0.0.0:50051 表示监听本机所有网卡的 50051 端口;InsecureServerCredentials() 表示明文通信,适合学习和本地测试;HandleRpcs() 则负责不断从 CompletionQueue 中取事件
3. CallData:一次 RPC 的状态对象
CallData 内部保存了处理一次 SayHello 所需的所有东西:service_ 用来注册请求,cq_ 是事件队列,ctx_ 是服务端上下文,request_ 保存客户端请求,reply_ 保存服务端响应,responder_ 负责异步发送响应,status_ 表示当前阶段
cpp
Greeter::AsyncService* service_;
ServerCompletionQueue* cq_;
ServerContext ctx_;
HelloRequest request_;
HelloReply reply_;
ServerAsyncResponseWriter<HelloReply> responder_;
enum CallStatus { CREATE, PROCESS, FINISH };
CallStatus status_;
ServerAsyncResponseWriter<HelloReply> 是异步服务端的一元响应写入器。同步服务端是直接填 HelloReply* reply,然后 return Status::OK;异步服务端则要调用 responder_.Finish(reply_, Status::OK, this),把响应发送这个动作也交给 gRPC 异步完成
4. CREATE:注册接收请求
CallData 构造时把状态设为 CREATE,并立刻调用 Proceed()。第一次进入 Proceed() 时,会把状态改成 PROCESS,然后调用 service_->RequestSayHello(...)。这一步不是处理请求,而是告诉 gRPC:我现在准备好接收一个 SayHello 请求了,如果请求来了,就把请求内容写入 request_,把完成事件投递到 cq_,并使用 this 作为 tag
cpp
CallData(Greeter::AsyncService* service, ServerCompletionQueue* cq)
: service_(service), cq_(cq), responder_(&ctx_), status_(CREATE) {
Proceed();
}
if (status_ == CREATE) {
status_ = PROCESS;
service_->RequestSayHello(&ctx_, &request_, &responder_, cq_, cq_, this);
}
RequestSayHello() 的最后一个参数就是 tag。这里传 this,后面 cq_->Next(&tag, &ok) 返回时,tag 就是当前 CallData 对象地址,这样就能找到对应对象继续执行 Proceed()
5. PROCESS:处理请求并发送响应
当客户端请求到达后,服务端的 cq_->Next() 会返回,事件循环通过 tag 找到对应的 CallData,此时对象状态已经是 PROCESS,于是进入处理阶段。这里第一句 new CallData(service_, cq_) 很关键,它会立刻创建一个新的 CallData 去等待下一个请求。因为当前这个 CallData 已经被当前请求占用了,如果不创建新的接收对象,后续请求就没有对象继续调用 RequestSayHello()
cpp
else if (status_ == PROCESS) {
new CallData(service_, cq_);
std::string prefix("Hello ");
reply_.set_message(prefix + request_.name());
status_ = FINISH;
responder_.Finish(reply_, Status::OK, this);
}
业务逻辑只有一行:把客户端传来的 name 拼成 Hello xxx。随后状态改为 FINISH,调用 responder_.Finish(reply_, Status::OK, this) 异步发送响应。注意 Finish() 也会产生完成事件,响应真正发送完成后,CompletionQueue 中还会再次返回同一个 tag
6. FINISH:释放请求对象
当响应发送完成后,事件循环再次取到当前 CallData 的 tag,再次调用 Proceed()。此时状态已经是 FINISH,于是执行 delete this,表示这次 RPC 生命周期结束
cpp
else {
GPR_ASSERT(status_ == FINISH);
delete this;
}
delete this 看起来有点特殊,但在这个示例中是成立的,因为每个 CallData 都是用 new CallData(...) 创建的,并且只在自己的 FINISH 阶段释放。实际项目中如果状态更多、分支更多,就要更谨慎地管理生命周期,避免重复释放、提前释放或者内存泄漏
7. HandleRpcs:服务端事件循环
HandleRpcs() 是异步服务端的主循环。它先创建第一个 CallData,让服务端至少有一个对象调用 RequestSayHello() 等待请求;然后不断通过 cq_->Next(&tag, &ok) 取事件,取到后把 tag 转成 CallData*,调用对应对象的 Proceed()
cpp
void HandleRpcs() {
new CallData(&service_, cq_.get());
void* tag;
bool ok;
while (true) {
GPR_ASSERT(cq_->Next(&tag, &ok));
GPR_ASSERT(ok);
static_cast<CallData*>(tag)->Proceed();
}
}
这个循环的感觉和 epoll_wait() 很像:阻塞等待事件,事件来了拿到标识,根据标识找到上下文对象,然后调用处理函数
五、一次完整异步调用流程
1. 服务端先挂起一个接收请求的对象
服务端启动后进入 HandleRpcs(),先创建 CallData A。A 构造时进入 CREATE,调用 RequestSayHello() 注册接收请求,并把状态改为 PROCESS。此时 A 没有处理业务,而是在等待客户端的 SayHello 请求
2. 客户端发起 SayHello
客户端创建 HelloRequest,设置 name = "world",再通过 PrepareAsyncSayHello() 创建异步 RPC 对象,通过 StartCall() 发起调用,通过 Finish() 注册完成通知,最后阻塞在 cq.Next() 等待响应。这个客户端写法比较简单,虽然 API 是异步的,但执行上仍然是发完就等
3. 服务端收到请求并处理
请求到达后,服务端 cq_->Next() 返回,tag 是 CallData A 的地址,于是事件循环调用 A->Proceed()。此时 A 进入 PROCESS 分支,先创建 CallData B 继续等待后续请求,然后处理当前请求,把 reply_ 设置为 Hello world,最后调用 responder_.Finish() 发送响应,并把自己的状态改成 FINISH
4. 响应完成并清理对象
响应真正发送完成后,服务端 CompletionQueue 再次返回 CallData A 的 tag,A->Proceed() 进入 FINISH 分支,执行 delete this。客户端的 CompletionQueue 收到完成事件后,cq.Next() 返回,客户端检查 tag 和 ok,再检查 status.ok(),成功后读取 reply.message(),最终打印 Greeter received: Hello world
六、容易混淆的几个点
1. 异步 API 不等于一定并发
helloworld-async 客户端用了异步 API,但只发了一个请求,并且马上等待结果,所以没有真正体现异步并发优势。真正的异步客户端一般会同时发起多个 RPC,每个 RPC 对应一个上下文对象和不同 tag,然后统一消费完成队列
2. CompletionQueue 是通知机制,不是业务队列
CompletionQueue 中放的是异步操作完成事件,不是直接放业务消息。比如服务端 RequestSayHello() 完成,表示有请求到达;responder_.Finish() 完成,表示响应发送完成。业务数据本身在 request_、reply_ 等对象中
3. ok 和 Status 不要混为一谈
ok 表示这次异步操作是否正常完成;Status 表示 RPC 最终状态。客户端里 cq.Next() 返回后,ok == true 只能说明完成事件正常到达,还要继续看 status.ok() 才能判断 RPC 是否成功
4. 示例代码的错误处理比较简单
官方示例大量使用 GPR_ASSERT(ok),适合学习流程,但不适合直接放进生产环境。真实项目中,客户端取消、连接断开、超时、服务端关闭、CompletionQueue shutdown 都可能导致 ok == false 或 Next() 返回 false,需要根据当前状态清理资源,而不是直接断言退出
5. 服务端示例默认是单线程事件循环
这个服务端虽然用了异步模型,但当前示例只有一个线程在 cq_->Next() 上等待事件,所以它是"单线程事件循环 + 异步 API"。如果要提升并发能力,可以让多个线程共同消费同一个 CompletionQueue,或者使用多个完成队列配合多个线程,不过状态对象的线程安全和生命周期管理就要更加小心。