cpp 的 vtable 上面 coredump 了,如何分析定位?

作者: | 更新日期:

同事反馈的问题,最终发现是框架的设计问题。

本文首发于公众号:天空的代码世界,微信号:tiankonguse

一、背景

这几天,有个同事反馈使用 TRPC 框架 coredump 了。

看堆栈 core 在 vtable for __cxxabiv1::__class_type_info() 了。

面对这个神奇的问题,我参与进来一起分析定位,最终找到原因,并得出结论: TRPC 框架设计的有问题。

二、网络框架

最近几年都流程 RPC 调用。

比如你想进行 http 网络操作,就可以 new 一个 http RPC 对象,进行 get 或者 post RPC 网络调用即可。

比如你想进行 redis 网络操作,就可以 new 一个 redis RPC 对象,进行 get、set、del 等 RPC 网络调用即可。

作为一个 RPC 网络框架,自然也会有一套通用 RPC 协议。
进行网络调用的时候,也可以 new 一个 通用 RPC 对象,然后进行 invoke RPC 网络调用即可。

这么多不同形式的 RPC 对象,通常可以封装一下,每个协议对象只需要进行序列化与反序列化,其他操作都交给基类来做即可。

大概像下面的样子。

不要业务的 RPC 函数与协议往往不同,所以通用 RPC 网络对象往往使用模板进行封装。
每个具体服务都会根据 protobuf 的 RPC 配置自动生成一个具体的 RPC 对象,这个对象可以自定义 RPC 函数名与协议参数。

仅仅看网络调用关系,这种设计是合理的。

但是框架具体实现的时候,把不少东西耦合进来了,引进来不少问题,我们后面慢慢来看。

三、设计缺陷

这个网络框架的使用像下面的样子。

auto proxy = GetRPCProxy<AServiceProxy>(name, config);
auto ctx = MakeClientContext(context, proxy);
ret = proxy->Login(ctx, req, rsp);

关于这个使用方式,在 2020 年我第一次了解这个用法的含义后,就去 git 提交了几个 issues。

指出这种设计有问题,业务很容易误用,从而导致 XXX 问题。

具体来说, GetRPCProxy 对象背后是一个工厂类。
如果 name 不存在,则创建传入的 AServiceProxy 对象,并用 config 初始化。
如果 name 存在,则返回之前创建好的对象,此时会忽略传入的其他所有参数。

当时我随手举例出几个可能的实际问题。
比如工厂类存在并发问题,操作 map 时必须加锁。
比如业务会误用,每次都会传入新的 config ,实际上除了第一次,之后传入的 config 是没用的。

结果后来,讨论着讨论着,话题变成了大家应该按照官方文档来写代码。
之后就是讨论举例的实际问题该如何解决。

大部分人是不会去了解一个框架的设计细节的。
如果设计存在问题,仅仅依靠文档的约定来避免问题,那最终必然是无数人碰到这个设计导致的问题。

现在想想,根本原因是设计问题。
我们设计一个系统,就要是看到的与实际的功能一致。
不能看到的和实际不一致,然后增加一些文档来约束,应该设计上来约束。

那具体是什么设计问题呢?

可以称为幂等性问题或者二义性问题。
即 GetRPCProxy 这个函数,第一次调用与第二次调用的含义不一样,这是根本的问题所在。

三、实际问题

同事就是在这个背景下,遇到了 coredump 问题。

依旧是下面的代码,在调用 Login 函数的时候,coredump 了。

auto proxy = TRPC::GetRPCProxy<AServiceProxy>(name, config);
auto ctx = MakeClientContext(context, proxy);
ret = proxy->Login(ctx, req, rsp);

看堆栈找不到 vtable ,我第一印象是符号冲突,即不同作用域下有不同的 AServiceProxy。

于是提出两个猜想:要么符号冲突,要么业务代码有BUG,把内存写乱了。

所以,我们需要设计一些方案来,验证自己的猜想,找到具体的问题。

方案0:调用父类的RPC函数。

同事不知道为何,尝试了下调用父类的 RPC 函数 invoke。
神奇的时,竟然不再 coredump 了。

于是我猜测,可能是 AServiceProxy 的符号冲突,所有调用父类没问题。
也有可能是 AServiceProxy 的内存写乱了,父类区域没写乱,所以没问题。
后面需要不断找方法来验证下。

PS:现在想来,我应该问下同事为何会这样尝试,原因是什么?

方案1:完整作用域。

我提出的第一个解决方案是不要使用 using,不要使用 auto。

同事照这个做了,问题依旧存在。

我进行复盘与逻辑推理:这个方案确实没啥用,如果是这个原因,编译应该就不通过。

方案2:协议新旧版本冲突。

我还怀疑同一个 AServiceProxy 协议,引入了两个不同版本,其中一个是旧版本,新版本加字段了。
不过我后面又解释到,如果是这个问题,应该也会编译不通过。

方案3:代码隔离运行。

我把这段代码提取出来,自己写了一个 Helloword demo,没有复现问题。
于是我怀疑前面的代码有 BUG,堆栈乱了导致的。

于是使用 GDB 把 proxy 的虚函数符号表打印出来了。
令人震惊的时,proxy 对象竟然看不到 Login 这个虚函数,只能看到父类的虚函数。

由于这个 GDB 命令是网上抄的,我自我解释:可能这个命令只会打印父类的虚函数表吧。

注:后面会介绍打印虚函数表的原理。

方案4:二分删代码。

此时,我猜想前面的代码有 BUG,把工厂类的局部内存写乱了吧。
既然没办法了,那只能使用万能的删代码来定位问题了。

我先让同事把编译速度提到最高速度,程序压缩到最小。

注:编译机与运行机在不同环境,文件最小的时候,修改代码到验证的时间也最小。

准备:把这段代码封装在一个函数里。

测试1:整个服务收到请求只调用这一个函数。
测试结果符合预期,程序不 coredump 了。

测试2:这个函数放在之前异常的位置。
测试结果符合预期,coredump 还可以复现。
那显然是之前的某段代码有问题。

测试3:…

就这样,通过调整这个函数的位置或者删除中间的代码,最终找到原因。

原因不是内存写乱了,而是 GetRPCProxy 的使用问题。

四、问题原因

问题现象是在调用下面这段代码的时候,发生异常。

auto proxy = TRPC::GetRPCProxy<AServiceProxy>("hello001", config);
auto ctx = MakeClientContext(context, proxy);
ret = proxy->Login(ctx, req, rsp);

导致这个问题的原因时,异常代码之前还有一个代码,如下。

auto proxy = TRPC::GetRPCProxy<RPCProxy>("hello001", config);
auto ctx = MakeClientContext(context, proxy);
ret = proxy->invoke(ctx, req, rsp);

还记得这个类之间的关系吗?

AServiceProxy 是 RPCProxy 的子类,而同事写代码的时候,传了一样的 Name。

注:实际使用时,传的是变量,名字是配置化的,所以搜索字符串无法发现问题。

第二次调用的时候,由于 GetRPCProxy 是工厂,name 存在,就返回了 RPCProxy 对象指针。
期望获取的是 AServiceProxy 指针,这里指针转换的时候恰好可以转换通过。

很巧合,这个 BUG 就这样完美编译通过了。

而运行的时候,调用 Login 函数,在虚函数表里自然就找不到符号了。

这也是为啥 GDB 的时候,看不到 Login 的虚函数符号。
这也是为啥 同事直接调用 invoke 没问题,因为这个对象就是通用 RPC 对象。

总结下就是,网络框架 GetRPCProxy 的设计存在问题,每次调用的参数需要保持一致。

而同事这里,代码量非常大,至少有两万行,name 一不小心就冲突了是很常见的事情吧。

就这样,name 一样,传入的不同的 RPC 对象,符号表自然就对不齐了。

五、框架该如何设计

找到原因后,接下来要做的事情就分三部分了。

第一部分是同事这边的。
肯定是按照框架的设计,对 name 进行统一规划,防止冲突。

另外,还发现同事的 config 每次都在变化,以为传入就会生效。
那就需要修改为动态设置,而不是传入 config。

第二部分是框架近期需要做的。
拉群反馈了问题与原因,让框架的同事对类型进行强检查。
宁愿主动 coredump 知道死到哪了,也不能这样被动的 coredump 导致浪费大量时间来定位问题。

第三部分是框架长期需要做的。
这个我还在思考,那就是框架的这部分代码该如何重新设计。

当前的设计我认为是有问题的。
问题1:对外暴露的接口上的三个参数竟然有依赖关系,对象与 name 必须保持一致。 问题2:允许用户传入配置参数,然后又通过文档告诉用户(第二次)传的参数不会生效。

要解决这个问题,发现通过自问自答的方式就可以找到一种解决方案来。

问题1:为什么第二次传入的参数无效?
回答1:因为内部使用的工厂类,缓存起来了。

问题2:内部为啥使用工厂类?不能每次都创建一个吗?
回答2:基类很大,有好几K。这个类属于高频对象,每次创建比较消耗性能。

问题3:基类为啥这么大?不是只存几个配置信息吗?
回答3:整个客户端的路由发现、网络操作、监控上报、插件等都在基类中实现。

问题4:这个类的原始目的是什么?
回答4:子类进行序列化与反序列化,基本屏蔽其他所有细节。

问题5:那是否可以把其他细节封装在框架内,基类间接来调用?
回答5:确实可以,这样基类就很小,只有几字节,可以大量创建了。

问题6:路由信息与上报信息如何管理?
回答6:每个网络调用有一个 ctx,存在这里面即可(目前也是在这里面)。

问题7:架构图长什么样子?
回答7:大概长这样子。

问题8:此时业务该如何使用? 回答8:如下。

auto proxy = std::make_shared<RPCProxy>(config);
auto ctx = MakeClientContext(context);
ret = proxy->invoke(ctx, req, rsp);

问题9:MakeClientContext 好像也变化了,不再传 proxy 了?
回答9:是的,这个应该在基类中做的工作,构造时不能与 ctx 有这种耦合。

你看,通过这样一问一答就可以发现架构该如何设计。

那些反直觉的设计,肯定说明设计是存在问题的。
最终,无数人都会踩反直觉设计引入的各种坑。

既然这样,那就尽量不要设计这些反直觉的设计。

六、最后

最后补充下符号表的基础知识。

对于有虚函数的类,第一个地址储存的就是虚函数表的指针。
找到虚函数表后,这个表中又储存的是虚函数的指针。
所以还需要拿着指针去获取到函数的符号信息。

比如这样一个虚函数。

内存布局长这个样子。

使用 GDB 就可以通过地址计算,以及info symbol查到符号的名字了。

当然,为了方便,封装为了一个 python GDB 函数,我也上传到 github 上了。
感兴趣的可以后台回复”CheckSymbol”获取。

而对于这个框架的设计问题,节后去公司了,再找负责人讨论这个事情吧。

互动:你怎么看待这个框架设计问题呢?你认为应该如何设计呢?

《完》

-EOF-

本文公众号:天空的代码世界 个人微信号:tiankonguse

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越