记一次虚函数二进制兼容问题导致的coredump - 问题诊断
记录一次由于动态链接库提供的含有虚函数的类不兼容导致的诡异coredump。
问题排查
问题表面
首先找到程序挂掉前打印出如下:
terminate called after throwing an instance of 'std::system_error'
what(): Resource deadlock avoided
Aborted
google了下,大概率是因为以下两个原因:
- 线程自己join自己 => 悖论:即线程join结束的前提是线程本身结束执行,而线程本身结束的前提是join结束
- 两个线程互相join对方 => 悖论:即线程A结束的前提是B线程结束,线程B结束的前提是A线程结束
结合coredump程序挂掉的堆栈来看,涉及到对象析构=>对象的成员变量std::future
析构=>调用std::thread::join
,而刚好执行析构的线程是被std::async
创建,返回值保存在该对象的成员变量std::future
,这就造成了在自己线程自己join自己
std::future
通过std::async
返回,类似下边的调用方式
std::future future_ = std::async(std::launch::async, myfunc);
ps:
std::future
在离开作用域时如果之前没有调用过get会执行join等待函数执行完毕;之前看到过滑天下之大稽的写法,不把
std::async(std::launch::async, ...)
结果赋值出去,调用者会阻塞到目标函数结束,完全变成同步调用。
深入分析
接下来就需要分析下,为何会调用到析构函数。
先介绍下具体的业务,有个so库通过zeromq和其它进程通信,我们这边按照其提供的带有虚函数的class,继承实现其虚函数,再将该class传递给so提供的方法,在对应数据到来的时候so库会调用对应的虚函数,从而执行到我们业务的代码。
将其抽象了下,即so的实现如下:
class RunMe {
public:
virtual void fun1() { cout << "base func1" << endl; }
virtual void fun2() { cout << "base func2" << endl; }
virtual void fun3() { cout << "base func3" << endl; }
virtual ~RunMe() { cout << "base ~RunMe()" << endl; }
};
void call_run_me(RunMe *r) {
r->fun1();
r->fun2();
r->fun3();
}
其中RunMe
就是业务代码需要继承的类,实现其虚函数,将函数指针传递给class_run_me
,在具体数据到来就会调用具体的函数。这个是多态的典型用法,用户代码如下:
#include "mylib.h"
class MyRunMe : public RunMe {
public:
void fun1() override { cout << "my fun1" << endl; }
void fun2() override { cout << "my fun2" << endl; }
void fun3() override { cout << "my fun3" << endl; }
~MyRunMe() { cout << "~MyRunMe()" << endl; }
};
int main() {
MyRunMe r;
call_run_me(&r);
}
实际的业务是使用智能指针shared_ptr
来持有MyRunMe r
,so提供的也拥有接收shared_ptr的类成员函数call_run_me
的对象client,该client也由shared_ptr
所管理,双方都会拥有对方的shared_ptr
,这块我们这边写的非常不规范(不是我写的!),出现了循环引用,按理说这两个对象永远也不会被delete。但是这两个对象在整个程序的生命周期里都需要,虽然不优雅但是没有错误。不过如果用查内存泄漏的工具查下,这个肯定是definitely泄漏!
classDiagram RunMe <|-- MyRunMe RunMe: +func1() RunMe: +func2() RunMe: +func3() class MyRunMe{ +shared_ptr Client client_; +func1(); +func2(); +func3(); } class Client{ +shared_ptr RunMe r_; +std::future future_; +call_run_me(shared_ptr r); }
这块对象之间的关系很乱,总之,看coredump堆栈调用链是:
- 在call_run_me函数(单独线程执行)中RunMe r莫名调用析构函数
- 在RunMe r析构时会析构shared_ptr Client client_
- client_的引用计数==0,触发client_的析构
- client_析构触发future_析构
- future_析构触发std::thread::join
- 出现线程自己join自己被系统kill掉
问题根因
现在的问题就在于RunMe r的析构函数为何被调用,通过将原so替换成debug版本的so,再打开core文件可以看到出错的行号,通过源代码看到程序在r->fun4()之后堆栈就就是析构函数了,而r->fun4()函数为新增虚函数
突然想到在书中看到过析构函数是通过偏移量来决议
《Linux多线程服务端编程:使用muduo C++网络库》1p434
先说修改动态库导致二进制不兼容的例子。比如原来动态库里定义了 non-virtual,函数 void foo(int),新版的库把参数改成了 double。那么现有的可执行文件就无法启动,会发生 undefined symbol 错误,因为这两个函数的 mangled name 不同。但是对于 virtual 函数 foo(int),修改其参数类型并不会导致加载错误,而是会发生诡异的运行时错误。因为虚函数的决议(resolution)是靠偏移量,并不是靠符号名。再举一些源代码兼容但是二进制代码不兼容的例子:
给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
增加虚函数,会造成 vtbl 里的排列变化。(不要考虑“只在末尾增加”这种取巧行为,因为你的 class 可能已被继承。)
增加默认模板类型参数,比方说 Foo<T> 改为 Foo<T, Alloc=alloc<T> >,这会改变 name mangling。
改变 enum 的值,把 enum Color { Red = 3 }; 改为 Red = 4 。这会造成错位。当然,由于 enum 自动排列取值,添加 enum 项也是不安全的(在末尾添加除外)。
通过看当时版本和编译时/运行时so版本,可以确认编译时使用的旧的so未增加该虚函数,而运行时的so版本则为新版增加该虚函数,而该虚函数在类中的顺序增加在析构函数之前,导致运行时so调用该虚函数实际调用的是老版本的继承类的析构函数,导致一系列的连锁反应。
通过gdb中info vtbl r指针
可以看到虚函数表,其中并不含有新增的虚函数。基本可以确认是该问题
info vtbl
可以看到虚表中含有两个析构函数,经google查明编译器会在虚表生成两个析构函数指针,其中一个在析构函数的基础上增加了释放内存等操作(virtual function thunk),涉及到编译器的细节,不再深入研究。
问题复现
复现方法
具体代码在虚表版本不匹配测试
先声明定义两个版本的RunMe,新版本增加了fun_2_5()虚函数
// v1 so
class RunMe {
public:
virtual void fun1() { cout << "base func1" << endl; }
virtual void fun2() { cout << "base func2" << endl; }
virtual void fun3() { cout << "base func3" << endl; }
virtual ~RunMe() { cout << "base ~RunMe()" << endl; }
};
void call_run_me(RunMe *r) {
r->fun1();
r->fun2();
r->fun3();
}
// v2 so
class RunMe {
public:
virtual void fun1() { cout << "base func1" << endl; }
virtual void fun2() { cout << "base func2" << endl; }
virtual void fun2_5() { cout << "base func2.5" << endl; } // 对应增加的虚函数
virtual void fun3() { cout << "base func3" << endl; }
virtual ~RunMe() { cout << "base ~RunMe()" << endl; }
};
void call_run_me(RunMe *r) {
r->fun1();
r->fun2();
r->fun2_5();
r->fun3();
}
分别在各自的目录执行g++ mylib.cc -fPIC -shared -o libmylib.so -ggdb
编译成so
用户实现的main.cc
#include "mylib.h"
class MyRunMe : public RunMe {
public:
void fun1() override { cout << "my fun1" << endl; }
void fun2() override { cout << "my fun2" << endl; }
void fun3() override { cout << "my fun3" << endl; }
~MyRunMe() { cout << "~MyRunMe()" << endl; }
};
int main() {
MyRunMe r;
call_run_me(&r);
}
使用不同版本的so编译main.cc
g++ main.cc -L v1 -I v1 -lmylib -o main_use_v1 -ggdb # 使用v1下的so和头文件编译
g++ main.cc -L v2 -I v2 -lmylib -o main_use_v2 -ggdb # 使用v2下的so和头文件编译
虚表分析与运行情况
main中v1和v2中MyRunMe的虚表排序:
index | main_v1中的虚表vptr | main_v2中的虚表vptr |
---|---|---|
0 | fun1() | fun1() |
1 | fun2() | fun2() |
2 | fun3() | fun2.5() |
3 | ~MyRunMe() | fun3() |
4 | ~MyRunMe()编译器增加 | ~MyRunMe() |
5 | ~MyRunMe()编译器增加 |
而so中call_run_me调用虚表index的偏移量:
v1 call_run_me调用虚表index | v2 call_run_me调用虚表index |
---|---|
r->fun1()=>call vptr[0]() | r->fun1()=>call vptr[0]() |
r->fun2()=>call vptr[1]() | r->fun2()=>call vptr[1]() |
r->fun3()=>call vptr[2]() | r->fun2_5()=>call vptr[2]() |
r->~RunMe()=>call vptr[3]() | r->fun3()=>call vptr[3]() |
r->~RunMe()=>call vptr[4]() |
之后可以通过LD_LIBRARY_PATH设置不同的运行so即不同版本的call_run_me函数,调用不同的main程序从而设置不同版本的虚表。
例如使用v1 main的虚表,v2 so的call_run_me调用虚表,就可以执行LD_LIBRARY_PATH="v2/" ./main_use_v1
,得到结果:
~/Documents/git/recipes/test/vptr (main*) » LD_LIBRARY_PATH="v2/" ./main_use_v1
my fun1
my fun2
my fun3
~MyRunMe()
base ~RunMe()
~MyRunMe()
base ~RunMe()
可以发现v2 call_run_me中调用fun2_5,调用的是虚表vptr[2]()而对应v1 MyRunMe则是func3();而v2 call_run_me中调用fun3,调用的就是虚表vptr[3]()对应的就是v1 MyRunMe的析构函数,类似于此次coredump产生的原因。
总结
- 虚函数调用是通过偏移来决定的跟函数名无关,如果三方库如果更新了虚函数,即使跟本模块无关,最好也要重新编译下,否则可能会发生诡异的问题
- 虚函数的二进制兼容性并不好,如果用《Linux多线程服务端编程:使用muduo C++网络库》中所推荐的函数指针的方式来实现类似虚函数的功能则是极好的