局部静态变量实现的单例存在多个对象

背景

业务中出现日志打印失效,发现是因为管理日志对象的单例在运行过程中存在了多例的情况。下面通过还原业务场景来分析该问题。

测试代码

/* A.h */
#ifndef CALSS_A
#define CALSS_A

#include <iostream>
#include <cstddef>
class A {
public:
    static A& GetInstance();

    void SetNum(size_t num);
    size_t GetNum();

private:
    size_t m_num {0U};
};
#endif
/* A.cpp */
#include "A.h"
A& A::GetInstance()
{
    static A ins;
    std::cout << "A " << &ins << std::endl;
    return ins;
}

void A::SetNum(size_t num)
{
    m_num = num;
}

size_t A::GetNum()
{
    return m_num;
}
/* A2.h */
#ifndef CALSS_A_2
#define CALSS_A_2

#include <iostream>
#include <cstddef>


class A {
public:
    static A& GetInstance()
    {
        static A ins2;
        std::cout << "A2 " << &ins2 << std::endl;
        return ins2;
    }

    void SetNum(size_t num) 
    {
        m_num = num;
    }
    size_t GetNum()
    {
        return m_num;
    }

private:
    size_t m_num {0U};
};
#endif
/* B.h */
#ifndef CLASS_B
#define CLASS_B

#include <cstddef>

class B {
public:
    B();
    size_t GetNum();
};

#endif

/* B.cpp */
#include "B.h"
#include "A2.h"

B::B()
{
    A::GetInstance().SetNum(100U);
}

size_t B::GetNum()
{
    return A::GetInstance().GetNum();
}
#include "A.h"
#include "B.h"
#include <iostream>

int main()
{
    B b;
    A::GetInstance().SetNum(10U);
    std::cout << A::GetInstance().GetNum() << std::endl;
    std::cout << b.GetNum() << std::endl;
    return 0;
}

运行测试

通过简化的代码模拟业务中实际的依赖关系:头文件A.h中定义了类A,单例的实现在A.cpp中,生成动态库a;头文件A2.h中同样也定义了类A,单例的视线在头文件中,被B.cpp引用,生成动态库b;可执行文件a.out中会同时调用动态库a和动态库b中的接口,在实际业务中出现了多例的情况。
在这里插入图片描述

g++ A.cpp -I . -fpic -shared -o liba.so
g++ B.cpp -I . -fpic -shared -o libb.so
g++ main.cpp -L . -lb -la -I .

运行结果显示,只存在单例,获取到的是A2.h中定义的对象(libb.so)。

A2 0x7fbcdfb19068
A2 0x7fbcdfb19068
A2 0x7fbcdfb19068
10
A2 0x7fbcdfb19068
10

调整二进制动态库链接的顺序,获取到的是A.cpp中定义的对象(liba.so)。从目前测试情况分析,不会出现多例的情况,但是具体使用的符号,跟动态库链接的顺序有关系,二进制中会使用先链接的动态库的符号

g++ main.cpp -L . -la -lb -I .

A 0x7f99ef74f058
A 0x7f99ef74f058
A 0x7f99ef74f058
10
A 0x7f99ef74f058
10

从符号表分析:使用readelf读取动态库和二进制的符号表,动态库b中既存在单例获取成员函数A::GetInstance()的弱符号,又存在全局唯一对象A::GetInstance()::ins2的符号。结合上述现象,先链接动态库b时,使用的是动态库b中的A::GetInstance()符号,a中的同名符号被覆盖,后续获取到的单例是A2.h中定义的对象A::GetInstance()::ins2;后链接动态库b,使用的是动态库a中的A::GetInstance()符号,b中的同名符号被覆盖,因此获取到的单例是A.cpp中定义的对象。
readelf查看符号表-1
关于.symtab符号表:动态库a的.symtab符号表中存在A::GetInstance()::ins对象,类型为LOCAL,在编译单元外部只能通过函数A::GetInstance()访问。

.dynsym.symtab符号表的区别:

.dynsym和.symtab是ELF(Executable and Linkable Format)文件格式中两种不同的符号表(symbol table)。它们之间有以下主要区别:
1.用途不同:
.symtab是完整的符号表,包含了程序中所有的符号信息,包括静态链接和动态链接所需的符号。
.dynsym是动态链接所需的符号表,只包含那些需要在运行时动态解析的符号信息。
2.内容不同:
.symtab包含了所有类型的符号,包括函数、变量、类型等。
.dynsym只包含那些需要在运行时动态解析的符号,通常是外部符号(global/weak symbols)。
3.大小不同:
.symtab通常比.dynsym大得多,因为它包含了更多的符号信息。
这样可以减小可执行文件的大小,提高性能。
4.加载方式不同:
在加载ELF文件时,操作系统只需要加载.dynsym中的符号信息,而不需要加载完整的.symtab。
这样可以减少内存占用,提高加载速度。
5.调试用途不同:
.symtab包含了更多的调试信息,可以用于程序的调试和分析。
.dynsym只包含了运行时需要的符号信息,不包含调试信息。
总的来说,.dynsym是为动态链接优化的符号表,只包含运行时需要的符号信息,而.symtab是完整的符号表,包含了所有类型的符号信息,可用于程序的调试和分析。在构建和部署程序时,通常会保留.dynsym而去除.symtab,以减小可执行文件的大小和内存占用。

尝试打开编译器优化

前面证明链接时候的顺序不同,会加载不同内存地址的对象,但是在运行过程中还是单例。现在猜测运行过程中出现多例情况可能跟编译器的优化有关。因此,尝试打开编译的优化选项,重复上面的测试。

g++ A.cpp -I . -fpic -shared -o liba.so -O2
g++ B.cpp -I . -fpic -shared -o libb.so -O2
g++ main.cpp -L . -lb -la -I . -O2

运行结果显示,出现了多例的现象。

A2 0x7f019f611068
A 0x7f019f60c068
A 0x7f019f60c068
10
A2 0x7f019f611068
100

从符号表分析:与未打开编译器优化前最大的区别在于动态库b中单例获取成员函数A::GetInstance()的弱符号不见了,故动态库b中源码加载全局唯一对象A::GetInstance()::ins2的内存地址,动态库a中源码加载的是通过A::GetInstance()获取的对象的地址,两者地址不同。
因此,可以解释为什么在运行过程中出现了双例的情况。
在这里插入图片描述

进一步分析

动态库b中单例获取成员函数A::GetInstance()的弱符号不见了的原因:

头文件中定义的函数,特别是内联函数和模板函数,在编译和链接过程中通常会被展开或优化掉,不会产生独立的符号。

无论是链接时会存在双例的情况,还是运行时会存在双例的情况,都是不符合预期的。因此,如何避免?
很简单,单例的实现放在cpp中。

为什么相同符号定义在链接时没有报错

咨询AI结果:

如果在链接动态库时出现相同符号定义,但却没有报错的情况,这可能是由于以下几个原因造成的:

  1. 符号可见性控制:
  • 如果动态库中的符号被设置为默认隐藏或私有可见性,那么即使存在相同的符号定义,链接器也不会报告冲突。
  • 这是因为隐藏或私有可见性的符号只在本动态库内部可见,不会被其他动态库或主程序引用。
  1. 符号弱定义:
  • 如果动态库中的某些符号被定义为弱符号,那么即使存在相同的强符号定义,链接器也不会报告冲突。
  • 弱符号会被强符号覆盖,这种情况下链接器会选择使用强符号。
  1. 链接器选项设置:
  • 某些链接器选项,如 -Wl,–allow-multiple-definition,可以允许链接器接受多个相同符号的定义,而不报告错误。
  • 这种情况下,链接器会选择使用第一个遇到的符号定义。
  1. 动态库的加载顺序:
  • 如果存在多个动态库包含相同的符号定义,那么链接器会根据动态库的加载顺序来选择使用哪个符号定义。
  • 如果先加载的动态库中的符号定义被后加载的动态库中的同名符号覆盖,那么就不会报告错误。

虽然在链接时没有报错,但这种情况下可能会导致运行时出现问题,比如:

  • 使用了错误的符号定义,导致程序行为异常。
  • 动态库之间的依赖关系发生变化时,可能会出现意料之外的符号解析结果。
  • 在不同的环境或平台上,动态库的加载顺序可能不同,导致符号解析结果不一致。

为了避免这些问题,建议采取以下措施:

  1. 确保动态库中的符号可见性设置正确,隐藏不需要导出的符号。
  2. 尽量避免在动态库中使用弱符号定义。
  3. 谨慎使用允许多个符号定义的链接器选项。
  4. 仔细管理动态库的依赖关系和加载顺序,确保符号解析的一致性。
  5. 通过单元测试和集成测试,检查动态库在不同环境下的符号解析行为。 总之,即使在链接时没有报错,也需要仔细检查动态库中的符号定义,以确保程序在运行时能够正确地解析和使用这些符号。

从现象分析,应该是上述第四点导致:如果存在多个动态库包含相同的符号定义,那么链接器会根据动态库的加载顺序来选择使用哪个符号定义。这跟链接器的实现强相关。

测试过程中的小插曲

最早测试场景如下:动态库a包含类A的实现(单例在A.h中定义),动态库b依赖动态库a,二进制同时依赖动态库a和动态库b。
在这里插入图片描述

在windows环境下出现了双例的情况
在这里插入图片描述
在ubuntu环境下仍是单例
在这里插入图片描述
可能得原因是windows下和linux下g++编译器处理头文件时不完全相同。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值