V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
yanqiyu
V2EX  ›  C++

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

  •  
  •   yanqiyu ·
    karuboniru · 2023-11-13 07:52:10 +08:00 · 1904 次点击
    这是一个创建于 378 天前的主题,其中的信息可能已经有所发展或是发生改变。

    我也觉得很奇怪,我一个做物理的,写点 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 对象初始化在动态库加载触发的时候(这里触发初始化的动态库不一定是这些对象所在的动态库,也可以是另外的动态库,这些库都会被 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 。

    ...或者,实在不行就不析构这些对象了(部分情况可行,但是有时候要求这些东西做一些清理工作就不行)。

    ...或者,不要在析构函数调用别的静态对象了,搞得心惊胆战的,好处也不多。日志不打了也不会少块肉。

    14 条回复    2023-12-30 04:05:38 +08:00
    yanqiyu
        1
    yanqiyu  
    OP
       2023-11-13 08:04:26 +08:00 via Android
    还有一件我不是很理解的事情是虽然整个控制流是__run_exit_handlers 出发的,但是 gdb 打印调用栈的时候会在_dl_fini 断开,看不到谁调用的_dl_fini ,这个奇怪的设计搞得我在一开始调试的时候一头雾水。不知道有无二进制大佬告诉我为啥。
    owt5008137
        2
    owt5008137  
       2023-11-13 09:06:09 +08:00 via Android
    protobuf 和 gRPC 也有类似的坑,烦得很。
    虽然有属性可以延后全局变量构造,但是应该不适用你这里的问题。毕竟动态库还是一个一个关闭的,如果两个动态库交叉引用的话,一个关闭之后不仅仅是全局变量析构了,其他的一些资源也无效了。
    个人建议是要么两个库互相耦合的话就打包到一起,不方便搞到一起那么通过注册的方式注册依赖,析构的时候解绑,来实现依赖反转。如果 a 依赖 c ,那么如果 c 先析构则通过某个事件回调通知 c 解绑,如果 c 先析构则主动反注册解绑。这样谁先析构都无所谓了。
    proxytoworld
        3
    proxytoworld  
       2023-11-13 09:58:05 +08:00
    @yanqiyu 不能再_dl_fini 打断点吗
    tool2d
        4
    tool2d  
       2023-11-13 10:34:41 +08:00
    我只有在 windows 下才会考虑把 dll 当成插件模式。在 linux 下, 都是把 so 当作对主程序打代码补丁。

    两者底层设计逻辑,还是有比较大区别的。
    geelaw
        5
    geelaw  
       2023-11-13 10:47:01 +08:00 via iPhone
    https://stackoverflow.com/questions/469597/destruction-order-of-static-objects-in-c

    这里面有人提到:只有同一个翻译单元内有顺序保证;析构结束的顺序是构造结束的顺序的反序(一个静态存储期对象导致另一个无嵌套关系的静态存储期对象构造时,这点很重要)。
    jones2000
        6
    jones2000  
       2023-11-13 13:38:25 +08:00
    不行就换个思路, 没必要死磕 static , 比如换成 static 指针, 创建和析构都自己控制不就行了, 没必要交给系统完成。
    yanqiyu
        7
    yanqiyu  
    OP
       2023-11-13 18:18:31 +08:00
    @jones2000 #6 确实,现在看来最简单的方法是把所有全局对象的所有权放在一个静态对象里面,让这个对象构造和析构的时候处理所有其他全局类的依赖关系。

    @geelaw #5 主要是考虑到所有析构都是记录在全局的__exit_funcs 来处理的,虽然看到了这篇 stackoverflow 但是当时还是没理解“为什么跨越了 TU 就不好使了”,所以才开始研究发生了什么。

    @proxytoworld #3 有时候好使有时候不好使。gdb 会认为调用栈突然没保存 PC ,然后回溯就断掉了
    yanqiyu
        8
    yanqiyu  
    OP
       2023-11-13 18:21:26 +08:00
    @proxytoworld #3 现在想起来可能原因是动态库结束的顺序混乱了搞坏了一些运行时的结构。毕竟这时候程序始终会死于 corrupted double-linked list 这类的报错
    因为修好了动态库依赖之后回溯正确了
    geelaw
        9
    geelaw  
       2023-11-13 18:49:17 +08:00
    @yanqiyu #7 我大概是这么想的:因为操作系统允许卸载动态库( dlclose / FreeLibrary / ExitThreadAndFreeLibrary 等),因此跨动态库很难有 C++ 对象生命周期的语义保证。
    yanqiyu
        10
    yanqiyu  
    OP
       2023-11-13 19:13:30 +08:00
    @geelaw 这一点很合理,毕竟 dlclose 的时候清理和对应动态库关联的对象。但是 DT_NEEDED 拉进来的库关联的对象也一视同仁的处理了其实比较超出我预期,不过看了下代码,_dl_call_fini 同时负责 dlclose 的清理,那么也算是合理了。
    pi1ot
        11
    pi1ot  
       361 天前
    你真是做物理的?
    yanqiyu
        12
    yanqiyu  
    OP
       360 天前
    @pi1ot 做高能物理实验的人的生活和做数据挖掘的没两样.jpg
    soft101team
        13
    soft101team  
       351 天前
    这是学计算机的吗? s-b 一样
    yanqiyu
        14
    yanqiyu  
    OP
       331 天前 via Android
    @soft101team 笑死我了,我记得是很久之前嘲讽过的自称学计算机的哥们

    回答:我不是学计算机的,你是😂😂😂
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5477 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 09:33 · PVG 17:33 · LAX 01:33 · JFK 04:33
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.