前言
本文根据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的区别主要在于:
- 异步版本注册的服务类型为
Greeter::AsyncService,而同步版本注册的服务继承自Greeter::Service - 异步版本在注册完服务后, 又执行了
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):调用
-
...
// CREATE状态下注册SayHello请求处理
service_->RequestSayHello(
&ctx_, // ServerContext
&request_, // 请求数据缓冲区
&responder_, // 响应写入器
cq_, // 请求到达通知队列
cq_, // 操作完成通知队列
this // 标签:此实例地址
);
...
在了解了CallData的结构和功能之后,可以来看一下HandleRpcs的主流程: 可以发现,主流程比较简单。最开始创建第一个CallData实例,用于等待新请求,然后进入主循环。在主循环中不断的从事件队列中获取事件,获取后就调用
CallData::Process进行处理。这里从事件队列中可能得到的是 客户端请求 和 响应消息发送完成 两种事件。 下面来具体的描述一下一个消息的处理过程:
- 服务器在CallData对象初始化时调用
CallData::Process,注册SayHello请求处理,并且CallData对象从CREATE状态进入PROCESS状态。服务器开始等待请求。 - 客户端请求消息到达,grpc运行时将 请求事件 添加到 完成队列 中
- 服务器在主循环中去访问完成队列,发现有事件,取出,并调用
CallData::Process,CallData::Process做如下操作:1) 创建新的CallData实例, 用于处理后续的请求;2) 将状态设置为处理完成(FINISH) ;3) 设置并发送回包 - grpc运行时将 发送完成事件 添加到 完成队列 中
- 服务器在主循环中去访问完成队列,发现有事件,取出,再次调用
CallData::Process,检查CallData对象状态是否正确,然后释放当前CallData内存 - 新的请求到来之后,由第3步新创建的CallData对象进行处理
上述是按照串行的方式来展示的,但是实际上在新的CallData创建之后,就可以处理新的请求了,不必等到上一个请求回包之后再处理。这也是与同步方式不同的地方。
服务关闭。在ServerImpl的析构函数中处理,注意总是先将异步服务关闭,然后再将完成队列关闭。
客户端代码分析
客户端初始化。初始化流程和同步示例基本一致。重点都是创建gRPC通道连接到服务器。
消息处理。主要逻辑在 GreeterClient::SayHello中,流程如下:
- 设置准备相关数据。包括发送信息
HelloRequest request;回包信息HelloReply reply;上下文ClientContext context;完成队列CompletionQueue cq;状态存储Status status。其中与同步模式主要差别是完成队列CompletionQueue cq,这个与服务器中的ServerCompletionQueue* cq_类似,都是用于异步操作的事件通知。这里用到的事件只有RPC完成事件。注意客户端的CompletionQueue是在发送rpc请求之前自己声明局部变量,与RPC请求生命周期一致;而服务器的ServerCompletionQueue是通过ServerBuilder::AddCompletionQueue添加的,与服务实例本身的生命周期一致。但是从具体的实现细节上有什么区别呢?这里查看代码可以看到ServerCompletionQueue是CompletionQueue的子类。但是更具体的细节暂时不做过多研究。 - 获取异步RPC调用请求对象。调用
stub_->AsyncSayHello(&context, request, &cq),获得std::unique_ptr<ClientAsyncResponseReader<HelloReply> > rpc。这里应该并没有启动发送,只是创建了相关对象。注意这里将完成队列也作为参数传入了。 - 注册完成回调。调用
rpc->Finish(&reply, &status, (void*)1),其中第3个参数是属于这个RPC请求的标识,用于从完成队列获取信息后对不同的rpc请求进行区分。与服务器代码中的CallData指针作用相同。这里应该才会启动发送流程。在消息返回,RPC完成后,会将回包填充到reply中,RPC完成状态填充到status中,并将 RPC完成事件 添加到 完成队列 中。 - 阻塞等待完成队列,直到有事件到达。有事件到达后,对成功状态进行判断(包括标签匹配判断、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个请求, 足以验证并发处理
关注服务器输出: 可以分析流程如下:
- 初始化时,
CallData 0x5a3f58c41980,第一个被创建,并且注册请求,进入PROCESS状态。 - 第一个请求过来,
CallData = 0x5a3f58c42440被创建,用于处理新的请求,CallData 0x5a3f58c41980处理并且发送回包,进入FINISH状态。 - 第二个请求过来,
CallData = 0x5a3f58c43070被创建,用于处理新的请求,CallData 0x5a3f58c42440处理并且发送回包,进入FINISH状态。注意此时CallData 0x5a3f58c41980还没有处理 发送回包完成事件,请求还没有处理结束。 - 第三个请求过来,
CallData = 0x5a3f58c43f10被创建,用于处理新的请求,CallData 0x5a3f58c43070处理并且发送回包,进入FINISH状态。注意此时CallData 0x5a3f58c41980和CallData 0x5a3f58c42440都还没有处理 发送回包完成事件,请求还没有处理结束。 CallData 0x5a3f58c41980、CallData 0x5a3f58c42440和CallData 0x5a3f58c43070依次被销毁,处理结束。 可见,上一个请求还没有处理完之前,下一个请求已经可以进行处理了,不会有同步的阻塞流程,因此为并发处理。
其他说明
由于没有深入了解过grpc的源码和机制,所以文中很多内容是参考Deepseek以及程序运行现象的个人理解猜测,难免有不正确的地方,需要注意甄别。后续随着学习的深入,准确度也会变高。
参考资料
- 腾讯元宝-deepseek(yuanbao.tencent.com/)和deepseek官…
- grpc.io/docs/langua…
- mp.weixin.qq.com/s/50Tep3mq7…
- 文中所有mermaid流程图仓库源码路径: blog_code/hello_world_async_api/doc/mermaid.md