跨越动态库的时候就算借助 lazy init 的 static 对象也不能简单的假设析构顺序

查看 14|回复 0
作者:yanqiyu   
我也觉得很奇怪,我一个做物理的,写点 C++小工具,怎么就遇到了这么 nasty 的问题呢。
简单的说,有一个已有的库,提供物理过程的模拟,它带一个借助 local global object 来控制生命周期的 singleton 作为 Messenger,用来打日志。还有一个调用这个库的软件,它带一个 Factory Class ,用来构造一系列多态类。这些候选的类在自己的翻译单元内通过初始化全局变量的形式向这个 Factory Class 注册自己。然后这个 Factory Class 在构造和析构的时候都会打点日志。
Messenger 必然是先于 Factory Class 完成初始化的,因为 Factory Class 的构造函数已经打了日志了,那么 Factory Class 的构造函数返回之前 Messenger 已经完成构造并可用。
至少看到代码的时候直觉暗示我:这时候 Factory Class 一定会先于 Messenger 析构。毕竟这套系统的原理就是每个 static 对象构造函数完成的时候会通过atexit向__exit_funcs注册自己的析构函数。程序退出的时候运行时应该反向的逐个调用析构函数。这就保证了析构按照构造完成的反序进行。
好了,但是观察到的现象是程序结束的时候 Messenger 先析构,然后才是 Factory Class 。然后造成混乱,结果就是程序退出的时候必然吐一个核。虽然多数程序应该完成的功能都完成了,但是会造成很多混乱,比如在脚本里面就难以判断退出状态之类的。虽然完整的两个项目太复杂,并且其中一个还没开源,但是这里有最小重现 可以一看。
我也觉得很蒙 B ,问了一圈大佬也没获得正面的解答(为什么&怎么办)。虽然改用 Nifty Counter Idiom 大概能解决问题,但是这可能涉及大改造。对屎山动手术是我想要避免的。
然后就是快乐的打断点、看代码时间。完整的结论我写在了博客里面。快速的结论是(仅适用于比较新的 glibc ,但是考虑到很多反直觉的东西出发点都是 ELF 规范的要求所以大概对于 MacOS 也可能行为差不多)
  • C++标准规定的析构顺序是构造顺序反向只适用于一个“程序”内部,跨越动态库边界的时候虽然还是一个最终的“程序”,但已经不是 C++语法律师口中的的程序的范畴了。
  • __libc_start_main_impl 会向 __exit_funcs 注册 _dl_fini 这个函数,_dl_fini 会按照动态库的依赖关系调用 __exit_funcs 里面注册的析构函数。
  • 要是某个类构造发生在 __libc_start_main_impl 调用之前(比如上面提到的多态类通过全局变量初始化向 Factory 注册自己这种情况),那么他们的析构函数在 __exit_funcs 的位置会比 _dl_fini 靠前。
  • _dl_fini 按照动态库的依赖关系调用 _dl_call_fini,对于每个动态库它也会按照构造反向调用析构函数,但是它只会调用来自自己的 static object 的析构函数。

    所以,问题的关键就是跨越动态库&lazy init 对象初始化在动态库加载触发的时候(触发初始化的动态库不一定是这些对象所在的动态库),析构的时候动态库依赖关系优先级比构造顺序优先级高。
    然后这个项目整个项目恰好没正确指定这个顺序,甚至用了-undefined dynamic_lookup来保证只要最终的可执行文件符号都解决了就万事大吉。
    对于这个特定的软件,解决方案也很简单,用 as-needed 把调用库的软件的所有动态库动态链接到提供了 Messenger 的那个库上面。as-needed只是为了避免链接搞得太多。
    虽然看起来动态库依赖就能解决问题,但是技术上讲依然可以搞得更乱,比如我有 libab.so 有 a ,b 两个类。然后 libcd.so 有 c ,d 两个类,这四个类都会在进入 __libc_start_main_impl 之前完成构造。我希望 a 先于 c 析构、但是 d 先于 b 析构。简单的动态库依赖关系又不好使了。还是得上 Nifty Counter idiom 。
    ...或者,实在不行就不析构这些对象了(部分情况可行,但是有时候要求这些东西做一些清理工作就不行)。
    ...或者,不要在析构函数调用别的静态对象了,搞得心惊胆战的,好处也不多。日志不打了也不会少块肉。
  • 您需要登录后才可以回帖 登录 | 立即注册

    返回顶部