C++幕后故事(十二)--局部静态对象真的线程安全?

  1. 1.结论
  2. 2.实验论证
    1. 2.1 VS2013编译器实验
    2. 2.2 VS2017编译器实验
    3. 2.3 GCC 4.8.4 编译器实验
  3. 3. 猜想
  4. 4. 让VS生成正确的代码
  5. 5. 总结

读者如果觉得我文章还不错的,,希望可以多多支持下我,文章可以转发,但是必须保留原出处和原作者署名。为了获取更好的阅读体验请关注微信公众号。更多内容请关注我的微信公众号:cpp手艺人

image

这一节我们主要学习:

1.各家编译器是否都支持局部静态对象构造是否线程安全。
2.各家编译器是如何支持局部静态对象线程安全。

1.结论

首先把结论抛出来:

1.C++11标准中明确规定了局部静态变量的初始化是线程安全的。
2.GCC 4.8.4是支持的。
3.VS 系列编译器默认不支持。

2.实验论证

2.1 VS2013编译器实验

代码如下:

/****************************************************************************
**
** Copyright (C) 2019 635672377@qq.com
** All rights reserved.
**
****************************************************************************/

#ifndef static_local_obj_h 
#define static_local_obj_h

#include <Windows.h>

#include <iostream>
#include <thread>

using std::cout; 
using std::endl;
using std::thread;

namespace static_local_obj
{
class Obj
{
public:
    Obj() { ++number; }

    int number = 0;
};

void static_local_obj()
{
    static Obj obj;

    cout << obj.number << endl; 
}

void test_static_local_obj()
{
    static_local_obj();
}

}

int main(int argc, char *argv[])
{
static_local_obj::test_static_local_obj();
return 0;
}
#endif // virtual_fun_table_h

我们老规矩看看test_static_local_obj函数反汇编代码:

00EEDBB7  mov         dword ptr fs:[00000000h],eax  
; static Obj obj;
; 从数据段读取一个数据(标志位)
00EEDBBD  mov         eax,dword ptr ds:[00F04F00h]  
; 如果这个标志位为1,就表示已经初始化过,直接跳过再次初始化操作
; 第二次再到这里的话,eax的值就为1了,这时就会跳过Obj的构造
; 达到初始化一次的作用
00EEDBC2  and         eax,1  
00EEDBC5  jne         static_local_obj::static_local_obj+6Ch (0EEDBECh)  
00EEDBC7  mov         eax,dword ptr ds:[00F04F00h]  
; 将标志位置为1
00EEDBCC  or          eax,1  
; static Obj obj
00EEDBCF  mov         dword ptr ds:[00F04F00h],eax  
00EEDBD4  mov         dword ptr [ebp-4],0  
00EEDBDB  mov         ecx,0F04EFCh  
00EEDBE0  call        static_local_obj::Obj::Obj (0EE1262h) 

我来画个流程图,帮助大家理解下这个过程:

image

这样就清晰多了,等等,好像有点问题啊。
从上面的汇编来看,好像没有锁之类的同步代码,那编译器是怎么保证多线程下的安全的。
等等,不应该啊,我再试试。或许debug下做了不为人知的优化,我再试试release下编译。
我贴下vs 2013 release模式下的汇编代码,可以看出release模式下代码很简洁:

    ; static static_local_obj::Obj obj;
001B131C  mov         eax,dword ptr ds:[001B4474h]  
001B1321  test        al,1  
    ; 如果初始化过直接跳转到01B132Dh地址
001B1323  jne         main+7Dh (01B132Dh)  
001B1325  or          eax,1  
001B1328  mov         dword ptr ds:[001B4474h],eax  
    ; obj.number = 1;
001B132D  push        1B31ACh  
    ; 给obj的number赋值为1
001B1332  mov         dword ptr ds:[1B4478h],1 

看完汇编代码还是没有发现同步的代码,难道还是自己的姿势不对?
后来我就立马google了下,看了好几篇文章,终于发现一点眉目了。
在这个地址上面:https://devblogs.microsoft.com/cppblog/c111417-features-in-vs-2015-preview/
上面说到了关于局部静态变量的线程安全在vs 2015中才支持。

image

但是vs2013已经支持C++11了,居然还不支持这个特性,可见vs2013仅仅支持C++11的部分特性。

2.2 VS2017编译器实验

由于我手头上正好有vs 2017,觉得vs 2015都能够支持了,vs 2017肯定是不在话下的。
开心的启动vs 2017,新建了一个控制台项目,代码一复制二粘贴,F5跑起来。反汇编走起来。

    ; static Obj obj;
002F2E27  mov         eax,dword ptr ds:[002FD2DCh]  
    ; static Obj obj;
002F2E2C  and         eax,1  
002F2E2F  jne         static_local_obj::static_local_obj+76h (02F2E56h)  
002F2E31  mov         eax,dword ptr ds:[002FD2DCh]  
002F2E36  or          eax,1  
002F2E39  mov         dword ptr ds:[002FD2DCh],eax  
002F2E3E  mov         dword ptr [ebp-4],0  
002F2E45  mov         ecx,offset obj (02FD2D8h)  
002F2E4A  call        static_local_obj::Obj::Obj (02F114Fh)  
002F2E4F  mov         dword ptr [ebp-4],0FFFFFFFFh  
    ; cout << obj.number << endl; 
002F2E56  mov         esi,esp  
002F2E58  push        offset std::endl<char,std::char_traits<char> > (02F1370h) 

看到的这个反汇编代码和vs 2013相差无几啊,好像还是不对。看的我心里直犯嘀咕,难道这个编译器骗了我。那我们换个平台再试试看。

2.3 GCC 4.8.4 编译器实验

把代码复制到虚拟机环境中,在编译的时候使用指令:gcc –Wall –g –std=c++11 test_local_static.cpp。反汇编代码如下:

0x0804871d <+0>:    push   ebp
0x0804871e <+1>:    mov    ebp,esp
0x08048720 <+3>:    sub    esp,0x18
0x08048723 <+6>:    mov    eax,0x804a0d8
0x08048728 <+11>:   movzx  eax,BYTE PTR [eax]
; 拿到标志位数据,判断第一个字节是否为1,
; 如果为1直接表示已经初始化过,跳转到63行执行
0x0804872b <+14>:   test   al,al
0x0804872d <+16>:   jne    0x804875c <static_local_obj::static_local_obj()+63>
0x0804872f <+18>:   mov    DWORD PTR [esp],0x804a0d8
; 下面的几行汇编代码
; 调用__cxa_guard_acquire函数,类似加锁,如果返回值为0表示已经初始化过
; 返回值为1表示还未初始化
0x08048736 <+25>:   call   0x80485a0 <__cxa_guard_acquire@plt>
0x0804873b <+30>:   test   eax,eax
0x0804873d <+32>:   setne  al
0x08048740 <+35>:   test   al,al
0x08048742 <+37>:   je     0x804875c <static_local_obj::static_local_obj()+63>
0x08048744 <+39>:   mov    DWORD PTR [esp],0x804a0e0
; 调用Obj的构造函数
0x0804874b <+46>:   call   0x80487f6 <static_local_obj::Obj::Obj()>
0x08048750 <+51>:   mov    DWORD PTR [esp],0x804a0d8
; 调用__cxa_guard_release,类似释放锁一样
0x08048757 <+58>:   call   0x80485f0 <__cxa_guard_release@plt>
0x0804875c <+63>:   mov    eax,ds:0x804a0e0
0x08048761 <+68>:   mov    DWORD PTR [esp+0x4],eax
0x08048765 <+72>:   mov    DWORD PTR [esp],0x804a040
0x0804876c <+79>:   call   0x8048580 <_ZNSolsEi@plt>
0x08048771 <+84>:   mov    DWORD PTR [esp+0x4],0x8048610
0x08048779 <+92>:   mov    DWORD PTR [esp],eax
0x0804877c <+95>:   call   0x8048600 <_ZNSolsEPFRSoS_E@plt>
0x08048781 <+100>:  leave  
0x08048782 <+101>:  ret

我画出流程图帮助大家理解下:

image

这个__cxa_guard_acquire貌似就是个类似锁一样的玩意啊,百度了下,果然如此。它有挂起其他线程的作用。
GCC的实现其实感觉非常相似我们写单例模式的double check,这里它也进行了两次判断。

1.先判断标志位是否为1,为1就跳过初始化操作
2.再判断__cxa_guard_acquire返回值是否为1,如果不是1就跳过初始化

3. 猜想

到这里为止,我基本上能确定vs编译器的确没有实现。
但是我想了想,vs编译器应该是实现的,毕竟标准是这么规定的。难道vs编译器在编译的时候需要加上什么参数之类的东西?
后来经过我两天的不断搜索,终于在microsoft的官网地址:https://docs.microsoft.com/en-us/cpp/build/reference/zc-threadsafeinit-thread-safe-local-static-initialization?view=vs-2015,发现了蛛丝马迹。

The /Zc:threadSafeInit compiler option tells the compiler to initialize static local (function scope) variables in a thread-safe way, eliminating the need for manual synchronization. Only initialization is thread-safe. Use and modification of static local variables by multiple threads must still be manually synchronized. This option is available starting in Visual Studio 2015. By default, Visual Studio enables this option.

我简单的翻译下:

给编译器加上/Zc:threadSafeInit指令,就是告诉编译器确保初始化局部静态变量线程安全,不用手动的去同步操作。只有在初始化是线程安全的。在多线程下使用和修改局部静态变量,则需要手动同步。这个编译选项在vs2015才开始有的。默认是vs是开启的。

但是根据我测试下来发现,vs其实默认是关闭而不是开启的。vs编译器蒙了我啊。。。
这里我不免好奇了下,为什么vs编译器默认情况下是关闭这个选项的。
我在官网上发现了这句话:

If you know that thread-safety is not required, use this option to generate slightly smaller, faster code around static local declarations.

哦,我大概知道了。其实编译器默认关闭这个选项是为了在局部静态变量的声明的地方生成更高效的代码。

4. 让VS生成正确的代码

既然这个选项只有在vs2015以上才行,那么我还是使用vs2017应该没有问题。
打开项目的属性,在如图所示的地方加上指令/Zc:threadSafeInit

image

接下来,我们在反汇编看看生成的代码:

00C12E27  mov         eax,dword ptr [_tls_index (0C1D31Ch)]  
; static Obj obj;
00C12E2C  mov         ecx,dword ptr fs:[2Ch]  
00C12E33  mov         edx,dword ptr [ecx+eax*4]  
; 拿到存在数据区的标志位
00C12E36  mov         eax,dword ptr ds:[00C1D2DCh]  
00C12E3B  cmp         eax,dword ptr [edx+104h]  
; 第一次判断
00C12E41  jle         static_local_obj::static_local_obj+9Eh (0C12E7Eh)  
00C12E43  push        0C1D2DCh  
; 同步操作
00C12E48  call        __Init_thread_header (0C11136h)  
00C12E4D  add         esp,4  
00C12E50  cmp         dword ptr ds:[0C1D2DCh],0FFFFFFFFh  
; 第二次判断
00C12E57  jne         static_local_obj::static_local_obj+9Eh (0C12E7Eh)  
00C12E59  mov         dword ptr [ebp-4],0  
00C12E60  mov         ecx,offset obj (0C1D2D8h)  
; 调用对象构造函数
00C12E65  call        static_local_obj::Obj::Obj (0C1114Fh)  
00C12E6A  mov         dword ptr [ebp-4],0FFFFFFFFh  
00C12E71  push        0C1D2DCh  
; 解锁同步操作
00C12E76  call        __Init_thread_footer (0C1121Ch)  
00C12E7B  add         esp,4  
; cout << obj.number << endl; 
00C12E7E  mov         esi,esp  

果然加了编译选项之后,这个汇编变的很晦涩难懂了,但是我们主要看懂其中的关键点就行了。其实这个套路和在GCC是一样的,都进行了两次判断,上面的汇编代码也做了注释。只是VS下在同步是调用了Init_thread_header来确保是否线程安全。
我们继续跟踪下反汇编的代码,跟进去看下
Init_thread_header到底是个什么东西。这里我就贴出详细代码。这个函数位于thread_safe_statics.cpp文件中。

// Control access to the initialization expression.  Only one thread may leave
// this function before the variable has completed initialization, this thread
// will perform initialization.  All other threads are blocked until the
// initialization completes or fails due to an exception.
extern "C" void __cdecl _Init_thread_header(int* const pOnce) noexcept
{
    _Init_thread_lock();
    // Uninitialized =0
    if (*pOnce == Uninitialized)
{
    // BeingInitialized= -1
        *pOnce = BeingInitialized;
    }
    else
    {
        while (*pOnce == BeingInitialized)
        {
            // Timeout can be replaced with an infinite wait when XP support is
            // removed or the XP-based condition variable is sophisticated enough
            // to guarantee all waiting threads will be woken when the variable is
            // signalled.
            _Init_thread_wait(XpTimeout);

            if (*pOnce == Uninitialized)
            {
                *pOnce = BeingInitialized;
                _Init_thread_unlock();
                return;
            }
        }
        _Init_thread_epoch = _Init_global_epoch;
    }

    _Init_thread_unlock();
}

又回到我们熟悉的代码了,原来这里面加锁同步了。pOnce为0表示没有初始化过,pOnce为-1表示初始化过。
至此我们发现编译器在确保局部静态对象初始化线程安全的做法都是一样的

1.都会有个double check
2.调用某种类似锁的机制,可以挂起其他线程,确保初始化线程安全。

5. 总结

在整个实验验证下来,GCC是按照标准实现的。vs2013是不支持局部静态对象线程安全,
vs2015以上默认是关闭的,必须加上/Zc:threadSafeInit选项才支持。
当我看到了编译器是怎么实现线程安全的时候,我心中不免窃喜到,wow,原来那些大牛们也是这种方式实现的。这和我们经常写的单例模式中的double check原理如出一辙啊。
最后我感觉作为一名C++程序员不能太依赖编译器。毕竟C++标准委员会只规定了C++所支持的特性,但是这些特性怎么实现的却是各个厂家的编译器自由发挥,所以这块有很大的差异性。

这里我贴出,C++标准中关于局部静态变量的描述(在标准草案的6.7节中):

such a variable is initialized the first time control passes through its declaration; such a variable is considered initialized upon the completion of its initialization. If the initialization exits by throwing an exception, the initialization is not complete, so it will be tried again the next time control enters the declaration. If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization. If control re-enters the declaration recursively while the variable is being initialized, the behavior is undefined.

关于初始化有几个注意点:

1.只有在代码第一次执行到这里才会去初始化。
2.如果第一次初始化失败,第二次还是会重新初始化。
3.当有多个线程初始化时,线程会被阻塞,只有等到初始化完毕才会继续往下执行。
4.递归初始化是未定义行为。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 635672377@qq.com

文章标题:C++幕后故事(十二)--局部静态对象真的线程安全?

文章字数:2.9k

本文作者:刘世雄

发布时间:2020-01-10, 14:14:25

最后更新:2020-01-13, 01:48:36

原始链接:http://lsxcpp.com/2020/01/10/cpp-12-local-statci-thread-safe/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录