【问题标题】:Can I access a static local while it is being constructed in C++?我可以在使用 C++ 构建静态本地时访问它吗?
【发布时间】:2016-03-09 10:45:20
【问题描述】:

C++ 标准保证在第一次使用时实例化静态局部变量。但是,我想知道如果我在构建静态本地对象时访问它会发生什么。我假设这是UB。 但是在以下情况下避免这种情况的最佳做法是什么?

有问题的情况

Meyers Singleton 模式在第一次使用时使用静态 getInstance() 方法中的静态局部来构造对象。现在,如果构造函数(直接或间接)再次调用getInstance(),我们将面临 静态初始化尚未完成的情况。这是一个说明问题情况的最小示例:

class StaticLocal {
private:
    StaticLocal() {
        // Indirectly calls getInstance()
        parseConfig();
    }
    StaticLocal(const StaticLocal&) = delete;
    StaticLocal &operator=(const StaticLocal &) = delete;

    void parseConfig() {
        int d = StaticLocal::getInstance()->getData();
    }
    int getData() {
        return 1;
    }

public:
    static StaticLocal *getInstance() {
        static StaticLocal inst_;
        return &inst_;
    }

    void doIt() {};
};

int main()
{
    StaticLocal::getInstance()->doIt();
    return 0;
}

在 VS2010 中,这没有问题,但 VS2015 死锁。

对于这种简单、简化的情况,显而易见的解决方案是直接调用getData(),而不是再次调用getInstance()。但是,在更复杂的场景下(以我的实际情况),这种方案是不可行的。

尝试解决方案

如果我们将getInstance() 方法更改为处理这样​​的静态本地指针(从而放弃 Meyers Singleton 模式):

static StaticLocal *getInstance() {
    static StaticLocal *inst_ = nullptr;
    if (!inst_) inst_ = new StaticLocal;
    return inst_;
}

很明显,我们得到了无限递归。 inst_ 在第一次调用时是nullptr,所以我们用new StaticLocal 调用构造函数。此时,inst_ 仍然是 nullptr,因为它只会在 构造函数完成。但是构造函数会再次调用getInstance(),在inst_中找到nullptr,从而再次调用构造函数。一次又一次,...

一种可能的解决方案是将构造函数的主体移动到getInstance()

StaticLocal() { /* do nothing */ }

static StaticLocal *getInstance() {
    static StaticLocal *inst_ = nullptr;
    if (!inst_) {
        inst_ = new StaticLocal;
        inst_->parseConfig();
    }
    return inst_;
}

这会奏效。但是,我对这种情况并不满意,因为构造函数应该构造一个完整的对象。这种情况是否可以例外是有争议的,因为它是单例。但是,我不喜欢它。

但是更重要的是,如果类有一个非平凡的析构函数呢?

~StaticLocal() { /* Important Cleanup */ }

在上述情况下,析构函数永远不会被调用。我们失去了 RAII,因此失去了 C++ 的一个重要特征!我们身处 Java 或 C# 之类的世界...

所以我们可以用某种智能指针包装我们的单例:

static StaticLocal *getInstance() {
    static std::unique_ptr<StaticLocal> inst_;
    if (!inst_) {
        inst_.reset(new StaticLocal);
        inst_->parseConfig();
    }
    return inst_.get();
}

这将在程序退出时正确调用析构函数。但它迫使我们公开析构函数。

在这一点上,我觉得我正在做编译器的工作......

回到原来的问题

这种情况真的是未定义的行为吗?还是VS2015的编译器bug?

这种情况的最佳解决方案是什么,最好不要删除完整的构造函数和 RAII?

【问题讨论】:

  • 为什么要让你的 getInstance 函数返回一个指针? IMO 可以使用参考资料。
  • parseConfig是一个成员函数,可以写成int d = getData();
  • @H.Guijt 这个问题与错误的 CLR/子系统设置有关,导致在调用 main 之前崩溃。在我的问题中,这不是问题(调试、x86、控制台应用程序)
  • 所以有人试图访问一个尚未构造的对象,而它正在被构造。你想要发生什么?

标签: c++ visual-studio-2015 static-initialization


【解决方案1】:

这会导致c++ 11 standard 的未定义行为。相关部分是6.7:

如果控制同时进入声明,而变量是 正在初始化,并发执行将等待完成 的初始化。如果控制重新进入声明 在初始化变量时递归地,行为是 未定义。

标准示例如下:

int foo(int i) {
    static int s = foo(2*i); // recursive call - undefined
    return i+1;
}

您正面临死锁,因为 MSVC 插入互斥锁/解锁以使静态变量初始化线程安全。一旦你递归调用它,你就会在同一个线程中锁定同一个互斥锁两次,这会导致死锁。

This是llvm编译器内部实现静态初始化的方式。

IMO 的最佳解决方案是根本不使用单例。大量开发人员倾向于认为singleton is anti-pattern。你提到的问题真的很难调试,因为它发生在 main.js 之前。因为全局初始化的顺序是未定义的。此外,可能涉及多个翻译单元,因此编译器不会捕获此类错误。所以,当我在生产代码中遇到同样的问题时,我不得不删除所有的单例。

如果您仍然认为单例是正确的方法,那么当您的单例对象拥有(例如,将它们作为成员保存)所有调用 GetInstance 的类时,您需要以某种方式重新构造您的代码。单例初始化。将您的类想象成所有权树,其中单例是根。将引用传递给父级,当您创建一个子级时,如果子级需要它。

【讨论】:

    【解决方案2】:

    问题是在类内部,你应该使用“this”而不是调用getInstance,特别是:

    void parseConfig() {
        int d = StaticLocal::getInstance()->getData();
    }
    

    应该是:

    void parseConfig() {
        int d = getData();
    }
    

    对象是单例,因为构造函数是私有的,因此用户不能构造任意数量的对象。假设永远只有一个对象实例,编写整个类是不好的设计。在某些时候,有人可能会像这样扩展单例的概念:

    static StaticLocal *getInstance(int idx) {
        static StaticLocal inst_[3];
        if (idx < 0 || idx >= 3)
          throw // some error;
        return &inst_[idx];
    }
    

    当这种情况发生时,如果在整个类中没有调用 getInstance(),更新代码会容易得多。

    为什么会发生这样的变化?想象一下,20 年前你正在编写一个类来表示 CPU。当然,系统中永远只有一个 CPU,所以你让它成为一个单例。然后,突然之间,多核系统变得司空见惯。您仍然只需要与系统中的内核数量一样多的 CPU 类实例,但在程序运行之前您不会知道给定系统上实际有多少内核。

    故事的寓意:使用 this 指针不仅可以避免递归调用 getInstance(),而且还可以为您的代码提供未来证明。

    【讨论】:

      【解决方案3】:

      实际上,当前形式的这段代码陷入了三向无限递归。因此它永远不会起作用。

      getInstance() --> StaticLocal()
       ^                    |  
       |                    |  
       ----parseConfig() <---
      

      要让它发挥作用,以上三种方法中的任何一种都必须妥协并走出恶性循环。您判断正确,parseConfig() 是最佳人选。

      假设构造函数的所有递归内容都放入parseConfig(),非递归内容保留在构造函数中。然后您可以执行以下操作(仅相关代码):

          static StaticLocal *s_inst_ /* = nullptr */;  // <--- introduce a pointer
      
      public:
          static StaticLocal *getInstance() {
            if(s_inst_ == nullptr)
            {   
              static StaticLocal inst_;  // <--- RAII
              s_inst_ = &inst_;  // <--- never `delete s_inst_`!
              s_inst_->parseConfig();  // <--- moved from constructor to here
            }   
            return s_inst_;
          }   
      

      这很好用。

      【讨论】:

      • 这为我的担忧提供了最佳平衡。最大的优点是析构函数可以保持私有,并且使用 RAII 来销毁单例
      【解决方案4】:

      解决此问题的一种直接方法是分离职责,在这种情况下,“无论StaticLocal 应该做什么”和“读取配置数据

      class StaticLocal;
      
      class StaticLocalData
      {
      private:
        friend StaticLocal;
        StaticLocalData()
        {
        }
        StaticLocalData(const StaticLocalData&) = delete;
        StaticLocalData& operator=(const StaticLocalData&) = delete;
      
        int getData()
        {
          return 1;
        }
      
      public:
        static StaticLocalData* getInstance()
        {
          static StaticLocalData inst_;
          return &inst_;
        }
      };
      
      class StaticLocal
      {
      private:
        StaticLocal()
        {
          // Indirectly calls getInstance()
          parseConfig();
        }
        StaticLocal(const StaticLocal&) = delete;
        StaticLocal& operator=(const StaticLocal&) = delete;
      
        void parseConfig()
        {
          int d = StaticLocalData::getInstance()->getData();
        }
      
      public:
        static StaticLocal* getInstance()
        {
          static StaticLocal inst_;
          return &inst_;
        }
      
        void doIt(){};
      };
      
      int main()
      {
        StaticLocal::getInstance()->doIt();
        return 0;
      }
      

      这样StaticLocal不调用自己,圈子就坏了。

      另外,你有更清洁的课程。如果将StaticLocal 的实现移到单独的编译单元中,静态本地用户甚至不会知道StaticLocalData 的存在。

      您很有可能会发现您不需要将 StaticLocalData 的功能包装到 Singleton 中。

      【讨论】:

        【解决方案5】:

        所有版本的 C++ 标准都有一个段落导致这种未定义的行为。在 C++98 中,第 6.7 节第 4 段。

        允许实现执行早期初始化 相同下具有静态存储时长的其他本地对象 允许实现静态的条件 在命名空间范围内初始化具有静态存储持续时间的对象 (3.6.2)。否则第一次初始化这样的对象 控制通过它的声明;考虑这样的对象 在其初始化完成时初始化。如果 初始化通过抛出异常退出,初始化是 未完成,所以下次控制进入时会再试一次 宣言。如果控制重新进入声明(递归) 在初始化对象时,行为是未定义的。

        所有后续标准都具有基本相同的段落(只有差异无关紧要 - 例如交叉引用的章节编号等)。

        您所做的是实现单例的构造函数,以便它调用构造它的函数。 getInstance() 创建对象,构造函数(间接)调用getInstance()。因此,它与上面引用的最后一句话相冲突,并引入了未定义的行为。

        与任何递归一样,解决方案是重新实现以使递归不会发生,或者防止第一次调用和任何递归调用之间的干扰。

        有三种方法可以实现这一点。

        第一个,你说过你不想要的,是构造一个对象,然后解析数据来初始化它(两阶段构造)。

        第二种是先解析数据,只有解析的数据有效(即适合用于构造对象)才构造对象。

        第三个是让构造函数处理解析(您正在尝试这样做),但如果解析的数据无效,则强制构造函数失败(您的代码不会这样做)。

        第三个例子是不理会getInstance(),并重新构造构造函数,使其永远不会调用getInstance()

        static StaticLocalData* getInstance()
        {
            static StaticLocalData inst_;
            return &inst_;
        }
        
        StaticLocalData::StaticLocalData()
        {
            parseConfig();
        }
        
        void StaticLocalData::parseConfig()
        {
             int data = getData();    // data can be any type you like
        
             if (IsValid(data))
             {
                  //   this function is called from constructor so simply initialise
                  //    members of the current object using data
             }
             else
             {
                   //   okay, we're in the process of constructing our object, but
                   //     the data is invalid.  The constructor needs to fail
        
                   throw std::invalid_argument("Construction of static local data failed");
             }
        }
        

        在上面,IsValid() 表示检查解析数据是否有效的函数或表达式。

        这种方法实际上利用了我在上面从标准中引用的段落中的倒数第二句。它的作用是确保重复调用staticLocal::getInstance() 会一直导致异常,直到解析成功。解析成功后,该对象将存在,并且不会对其进行进一步尝试(将直接返回其地址)。

        如果调用者没有catch这个异常,效果很简单——程序会terminate()。如果调用者做了catch 异常,它不应该尝试使用指针。

         try
         {
               StaticLocal *thing = StaticLocal::getInstance();
        
               //  code using thing here will never be reached if an exception is thrown
        
         }
         catch (std::invalid_argument &e)
         {
               // thing does not exist here, so can't be used
               //     Worry about recovery, not trying to use thing
         }
        

        所以,是的,您的方法引入了未定义的行为。但标准中使行为未定义的同一部分也为解决方案提供了基础。

        【讨论】:

          【解决方案6】:

          对于dtor,我想你不必担心。一旦你定义了它,它将在 main() 退出后自动调用。

          【讨论】:

          • 这和问题有什么关系?
          【解决方案7】:

          How to implement multithread safe singleton in C++11 without using <mutex>

          c++11 中的单例声明在标准上是线程安全的。在VS2015中可以通过互斥体实现。

          所以,你最后的解决方案完全适用

          StaticLocal() { /* do nothing */ }
          
          static StaticLocal *getInstance() {
             static StaticLocal inst_; 
             std::call_once(once_flag, [&inst_]() {inst_.parseConfig(); return &inst_;});
             return &inst_;
          }
          

          关于析构函数:你可以通过使用注册你的单例析构函数 int atexit(void (*function)(void));。这适用于Linux,也可能存在于Win中,作为标准库中的函数。

          【讨论】:

          • 这不是线程安全的——在inst_(安全地)初始化为nullptr之后,多个线程可以进入if块并竞相构造“the”单例。
          • 是的,你说得对,对不起我的匆忙。修复我的答案:static StaticLocal inst_; std::call_once(once_flag, [&amp;inst_]() {inst_.parseConfig(); return &amp;inst_;})
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2011-07-12
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多