记一次虚函数二进制兼容问题导致的coredump - 问题诊断

记录一次由于动态链接库提供的含有虚函数的类不兼容导致的诡异coredump。

首先找到程序挂掉前打印出如下:

shell

terminate called after throwing an instance of 'std::system_error'
  what():  Resource deadlock avoided
Aborted

google了下,大概率是因为以下两个原因:

  1. 线程自己join自己 => 悖论:即线程join结束的前提是线程本身结束执行,而线程本身结束的前提是join结束
  2. 两个线程互相join对方 => 悖论:即线程A结束的前提是B线程结束,线程B结束的前提是A线程结束

结合coredump程序挂掉的堆栈来看,涉及到对象析构=>对象的成员变量std::future析构=>调用std::thread::join,而刚好执行析构的线程是被std::async创建,返回值保存在该对象的成员变量std::future,这就造成了在自己线程自己join自己

std::future通过std::async返回,类似下边的调用方式

c++

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的实现如下:

c++

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,在具体数据到来就会调用具体的函数。这个是多态的典型用法,用户代码如下:

c++

#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堆栈调用链是:

  1. 在call_run_me函数(单独线程执行)中RunMe r莫名调用析构函数
  2. 在RunMe r析构时会析构shared_ptr Client client_
  3. client_的引用计数==0,触发client_的析构
  4. client_析构触发future_析构
  5. future_析构触发std::thread::join
  6. 出现线程自己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()虚函数

c++

// 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

c++

#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

shell

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的虚表排序:

indexmain_v1中的虚表vptrmain_v2中的虚表vptr
0fun1()fun1()
1fun2()fun2()
2fun3()fun2.5()
3~MyRunMe()fun3()
4~MyRunMe()编译器增加~MyRunMe()
5~MyRunMe()编译器增加

而so中call_run_me调用虚表index的偏移量:

v1 call_run_me调用虚表indexv2 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,得到结果:

shell

~/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产生的原因。

  1. 虚函数调用是通过偏移来决定的跟函数名无关,如果三方库如果更新了虚函数,即使跟本模块无关,最好也要重新编译下,否则可能会发生诡异的问题
  2. 虚函数的二进制兼容性并不好,如果用《Linux多线程服务端编程:使用muduo C++网络库》中所推荐的函数指针的方式来实现类似虚函数的功能则是极好的