【C/C++ 关键字 存储类说明符 】 线程局部变量的魔法:C++ 中 thread_local的用法

简介: 【C/C++ 关键字 存储类说明符 】 线程局部变量的魔法:C++ 中 thread_local的用法

概述

thread_local指示对象拥有线程存储期。也就是对象的存储在线程开始时分配,而在线程结束时解分配。每个线程拥有其自身的对象实例。唯有声明为 thread_local 的对象拥有此存储期。 thread_local 能与 static 或 extern 结合一同出现,以调整链接(分别指定内部或外部链接),详细的可以查阅:存储类说明符 - cppreference.com

使用 thread_local 说明符声明的变量仅可在它在其上创建的线程上访问。 变量在创建线程时创建,并在销毁线程时销毁。 每个线程都有其自己的变量副本。
thread_local 说明符可以与 staticextern 合并。这将影响变量的链接属性。


表现形式

在 C++ 中,Thread Local Storage (TLS) 是通过使用 thread_local 关键字来实现的。它可以用于修饰全局变量、静态变量或类的静态成员变量,使其成为线程局部变量。对于一个使用 thread_local 修饰的变量,每个线程都拥有一个独立的副本。以下是使用 thread_local 的一些原型示例:

  • 全局变量
  • 每个线程都有自己单独的x副本,互不干预。
thread_local int globalVar; 

  • 局部变量(静态变量)
  • 线程存储期的变量都是和线程绑定的,所以只有第一次声明时被赋值。可以理解为线程专用的static变量。不过变量的作用域依然是在本身的作用域内。
void foo() {
   thread_local int staticVar;
}

  • 类对象(静态变量)
  • 与局部变量的情况相同,创建的实例相对于thread是static的,一般情况要求我们:thread_local对象声明时赋值.

类成员变量

  • thread_local作为类成员变量时必须是static的.
  • thread_local作为类成员时也是对于每个thread分别分配了一个,而static则是全局一个.
class MyClass {
public:
   static thread_local int staticMemberVar;
};

需要注意的是,使用 thread_local 关键字修饰的变量在每个线程中都有一个独立的副本,因此每个线程对其进行的操作都不会影响其他线程。在多线程环境下,这可以帮助避免数据竞争和同步开销。


作用和适用的场景

C++ 的 Thread Local Storage(TLS)特性在多线程环境中非常有用,它允许每个线程拥有一份单独的全局或静态变量副本。适用的场景包括:

  • 避免数据竞争:当多个线程需要访问全局或静态变量时,使用 TLS 可以为每个线程提供一个变量副本,从而避免数据竞争。每个线程只操作自己的变量副本,不会影响其他线程。
  • 减少同步开销:由于每个线程都有自己的变量副本,因此在某些情况下可以减少同步开销,例如减少互斥锁的使用。这可以提高多线程程序的性能。
  • 线程独立的资源分配:当某个资源需要在多个线程之间共享,但又希望每个线程独立使用该资源时,可以使用 TLS。例如,线程独立的内存分配器、线程独立的随机数生成器等。
  • 线程特有的状态跟踪:TLS 可用于跟踪每个线程的特定状态,如错误信息、递归调用计数、线程局部缓存等。这可以使每个线程在处理自己的数据时,能够访问和操作这些特定状态。
  • 模拟线程局部变量:在一些没有原生支持线程局部变量的环境中,可以使用 TLS 来模拟这个功能。例如,在 C++11 之前的版本中,可以通过自定义的实现来模拟线程局部变量。

需要注意的是,TLS 可能会增加内存消耗,因为每个线程都有自己的变量副本。此外,在某些情况下,TLS 可能会导致一定的性能损失,因为操作系统需要在线程切换时处理线程局部数据。因此,在使用 TLS 时,请确保权衡这些因素,并在适当的场景下使用。


实现逻辑

C++ 的 Thread Local Storage(TLS)特性在底层的实现和逻辑上有很多不同的方面。主要实现方法包括编译器支持、操作系统支持和库支持。在这里,我们将概述 TLS 的一般实现原理和底层逻辑:

  • 编译器支持:编译器在生成目标代码时,对于使用 thread_local 修饰的变量,会将其分配到特殊的存储区域。这个存储区域是每个线程独立的,因此在运行时,每个线程都可以访问自己独立的变量副本。
  • 操作系统支持:操作系统通常提供一种机制来管理线程局部存储。例如,Windows 使用 TlsAlloc、TlsGetValue 和 TlsSetValue 等函数管理线程局部存储,而 Linux 使用 pthread_key_create、pthread_getspecific 和 pthread_setspecific 等函数。当一个线程访问 thread_local 变量时,操作系统会将请求重定向到这个线程对应的局部存储区域。
  • 库支持:在 C++ 标准库中,线程局部存储的功能由 thread_local 关键字和相关的库函数提供。这些库函数在底层调用操作系统提供的 API 来实现线程局部存储的功能。
  • 线程创建时的初始化:当一个新线程被创建时,运行时系统会分配一个独立的 TLS 区域,并为该区域中的 thread_local 变量进行初始化。这些初始化操作可能包括调用默认构造函数、执行初始化表达式等。
  • 线程退出时的清理:当一个线程退出时,运行时系统会负责释放线程局部存储区域,并为该区域中的 thread_local 变量调用析构函数。这个过程通常由操作系统或 C++ 运行时库自动完成。
    需要注意的是,不同的编译器和操作系统可能有不同的实现细节。在实际使用中,开发者无需了解所有这些底层逻辑,只需使用 thread_local 关键字和相关的库函数就可以享受到线程局部存储带来的便利。

示例

示例1:使用 thread_local 计算每个线程的累积和

#include <iostream>
#include <thread>
#include <vector>
thread_local int sum = 0;
void accumulate(int n) {
  for (int i = 1; i <= n; ++i) {
    sum += i;
  }
  std::cout << "Thread id: " << std::this_thread::get_id() << ", Sum: " << sum << std::endl;
}
int main() {
  std::vector<std::thread> threads;
  for (int i = 1; i <= 5; ++i) {
threads.emplace_back(accumulate, i * 10);
}
for (auto& t : threads) {
t.join();
}
return 0;
}

示例2:模拟一个简单的线程安全计数器

#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::mutex mtx;
unsigned int globalCounter = 0;
thread_local unsigned int localCounter = 0;
void incrementCounter(int n) {
  for (int i = 0; i < n; ++i) {
    ++localCounter;
    std::unique_lock<std::mutex> lock(mtx);
    ++globalCounter;
    lock.unlock();
  }
  std::cout << "Thread id: " << std::this_thread::get_id()
            << ", Local counter: " << localCounter
            << ", Global counter: " << globalCounter << std::endl;
}
int main() {
  std::vector<std::thread> threads;
  for (int i = 0; i < 5; ++i) {
    threads.emplace_back(incrementCounter, 100);
  }
  for (auto& t : threads) {
    t.join();
  }
  std::cout << "Final global counter: " << globalCounter << std::endl;
  return 0;
}

这两个示例展示了如何使用 thread_local 关键字创建线程局部变量。在第一个示例中,我们计算了每个线程的累积和。在第二个示例中,我们模拟了一个简单的线程安全计数器,它使用 thread_local 存储每个线程的局部计数,并使用互斥锁保护全局计数器。


需要注意的地方

thread_local 变量是线程局部存储的变量,它的生命周期与线程的生命周期相同。在多线程程序中,thread_local 变量可以用来保存线程相关的状态,从而避免了线程间的共享状态可能导致的同步问题。
然而,如果在使用 thread_local 变量时不小心,可能会导致内存泄漏问题。具体来说,如果一个线程结束时,它仍然持有一个指向 thread_local 变量的指针,那么这个 thread_local 变量就会一直存在,直到整个程序结束才会被释放。如果这个 thread_local 变量是一个大的对象,那么就会导致内存泄漏。
为了避免这个问题,需要确保在线程结束时及时清理 thread_local 变量。一种常见的做法是在 thread_local 变量的析构函数中清理相关的资源。例如,如果 thread_local 变量是一个指针类型,那么在析构函数中应该释放指针所指向的资源。同时,还可以使用一些 RAII 技巧,例如使用 std::unique_ptr 等智能指针,来帮助自动化资源清理的工作。


除了在 thread_local 变量的析构函数中及时清理资源外,还有一些其他的可能导致内存泄漏的情况,例如:
1.在使用 thread_local 变量时发生异常,导致析构函数没有被正确调用,从而导致资源泄漏。
2.在使用 thread_local 变量的线程中调用 std::exit 函数或者使用 std::quick_exit 函数退出程序,导致 thread_local 变量的析构函数没有被调用,从而导致资源泄漏。
3.在使用 thread_local 变量的线程中创建了一些子线程,并且这些子线程也使用了相同的 thread_local 变量,但是没有正确清理子线程中的 thread_local 变量,从而导致资源泄漏。


针对这些情况,我们需要采取相应的措施来避免内存泄漏,例如:
1.使用 RAII 技巧,例如使用 std::unique_ptr 等智能指针来管理资源,从而避免异常导致的资源泄漏。
2.在程序退出时,可以使用 std::atexit 函数来注册一个清理函数,该函数会在程序退出前被调用,从而确保 thread_local 变量的析构函数被正确调用。
3.在子线程中正确清理 thread_local 变量,可以使用类似于在主线程中清理 thread_local 变量的方式,即在 thread_local 变量的析构函数中清理相关资源。同时,还需要确保在子线程退出时,thread_local 变量的析构函数被正确调用。

总结

  • 本质上thread_local修饰后仍然是一个变量,我们依旧能够使用取地址操作者通过引用的方法传递给其他线程对其进行修改,
  • thread-local storage 和 static(或者说global) 存储很类似,每一个线程都将拥有一份这个数据的拷贝,thread_local对象的生命周期从线程开始时开始(对于全局变量),或者首先分配空间。当线程退出的时候对象析构;
  • 一般在声明时赋值,在本thread中只执行一次。当用于类成员变量时,必须是static的。
目录
相关文章
|
6天前
|
C++ 芯片
【C++面向对象——类与对象】Computer类(头歌实践教学平台习题)【合集】
声明一个简单的Computer类,含有数据成员芯片(cpu)、内存(ram)、光驱(cdrom)等等,以及两个公有成员函数run、stop。只能在类的内部访问。这是一种数据隐藏的机制,用于保护类的数据不被外部随意修改。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。成员可以在派生类(继承该类的子类)中访问。成员,在类的外部不能直接访问。可以在类的外部直接访问。为了完成本关任务,你需要掌握。
43 18
|
6天前
|
存储 编译器 数据安全/隐私保护
【C++面向对象——类与对象】CPU类(头歌实践教学平台习题)【合集】
声明一个CPU类,包含等级(rank)、频率(frequency)、电压(voltage)等属性,以及两个公有成员函数run、stop。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。​ 相关知识 类的声明和使用。 类的声明和对象的声明。 构造函数和析构函数的执行。 一、类的声明和使用 1.类的声明基础 在C++中,类是创建对象的蓝图。类的声明定义了类的成员,包括数据成员(变量)和成员函数(方法)。一个简单的类声明示例如下: classMyClass{ public: int
32 13
|
7天前
|
编译器 数据安全/隐私保护 C++
【C++面向对象——继承与派生】派生类的应用(头歌实践教学平台习题)【合集】
本实验旨在学习类的继承关系、不同继承方式下的访问控制及利用虚基类解决二义性问题。主要内容包括: 1. **类的继承关系基础概念**:介绍继承的定义及声明派生类的语法。 2. **不同继承方式下对基类成员的访问控制**:详细说明`public`、`private`和`protected`继承方式对基类成员的访问权限影响。 3. **利用虚基类解决二义性问题**:解释多继承中可能出现的二义性及其解决方案——虚基类。 实验任务要求从`people`类派生出`student`、`teacher`、`graduate`和`TA`类,添加特定属性并测试这些类的功能。最终通过创建教师和助教实例,验证代码
25 5
|
23天前
|
NoSQL Redis
单线程传奇Redis,为何引入多线程?
Redis 4.0 引入多线程支持,主要用于后台对象删除、处理阻塞命令和网络 I/O 等操作,以提高并发性和性能。尽管如此,Redis 仍保留单线程执行模型处理客户端请求,确保高效性和简单性。多线程仅用于优化后台任务,如异步删除过期对象和分担读写操作,从而提升整体性能。
53 1
|
3月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
68 1
|
3月前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
47 3
|
3月前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
31 2
|
3月前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
49 2
|
3月前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
56 1
|
3月前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
63 1