RPC 异步调用基本使用方法:基于官方helloworld-async 示例

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 的核心可以先记成三个词:CompletionQueuetag状态机

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 的基本套路。客户端会用到 CompletionQueueClientAsyncResponseReaderPrepareAsyncSayHello()StartCall()Finish();服务端会用到 ServerCompletionQueueGreeter::AsyncServiceServerAsyncResponseWriterRequestSayHello(),以及一个手写的 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()
fdevent.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_ChannelStub 的概念上一篇已经讲过,这里只看异步相关部分:客户端通过 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;

这里要区分 okstatusok 是完成队列里某个异步操作是否正常完成,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 AA 构造时进入 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 == falseNext() 返回 false,需要根据当前状态清理资源,而不是直接断言退出

5. 服务端示例默认是单线程事件循环

这个服务端虽然用了异步模型,但当前示例只有一个线程在 cq_->Next() 上等待事件,所以它是"单线程事件循环 + 异步 API"。如果要提升并发能力,可以让多个线程共同消费同一个 CompletionQueue,或者使用多个完成队列配合多个线程,不过状态对象的线程安全和生命周期管理就要更加小心。

相关推荐
xiao阿娜的妙妙屋3 小时前
还在用轮播图当主图视频?2026年商家把视频做得更高级的AI工具推荐
经验分享
sparEE3 小时前
c++面向对象:对象的赋值
开发语言·c++
此生决int3 小时前
快速复习之数据结构篇——栈和队列
数据结构·c++
H_BB3 小时前
第17届蓝桥杯备战历程
c++·算法·职场和发展·蓝桥杯
daad7773 小时前
记录一次上下文切换次数的统计
服务器·c++·算法
tankeven4 小时前
C++ Lambda 表达式
c++
sheeta19984 小时前
苍穹外卖Day12笔记
笔记
fangzt20104 小时前
插件系统:让其他人也能给编辑器写节点
c++
诙_4 小时前
深入理解C++文件操作
开发语言·c++