C++ grpc 异步回调API教程学习

0 阅读9分钟

前言

本文根据grpc.io/docs/langua… grpc教程学习运行,主要是使用 gRPC 的异步回调 API 在 C++ 中编写一个简单的服务器和客户端,与 mp.weixin.qq.com/s/jPT8GBiNB… 中一样, 基于 RouteGuide 示例。更多的是学习记录,水平不高,能力有限,错漏之处,还请见谅。欢迎友好讨论。

环境信息

  • 操作系统版本: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 编译文件"获取我编译内容的压缩包 。

代码运行流程

编译

参考 mp.weixin.qq.com/s/50Tep3mq7… 克隆仓库 github.com/EarthlyImmo… 并配置grpc依赖。

配置好后,可以先修改一下 start_build.sh 中的gcc和g++的路径。

在blog_code/route_guide_async_callback_api目录下执行:

./start_build.sh

完成编译。

运行

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

./server --db_path=../../route_guide_db.json

另起一个终端,在blog_code/route_guide_async_callback_api/build/client文件夹下运行客户端:

./client --db_path=../../route_guide_db.json

这部分代码大部分是copy的grpc官方的示例,这里对cmake文件和目录结构做了调整,对部分注释或者日志做了调整。

代码简单分析

route_guide的相关逻辑在 mp.weixin.qq.com/s/jPT8GBiNB… 中已经分析过。这里主要给出异步回调接口的相关实现分析。

服务器代码分析

与同步版本不同,异步回调API版本的服务器实现类继承自RouteGuide::CallbackService而不是RouteGuide::Service

首先看 一元RPC(Unary RPC)

  • reactor类型:grpc::ServerUnaryReactor

  • 生命周期相关回调:

    • OnDone(): RPC 正常完成时调用。这里会打日志,并且释放reactor对象
    • OnCancel(): RPC 被客户端取消时调用,这里实现只打了日志。在示例中本身没有用到这个功能。
  • 主要逻辑:比较简单,在构造函数中

整个处理流程如下。

然后看 服务器端流式 RPC(Server-side streaming RPC)

  • reactor类型:grpc::ServerWriteReactor

  • 生命周期相关回调:与 一元RPC(Unary RPC) 基本一致

  • 发送相关回调:

    • OnWriteDone:每个消息发送完成之后调用。用于调用下次发送。
  • 主要逻辑:

    • 发送逻辑在NextWrite函数中,做了如下事情:按照顺序遍历总的点位列表,如果找到在指定矩形中的点,则调用StartWrite写入并返回;如果点位列表遍历完成,则发送finish信号
    • NextWrite函数在构造函数和OnWriteDone函数中调用。OnWriteDone函数在每个写入完成之后调用。最终实现对所有点位的遍历以及对符合要求的点位的发送。

整个处理流程如下:

上述处理流程中没有提到代码中的一个异常处理,即在OnWriteDone中,如果ok为false,会提前finish,但是这里finish之后,并没有返回。 可以简单测试一下,如果触发了,会怎么样,修改服务器代码: 修改客户端代码: 编译后运行。客户端输出: 服务器输出: 服务器core了。位置在 callback_common.h:177,对应检查没有通过,函数在 CallbackWithSuccessTag::Set。毕竟这样模拟不能真的模拟出出错的情况,所以看类名最后还是走到了正确的路径core的。这可能不足以说明在真的出错的时候,这里的处理也会core。

如果要深究,可能要阅读源码甚至编译源码调试,不过当前我没有这个时间和精力。我也询问了AI,AI说这里确实应该返回,理由如下:

  1. 逻辑错误:当 okfalse 时,表示写入失败,此时应该结束整个流,不应该再尝试写入更多数据
  2. 潜在的双重结束问题:如果调用 Finish 后再调用 NextWrite(),而 NextWrite() 中可能也会调用 Finish,这会导致重复调用 Finish

因此还是在这里加入一个return,不再继续写入。也可以测试一下加入return的效果,服务器代码测试修改如下: 客户端输出: 服务器输出: 服务器没有崩溃,且RPC正常结束了。

然后看 客户端流式传输RPC(Client-side streaming RPC)

  • reactor类型:grpc::ServerReadReactor

  • 生命周期相关回调:与 一元RPC(Unary RPC) 基本一致

  • 读取相关回调:

    • OnReadDone:每个消息读取完成之后调用。如果读取成功,则进行信息统计,并且调用StartRead继续读取下一个点;如果读取结束,则设置返回信息并结束RPC
  • 主要逻辑:

    • 在构造函数中调用StartRead开始读取客户端发送的点
    • OnReadDone中驱动继续读取或者读取结束进行回包

整个处理流程如下:

最后看 双向流式 RPC(Bidirectional streaming RPC)

  • reactor类型:grpc::ServerBidiReactor

  • 生命周期相关回调:与 一元RPC(Unary RPC) 基本一致

  • 读取相关回调:

    • OnReadDone:每个消息读取完成之后调用。如果读取成功,则收集指定点位上的信息,并且调用NextWrite开启发送流程;如果读取结束,则结束RPC。
  • 发送相关回调

    • OnWriteDone:每次发送完成之后调用。调用NextWrite驱动发送流程
  • 主要逻辑:

    • 在构造函数中调用StartRead开始读取客户端发送的点
    • OnReadDone中在每个消息读取完成之后启动写入或者结束RPC
    • OnWriteDone中驱动写入
    • NextWrite中处理:如果当前写入注释工作还没有完成,则写入注释;如果完成了,则将当前注释加入到received_notes_中,并且开启下一次读取。

整个处理流程如下:

双向流式RPC有一个所有请求共用的数据std::vector<RouteNote> received_notes_,gRPC框架为了高效处理并发请求,通常会使用线程池,在并发请求时,有可能会有多个线程需要访问received_notes_,因此在每次访问received_notes_时,都会进行加锁。

对比 服务器端流式 RPC(Server-side streaming RPC)OnWriteDone,可以发现,双向流式 RPC(Bidirectional streaming RPC)OnWriteDone没有处理错误的情况,因此这里进行修改,如果出错,则直接结束RPC。

客户端代码分析

异步回调API与同步API的客户端初始化基本一致,差别主要在具体的协议处理上。

首先看 一元RPC(Unary RPC) 。主要逻辑在GetOneFeature中。

  • 使用各个局部变量解析:

    • ClientContext context:保存此次RPC调用的上下文信息。示例中并没有使用到
    • std::mutex mu& std::condition_variable cv:用于线程同步。互斥锁(mu)保护共享状态,条件变量(cv)用于线程间通知。通过这两个变量来实现同步等待结果。
    • bool done:共享状态标志,它指示异步回调是否已完成处理。所有对其的读写都必须在互斥锁保护下进行。
  • 主要流程:

    • stub_->async()->GetFeature:发送信息并且注册消息处理回调,消息返回后,会在一个独立的内部线程中调用注册的回调函数进行处理。回调处理完成之后,通过cv.notify_one()唤醒正在条件变量上等待的主线程。
    • cv.wait(lock, [&done] { return done; }):主线程在此处阻塞等待。它会在等待期间释放互斥锁,允许回调线程获取锁并修改状态。当回调函数调用notify_onedone变为true时,主线程被唤醒,并重新获取锁,然后继续执行。

整个流程处理如下:

然后看 服务器端流式 RPC(Server-side streaming RPC)

  • reactor类型:grpc::ClientReadReactor

  • ClientContext context_std::mutex mu_个和std::condition_variable cv_:与一元RPC中的局部变量对应信息基本一致。

  • 生命周期相关回调:

    • OnDone:服务器响应读取完成后回调。这里主要是设置RPC返回状态、请求完成状态并且通过cv.notify_one()唤醒正在条件变量上等待的主线程。
  • 读取回调:

    • OnReadDone:读取完一个响应后调用。主要是打印出读取到的点位信息,并且开启下一次读取。这里如果读取失败,没有进行相应的处理。
  • 主要逻辑:

    • 在构造函数中注册reactor、开始读取、发送请求。注意,请求是在StartCall调用后才真正发出的。
    • OnReadDone中驱动读取下一个
    • 全部返回消息读取完毕之后,调用OnDone通知读取完成,Await返回。

整个流程处理如下:

然后看 客户端流式传输RPC(Client-side streaming RPC)

  • reactor类型:grpc::ClientWriteReactor

  • ClientContext context_std::mutex mu_个和std::condition_variable cv_:与一元RPC中的局部变量对应信息基本一致。

  • 生命周期相关回调:

    • OnDone:与 服务器端流式 RPC(Server-side streaming RPC) 基本一致
  • 发送完成回调:

    • OnWriteDone:延迟一段时间之后,发送下一个请求。这里如果写入失败,也没有进行处理。
  • 主要逻辑:

    • 在构造函数中注册reactor、增加反应器内部的“持有计数”(AddHold,防止反应器在预期的异步操作完成之前被销毁)、开始写入、发送请求。注意,请求是在StartCall调用后才真正发出的。
    • OnWriteDone中驱动写入下一个
    • 全部消息发送完毕之后,调用OnDone通知发送完成,Await返回

整个流程处理如下:

最后看 双向流式 RPC(Bidirectional streaming RPC)

  • reactor类型:grpc::ClientBidiReactor

  • ClientContext context_std::mutex mu_个和std::condition_variable cv_:与一元RPC中的局部变量对应信息基本一致。

  • 生命周期相关回调:

    • OnDone:与 服务器端流式 RPC(Server-side streaming RPC) 基本一致
  • 发送完成回调:

    • OnWriteDone:发送下一个请求。这里如果写入失败,也没有进行处理。
  • 读取完成回调:

    • OnReadDone:读取下一个响应。这里如果读取失败,也没有进行处理。
  • 主要逻辑:

    • 在构造函数中注册reactor、开始写入、开始读取。注意,请求是在StartCall调用之后才真正发出的
    • OnWriteDone中驱动写入下一个;在OnReadDone中驱动读取下一个
    • 全部消息发送完毕且读取完毕之后,调用OnDone通知完成,Await返回

整个流程处理如下:

其他说明

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

参考资料

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