C++ grpc 异步API教程学习

26 阅读11分钟

前言

本文根据grpc.io/docs/langua… C++ grpc 示例学习运行,主要是如何使用 gRPC 的异步/非阻塞 API 编写一个简单的 C++ 服务器和客户端。更多的是学习记录,水平不高,能力有限,错漏之处,还请见谅。欢迎友好讨论。

环境信息

  • 操作系统版本:ubuntu24.04
  • CMake版本:4.2.0
  • Git版本:2.43.0
  • GCC版本:gcc 13.3.0

在之前的教程mp.weixin.qq.com/s/50Tep3mq7… 中给出的是在Centos 7.6下的部署流程,现在我重装了操作系统为ubuntu 24.04,并且重新编译了grpc文件。相关二进制文件可以关注公众号 只做人间不老仙,后台发送 "grpc ubuntu 编译文件"获取我编译内容的压缩包 。

grpc官方仓库运行流程

编译

mp.weixin.qq.com/s/50Tep3mq7… C++ grpc helloworld示例程序编译运行 一节基本一致。这里再次给出。 在grpc源码文件夹下执行

cd examples/cpp/helloworld

然后运行Cmake指令

export MY_INSTALL_DIR=xxxxxxxx # grpc本地安装路径
mkdir -p cmake/build
cd cmake/build
cmake -DCMAKE_PREFIX_PATH=$MY_INSTALL_DIR ../..
# 执行编译
make

运行

在examples/cpp/helloworld/cmake/build文件下运行服务器

./greeter_async_server

重新开一个终端,在examples/cpp/helloworld/cmake/build文件夹下运行客户端

./greeter_async_client

个人仓库代码运行流程

编译

参考 mp.weixin.qq.com/s/50Tep3mq7… 克隆仓库 github.com/EarthlyImmo… 并配置grpc依赖。 配置好后,可以先修改一下 start_build.sh 中的gcc和g++的路径。 在blog_code/hello_world_async_api目录下执行:

./start_build.sh

完成编译。

运行

在blog_code/hello_world_async_api/build/server目录下运行服务器:

./server

另起一个终端,在blog_code/hello_world_async_api/build/client 目录下运行客户端:

./client

下面我将对示例代码进行简单分析。以下分析均使用个人仓库代码测试

代码简单分析

从表现上来说,这段代码很简单,与mp.weixin.qq.com/s/50Tep3mq7… "world", 服务器返回给客户端 "hello, world",客户端会把收到的这个字符串打印出来。不同的是,这个示例使用的是异步API。

服务器代码分析

服务器初始化。 服务器初始化部分与同步版本的helloworld的区别主要在于:

  1. 异步版本注册的服务类型为Greeter::AsyncService,而同步版本注册的服务继承自Greeter::Service
  2. 异步版本在注册完服务后, 又执行了AddCompletionQueue增加了完成队列,同步版本没有。

那么完成队列 std::unique_ptr<ServerCompletionQueue> cq_的作用是什么?在这个例子中,它用于异步操作的事件通知,异步事件触发后, gRPC 运行时将事件入队,然后主程序去队列中读取事件,进行处理,是生产者-消费者模式。在本示例中,服务器用到了两个事件:客户端请求到达;响应发送完成。后续会提到。

消息处理。 可以明显看到,主要的消息处理流程在HandleRpcs中,而其中CallData是重要的结构。 CallData可以算是每个RPC请求的生命周期管理器,每个RPC请求对应一个CallData结构。CallData结构的说明如下:

  • 成员变量:

    • Greeter::AsyncService* service_:异步服务实例。指针,并不由CallData管理。
    • ServerCompletionQueue* cq_:完成队列,用于接收请求到达和响应发送完成的事件通知。指针,并不由CallData管理。
    • ServerContext ctx_:RPC的上下文,可用于控制RPC的行为,如压缩,认证等,本例中没有用到。
    • HelloRequest request_:从客户端接收的请求信息。
    • HelloReply reply_:要发送给客户端的回包信息。
    • ServerAsyncResponseWriter<HelloReply> responder_:异步响应写入器,封装了响应发送的底层细节,用于实现异步发送响应给客户端。
    • CallStatus status_:请求处理状态,分为 请求创建(CREATE)、请求处理(PROCESS)、处理完成(FINISH) 三种状态。
  • 成员函数

    • CallData(Greeter::AsyncService* service, ServerCompletionQueue* cq):构造函数,功能如下:

      • 初始化成员变量。值得注意的是,异步响应写入器 responder_ 在初始化时要绑定上下文 ctx_;初始化时 请求处理状态status_ 为请求创建状态(CREATE)
      • 立即首次执行Proceed
    • void Proceed():RPC请求状态机转换处理函数。针对三种不同的状态,进行不同的处理

      • 请求创建(CREATE):调用 service_->RequestSayHello 注册SayHello请求处理,并将状态设置为请求处理(PROCESS)。注意service_->RequestSayHello 的调用参数中的第4个和第5个参数,目前传入的都是 ServerCompletionQueue* cq_,但是实际上是可以不一样的,一个是 请求到达时发送通知的队列 另外一个是 操作完成时发送通知的队列,在本例中使用的是同一个队列;而第6个参数是标签,用于标识这个特定请求,这里传入的是当前CallData对象的this指针,因此一个CallData对象,标识一个唯一的RPC请求。这个标识本身也可以用于上下文信息的传递。
      • 请求处理(PROCESS):做如下几件事情 1) 创建新的CallData实例, 确保始终有实例在等待新请求;2) 将状态设置为处理完成(FINISH) ;3) 设置并发送回包,调用responder_.Finish,注意这里的调用responder_.Finish的第三个参数也是标签,同样的使用的是当前CallData对象的this指针。
      • 处理完成(FINISH):相对简单,只是检查一下当前状态是否正确,然后释放当前CallData内存。
...
// CREATE状态下注册SayHello请求处理
service_->RequestSayHello(
    &ctx_,           // ServerContext
    &request_,       // 请求数据缓冲区
    &responder_,     // 响应写入器
    cq_,             // 请求到达通知队列
    cq_,             // 操作完成通知队列  
    this             // 标签:此实例地址
);
...

在了解了CallData的结构和功能之后,可以来看一下HandleRpcs的主流程: 可以发现,主流程比较简单。最开始创建第一个CallData实例,用于等待新请求,然后进入主循环。在主循环中不断的从事件队列中获取事件,获取后就调用CallData::Process进行处理。这里从事件队列中可能得到的是 客户端请求 和 响应消息发送完成 两种事件。 下面来具体的描述一下一个消息的处理过程:

  1. 服务器在CallData对象初始化时调用CallData::Process,注册SayHello请求处理,并且CallData对象从CREATE状态进入PROCESS状态。服务器开始等待请求。
  2. 客户端请求消息到达,grpc运行时将 请求事件 添加到 完成队列 中
  3. 服务器在主循环中去访问完成队列,发现有事件,取出,并调用CallData::ProcessCallData::Process做如下操作:1) 创建新的CallData实例, 用于处理后续的请求;2) 将状态设置为处理完成(FINISH) ;3) 设置并发送回包
  4. grpc运行时将 发送完成事件 添加到 完成队列 中
  5. 服务器在主循环中去访问完成队列,发现有事件,取出,再次调用CallData::Process,检查CallData对象状态是否正确,然后释放当前CallData内存
  6. 新的请求到来之后,由第3步新创建的CallData对象进行处理

上述是按照串行的方式来展示的,但是实际上在新的CallData创建之后,就可以处理新的请求了,不必等到上一个请求回包之后再处理。这也是与同步方式不同的地方。

服务关闭。在ServerImpl的析构函数中处理,注意总是先将异步服务关闭,然后再将完成队列关闭。

客户端代码分析

客户端初始化。初始化流程和同步示例基本一致。重点都是创建gRPC通道连接到服务器。

消息处理。主要逻辑在 GreeterClient::SayHello中,流程如下:

  1. 设置准备相关数据。包括发送信息 HelloRequest request;回包信息HelloReply reply;上下文ClientContext context;完成队列CompletionQueue cq;状态存储Status status。其中与同步模式主要差别是完成队列CompletionQueue cq,这个与服务器中的ServerCompletionQueue* cq_类似,都是用于异步操作的事件通知。这里用到的事件只有RPC完成事件。注意客户端的CompletionQueue是在发送rpc请求之前自己声明局部变量,与RPC请求生命周期一致;而服务器的ServerCompletionQueue是通过ServerBuilder::AddCompletionQueue添加的,与服务实例本身的生命周期一致。但是从具体的实现细节上有什么区别呢?这里查看代码可以看到ServerCompletionQueue是CompletionQueue的子类。但是更具体的细节暂时不做过多研究。
  2. 获取异步RPC调用请求对象。调用stub_->AsyncSayHello(&context, request, &cq),获得std::unique_ptr<ClientAsyncResponseReader<HelloReply> > rpc。这里应该并没有启动发送,只是创建了相关对象。注意这里将完成队列也作为参数传入了。
  3. 注册完成回调。调用rpc->Finish(&reply, &status, (void*)1),其中第3个参数是属于这个RPC请求的标识,用于从完成队列获取信息后对不同的rpc请求进行区分。与服务器代码中的CallData指针作用相同。这里应该才会启动发送流程。在消息返回,RPC完成后,会将回包填充到reply中,RPC完成状态填充到status中,并将 RPC完成事件 添加到 完成队列 中。
  4. 阻塞等待完成队列,直到有事件到达。有事件到达后,对成功状态进行判断(包括标签匹配判断、cq操作成功判断、RPC状态判断),并且返回具体消息信息。

结果输出。将从服务器收到的回包信息打印出来。

测试并发消息处理

测试并发消息处理的客户端程序在hello_world_async_api/concurrent_test_client目录下。使用异步模式发送消息,因此可以模拟服务器并发的情况。 测试前首先进行编译,

# 在blog_code/hello_world_async_api目录下执行:
./start_build.sh

编译成功之后,首先运行服务器:

# 在blog_code/hello_world_async_api/build/server目录下执行
./server

然后运行并发测试客户端:

# 在blog_code/hello_world_async_api/build/concurrent_test_client目录下执行
./concurrent_test_client --num_requests=3   # 3个请求, 足以验证并发处理

关注服务器输出: 可以分析流程如下:

  1. 初始化时, CallData 0x5a3f58c41980,第一个被创建,并且注册请求,进入PROCESS状态。
  2. 第一个请求过来,CallData = 0x5a3f58c42440 被创建,用于处理新的请求, CallData 0x5a3f58c41980 处理并且发送回包,进入FINISH状态。
  3. 第二个请求过来,CallData = 0x5a3f58c43070被创建,用于处理新的请求, CallData 0x5a3f58c42440 处理并且发送回包,进入FINISH状态。注意此时CallData 0x5a3f58c41980 还没有处理 发送回包完成事件,请求还没有处理结束。
  4. 第三个请求过来,CallData = 0x5a3f58c43f10被创建,用于处理新的请求, CallData 0x5a3f58c43070 处理并且发送回包,进入FINISH状态。注意此时CallData 0x5a3f58c41980CallData 0x5a3f58c42440 都还没有处理 发送回包完成事件,请求还没有处理结束。
  5. CallData 0x5a3f58c41980CallData 0x5a3f58c42440CallData 0x5a3f58c43070 依次被销毁,处理结束。 可见,上一个请求还没有处理完之前,下一个请求已经可以进行处理了,不会有同步的阻塞流程,因此为并发处理。

其他说明

由于没有深入了解过grpc的源码和机制,所以文中很多内容是参考Deepseek以及程序运行现象的个人理解猜测,难免有不正确的地方,需要注意甄别。后续随着学习的深入,准确度也会变高。

参考资料

欢迎关注公众号:只做人间不老仙