parallel101 / cppguidebook

小彭老师领衔编写,现代C++的中文百科全书
https://142857.red/book/
Other
715 stars 56 forks source link

单例模式的代码涉及到动态库时会出现多实例问题,非全局唯一 #62

Closed Xushi8 closed 2 weeks ago

Xushi8 commented 3 weeks ago

godbolt 复现链接

原因

static 变量在头文件中进行实例化,而 static 变量的唯一性是动态库级别的。

导出动态库时,库会为其实例化。当你的程序第一次需要使用该对象时,它会发现自己并没有实例化这个对象(因为之前的实例化是动态库做的,属于动态库,不属于当前的程序),于是又实例化一次,导致冲突。

为什么 Linux 不会出问题?

细心的话应该能发现 godbolt 复现使用的是 mingw-clang,它工作在 Windows。因为这个 BUG 无法在 Linux 上复现。gcc 和 clang 默认会导出所有符号,而 Linux 使用的 ELF 文件格式在加载时只会处理一次符号,之后遇到同名符号时会直接复用已有的定义,从而保证了符号的唯一性。这种现象似乎避免了冲突,但实际上是“误打误撞”起到了作用。

需要注意的是,这种依赖符号加载顺序和文件格式的代码并不安全,未来若文件格式或加载策略改变,可能导致问题。因此,不建议依赖这种特性来解决符号冲突,还是应当使用更安全的方法来确保代码的可移植性和稳定性。

解决方法

个人建议使用懒汉模式,较不易出错。

题外话

应该是饿模式、懒模式,文档中使用的是 饿模式、懒模式。这里的应理解为汉子的意思。

参考链接

archibate commented 3 weeks ago

linux中没问题是因为他把inline作为weak符号,且so动态库的符号解析发生在运行时(由ld-linux.so完成)而不是编译时(由ld完成)。也就是说linux的可执行文件中的call指令如果调用了so中的函数,那么实际上这个指令会等到so加载成功时才会被ld-linux.so填充,然后才能正常执行。这就是为什么elf格式中需要保留符号名登信息,为的是ld-linux.so在装载可执行文件时能够动态修改这些call指令。 而wendous中每个exe和dll都是一个孤岛,exe或dll编译完成后符号解析就已经完成,其中的机器码完全确定,call指令中调用的dll函数地址也已确定,无法修改,也就是说pe文件中不包含任何符号信息,那么wendous是如何实现的动态加载dll并更新符号地址的呢? 是因为wendous的exe文件链接dll时,实际上并不是链接dll,而是链接了一个同名的“插桩库”,这个库和dll的名字一样,只不过后缀名是lib,这个lib里有dll导出的所有符号的插桩,只是插桩,没有实现,所以比正常生成的静态库小。dll每导出一个符号,这个自动生成的lib里就会有一个插桩,插桩的内容为动态LoadLibrary并GetProcAddress,动态获取函数的地址,并且跳转过去。 这就是为什么wendous的dll中的函数必须dllexport才能使exe访问,并且需要链接一个lib而不是dll。而linux中的so却可以直接链接并且默认所有符号都可以用,因为elf格式有完整的符号信息(即使ld链接完成),可以进一步被ld-linux.so继续动态链接。 inline的作用让一个目标中重复定义的同名函数或变量,共享同一份定义(实际上ld会随机挑选一个,把其他的删了,不进入最终文件)。 所以,wendous上inline定义全局变量,无法实现单例,是因为exe的链接器和dll的链接器各自为政。在exe.c中导入了一个单例头文件中的inline变量定义,在这个exe中只保留一个定义。在dll.c中又导入了单例头文件,在这个dll中只有一个定义。虽然在各自体内是只有一个定义了,但是exe如果加载了这个dll,一个进程中就同时有两个定义了,各自独立,互不共享。 而linux中虽然也是可执行文件和so中各有一份定义,但是elf把符号的信息和属性(包括weak属性)保留,在可执行文件启动时,ld-linux.so在看到两者的这个符号都具有weak属性后,在运行时动态把重复的剔除了,从而最终仍然只有一份定义,exe和so共享。

无法顺畅的大口呼吸,是活着的最好证明

---原始邮件--- 发件人: @.> 发送时间: 2024年10月29日(周二) 晚上10:31 收件人: @.>; 抄送: @.***>; 主题: [parallel101/cppguidebook] 单例模式的代码涉及到动态库时会出现多实例问题,非全局唯一 (Issue #62)

godbolt 复现链接

全局变量(饿汉模式)

函数内部的 static 变量(懒汉模式)

原因

将 static 变量在头文件中进行实例化,而 static 变量的唯一性是动态库级别的。

导出动态库时,库会为其实例化。当你的程序第一次需要使用该对象时,它会发现自己并没有实例化这个对象(因为之前的实例化是动态库做的,属于动态库,不属于当前的程序),于是又实例化一次,导致冲突。

为什么 Linux 不会出问题?

细心的话应该能发现 godbolt 复现使用的是 mingw-clang,它工作在 Windows。因为这个 BUG 无法在 Linux 上复现。gcc 和 clang 默认会导出所有符号,而 Linux 使用的 ELF 文件格式在加载时只会处理一次符号,之后遇到同名符号时会直接复用已有的定义,从而保证了符号的唯一性。这种现象似乎避免了冲突,但实际上是“误打误撞”起到了作用。

需要注意的是,这种依赖符号加载顺序和文件格式的代码并不安全,未来若文件格式或加载策略改变,可能导致问题。因此,不建议依赖这种特性来解决符号冲突,还是应当使用更安全的方法来确保代码的可移植性和稳定性。

解决方法

饿汉模式:不在头文件实例化而是在源文件实例化。参考:饿汉模式的解决

懒汉模式:Instance 得到单例类的函数头文件仅保留声明,实现放在源文件。参考:懒汉模式的解决

个人建议使用懒汉模式,较不易出错。

题外话

应该是饿汉模式、懒汉模式,文档中使用的是 饿汗模式、懒汗模式。这里的汉应理解为汉子、人的意思。

参考链接

C++ 单例模式跨 DLL 是不是会出问题?

C++ 中的单例模式真的“单例”吗?

— Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you are subscribed to this thread.Message ID: @.***>

archibate commented 2 weeks ago

总之,这并不是单例模式的问题,是inline的问题。如果你的单例需要跨dll,那就不能用头文件里inline这种偷懒的办法。需要乖乖分离声明和定义,在源文件里定义,在头文件里extern。

Xushi8 commented 2 weeks ago

这确实不是单例模式的问题,不过单例模式可能是最容易被用错的。毕竟全局变量可能都是老老实实 extern,但是多数人的单例模板由于设计到线程安全等问题可能都是抄的,而绝大多数的模板都犯了上述的错误。 考虑到动态库是一个比较常见的需求,因此有必要强调一下。

archibate commented 2 weeks ago

07494f6 已修复