共享库中静态对象的销毁顺序

15

我有一个主程序 (main.cpp) 和一个共享库(test.htest.cpp):

test.h:

#include <stdio.h>

struct A {
    A() { printf("A ctor\n"); }
    ~A() { printf("A dtor\n"); }
};

A& getA();

test.cpp:

#include "test.h"

A& getA() {
    static A a;
    return a;
}

main.cpp:

#include "test.h"

struct B {
    B() { printf("B ctor\n"); }
    ~B() { printf("B dtor\n"); }
};

B& getB() {
    static B b;
    return b;
}

int main() {
    B& b = getB();
    A& a = getA();
    return 0;
}

这是我在Linux上编译这些源代码的方法:

g++ -shared -fPIC test.cpp -o libtest.so
g++ main.cpp -ltest

Linux下的输出:

B ctor
A ctor
A dtor
B dtor

当我在Windows上运行此示例(进行一些调整,如添加dllexport)之后,使用MSVS 2015/2017,我得到了以下结果:

B ctor
A ctor
B dtor
A dtor

对我来说,第一个输出似乎符合标准。例如,请参见: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4296.pdf

从第3.6.3.1段开始:

如果具有静态存储的对象的构造函数或动态初始化的完成在另一对象之前,那么第二个对象的析构函数完成的顺序在第一个对象的析构函数启动之前。

这意味着如果首先构造了B对象,则应当最后销毁它——这是在Linux上观察到的结果。但Windows的输出结果不同。这是MSVC的错误还是我漏看了什么?


@NathanOliver 我看唯一的区别是“在之前被排序”(过去式)与“强烈发生在之前”(在你的链接中)。似乎是一个小改变 - 可能只是为了减少并发构建场景的混淆。 - eXXXXXXXXXXX2
test.cpp 的代码在 dll 中吗?这是分离的二进制模块吗?main.cpp 在 exe 中,test.cpp 在 dll 中吗?如果是的话 - 这样做必须全部正确。 - RbMm
@RbMm 是的,test.cpp在dll中,而main.cpp在exe中。 - eXXXXXXXXXXX2
@eXXXXXXXXXXX2 - 在这种情况下,Windows中的顺序必须全部正确。这与编译器无关。 - RbMm
避免使用全局对象。如果无法避免使用全局对象,则将它们嵌套为单例实例获取全局函数中的静态对象。请注意,这可能会有一点性能损失,因为静态初始化器可能具有秘密的布尔值和/或互斥量。 - Eljay
显示剩余6条评论
4个回答

9
整个 DLL 的概念超出了 C++ 标准的范围。
在 Windows 中,DLL 可以在程序执行期间动态卸载。为了支持这一点,每个 DLL 将处理在其加载时构造的静态变量的销毁。结果是,静态变量将按照 DLL 卸载顺序(当它们接收到 DLL_PROCESS_DETACH 通知时)的顺序被销毁。DLLs 和 Visual C++ 运行时库行为 描述了这个过程。

绝对正确。关于这个内部实现,我们可以在 crt\src\vcruntime\utility.cpp 中查看。通过 module_local_atexit_table 符号进行搜索,了解 On-Exit Table 相关信息: - RbMm
当一个模块使用通用CRT DLL时,_onexit()、atexit()和at_quick_exit()的行为取决于该模块是EXE还是DLL。如果它是EXE,则这些函数的调用将传递给通用CRT DLL,并且回调将在其atexit函数表中注册。这样,由EXE注册的函数会在调用任何一个exit()函数时被调用。如果该模块是DLL,则它有自己的已注册函数表。当卸载DLL(在DLL_PROCESS_DETACH期间)时,将执行此表。 - RbMm

4
我看到你的分析中缺少两个方面。
编程:标准对程序执行方式有要求。你的程序由命令“g++ main.cpp -ltest”生成的可执行文件(即a.out或a.exe)组成。特别地,你的程序不包含任何它链接的共享库。因此,共享库执行的任何内容都不在标准范围内。
嗯,几乎是这样的。既然你用C++编写了你的共享库,你的libtest.so或test.dll文件就属于标准范围内,但是它独立于调用它的可执行文件。也就是说,在忽略共享库的情况下,a.exe的可观察行为必须符合标准,而在忽略可执行文件的情况下,test.dll的可观察行为也必须符合标准。
你有两个相关但技术上是独立的程序,每个程序都适用于标准。C++标准不涵盖独立程序之间的互动。
如果你需要参考资料,建议查看“翻译阶段”中的第9款([lex.phases]——在你所引用的标准版本中为第2.2节)。连接后的结果a.out是程序映像,而test.dll是执行环境的一部分。
严格顺序:你好像漏了“严格顺序”的定义。是的,输出结果在“B ctor”之前有“A ctor”。但是,仅仅由此并不能说明b的构造函数在a的构造函数之前严格排序。C++标准在[intro.execution]中(在你所引用的标准版本中为第1.9节的第13款)对“严格顺序”给出了精确定义。使用这个精确定义,可以得出如果b的构造函数在a的构造函数之前严格排序的话,那么输出结果应该是“B ctor”在“A ctor”之前。然而,反过来(你所假设的情况)是不成立的。
在评论中,您建议将“sequenced before”替换为“strongly happens before”是一个小改变。实际上,因为“strongly happens before”在新版本的标准(第6.8.2.1节[intro.races]的第12条)中也有一个精确的含义,所以这并不是小改变。结果表明,“strongly happens before”意味着“sequenced before”或另外三种情况之一。因此,措辞的变化是对该标准的有意扩大,涵盖比以前更多的情况。

2
构造函数和析构函数的相对顺序仅在静态链接可执行文件或(共享)库中定义。它由作用域规则和链接时静态对象的顺序来定义。后者也很模糊,因为有时很难保证链接的顺序。
共享库(dlls)由操作系统在执行开始时加载或可以由程序按需加载。因此,没有已知的加载这些库的顺序。结果,它们之间的构造函数和析构函数的顺序可能会有所不同。只有在单个库内部才能保证它们的相对顺序。
通常,在跨库或跨不同文件的情况下,如果构造函数或析构函数的顺序很重要,则有一些简单的技术可以实现。其中一个是使用对象的指针。例如,如果对象A要求在其之前构造对象B,则可以这样做:
A *aPtr = nullptr;
class B {
public:
    B() {
      if (aPtr == nullptr) 
         aPtr = new A();
      aPtr->doSomething();
    }
 };
 ...
 B *b = new B();

上述操作将确保在使用A之前先进行构造。在此过程中,您可以保持已分配对象的列表,或将指针、共享指针等保存在其他对象中以协调有序的销毁,例如在退出主函数之前。
因此,为了说明上述操作,我以一种基本的方式重新实现了您的示例。当然,处理它的方法肯定有多种。在此示例中,按照上述技术构建了销毁列表,并将已分配的A和B放置在该列表中,在结束时按特定顺序销毁。
test.h
#include <stdio.h>
#include <list>
using namespace std;

// to create a simple list for destructios. 
struct Destructor {
  virtual ~Destructor(){}
};

extern list<Destructor*> *dList;

struct A : public Destructor{
 A() {
  // check existencd of the destruction list.
  if (dList == nullptr)
    dList = new list<Destructor*>();
  dList->push_front(this);

  printf("A ctor\n"); 
 }
 ~A() { printf("A dtor\n"); }
};

A& getA();

test.cpp

#include "test.h"

A& getA() {
    static A *a = new A();;
    return *a;
}

list<Destructor *> *dList = nullptr;

main.cpp

#include "test.h"

struct B : public Destructor {
  B() {
   // check existence of the destruciton list
  if (dList == nullptr)
    dList = new list<Destructor*>();
  dList->push_front(this);

  printf("B ctor\n");
 }
 ~B() { printf("B dtor\n"); }
};

B& getB() {
  static B *b = new B();;
  return *b;
}


int main() {
 B& b = getB();
 A& a = getA();

 // run destructors
 if (dList != nullptr) {
  while (!dList->empty()) {
    Destructor *d = dList->front();
    dList->pop_front();
    delete d;
  }
  delete dList;
 }
 return 0;
}

1
即使在Linux上,如果您手动使用dlopen()和dlclose()打开和关闭DLL,也可能会遇到静态构造函数和析构函数调用的交叉:

testa.cpp:

#include <stdio.h>

struct A {
    A() { printf("A ctor\n"); }
    ~A() { printf("A dtor\n"); }
};

A& getA() {
    static A a;
    return a;
}

(testb.cpp类似于testa.cpp,除了将A更改为Ba更改为b之外)

main.cpp:

#include <stdio.h>
#include <dlfcn.h>

class A;
class B;

typedef A& getAtype();
typedef B& getBtype();

int main(int argc, char *argv[])
{
    void* liba = dlopen("./libtesta.so", RTLD_NOW);
    printf("dll libtesta.so opened\n");
    void* libb = dlopen("./libtestb.so", RTLD_NOW);
    printf("dll libtestb.so opened\n");
    getAtype* getA = reinterpret_cast<getAtype*>(dlsym(liba, "_Z4getAv"));
    printf("gotten getA\n");
    getBtype* getB = reinterpret_cast<getBtype*>(dlsym(libb, "_Z4getBv"));
    printf("gotten getB\n");
    A& a = (*getA)();
    printf("gotten a\n");
    B& b = (*getB)();
    printf("gotten b\n");

    dlclose(liba);
    printf("dll libtesta.so closed\n");
    dlclose(libb);
    printf("dll libtestb.so closed\n");

    return 0;
}

输出结果为:

dll libtesta.so opened
dll libtestb.so opened
gotten getA
gotten getB
A ctor
gotten a
B ctor
gotten b
A dtor
dll libtesta.so closed
B dtor
dll libtestb.so closed

有趣的是,a 的构造函数的执行被推迟到实际调用 getA() 时。对于 b 也是同样的情况。如果将 ab 的静态声明从它们的 getter 函数移动到模块级别,则构造函数会在加载 DLL 时自动调用。
当然,如果在调用 dlclose(liba)dlclose(libb) 后仍在 main() 函数中使用 ab,应用程序将崩溃。
如果正常编译和链接应用程序,则运行时环境中的代码将执行 dlopen()dlclose() 的调用。你测试的 Windows 版本似乎按照你意料之外的顺序执行这些调用。微软选择这样做的原因可能是,在程序退出时,主应用程序中的任何内容更有可能仍然依赖于 DLL 中的任何内容,而不是反过来。因此,库中的静态对象通常应在主应用程序被销毁后再被销毁。
用同样的推理,初始化顺序也应该被反转:DLL应该先于主应用程序。因此,在初始化和清理方面,Linux都做错了,而Windows至少在清理方面做得正确。

关于推迟构造函数的执行,我会称其为“预期的”而不是“有趣的”。这种行为是使用函数级静态变量有时可以避免静态初始化失败的原因。 - JaMiT
为什么应该先加载DLL?(考虑到函数级别的静态变量——在可能使用之前不需要初始化任何内容。)更好的是,操作系统应该如何知道在程序加载并有机会请求加载DLL之前要加载哪些DLL? - JaMiT
@JaMiT 当然,在加载 DLLs 之前必须加载程序。但高级别初始化的顺序(我在这里称 C++ 静态对象的创建为高级别)不受操作系统需求的限制,而是由运行时环境确定的。在那里,我的个人意见是,最好的初始化顺序应该是先初始化 C++ 库(这样每个人都可以使用 std::cout),然后是任何通用的 DLLs,然后是依赖于其他已初始化 DLLs 的 DLLs,最后是应用程序本身。 - Kai Petzke
那听起来像是一个“草人”论点。手头的问题只涉及到一个 DLL,所以所有关于多个 DLL 初始化顺序的讨论都是无意义的。此外,唯一被提到的“操作系统需求”是操作系统需要在加载后指定要加载哪些动态库——与库如何执行无关。我比之前更加困惑你的答案试图传达什么了。 - JaMiT

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接