我也觉得很奇怪,我一个做物理的,写点 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 也可能行为差不多)
__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 对象初始化在动态库加载触发的时候(这里触发初始化的动态库不一定是这些对象所在的动态库,也可以是另外的动态库,这些库都会被 ld.so
加载并初始化全局对象),析构的时候动态库依赖关系优先级比构造顺序优先级高。
然后这个项目整个项目恰好没正确指定这个顺序,甚至用了-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 。
...或者,实在不行就不析构这些对象了(部分情况可行,但是有时候要求这些东西做一些清理工作就不行)。
...或者,不要在析构函数调用别的静态对象了,搞得心惊胆战的,好处也不多。日志不打了也不会少块肉。
1
yanqiyu OP 还有一件我不是很理解的事情是虽然整个控制流是__run_exit_handlers 出发的,但是 gdb 打印调用栈的时候会在_dl_fini 断开,看不到谁调用的_dl_fini ,这个奇怪的设计搞得我在一开始调试的时候一头雾水。不知道有无二进制大佬告诉我为啥。
|
2
owt5008137 2023-11-13 09:06:09 +08:00 via Android
protobuf 和 gRPC 也有类似的坑,烦得很。
虽然有属性可以延后全局变量构造,但是应该不适用你这里的问题。毕竟动态库还是一个一个关闭的,如果两个动态库交叉引用的话,一个关闭之后不仅仅是全局变量析构了,其他的一些资源也无效了。 个人建议是要么两个库互相耦合的话就打包到一起,不方便搞到一起那么通过注册的方式注册依赖,析构的时候解绑,来实现依赖反转。如果 a 依赖 c ,那么如果 c 先析构则通过某个事件回调通知 c 解绑,如果 c 先析构则主动反注册解绑。这样谁先析构都无所谓了。 |
3
proxytoworld 2023-11-13 09:58:05 +08:00
@yanqiyu 不能再_dl_fini 打断点吗
|
4
tool2d 2023-11-13 10:34:41 +08:00
我只有在 windows 下才会考虑把 dll 当成插件模式。在 linux 下, 都是把 so 当作对主程序打代码补丁。
两者底层设计逻辑,还是有比较大区别的。 |
5
geelaw 2023-11-13 10:47:01 +08:00 via iPhone
https://stackoverflow.com/questions/469597/destruction-order-of-static-objects-in-c
这里面有人提到:只有同一个翻译单元内有顺序保证;析构结束的顺序是构造结束的顺序的反序(一个静态存储期对象导致另一个无嵌套关系的静态存储期对象构造时,这点很重要)。 |
6
jones2000 2023-11-13 13:38:25 +08:00
不行就换个思路, 没必要死磕 static , 比如换成 static 指针, 创建和析构都自己控制不就行了, 没必要交给系统完成。
|
7
yanqiyu OP @jones2000 #6 确实,现在看来最简单的方法是把所有全局对象的所有权放在一个静态对象里面,让这个对象构造和析构的时候处理所有其他全局类的依赖关系。
@geelaw #5 主要是考虑到所有析构都是记录在全局的__exit_funcs 来处理的,虽然看到了这篇 stackoverflow 但是当时还是没理解“为什么跨越了 TU 就不好使了”,所以才开始研究发生了什么。 @proxytoworld #3 有时候好使有时候不好使。gdb 会认为调用栈突然没保存 PC ,然后回溯就断掉了 |
8
yanqiyu OP @proxytoworld #3 现在想起来可能原因是动态库结束的顺序混乱了搞坏了一些运行时的结构。毕竟这时候程序始终会死于 corrupted double-linked list 这类的报错
因为修好了动态库依赖之后回溯正确了 |
9
geelaw 2023-11-13 18:49:17 +08:00
@yanqiyu #7 我大概是这么想的:因为操作系统允许卸载动态库( dlclose / FreeLibrary / ExitThreadAndFreeLibrary 等),因此跨动态库很难有 C++ 对象生命周期的语义保证。
|
10
yanqiyu OP @geelaw 这一点很合理,毕竟 dlclose 的时候清理和对应动态库关联的对象。但是 DT_NEEDED 拉进来的库关联的对象也一视同仁的处理了其实比较超出我预期,不过看了下代码,_dl_call_fini 同时负责 dlclose 的清理,那么也算是合理了。
|
11
pi1ot 2023-11-30 17:26:12 +08:00
你真是做物理的?
|
13
soft101team 2023-12-09 20:53:43 +08:00
这是学计算机的吗? s-b 一样
|
14
yanqiyu OP |