Qt与gRPC集成实战:用信号槽打通CompletionQueue与事件循环

发布时间:2026/10/6 3:53:38
Qt与gRPC集成实战:用信号槽打通CompletionQueue与事件循环 我们接着上一篇往下聊。上一篇把grpc和Qt的基础关系梳理了一遍这次不再空谈理论直接拿一个真正能跑的代码来说事。我改的例子是grpc官方examples里的helloworld原版是纯C的main函数写法跟Qt的事件循环完全不搭边。我把它改造成一个基于QCoreApplication的Qt工程客户端发一个名字过去服务端把问候语返回来整个过程走完你对grpc在Qt里的工作方式就有体感了。很多人在这一步卡住不是grpc本身多难而是官方例子和Qt的“世界观”格格不入。grpc的异步调用有自己的完成队列机制Qt的逻辑是信号槽和事件循环两套东西碰在一起得有个中间人来做翻译。这次就围绕这个中间人怎么设计、怎么写、怎么避坑展开。先说清楚这篇的代码基于Qt 5.15.2 LTS、CMake 3.16以上、gRPC 1.40左右Windows下用MSVC 2019的64位工具链。这套组合是我自己实测过的稳定坑少。如果你用的是MinGW建议换掉后面会讲为什么。1. 这个练手例子从哪来官方helloworld与Qt的初次碰撞1.1 官方示例的本来面目grpc官方仓库的examples/cpp/helloworld目录下有一个最基础的客户端和服务端。它的结构是greeter_server.cc启动一个Greeter服务监听50051端口收到HelloRequest后把消息拼成HelloReply返回。greeter_client.cc同步调用SayHello把服务端返回的结果打印到控制台。helloworld.proto定义Greeter服务和两个消息类型。整个例子的控制流是顺序的main函数里new一个GreeterClient调用SayHello阻塞等结果然后打印。这套逻辑在命令行工具里没有任何问题但放进Qt工程里就难受了Qt的主线程要跑事件循环app.exec()同步阻塞的grpc调用会把UI线程彻底堵死。官方示例不关心线程安全但Qt的UI操作必须在主线程这两者之间天然有矛盾。官方示例的生命周期管理是裸指针和手动deleteQt里有父子对象机制放一起很别扭。所以我当时的第一反应是不能直接拿官方示例塞进Qt必须把gRPC的调用从“阻塞函数”改造成“异步事件源”。1.2 为什么选择异步调用而不是同步调用把同步调用放进一个std::thread里跑结果通过信号槽抛回主线程这是很多人第一时间的想法。这样做对单个请求来说确实能跑通。但一旦请求频繁或者并发量上来你就得自己管理线程池、超时、取消、重连所有grpc本来已经帮你做好的事情全都要重造一遍轮子。grpc异步接口解决的就是这个问题。它把请求的发起和完成拆开你发一个请求然后转身去干别的等grpc内部把网络收发和序列化都做完它会在CompletionQueue完成队列里给你一个通知。你要做的只是在一个事件循环里等这个通知。这和Qt的事件循环在抽象层面上其实是一回事一个是等grpc的完成通知一个是等界面事件。我要做的就是让这两套事件循环互相通信。这是整个练手例子的核心骨架。2. 动手改之前的三个关键认知同步、异步、CompletionQueue2.1 三种调用方式一个比一个接近生产环境grpc的C接口提供三种调用方式同步Sync、异步Async、回调Callback。同步接口最简单但它是阻塞的异步接口基于CompletionQueue需要自己写事件循环回调接口是grpc 1.30之后加的新模型用起来比异步接口直观一些但它在Qt里的集成思路和异步接口是相通的。我这次用的是异步接口原因是它最底层、最能说明问题。把异步接口理解透了回调接口你一看就懂。反过来如果一上来就用回调接口遇到现场状态混乱的时候你会抓瞎因为你根本不知道底层发生了什么。异步接口的三个核心概念我用大白话解释一遍Stub客户端这边的服务代理。你调stub-SayHello等于把请求包好交给网络层。ClientContext一次RPC调用的上下文负责超时、取消、携带元数据。它和这次调用同生命周期。CompletionQueue完成队列。grpc内部处理完一个异步操作后把一个tag塞进这个队列你用cq.Next()就能取出那些完成的tag。2.2 Next()到底是什么从点菜到上菜的完整链路理解CompletionQueue最好的方式是把它想象成一家餐厅的传菜口。你向服务台下单发起RPC调用然后离开窗口想干嘛干嘛。厨师做好菜以后服务员把菜放在传菜口按一下铃。你听到铃声过来把菜端走。在grpc的世界里下单行为是PrepareAsyncSayHello StartCall Finish。传菜口的铃是CompletionQueue。你听到铃声去端菜对应的是cq.Next(tag, ok)这个函数默认阻塞直到有菜可以端。tag就是你之前下单时带的一个标记可以通过它判断是哪个订单哪次调用完成了。这个机制和Qt的“事件循环”就非常像了。QCoreApplication的exec()也是在不断等系统投递窗口消息。想通了这一点把grpc的CompletionQueue和Qt的事件循环结合起来就可以动手写代码了。2.3 为什么不能在grpc回调里直接搞UI这是整个练手例子最容易踩的坑也是最多人犯的错误。grpc的CompletionQueue在哪个线程里被Next()循环完成通知就在哪个线程触发。如果你在UI线程里跑cq.Next()UI会卡死如果你在worker线程里跑cq.Next()那菜端回来这个动作发生在worker线程你在这个线程里直接调用setText之类的UI操作轻则崩溃重则行为诡异。Qt的信号槽机制允许你跨线程发射信号槽函数在接收者所在线程执行。所以思路很清晰worker线程处理grpc的完成队列拿到结果之后emit一个信号通过Qt的队列连接QueuedConnection把结果抛回主线程在主线程里安全地更新UI或者业务数据。这是整个练手例子里面最核心的架构决策没有之一。3. 改完之后的工程长什么样proto、CMake与客户端封装3.1 proto文件几乎不用动但要清楚它生成了什么helloworld.proto基本不需要改动因为它就是个最简单的gRPC示例。我原样保留了syntax proto3; package helloworld; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} } message HelloRequest { string name 1; } message HelloReply { string message 1; }这里只需要注意一件事生成后的C代码会被放在哪个命名空间。protobuf的规则是proto文件里的package helloworld会映射成C的命名空间helloworld。所以生成的头文件里你看到的是helloworld::Greeter、helloworld::HelloRequest、helloworld::HelloReply这些类型。记住这个对应关系后边写代码时才能找对门牌号。我在工程里用了一个专门的目录放proto文件和生成文件proto和生成的代码分开方便区分版本。3.2 CMakeLists.txt四件必须做对的事这个工程的CMakeLists.txt看起来不长但至少有四个点缺一个都会让你体验什么叫“编译十分钟排错两小时”。cmake_minimum_required(VERSION 3.16) project(QtGrpcDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 1. 找包 find_package(Qt5 COMPONENTS Core REQUIRED) find_package(Protobuf REQUIRED) find_package(gRPC CONFIG REQUIRED) # 2. 用 protoc 和 grpc_cpp_plugin 从 proto 生成 C 代码 set(PROTO_FILE ${CMAKE_CURRENT_SOURCE_DIR}/helloworld.proto) set(GENERATED_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated) file(MAKE_DIRECTORY ${GENERATED_DIR}) add_custom_command( OUTPUT ${GENERATED_DIR}/helloworld.pb.cc ${GENERATED_DIR}/helloworld.pb.h ${GENERATED_DIR}/helloworld.grpc.pb.cc ${GENERATED_DIR}/helloworld.grpc.pb.h COMMAND protobuf::protoc ARGS --grpc_out${GENERATED_DIR} --cpp_out${GENERATED_DIR} -I ${CMAKE_CURRENT_SOURCE_DIR} --pluginprotoc-gen-grpc$TARGET_FILE:gRPC::grpc_cpp_plugin ${PROTO_FILE} DEPENDS ${PROTO_FILE} ) # 3. 把生成的代码编进目标 add_executable(QtGrpcDemo main.cpp greeter_client.cpp greeter_client.h ${GENERATED_DIR}/helloworld.pb.cc ${GENERATED_DIR}/helloworld.grpc.pb.cc ) # 4. 带上头文件路径和库 target_include_directories(QtGrpcDemo PRIVATE ${GENERATED_DIR}) target_link_libraries(QtGrpcDemo PRIVATE Qt5::Core gRPC::grpc protobuf::libprotobuf )第一件事find_package(gRPC CONFIG REQUIRED)。gRPC官方推荐用CONFIG模式这样会导入gRPC::grpc这类目标。如果找不到包你需要在CMake里手动指定gRPC_DIR指向装有gRPCConfig.cmake的目录。第二件事protoc生成命令。这里有两个生成器一个是protobuf官方的--cpp_out生成消息类的代码另一个是grpc的--grpc_out配合--pluginprotoc-gen-grpc生成服务桩代码。两个缺一不可。第三件事CMAKE_AUTOMOC一定要开。因为greeter_client.h里有Q_OBJECT宏头文件里的信号槽元信息需要由moc处理不开AUTOMOC会导致链接时出现一堆“未定义的vtable”错误。第四件事链接库别漏。protobuf::libprotobuf是消息类的运行库gRPC::grpc是客户端和服务端共用的gRPC C运行时库。漏掉任何一个都会在链接阶段报一堆莫名其妙的无符号引用。3.3 客户端封装把gRPC异步调用翻译成信号槽这是整个练手例子里最关键的代码。我定义了一个GreeterClient类它继承自QObject对外暴露一个sayHello(const QString)方法用信号replyReceived(QString)把结果抛回给界面层。类的头文件长这样#pragma once #include QObject #include QString #include grpcpp/grpcpp.h #include helloworld.grpc.pb.h class GreeterClient : public QObject { Q_OBJECT public: explicit GreeterClient(std::shared_ptrgrpc::Channel channel, QObject *parent nullptr); public slots: void sayHello(const QString name); signals: void replyReceived(const QString message); void requestFailed(const QString errorMessage); private: struct AsyncCall { grpc::ClientContext context; grpc::Status status; helloworld::HelloReply reply; }; void pollCompletionQueue(); std::unique_ptrhelloworld::Greeter::Stub m_stub; grpc::CompletionQueue m_completionQueue; bool m_shuttingDown false; };关键设计全在这个类里。AsyncCall结构体是这次RPC调用的完整现场context、status、reply都打包在一起通过tag传给CompletionQueue。等Next()把它拿出来所有信息都还在不会出现“回复是别人的”这种错乱。构造函数只做两件事用channel创建stub然后启动一个worker线程去跑轮询函数。GreeterClient::GreeterClient(std::shared_ptrgrpc::Channel channel, QObject *parent) : QObject(parent) , m_stub(helloworld::Greeter::NewStub(std::move(channel))) { std::thread(GreeterClient::pollCompletionQueue, this).detach(); }发请求的函数长这样void GreeterClient::sayHello(const QString name) { auto *call new AsyncCall; helloworld::HelloRequest request; request.set_name(name.toStdString()); auto responder m_stub-PrepareAsyncSayHello(call-context, request, m_completionQueue); responder-StartCall(); responder-Finish(call-reply, call-status, call); }看到PrepareAsyncSayHello返回的responder它是一种“半成品”RPC对象调StartCall()才会真正把包发出去Finish()注册回调告诉grpc完成后把结果放进call指向的地址然后把call本身作为tag塞进完成队列。worker线程里的轮询函数void GreeterClient::pollCompletionQueue() { void *tag nullptr; bool ok false; while (true) { if (!m_completionQueue.Next(tag, ok)) { break; } auto *call static_castAsyncCall *(tag); if (call-status.ok()) { emit replyReceived(QString::fromStdString(call-reply.message())); } else { emit requestFailed(QString::fromStdString(call-status.error_message())); } delete call; } }这里有几个细节需要特别说明。第一emit replyReceived是在worker线程里触发的但Qt的信号槽机制会自动判断发射线程和接收线程是否一致如果接收者在主线程它会自动以QueuedConnection方式投递事件到主线程事件循环。这就是为什么我反复强调Qt的信号槽是连接grpc和UI层最好的桥梁。第二cq.Next(tag, ok)默认是阻塞的会一直等到完成队列里出现事件。正常的单客户端场景下这个循环不会退出所以用detach()创建线程没有关系。但程序退出前你需要显式调用m_completionQueue.Shutdown()否则这个阻塞的Next()一直不返回线程无法退出会造成“退出时程序卡死”的问题。第三有一些示例代码会在completion queue的Next()返回false时delete掉自己的AsyncCall对象。但我在这个程序里并没有主动Shutdown所以Next()一直阻塞。这里需要根据你的实际工程里服务端和客户端的生命周期来决定。如果服务端提前关闭Next()可能因为连接断掉而返回false那就要把delete逻辑放进来。我这个例子倾向于客户端主动退出时销毁GreeterClient然后在析构里Shutdown保证不泄漏。4. 完整跑通的验证路径服务端、客户端、Qt事件循环串联4.1 服务端从官方greeter_server到Qt风格的几行代码这次练手我同时也改造了服务端。其实服务端改动不大因为服务端不需要和UI交互它的“线程模型”就是同步处理请求。官方示例的greeter_server.cc有一个SayHello的实现返回一个问候语grpc::Status GreeterServiceImpl::SayHello( grpc::ServerContext *context, const helloworld::HelloRequest *request, helloworld::HelloReply *reply) { std::string prefix(Hello ); reply-set_message(prefix request-name()); return grpc::Status::OK; }在Qt工程里这个实现可以原样照搬。但启动服务的main函数要改成用QCoreApplicationint main(int argc, char *argv[]) { QCoreApplication app(argc, argv); std::string serverAddress(0.0.0.0:50051); GreeterServiceImpl service; grpc::ServerBuilder builder; builder.AddListeningPort(serverAddress, grpc::InsecureServerCredentials()); builder.RegisterService(service); std::unique_ptrgrpc::Server server(builder.BuildAndStart()); qDebug() Server listening on QString::fromStdString(serverAddress); return app.exec(); }把服务跑在一个QCoreApplication里好处是退出时可以走Qt的优雅关闭逻辑不会因为命令行main函数直接return导致服务端口没有释放干净。我实际测过在Windows上如果不这样弄频繁重启服务端很容易遇到“端口被占用”的假象其实是之前进程还在TIME_WAIT。4.2 客户端主函数发请求、等信号、写结果客户端的main函数和之前的纯C版本差别很大。纯C版本是GreeterClient greeter(grpc::CreateChannel(localhost:50051, grpc::InsecureChannelCredentials())); std::string reply greeter.SayHello(world);Qt版本改成了事件驱动int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); auto channel grpc::CreateChannel(localhost:50051, grpc::InsecureChannelCredentials()); auto client std::make_uniqueGreeterClient(channel); QObject::connect(client.get(), GreeterClient::replyReceived, [app](const QString message) { qDebug() Got reply: message; app.quit(); }); QObject::connect(client.get(), GreeterClient::requestFailed, [app](const QString error) { qWarning() Request failed: error; app.quit(); }); client-sayHello(qt grpc); return app.exec(); }这个连接的写法有讲究连接接收者是main函数里的lambda它捕获了app的引用所以lambda执行时相当于在主线程里跑。当replyReceived信号触发时如果GreeterClient的worker线程和主线程不是同一个线程Qt会自动把事件投递到主线程的队列里等exec()的循环处理。此时你会发现整个程序的生命周期完全由事件驱动请求发出信号回来程序退出。这正是Qt和grpc结合以后应该有的样子。4.3 验证时最容易忽略的地层检查清单跑通之后我把验证步骤整理成了清单每次换环境换版本时都按这个过一遍服务端和客户端的proto文件是否完全一致。字段编号、服务名、包名有任何差异都会导致连接建立失败或响应解析错误。端口占用检查。Windows下netstat -ano | findstr 50051看到TIME_WAIT状态多半是上次没退干净等一会或者换端口。防火墙是否拦截。如果从另一台机器测试Windows防火墙弹出的对话框没点是就等着GRPC_ABORT或DEADLINE_EXCEEDED报错吧。用grpcurl或grpc_cli去探一下服务是否在监听这个作为独立验证手段非常有用比直接跑客户端更能定位是谁的问题。我写这个例子时第一遍跑通花了大半个晚上。后来发现是防火墙拦截了50051端口根本和代码无关。从那以后我的经验是先验证端口、再验证proto、最后才怀疑代码。5. 我自己踩过的坑编译期、链接期、运行期三份账单5.1 不能混用Qt版本和gRPC版本一个知名的运行时报错第一次编译通过后我特别开心觉得这波稳了。结果一运行程序弹了一个很吓人的错误框cannot mix incompatible Qt library (5.15.3) with this library (5.15.2)问题很明确Qt的release DLL和debug DLL混用了。这个报错跟grpc本身没关系是Qt库的版本和编译环境不匹配导致的。当时我的系统里同时装了5.15.2和5.15.3两个版本CMake find_package时找到了其中一个运行时PATH里又找到另一个导致动态库版本对不上。解决办法是要么把所有Qt库都统一到同一个版本。要么在工程里用Qt的qt_attribution和qt_standard_project_setup那些标准CMake技巧来指定绝对路径。后来我干脆统一用5.15.2因为这个版本是LTS而且当时大多数预编译的gRPC也是针对它做的兼容测试。在项目里commit后注明Qt版本避免同事误用5.15.3。5.2 MSVC和MinGW的选择为什么我让你别碰MinGWgrpc的预编译二进制官方主要提供的是MSVC版本的。用MinGW去链接gRPC通常有两种下场第一种链接时爆出几百个“undefined reference”的错误因为MinGW的C ABI和MSVC不兼容第二种能编译但运行时报错因为异常处理和RTTI的代码生成方式不同。热词列表里我看到有“desktop qt 5.9.9 mingw”的搜索那个组合如果真拿来搞grpc大概率是找死。这次练手我用的就是MSVC 2019 64位工具链配Qt 5.15.2 msvc2019_64一次通过没有额外折腾。你要是在Windows下想尽快跑通grpc别跟工具链较劲直接MSVC。如果你只能在Linux环境下开发那Debian/Ubuntu的gcc配Qt的gcc版本是标准组合问题相对少一些。但依然要注意gRPC对最低GCC版本的要求太老的编译器编不过去。5.3 程序退出时卡死的真凶CompletionQueue的Shutdown时机第一次实现异步客户端时我发现每次程序退出都要等好几秒有时候直接像死了一样。后来定位到是CompletionQueue的Next()阻塞了worker线程线程的析构函数默认会等待线程执行完毕而Next()又被卡住了。解决办法是在GreeterClient的析构函数里先调用m_completionQueue.Shutdown()让cq.Next()返回falseworker线程退出循环然后join线程GreeterClient::~GreeterClient() { m_shuttingDown true; m_completionQueue.Shutdown(); if (m_workerThread.joinable()) { m_workerThread.join(); } }如果你用的是detach()方式析构时没有join的操作那Shutdown()本身也会让阻塞在Next()上的线程返回false并退出循环。但join更稳妥能保证析构返回时线程肯定已经结束不会出现线程还在用已经释放的GreeterClient成员变量。这个坑在官方示例里完全看不出来因为官方示例的main函数在同步阻塞那条路上就return了根本来不及让你体会到cq.Shutdown()的意义。但实际工程里客户端要反复创建和销毁这一条不处理好就是定时炸弹。5.4 Release版链接Release版Debug版链接Debug版MSVC下最容易踩的一个链接陷阱是runtime library不匹配。如果grpc的dll是MT静态链接运行时库而你编译Qt工程用的是MD动态链接运行时库链接的时候一般不会直接报错但运行时会因为堆管理器的差异导致崩溃。我的经验是在整个工程里统一用MD也就是Release版用/ MDDebug版用/MDd。官方预编译的gRPC库也是用MD编译的这样配合最稳妥。如果你用的gRPC是vcpkg装的它默认就是MD没有这个问题。5.5 热词里那些高频问题其实都指向同一个根因热搜词里有几个很眼熟的问题比如“qt 崩溃”、“qt多线程”、“qt曲线刷新能放在另一个线程里面吗”。看起来分散但根子是同一个跨线程操作了不属于当前线程的对象。拿这个练手例子来说grpc的worker线程拿到结果后如果直接操作一个QTextEdit对象那大概率崩溃因为QTextEdit属于主线程它的内部状态不是线程安全的。正确的做法永远是worker线程只负责拿数据然后通过信号槽把数据投递回主线程由主线程去更新界面。这个思路不仅适用于grpc你以后在Qt里集成Modbus、串口、TCP、UDP这类网络或硬件通信库时都是同样一套模式IO线程把事件翻译成信号UI线程槽函数更新界面。所有异步事件源都用这个方法接入Qt代码结构会非常统一。6. 这个例子还能怎么扩展线程模型与界面解耦的路子6.1 从异步到回调grpc的新接口会让代码更简洁如果你用的是gRPC 1.30以上的版本可以考虑改用回调接口。同场景下客户端代码大概是这样class GreeterClient : public QObject { Q_OBJECT public: void sayHello(const QString name) { auto *cb new AsyncCall; cb-request.set_name(name.toStdString()); m_stub-async()-SayHello(cb-context, cb-request, cb-reply, [this, cb](grpc::Status status) { if (status.ok()) { emit replyReceived(QString::fromStdString(cb-reply.message())); } else { emit requestFailed(QString::fromStdString(status.error_message())); } delete cb; }); } };区别在于你不用再显式管理CompletionQueuegrpc内部已经帮你处理好了完成回调在哪个线程执行。但注意回调仍然是在“某个内部线程”里执行的所以跨线程结论没变拿到结果后必须emit信号不能直接操作UI。6.2 从单请求到流式RPC理解这块你的客户端才算完整练手例子只做了unary一元调用一次请求一次响应。但grpc真正的威力在于流式RPC服务端流、客户端流、双向流。在Qt里做流式RPC其实也是同一套模式 你把Reader/Writer挂到CompletionQueue上然后就等Next()返回事件拿到一个事件就emit一个信号。唯一多的东西是“读下一个”的循环逻辑grpc读完了会给你一个finished事件。这个扩展方向建议你玩完这个例子之后去尝试一下。理解流式RPC之后你对CompletionQueue的理解才算真正到位。6.3 从进程内到跨机器这决定了你channel的配置方式我现在这个例子用的是InsecureChannelCredentials也就是明文通信只适合本机调试和学习。如果要跨机器或者上生产至少要启用TLS加密。grpc的SslCredentials需要你提供ca证书、客户端证书和私钥代码上改动不大但是证书的生成、管理和轮换是另一套知识。好消息是这些和Qt的集成方式完全一样换个创建Channel的Credential参数就行其他的异步调用流程不用动。所以先把不加密的例子跑通再补加密路径是平滑的。6.4 从QCoreApplication到QMainWindow界面层接入的示范我这篇为了聚焦grpc本身的集成用了一个QCoreApplication的控制台程序。但大多数人最终要的是带界面的Qt应用。把控制台改成带界面的改动其实很小把QCoreApplication换成QApplication。在界面上加一个LineEdit输入名字一个Button触发请求一个Label显示结果。connect按钮的点击信号到client的sayHello槽函数再connect客户端的replyReceived信号到Label的setText。本质上没有新东西就是普通的Qt信号槽接线。代码结构还是那个GreeterClient负责grpc通信界面只和GreeterClient的信号槽打交道grpc的线程完全不影响UI。做完这个改造你再回头看之前的ts问题心里就能画出完整的图了。写到这里这个练手例子能踩的坑、能挖的点基本都摊开了。我个人的体会是grpc本身不难难的是把它嵌入一个你不熟悉的事件循环体系。Qt和grpc的双事件循环打通了后面的路就顺了。如果你也想练手建议别急着抄完整代码先自己把官方示例跑起来再在这个基础上改成异步、改成Qt类走一遍和我类似的改造过程比看十篇文章都管用。