头图

大家好,我是小康。

在上篇文章中,我们讲了 static 成员变量和函数的用法,这次我们来聊聊更实用的内容 — 多线程中的 static 变量线程安全问题。

多线程环境下,static 变量可能遇到两个方面的线程安全挑战:

  1. 初始化是否线程安全:当 static 变量第一次使用时,多个线程是否会同时尝试初始化,导致冲突?
  2. 后续修改是否线程安全:变量初始化后,如果多个线程同时修改,会不会发生数据竞争?

接下来,我会通过几个经典的例子,带大家看看 static 变量在多线程中的表现,先聊初始化的安全性,再讲解如何保证后续修改的安全。一起搞明白这些问题,写出更靠谱的代码!

微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

先来看看初始化:

情景 1:static 局部变量初始化线程安全问题

在多线程环境中,static 局部变量是函数内部的静态变量,虽然作用域只在函数内部,但多个线程会共享它的存储。

1.1 static 局部 int 型变量

示例代码:

int getStaticLocal() {
    static int value = 10;
    return value;
}

使用 g++ 编译生成汇编代码:

g++ -std=c++11 -o run.s -S global.cpp -masm=intel -pthread
# -masm=intel 选项告诉 GCC 生成汇编代码时使用 Intel 风格语法,而不是默认的风格:AT&T 风格。
# Intel 风格的汇编代码更简单易懂。

汇编代码解读(核心部分)

.data
.align 4
.type   _ZZ14getStaticLocalvE5value, @object
.size   _ZZ14getStaticLocalvE5value, 4
_ZZ14getStaticLocalvE5value:
    .long   10  # 静态局部变量直接初始化为10
  • .data:静态变量存储在数据段中,全局共享。
  • .align 4:确保变量地址是 4 字节对齐,优化性能。
  • _ZZ14getStaticLocalvE5value:编译器生成的变量名,直接初始化为 10

是否线程安全?

  • 这种简单类型的静态变量(如 int)在程序加载时就已经初始化完成。
  • 初始化是一次性的,且对所有线程可见,不涉及运行时的竞争,因此是线程安全的。

1.2 static 局部类对象

示例代码:

class Test {
public:
    int a, b;
    Test() { a = 10; b = 12; }
};

Test& getStaticLocal() {
    static Test value;  // 局部静态类对象
    return value;
}

汇编代码解读(核心部分):

_ZN4TestC2Ev:
.LFB1:
        // ... 构造函数初始化逻辑
        # 在这里初始化 Test 对象的成员变量(如 a, b 等)。
.LFE1:                               
  .local  _ZZ14getStaticLocalvE5value                          # 定义静态局部变量的存储
  .comm   _ZZ14getStaticLocalvE5value,8,8                  # 为静态变量分配 8 字节的空间,表示 Test 对象
  .local  _ZGVZ14getStaticLocalvE5value                      # 定义静态局部变量的初始化标志
  .comm   _ZGVZ14getStaticLocalvE5value,8,8              # 为初始化标志变量分配 8 字节的空间
        # 标志变量用于记录静态局部变量是否已经被初始化,确保线程安全。

_Z14getStaticLocalv:
    movzx   eax, BYTE PTR _ZGVZ14getStaticLocalvE5value[rip]  # 检查标志变量
    test    al, al
    je      .L3                                               # 已初始化,跳过

    lea     rdi, _ZGVZ14getStaticLocalvE5value[rip]           # 加载标志变量地址
    call    __cxa_guard_acquire@PLT                           # 加锁,确保线程安全
    test    eax, eax
    je      .L3                                               # 如果未获得锁,跳过

    lea     rdi, _ZZ14getStaticLocalvE5value[rip]             # 加载静态变量地址
    call    _ZN4TestC1Ev                                      # 调用构造函数初始化

    lea     rdi, _ZGVZ14getStaticLocalvE5value[rip]
    call    __cxa_guard_release@PLT                           # 解锁并标记完成

.L3:
    lea     rax, _ZZ14getStaticLocalvE5value[rip]             # 返回静态变量地址
    ret

核心逻辑:

1.检查标志变量:用 _ZGVZ14getStaticLocalvE5value 记录对象是否已初始化。如果已初始化,直接返回对象地址。

2.线程安全保护:

  • 调用 __cxa_guard_acquire 给标志变量加锁,确保只有一个线程可以初始化。
  • 其他线程会等待初始化完成。

3.调用构造函数:如果标志变量未初始化,调用构造函数 _ZN4TestC1Ev 初始化对象。

4.标记完成:调用 __cxa_guard_release,将标志变量设置为已初始化,解锁后其他线程可直接使用对象。

是否线程安全?
  • 由于初始化过程涉及调用构造函数,并且构造可能包含复杂操作,编译器自动生成代码(__cxa_guard_acquire__cxa_guard_release)确保线程安全。
  • 只有第一个线程会执行构造逻辑,其他线程会等待构造完成后直接使用。

小结:

简单类型(如 int):

  • 初始化在程序加载时完成,无需额外线程保护,天然线程安全。

复杂类型(如类对象):

  • 初始化需要调用构造函数,编译器会通过 __cxa_guard_acquire__cxa_guard_release 确保线程安全。
  • 只有第一个线程会初始化,其他线程等待完成后直接使用。

情景 2:static 全局变量初始化的线程安全问题

static 全局变量和 static 局部变量有点类似,区别是它的作用域更大,可以在整个文件中使用,而且从程序开始到结束,它都一直存在。

和之前的讲解一样,我们还是通过两种类型的全局 static 变量(简单类型和类对象)来分析它们的初始化过程,以及是否存在线程安全问题:

2.1 static 全局int 型变量

示例代码

static int value = 10;

int main() {
    return 0;
}

汇编代码解读(核心部分):

.data
.align 4
.type   _ZL5value, @object
.size   _ZL5value, 4
_ZL5value:
    .long   10

.text
.globl  main
.type   main, @function
main:
    mov     eax, 0
    ret

解释一下:

1、数据段定义:

  • .data 指令标记了接下来的内容属于数据段,这个段用于存储程序的全局变量和静态变量。
  • .align 4 确保变量按照 4 字节对齐,这是整型变量常见的对齐方式,有助于提高访问速度。
  • .type _ZL5value, @object.size _ZL5value, 4 声明 _ZL5value(程序中 value 变量的内部符号)是一个对象,大小为 4 字节。

2、静态变量初始化:

  • _ZL5value: 这是变量的标签,用于引用或修改变量。
  • .long 10 直接为变量 value 赋值为 10。这个值在程序加载到内存时就已经设置好了,而且是在程序的 main 代码执行前完成的。

3、主函数定义:

  • .text 指令开始了代码段的定义,这是存放程序执行代码的部分。
  • main: 标签表示主函数的开始。
  • mov eax, 0 是将 0 移到 eax 寄存器,用作函数的返回值。
  • ret 指令结束函数,返回到调用者。

是否线程安全?

  • 是的,这种简单类型的 static 全局变量初始化是线程安全的,因为它的初始化是在程序加载阶段完成的,而这时程序只有一个线程运行。

2.2 static 全局类对象

示例代码

class Test {
public:
    int a;
    int b;
    Test() {
        a = 10;
        b = 12;
    }
};

static Test value;

int main() {
    return 0;
}

汇编代码解读(核心部分):

.type _ZN4TestC2Ev, @function
_ZN4TestC2Ev:
    // 构造函数初始化逻辑
    ret

.comm _ZL5value,8,8        # 为 'value' 分配空间

_Z41__static_initialization_and_destruction_0ii:
.LFB4:
        call    _ZN4TestC1Ev  // 调用构造函数初始化

.globl _GLOBAL__sub_I_main
.type _GLOBAL__sub_I_main, @function
_GLOBAL__sub_I_main:
   // ...
    call _Z41__static_initialization_and_destruction_0ii
    ret

.section .init_array,"aw"
.quad _GLOBAL__sub_I_main   # 将初始化函数加入到初始化数组

代码解释:

1、类构造函数的汇编 (_ZN4TestC2Ev)

  • 当你创建一个 Test 类的对象时,这段代码被调用来设置对象的 ab 两个成员变量的值分别为 1012

2、全局静态变量的内存分配 (_ZL5value)

  • 程序使用 .comm 指令为名叫 valueTest 对象预留了一块内存空间,这样它就有地方存储数据了。

3、全局静态对象的初始化

  • 在程序真正开始执行 main 代码之前,有一个特殊的函数 (_GLOBAL__sub_I_main) 自动运行,它的工作是调用构造函数来初始化 value 对象。
  • 这个特殊的初始化函数通过 .init_array 部分安排在程序启动时自动执行,确保 value 准备就绪,再运行 main 函数或任何其他代码。

是否线程安全?

  • 是的,和 static 全局 int 型变量类似,整个初始化过程是在单线程环境下进行的。这就意味着,在 main 函数开始执行之前,没有其他线程在运行,因此初始化 value 的过程是线程安全的。

情景3:静态成员变量的初始化是否线程安全?

静态成员变量是属于类本身的,而不是某个对象独有的。它们的存储位置是全局的,所有对象共享同一份数据。

3.1 static 成员变量(int 型)

示例代码:

class Counter {
public:
    static int count; // 静态成员变量
};

// 静态成员变量初始化
int Counter::count =  42;

生成的汇编代码(核心部分):

.data
.align 4
.type   _ZN7Counter5countE, @object  # 定义 Counter::count 静态成员变量
.size   _ZN7Counter5countE, 4
_ZN7Counter5countE:
    .long   42                       # 初始化为 42

.text
.globl  main
.type   main, @function
main:
    mov     eax, 0                   # main 函数返回值设置为 0
    ret                              # 返回

代码说明:

1、静态成员变量的存储:

  • Counter::count 被存储在数据段(.data 段)中。
  • _ZN7Counter5countE 是编译器生成的唯一标识符,用来表示 Counter::count

2、初始化过程:

  • .long 42 表示静态变量 Counter::count 被初始化为 42
  • 这个初始化发生在程序加载时,甚至在 main 函数运行之前。

3、主函数:

  • 汇编中显示 main 函数的逻辑和静态变量的初始化无关,因为静态成员变量的初始化在程序加载阶段已经完成。

是否线程安全?

  • 是的,静态成员变量的初始化是线程安全的。
  • 原因很简单:

    • 初始化发生在程序加载阶段,这时只有一个线程在运行。
    • 初始化完成后,所有线程共享同一份数据,没有竞争问题。
微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

3.2 static 成员类对象变量

静态成员类对象和普通静态成员变量有点类似,但它需要调用类的构造函数来完成初始化,过程稍微复杂一些。我们通过示例代码和汇编来看看它是怎么初始化的,以及是否线程安全。

示例代码:

class Example {
public:
    int value;
    Example(int v) : value(v) {}  // 构造函数,初始化 value
};

class Counter {
public:
    static Example example;  // 静态成员类对象
};

// 静态成员类对象的初始化
Example Counter::example(42);

汇编代码解读(核心部分):

.text
.globl  _ZN7Counter7exampleE
.bss
.align 4
.type   _ZN7Counter7exampleE, @object
.size   _ZN7Counter7exampleE, 4
_ZN7Counter7exampleE:
    .zero   4  ; 为 Counter::example 分配 4 字节并初始化为 0

.section .init_array
.quad   _GLOBAL__sub_I__ZN7Counter7exampleE  ; 将初始化函数加入到初始化数组

.text
_Z41__static_initialization_and_destruction_0ii:
    mov     esi, 42
    lea     rdi, _ZN7Counter7exampleE[rip]
    call    _ZN7ExampleC1Ei  ; 调用 Example 的构造函数初始化 Counter::example

.text
_GLOBAL__sub_I__ZN7Counter7exampleE:
    call    _Z41__static_initialization_and_destruction_0ii  ; 调用静态初始化函数
    ret

解释:

1、对象存储:

  • _ZN7Counter7exampleECounter::example 的静态对象。
  • .bss 段中分配了 4 字节的空间,用来存储 Example 对象,并初始化为 0。

2、初始化函数:

  • _Z41__static_initialization_and_destruction_0ii 是静态成员对象的初始化函数。
  • 该函数会将 42 作为参数,调用 Example 的构造函数 _ZN7ExampleC1Ei 来初始化 Counter::example

3、程序启动时执行:

  • _GLOBAL__sub_I__ZN7Counter7exampleE 是一个特殊的初始化函数,该函数会调用第 2 步的_Z41__static_initialization_and_destruction_0ii 初始化函数。
  • 它会被注册到 .init_array 段,程序启动时会自动调用,确保 Counter::examplemain 函数执行前就被正确初始化。

是否线程安全?

是的,静态成员类对象的初始化是线程安全的。
原因如下:

1、初始化阶段是单线程:

  • Counter::example 的初始化函数 _GLOBAL__sub_I__ZN7Counter7exampleE 在程序启动时调用,而这时程序是单线程运行的,没有并发竞争。

2、初始化完成后全局共享:

  • Counter::example 在初始化完成后,所有线程共享同一个对象,后续访问不会再涉及初始化问题。

小结一下:

从 C++11 开始,static 局部对象、全局对象和静态成员对象的初始化线程安全问题已经被编译器解决了。简单来说,初始化时只有一个线程能执行,其他线程会乖乖等着,所以我们不用再担心竞争问题啦!

后续修改的线程安全问题

我们刚刚分析了 static 变量在初始化阶段的线程安全问题,接下来看看在多线程环境下对 static 变量进行修改时会发生什么。比如说,多个线程同时对同一个 static 变量执行自增操作(value++),会不会出现问题?

是否线程安全?

很遗憾,针对以上三种情景的 static 对象,后续修改都不是线程安全的
虽然初始化时没有问题,但修改时,value++ 这样的操作其实并不是一个简单的步骤,它分成了以下三步:

  1. 读取当前值:从内存中读取 value的值。
  2. 修改值:对读取的值进行加 1 的运算。
  3. 写回新值:将新值写回到内存中。

多个线程同时执行时,可能会互相覆盖,导致最终结果不正确。这种情况叫做数据竞争。

举个例子,如果没有采取任何同步手段,多个线程同时执行这三步,就可能出现如下情况:

  • 线程 A 和线程 B 同时读取 value的值,例如都是 42
  • 线程 A 将 42 + 1 = 43 写回,线程 B 也将 42 + 1 = 43 写回。
  • 最终结果是 43,而不是预期的 44

解决方案

为了让 static 变量的修改在多线程环境下是安全的,可以采用以下几种方法,以全局 static 变量为例进行说明:

1. 使用互斥锁(std::mutex

互斥锁是最常用的方式,它可以让每个线程在操作变量时都能独占这个变量,避免冲突。

#include <iostream>
#include <mutex>
#include <thread>

static int value = 0;  // 全局静态变量
std::mutex mtx;

void increment() {
    for (int i = 0; i < 1000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);  // 加锁
        value++;
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final value: " << value << std::endl;
    return 0;
}

优点:简单易用,能保证线程安全。
缺点:加锁操作会有一定的性能开销。

2. 使用原子变量(std::atomic

原子变量是一种特殊的变量,它的所有操作(例如读取、修改、写入)都是原子的,也就是说不会被其他线程打断。

示例代码:

#include <iostream>
#include <atomic>
#include <thread>

static std::atomic<int> value = 0;  // 全局原子变量

void increment() {
    for (int i = 0; i < 1000; ++i) {
        value++;  // 原子操作,线程安全
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final value: " << value.load() << std::endl;
    return 0;
}

优点:

  • 没有锁的性能开销,效率通常更高。
  • 简单,适合这种对单个变量的简单操作。

缺点:

  • 只适用于单个变量的原子操作,处理复杂逻辑时会力不从心。

3. 使用线程本地存储(Thread-Local Storage)

这种方式不直接保护共享变量,而是避免竞争,每个线程都维护自己的副本。等线程操作完成后,再统一合并结果。

示例代码:

#include <iostream>
#include <thread>
#include <vector>

static thread_local int local_value = 0;  // 每个线程的局部变量
static int global_value = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 1000; ++i) {
        local_value++;
    }
    std::lock_guard<std::mutex> lock(mtx);  // 合并到全局变量
    global_value += local_value;
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final value: " << global_value << std::endl;
    return 0;
}

优点:

  • 各线程独立计算,无需频繁加锁,性能更高。

缺点:

  • 适用于分批合并数据的场景,实时性差。

4. 使用读写锁(std::shared_mutex)— C++17引入

读写锁允许多个线程同时读取共享变量(因为读操作不会冲突),但只允许一个线程写入。这种方式可以提高性能。

示例代码:

#include <iostream>
#include <shared_mutex>
#include <thread>

static int value = 0;  // 全局静态变量
std::shared_mutex rwlock;

void readValue() {
    std::shared_lock<std::shared_mutex> lock(rwlock);  // 读锁
    std::cout << "Read value: " << value << std::endl;
}

void writeValue() {
    std::unique_lock<std::shared_mutex> lock(rwlock);  // 写锁
    value++;
}

int main() {
    std::thread t1(readValue);
    std::thread t2(writeValue);
    std::thread t3(readValue);

    t1.join();
    t2.join();
    t3.join();

    return 0;
}

优点:

  • 适合读多写少的场景,大量读操作不会互相阻塞。

缺点:

  • 如果写操作很多,性能会接近普通互斥锁。
注意:如果你的系统不支持 C++17 而无法使用 std::shared_mutex, 可以选择使用 POSIX 线程库(pthread)提供的读写锁接口,例如 pthread_rwlock_t,来实现类似的线程同步功能。

小结一下:

  • 后续修改不是线程安全的,因为操作 static 变量时会有数据竞争问题。
  • 可以用 互斥锁(简单直接但有性能开销)、原子变量(高效但适合简单操作)、线程本地存储(避免竞争但独立操作)或者 读写锁(读多写少场景下性能优越)等方式解决。
  • 如果只需要简单的线程安全操作,建议用 std::atomic,它使用方便且性能好。

选择适合自己场景的方式,静态变量在多线程中就能安全使用了!

总结:

Static 变量线程安全性总结表

以下是对各种 static 变量的初始化和后续修改线程安全性的总结:

类型初始化是否线程安全后续修改是否线程安全
static<br/> 局部 int<br/> 型变量是(在程序加载时就已经初始化完成)否(需要显式同步,如加锁或使用原子操作)
static<br/> 局部类对象是(在 main 之后运行时调用构造函数完成初始化,使用 __cxa_guard_acquire<br/> 和 __cxa_guard_release<br/> 保证初始化线程安全)否(需要显式同步,同上)
static<br/> 成员 int<br/> 型变量是(在程序加载时初始化,单线程环境)否(需要显式同步)
static<br/> 类对象成员是(在执行 main 函数之前通过全局初始化函数调用构造函数完成,单线程环境)否(需要显式同步)
static<br/> 全局 int<br/> 型变量是(在程序加载阶段完成初始化)否(需要显式同步)
static<br/> 全局类对象是(在执行 main 函数之前通过全局初始化函数调用构造函数完成,单线程环境)否(需要显式同步)

说明:

  • 简单类型的 static 变量初始化更早,发生在程序加载阶段,由操作系统加载器直接完成(还没开始执行 main 函数)。
  • static 类对象成员和全局类对象稍晚一些,通过运行时系统调用构造函数初始化(也在 main 执行前完成)。
  • static 局部对象的初始化则更晚,在程序运行时首次调用函数时才通过构造函数完成初始化(在 main 执行后完成)。

最后:

今天我们一起了解了多线程中 static 变量的线程安全问题,重点看了它们在初始化和后续修改时的表现。总结一下:

  • 初始化方面:C++11 之后,编译器已经帮我们解决了 static 变量的初始化线程安全问题,无论是局部变量、全局变量,还是类的静态成员变量,都能在初始化时自动保证线程安全,我们可以放心使用。
  • 后续修改方面:如果多个线程需要修改同一个 static 变量,还是得自己动手处理同步问题,比如用互斥锁、原子变量或者其他并发工具。

多线程开发中的线程安全问题一直是块硬骨头,但只要了解清楚 static 变量的行为,选对工具,我们也能轻松搞定!

如果觉得这篇文章对你有帮助,欢迎点赞、收藏、关注!,或者分享给更多对 C++ 编程感兴趣的朋友。也别忘了关注我的公众号「跟着小康学编程」,和我一起探索更多编程知识!下次见啦! 😊

怎么关注我的公众号?

扫下方公众号二维码即可关注。

另外,小康还建了一个技术交流群,专门聊技术、答疑解惑。如果你在读文章时碰到不懂的地方,随时欢迎来群里提问!我会尽力帮大家解答,群里还有不少技术大佬在线支援,咱们一起学习进步,互相成长!


小康
33 声望4 粉丝

一枚分享编程技术和 AI 相关的程序员 ~