从一次字符串拼接失败说起

2023-06-13 14:52:33 浏览数 (1)

你好,我是雨乐!

几个月前的时候,群里有一次讨论,关于单例模式实现的,其中,提到了一种使用static方式,也就是Scott Meyers提出的另一种更优雅的单例模式实现,俗称Scott Meyers单例模式。当时聊到的一个关键点是静态变量的初始化线程安全问题,今天借助本文,聊聊静态变量的另外一个问题:静态变量初始化顺序

从一个示例开始

首先看下如下代码:

static_test.h

代码语言:javascript复制
#include <string>

extern std::string str;

static_test.cc

代码语言:javascript复制
std::string str = "test";

main.cc

代码语言:javascript复制
#include "static_test.h"
#include <iostream>

static std::string msg = "hello "    str   " world!";

int main() {
  std::cout << msg << std::endl;
}

好了,在阅读下文之前,不妨先思考下,main()函数中的输出结果是什么,很多人第一反应是hello test world!,恭喜你,跟我一样,答错了~~~

现在看下编译器的结果:

代码语言:javascript复制
g   -g static_test.cc main.cc -o static_test && ./static_test
hello  world

没错,编译器的输出结果是hello world!

之所以编译器的输出与我们的预期不一致,是因为静态变量初始化顺序导致。

初始化

我们知道,对于已经初始化的全局和静态变量时存放在可执行文件的数据段(.data),对于未初始化的全局和静态变量,则在BSS段中。如果对这块没有做过深入的研究,往往很容易出错,先看下示例:

代码语言:javascript复制
struct Test {
    int i;
    Test(int ii) : i(ii) {}
    Test() {}
};

Test t1 = Test(5);
Test t2;
static Test t3;
static Test t4{5};

int i = 1;
int j;
static int k;
static int l = 1;


int main() {
    return 0;
}

相信很多人看了上面代码后给出的答案会是t1 t4 i l 在.data,t2 t3 j k在.bss。在给出答案之前,不妨看下编译器的输出结果:

g test.cpp && objdump -dj .data a.out:

代码语言:javascript复制
a.out:     file format elf64-x86-64


Disassembly of section .data:

0000000000600a88 <__data_start>:
    ...

0000000000600a90 <__dso_handle>:
    ...

0000000000600a98 <i>:
  600a98: 01 00 00 00                                         ....

0000000000600a9c <_ZL1l>:
  600a9c: 01 00 00 00                                         ....

objdump -dj .bss a.out:

代码语言:javascript复制
a.out:     file format elf64-x86-64


Disassembly of section .bss:

0000000000600aa0 <completed.6842>:
    ...

0000000000600aa8 <dtor_idx.6844>:
    ...

0000000000600ab0 <t1>:
  600ab0: 00 00 00 00                                         ....

0000000000600ab4 <t2>:
  600ab4: 00 00 00 00                                         ....

0000000000600ab8 <j>:
  600ab8: 00 00 00 00                                         ....

0000000000600abc <_ZL2t3>:
  600abc: 00 00 00 00                                         ....

0000000000600ac0 <_ZL2t4>:
  600ac0: 00 00 00 00                                         ....

0000000000600ac4 <_ZL1k>:
  600ac4: 00 00 00 00                                         ....

从上述输出可知只有i、l在.data段,其它的在.bss段,还有一个比较有意思的点就是**.bss段的数据都被0进行初始化**,针对这两个问题:

  • • t1 t2 t3 t4都调用了构造函数(有些是拷贝有些是默认构造函数)进行了初始化,但因为其类型不是POD,所以其被放在bss段
  • • 编译器默认的编译选项是**-fzero-initialized-in-bss,即对bss段进行0初始化,如果不想进行0初始化,可以使用-fno-zero-initialized-in-bss**

针对上面的输出,i、l在.data段,可称之为常量初始化,而其它变量在.bss段且被0初始化,称之为0初始化。从可执行程序的角度来说,如果一个数据未被初始化,就不需要为其分配空间,所以.data 和.bss 的区别就是 .bss 并不占用可执行文件的大小,仅仅记录需要用多少空间来存储这些未初始化的数据,而不分配实际空间,编译器往往通过memset(bss_str, len, 0)进行初始化,类似于如下这种:

代码语言:javascript复制
static void zero_fill_bss(void) 
    extern char __START_BSS[];
    extern char __END_BSS[];
 
    memset(__START_BSS, 0, (__END_BSS - __START_BSS));
}

看到这,可能大家会有个疑问,.bss段什么时候会进行真正的初始化呢?记得一开始接触全局变量和静态变量的时候,书上就有提到,在可执行程序执行之前(main函数运行之前),会进行一些初始化操作,.bss就是在这个阶段进行初始化的。也就是说.data和.bss段的数据,在main()函数执行之前就初始化完成,那么,可以得出的结论是这部分数据不存在多线程竞争的问题(main()函数执行前还不存在多线程现象)。

根据标准的定义:

Together, zero-initialization and constant initialization are called static initialization; all other initialization is dynamic initialization.

也就是说要将静态变量活全局变量初始化分类的话,可以分为静态初始化动态初始化,其中静态初始化已经在上面例子中讲到,就是说编译器在编译的过程中完成(包括常量初始化和0初始化两种),剩下的就是动态初始化:

Dynamic initialization happens at runtime for variables that can’t be evaluated at compile time2. Here, static variables are initialized every time the executable is run and not just once during compilation

动态初始化,又称为运行时初始化或者懒汉式初始化,是指在程序运行阶段才能完成的初始化,比如动态分配的内存,通过函数参数进行初始化赋值,或者使用函数返回值初始化等等,常见于函数调用方式,如下:

代码语言:javascript复制
int fun() {
  static int a = 0;
  return a;
}

int main() {
  int x = fun();
  return 0;
}

初始化顺序

在上一节中,我们聊到了编译器对静态变量的初始化相关知识点,c 标准规定,在同一个编译单元中,对全局变量或者静态变量的初始化顺序与其定义顺序一致。但是对于不同的编译单元中的静态变量的初始化顺序,标准没有做规定,也就是说假如两个全局静态变量A和B分别存在与两个.cc文件中,那么编译器对于这俩的初始化顺序是不确定的,而正是因为这个原因,才是导致了文章开头示例的输出结果不符合语气的关键。对于这种因为不同编译单元初始化顺序导致的异常,cppreference将其称之为Static Initialization Order Fiasco

The static initialization order fiasco refers to the ambiguity in the order that objects with static storage duration in different translation units are initialized in. If an object in one translation unit relies on an object in another translation unit already being initialized, a crash can occur if the compiler decides to initialize them in the wrong order. For example, the order in which .cpp files are specified on the command line may alter this order. The Construct on First Use Idiom can be used to avoid the static initialization order fiasco and ensure that all objects are initialized in the correct order. Within a single translation unit, the fiasco does not apply because the objects are initialized from top to bottom.

继续回到文章开头的示例,在程序执行main()函数之前,进行初始化操作,因为没有规定不同编译单元中的初始化顺序,所以先初始化main.cc中的静态变量msg为hello world!(因为此时static_test.cc中的str还未进行初始化),然后再初始化static_test.cc中的静态变量。接着执行main()函数,进行输出操作...

解决

既然出现了因为不同编译单元中的静态变量初始化导致,那么就需要针对性的解决这个问题,通常有如下几个方案:

  • • 将所有的静态全局变量放在一个编译单元中(如果涉及到依赖的话,需要修改顺序)
  • • 强制编译器在编译阶段进行初始化,通常有constexprconstinit两种
  • • Initialization On First Use,即在使用时候,通过函数获取静态对象的方式进行初始化:
代码语言:javascript复制
 // static_test.h
 #include <string>
 
 static std::string str;
 
 // static_test.cc
 std::string GetStr() {
   str = "test";
   reurn str;
 }
 
 // main.cc
 #include "static_test.h"
 #include <iostream>
 
 static std::string msg = "hello "    GetStr()   " world!";
 
 int main() {
   std::cout << msg << std::endl;
 }
  • • 指定初始化优先级(即顺序,以下实现仅限于gcc,msvc未做研究):
代码语言:javascript复制
  // static_test.h
  #include <string>
  
  static std::string str;
  
  // static_test.cc
  std::string __attribute__((init_priority(300))) str = "test";
  
  // main.cc
  #include "static_test.h"
  #include <iostream>
  
  static std::string __attribute__((init_priority(400))) msg = "hello "    str   " world";
  
  int main() {
    std::cout << msg << std::endl;
  }

在上述代码中指定了静态变量str的优先级300,msg的优先级400,那么在执行的时候,会先初始化str,然后初始化msg,这样就会得到预期结果。

结语

静态变量在程序中使用很常见,其引起的静态初始化顺序难题也就随之而来,对于这种初始化顺序导致的异常,通过很难察觉,由于标准没有规定执行标准,因此编译器往往也不会给出报错或者警告。所以,在写代码的时候,应该避免这种情况的发生,当有时候不得不使用静态变量的时候,需要注意是否会导致初始化顺序问题,如果遇到了,则开源参考上一节的解决方式~~

今天的文章就到这,我们下期见!

0 人点赞