Browse by type
作者:ikerli 2022-05-13 使用 TinyRPC, 轻松地构建高性能分布式 RPC 服务!
TinyRPC 是一款基于 C++11 标准开发的小型异步 RPC 框架。TinyRPC 的核心代码应该也就几千行样子,尽量保持了简洁且较高的易读性。
麻雀虽小五脏俱全,从命名上就能看出来,TinyRPC 框架主要用义是为了让读者能快速地、轻量化地搭建出具有较高性能的异步RPC 服务。至少用 TinyRPC 搭建的 RPC 服务能应付目前大多数场景了。
TinyRPC 没有实现跨平台,只支持 Linux 系统,并且必须是 64 位的系统,因为协程切换只实现了 64 位系统的代码,而没有兼容 32 位系统。这是有意的,因为作者只会 Linux 下开发,没能力做到跨平台。
TinyRPC 的核心思想有两个: 1. 让搭建高性能 RPC 服务变得简单 2. 让异步调用 RPC 变得简单
必须说明的是, TinyRPC 代码没有达到工业强度,最好不要直接用到生产环境,也可能存在一些未知 BUG,甚至 coredump。读者请自行辨别,谨慎使用!
TinyRPC 框架目前支持两类协议: 1. 纯 HTTP 协议: TinyRPC 实现了简单的很基本的 HTTP(1.1) 协议的编、解码,完全可以使用 HTTP 协议搭建一个 RPC 服务。 2. TinyPB 协议: 一种基于 Protobuf 的自定义协议,属于二进制协议。
TinyRPC 是一款异步的 RPC 框架,这就意味着服务之前的调用是非常高效的。目前来说,TinyRPC 支持两种RPC 调用方式:阻塞协程式异步调用 和 非阻塞协程式异步调用。
阻塞协程式异步调用这个名字看上去很奇怪,阻塞像是很低效的做法。然而其实他是非常高效的。他的思想是用同步的代码,实现异步的性能。 也就是说,TinyRPC 在 RPC 调用时候不需要像其他异步操作一样需要写复杂的回调函数,只需要直接调用即可。这看上去是同步的过程,实际上由于内部的协程封装实现了完全的异步。而作为外层的使用者完全不必关系这些琐碎的细节。
阻塞协程式异步调用对应 TinyPbRpcChannel 类,一个简单的调用例子如下:
tinyrpc::TinyPbRpcChannel channel(std::make_shared<tinyrpc::IPAddress>("127.0.0.1", 39999));
QueryService_Stub stub(&channel);
tinyrpc::TinyPbRpcController rpc_controller;
rpc_controller.SetTimeout(10000);
DebugLog << "RootHttpServlet begin to call RPC" << count;
stub.query_name(&rpc_controller, &rpc_req, &rpc_res, NULL);
DebugLog << "RootHttpServlet end to call RPC" << count;
这看上去跟普通的阻塞式调用没什么区别,然而实际上在 stub.query_name 这一行是完全异步的,简单来说。线程不会阻塞在这一行,而会转而去处理其他协程,只有当数据返回就绪时,query_name 函数自动返回,继续下面的操作。 这个过程的执行流如图所示:

从图中可以看出,在调用 query_name 到 query_name 返回这段时间 T,CPU 的执行权已经完全移交给主协程了,也就说是这段时间主协程可以用来做任何事情:包括响应客户端请求、执行定时任务、陷入 epoll_wait 等待事件就绪等。对单个协程来说,它的执行流被阻塞了。但对于整个线程来说是完全没有被阻塞,它始终在执行着任务。
另外这个过程完全没有注册回调函数、另起线程之类的操作,可它确确实实达到异步了。这也是 TinyRPC 的核心思想之一。
这种调用方式是 TinyRPC 推荐的方式,它的优点如下: 1. 代码实现很简单,直接同步式调用,不需要写回调函数。 2. 对IO线程数没有限制,即使只有 1 个 IO 线程,仍然能达到这种效果。 3. 对于线程来说,他是不会阻塞线程的。
当然,它的缺点也存在: 1. 对于当前协程来说,他是阻塞的,必须等待协程再次被唤醒(RESUME)才能执行下面的代码。
非阻塞协程式异步调用是 TinyRPC 支持的另一种 RPC 调用方式,它解决了阻塞协程式异步调用 的一些缺点,当然也同时引入了一些限制。这种方式有点类似于 C++11 的 future 特性, 但也不完全一样。
非阻塞协程式异步调用对应 TinyPbRpcAsyncChannel,一个简单调用例子如下:
{
std::shared_ptr<queryAgeReq> rpc_req = std::make_shared<queryAgeReq>();
std::shared_ptr<queryAgeRes> rpc_res = std::make_shared<queryAgeRes>();
AppDebugLog << "now to call QueryServer TinyRPC server to query who's id is " << req->m_query_maps["id"];
rpc_req->set_id(std::atoi(req->m_query_maps["id"].c_str()));
std::shared_ptr<tinyrpc::TinyPbRpcController> rpc_controller = std::make_shared<tinyrpc::TinyPbRpcController>();
rpc_controller->SetTimeout(10000);
tinyrpc::IPAddress::ptr addr = std::make_shared<tinyrpc::IPAddress>("127.0.0.1", 39999);
tinyrpc::TinyPbRpcAsyncChannel::ptr async_channel =
std::make_shared<tinyrpc::TinyPbRpcAsyncChannel>(addr);
async_channel->saveCallee(rpc_controller, rpc_req, rpc_res, nullptr);
QueryService_Stub stub(async_channel.get());
stub.query_age(rpc_controller.get(), rpc_req.get(), rpc_res.get(), NULL);
}
注意在这种调用方式中,query_age 会立马返回,协程 C1 可以继续执行下面的代码。但这并不代表着调用 RPC 完成,如果你需要获取调用结果,请使用:
async_channel->wait();
此时协程 C1 会阻塞直到异步 RPC 调用完成,注意只会阻塞当前协程 C1,而不是当前线程(其实调用 wait 后就相当于把当前协程 C1 Yiled 了,等待 RPC 完成后自动 Resume)。
当然,wait() 是可选的。如果你不关心调用结果,完全可以不调用 wait。即相当于一个异步的任务队列。
这种调用方式的原理很简单,会新生成一个协程 C2 去处理这次 RPC 调用,把这个协程 C2 加入调度池任务里面,而原来的协程 C1 可以继续往下执行。
新协程 C2 会在适当的时候被IO线程调度(可能是IO线程池里面任意一个 IO线程), 当 RPC 调用完成后,会唤醒原协程 C1 通知调用完成(前提是 C1 中调用了 wait 等待结果)。
这个调用链路如图:

总之,非阻塞协程式异步调用的优点如下: 1. RPC 调用不阻塞当前协程 C1,C1 可以继续往下执行代码(若遇到 wait 则会阻塞)。
而缺点如下: 1. 所有 RPC 调用相关的对象,必须是堆上的对象,而不是栈对象, 包括 req、res、controller、async_rpc_channel。强烈推荐使用 shared_ptr,否则可能会有意想不到的问题(基本是必须使用了)。 2. 在 RPC 调用前必须调用 TinyPbRpcAsyncChannel::saveCallee(), 提前预留资源的引用计数。实际上是第1点的补充,相当于强制要求使用 shared_ptr 了。
解释一下第一点:调用相关的对象是在线程 A 中声明的,但由于是异步 RPC 调用,整个调用过程是又另外一个线程 B 执行的。因此你必须确保当线程 B 在这些 RPC 调用的时候,这些对象还存在,即没有被销毁。 那为什么不能是栈对象?想像一下,假设你在某个函数中异步调用 RPC,如果这些对象都是栈对象,那么当函数结束时这些栈对象自动被销毁了,线程 B 此时显然会 coredump 掉。因此请在堆上申请对象。另外,推荐使用 shared_ptr 是因为 TinyPbRpcAsyncChannel 内部已经封装好细节了,当异步 RPC 完成之后会自动销毁对象,你不必担心内存泄露的问题!
TinyRPC 底层使用的是 Reactor 架构,同时又结合了多线程,其性能是能得到保障的。进行几个简单的性能测试结果如下:
测试机配置信息:Centos虚拟机,内存6G,CPU为4核
测试工具:wrk: https://github.com/wg/wrk.git
部署信息:wrk 与 TinyRPC 服务部署在同一台虚拟机上, 关闭 TinyRPC 日志
测试命令:
// -c 为并发连接数,按照表格数据依次修改
wrk -c 1000 -t 8 -d 30 --latency 'http://127.0.0.1:19999/qps?id=1'
测试结果: | QPS | WRK 并发连接 1000 | WRK 并发连接 2000 | WRK 并发连接 5000 | WRK 并发连接 10000 | | ---- | ---- | ---- | ---- | ---- | | IO线程数为 1 | 27000 QPS | 26000 QPS | 20000 QPS |20000 QPS | | IO线程数为 4 | 140000 QPS | 130000 QPS | 123000 QPS| 118000 QPS | | IO线程数为 8 | 135000 QPS | 120000 QPS| 100000 QPS| 100000 QPS | | IO线程数为 16 | 125000 QPS | 127000 QPS |123000 QPS | 118000 QPS |
// IO 线程为 4, 并发连接 1000 的测试结果
[ikerli@localhost bin]$ wrk -c 1000 -t 8 -d 30 --latency 'http://127.0.0.1:19999/qps?id=1'
Running 30s test @ http://127.0.0.1:19999/qps?id=1
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 9.79ms 63.83ms 1.68s 99.24%
Req/Sec 17.12k 8.83k 97.54k 72.61%
Latency Distribution
50% 4.37ms
75% 7.99ms
90% 11.65ms
99% 27.13ms
4042451 requests in 30.07s, 801.88MB read
Socket errors: connect 0, read 0, write 0, timeout 205
Requests/sec: 134442.12
Transfer/sec: 26.67MB
由以上测试结果,TinyRPC 框架的 QPS 可达到 14W 左右。
要正确编译 TinyRPC, 至少要先安装这几个库:
protobuf 是 google 开源的有名的序列化库。谷歌出品,必属精品!TinyRPC 的 TinyPB 协议是基于 protobuf 来 序列化/反序列化 的,因此这个库是必须的。 其地址为:https://github.com/protocolbuffers/protobuf
推荐安装版本 3.19.4 及以上。安装过程不再赘述, 注意将头文件和库文件 copy 到对应的系统路径下。
由于 TinyRPC 读取配置使用了 xml 文件,因此需要安装 tinyxml 库来解析配置文件。
下载地址:https://sourceforge.net/projects/tinyxml/
要生成 libtinyxml.a 静态库,需要简单修改 makefile 如下:
# 84 行修改为如下
OUTPUT := libtinyxml.a
# 194, 105 行修改如下
${OUTPUT}: ${OBJS}
${AR} $@ ${LDFLAGS} ${OBJS} ${LIBS} ${EXTRA_LIBS}
安装过程如下:
cd tinyxml
make -j4
# copy 库文件到系统库文件搜索路径下
cp libtinyxml.a /usr/lib/
# copy 头文件到系统头文件搜索路径下
mkdir /usr/include/tinyxml
cp *.h /usr/include/tinyxml
有些库不是那么容易安装,为了不妨碍核心功能的实现,我把这些库都作为插件来编译了。 这些插件库不是强依赖的,因为它不属于 TinyRPC 服务的核心功能,只能算是功能上的锦上添花。为了不影响基础库的编译, TinyRPC 把这些库作为插件来加载,通过宏定义来控制编译。
需要说明的是:即使你不安装这些插件库, TinyRPC 依然能正常编译,它的核心基础功能是完全具备的。
TinyRPC 简单封装了下 MySQL 的调用,因为 MySQL 确实在 RPC 服务中用的比较多了。 需要安装 MySQL 的 glibc 库,用于 MySQL 操作, 选择所需的版本安装即可(建议 5.7 以上)
官方下载地址:https://downloads.mysql.com/archives/community/
注意,以上几个库的头文件我都放在了 /usr/include 下, 库文件放在了 /usr/lib 下。因此在 makefile 中并没有指定其头文件和库文件路径,因为其被安装在了系统默认搜索路径中,无需特殊指定。
另外,额外需要修改下 makefile 文件才能支持插件的编译,否则默认的编译是不会包含这些插件功能的。
修改也很简单,如下即可:
1. 添加宏定义 -D DECLARE_MYSQL_PLUGIN, 表示需要编译 MySQL 插件
# CXXFLAGS += -g -O0 -std=c++11 -Wall -Wno-deprecated -Wno-unused-but-set-variable
CXXFLAGS += -g -O0 -std=c++11 -Wall -Wno-deprecated -Wno-unused-but-set-variable -D DECLARE_MYSQL_PLUGIN
2. 添加库文件路径
MYSQL_LIB = /usr/lib/libmysqlclient.a
PLUGIN_LIB = $(MYSQL_LIB)
在安装了前置的几个库之后,就可以开始编译和安装 TinyRPC 了。安装过程十分简单,只要不出什么意外就好了。
祈祷一下一次性成功,然后直接执行以下几个命令即可:
git clone https://github.com/Gooddbird/tinyrpc
cd tinyrpc
mkdir bin && mkdir lib
// 生成测试pb桩文件
cd testcases
protoc --cpp_out=./ test_tinypb_server.proto
cd ..
// 先执行编译
make -j4
// 编译成功后直接安装就行了
make install
注意, make install 完成后,默认会在 /usr/lib 路径下安装 libtinyrpc.a 静态库文件,以及在 /usr/include/tinyrpc 下安装所有的头文件。
如果编译出现问题,欢迎提 issue, 我会尽快回应。
卸载也很简单,如下即可:
make uninstall
注:如果此前已经安装过 TinyRPC, 建议先执行卸载命令后再重新 make install 安装.
TinyPB 协议基于 Protobuf 来序列化的,在搭建基于 TinyPB 协议的 RPC 服务之前,需要先定义接口文档。具体的 Protobuf 文档需要根据业务的实际功能来编写,这里给出一个例子如下:
// test_tinypb_server.proto
syntax = "proto3";
option cc_generic_services = true;
message queryAgeReq {
int32 req_no = 1;
int32 id = 2;
}
message queryAgeRes {
int32 ret_code = 1;
string res_info = 2;
int32 req_no = 3;
int32 id = 4;
int32 age = 5;
}
message queryNameReq {
int32 req_no = 1;
int32 id = 2;
int32 type = 3;
}
message queryNameRes {
int32 ret_code = 1;
string res_info = 2;
int32 req_no = 3;
int32 id = 4;
string name = 5;
}
service QueryService {
// rpc method name
rpc query_name(queryNameReq) returns (queryNameRes);
// rpc method name
rpc query_age(queryAgeReq) returns (queryAgeRes);
}
使用 protoc 工具生成对应的 C++ 代码:
protoc --cpp_out=./ test_tinypb_server.proto
TinyRPC 读取标准的 xml 配置文件完成一些服务初始化设置,这个配置文件模板如下,一般只需要按需调整参数即可:
<?xml version="1.0" encoding="UTF-8" ?>
<root>
<log>
<log_path>./</log_path>
<log_prefix>test_tinypb_server</log_prefix>
<log_max_file_size>5</log_max_file_size>
<rpc_log_level>DEBUG</rpc_log_level>
<app_log_level>DEBUG</app_log_level>
<log_sync_inteval>500</log_sync_inteval>
</log>
<coroutine>
<coroutine_stack_size>256</coroutine_stack_size>
<coroutine_pool_size>1000</coroutine_pool_size>
</coroutine>
<msg_req_len>20</msg_req_len>
<max_connect_timeout>75</max_connect_timeout>
<iothread_num>8</iothread_num>
<time_wheel>
<bucket_num>6</bucket_num>
<inteval>10</inteval>
</time_wheel>
<server>
<ip>127.0.0.1</ip>
<port>39999</port>
<protocal>TinyPB</protocal>
</server>
</root>
protobuf 文件提供的只是接口说明,而实际的业务逻辑需要自己实现。只需要继承 QueryService 并重写方法即可,例如: ```c++ // test_tinypb_server.cc class QueryServiceImpl : public QueryService { public: QueryServiceImpl() {} ~QueryServiceImpl() {}
void query_age(google::protobuf::RpcController controller, const ::queryAgeReq request, ::queryAgeRes response, ::google::protobuf::Closure done) {
AppInfoLog << "QueryServiceImpl.query_age, req={"<< request->ShortDebugString() << "}";
response->set_ret_code(0);
response->set_res_i
$ claude mcp add tinyrpc \
-- python -m otcore.mcp_server <graph>