天天看点

C++11的thread_local原理和应用范例

作者:IT民工冯老师

前言

在内存模型之上,C++提供了线程加锁的并发模型来消除数据竞争(比如全局数据。数据竞争可能产生极为隐晦的并发错误)。但是,线程加锁级别的并发是应用程序使用并发的比较差的模型。

C++11引入了thread_local来保证线程访问全局数据的安全,thread_local告诉线程进行数据本地存储(线程本地存储一个数据备份),从而消除线程间数据竞争的一种方式。

thread_local概念

thread_local是C++11引入的新特性,是一个关键字,用于修饰变量(thread_local关键字和static、extern关键字在使用上是不冲突的),告诉线程对此关键词修饰的数据进行本地存储。

使用的头文件:#include< thread>

thread_local与“线程加锁”的区别

thread_local:把全局数据拷贝出一份自己使用,从进程内部所有线程共用变成线程内部私有。随后线程使用的变量就是这个备份数据,不再关心原全局数据的数值。

(进程内部的数据在那里,只是系统给当前线程分配了同样类型同样名称的变量,专供本线程使用。)

线程加锁的方式:全局数据进程内部所有线程共用,每个线程都可以对这个数据进行读和写,为了保证数据同一时间只能有一个修改,所以加锁进行限制。

thread_local应用场景

为了防止变量值的不可预测性,保证各线程内变量值的互不干扰,想要达到线程内独有的全局变量的场合。

举例(仅供参考):

①修饰全局业务唯一性的某个全局变量A,在fun_A函数中进行A的赋值(比如,随机数、随机串、线程池个数等赋值计算)。

以前当前进行内部所有线程都共用A和fun_A,但是根据业务扩展需要,每个线程内部都需要维护一份业务唯一性数据。但是业务特殊性导致线程之间业务差异比较大,无法通过拷贝多个进程实现多种业务。

所以为了修改方便,可以选择对A的变量增加thread_local修饰的方案。

thread_local的使用范例

thread_local主要是用来修饰变量,可以修饰变量的种类有以下四种:

1)全局变量

2)局部变量

3)类对象

4)类成员变量

下面我们以全局变量和局部变量为例,来说明thread_local修饰变量的效果。

示例代码:

#include <iostream>
#include <thread>

int g_a = 10;
thread_local int g_b = 20;
static int g_c = 30;
thread_local static int g_d = 40;

void fun(const std::string& name)
{
    for (int i = 0; i < 3; ++i)
    {
        thread_local  int temp = 1;  //只在每个线程创建时初始化一次
        temp++;
        std::cout << " thread[" << name << "]: & temp=" << &temp << "  temp =" << temp << std::endl;
    }

    for (int j = 0; j < 3; ++j)
    {
        thread_local  static int s_temp_2 = 1;  //只在每个线程创建时初始化一次
        s_temp_2++;
        std::cout << " thread[" << name << "]:  &s_temp_2=" << &s_temp_2 << "  s_temp_2 =" << s_temp_2 << std::endl;
    }
}

void test_fun(const std::string& name)
{
    std::cout << std::endl << name << " 子线程ID :" << std::this_thread::get_id() << std::endl;
    if (name == "t2")//在t2线程定义变量,用以区分不同线程的内存分配地址
    {
        int t_2 = 5;
        std::cout << " 局部变量 &t_2=" << &t_2 << " t_2=" << t_2 << std::endl;
    }

    //从子线程查看数据的地址
    g_a++;
    g_b++;
    g_c++;
    g_d++;
    std::cout << "&g_a=" << &g_a << " g_a=" << g_a << std::endl;
    std::cout << "&g_b=" << &g_b << " g_b=" << g_b << " <-thread_local" << std::endl;
    std::cout << "&g_c=" << &g_c << " g_c=" << g_c << std::endl;
    std::cout << "&g_d=" << &g_d << " g_d=" << g_d << " <-thread_local" << std::endl;

    fun(name);//测试局部数据
}

int main()
{
    std::cout << "主线程ID :" << std::this_thread::get_id() << std::endl;
    int temp = 5;
    std::cout << "局部变量 &temp=" << &temp << " temp=" << temp << std::endl;

    //查看主线程数据的地址
    std::cout << "&g_a=" << &g_a << " g_a=" << g_a << std::endl;
    std::cout << "&g_b=" << &g_b << " g_b=" << g_b << " <-thread_local" << std::endl;
    std::cout << "&g_c=" << &g_c << " g_c=" << g_c << std::endl;
    std::cout << "&g_d=" << &g_d << " g_d=" << g_d << " <-thread_local" << std::endl;

    std::thread t1(test_fun, "t1");

    //为了方便通过屏幕输出查看结果,通过sleep防止不同线程输出顺序错乱
    std::this_thread::sleep_for(std::chrono::seconds(1));

    std::thread t2(test_fun, "t2");

    //等待线程处理结束
    t1.join();
    t2.join();

    return 0;
}           

运行结果:

C++11的thread_local原理和应用范例

VS2022运行结果

结论:

1.编译器把thread_local修饰的变量(全局、静态全局、局部、静态局部等被修饰的变量)都放在一个专门的空间(不是进程的全局区,也不是局部变量区域)进行存储。比如图中的黄色部分,都分配在15BC52Exxxx区间。

2.thread_local修饰的全局变量,主线程使用单独的一份内存(比如图中的红色部分),所有子线程使用共同的内存(比如图中的蓝色部分)。

3.thread_local修饰的变量,数值被隔离、被备份,即数据是当前线程内部私有,不会和其他线程相互影响。(线程退出会销毁这些"被备份"的数据)

4.thread_local修饰的变量,在主线程中初始化。

5.局部变量的thread_local和局部static变量效果很相似,第一次运行时候进行初始化,从第二次以后就跳过初始化,直接使用该变量。局部thread_local的生命周期是在线程启动时候进行初始化,到线程退出时候进行析构销毁。

补充:进程和线程的区别

①概念角度

对操作系统来说,进程是资源分配的基本单位,而线程则是任务调度的基本单位。

内核中的任务调度实际是在调度线程,进程只是给线程提供虚拟内存、全局变量等资源。

线程产生的原因:进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点:

进程在同一时间只能干一件事。进程在执行的过程中如果阻塞,整个进程就会挂起,即使进程中有些工作不依赖于等待的资源,仍然不会执行。

因此,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时空开销,提高并发性。

②上下文切换角度

线程上下文切换时,共享相同的虚拟内存和全局变量等资源不需要修改。而线程自己的私有数据,如栈和寄存器等,上下文切换时需要保存。

当进程中只有一个线程时,可以认为进程就等于线程。

当进程拥有多个线程时,这些线程会共享父进程的资源(即共享相同的虚拟内存和全局变量等资源)。这些资源在上下文切换时是不需要修改的。

另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

因此线程上下文切换有两种情况:

·前后两个线程属于不同进程,因为资源不共享,所以切换过程就跟进程上下文切换是一样的;

·前后两个线程属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

因为进程切换,消耗的资源大。所以涉及到频繁的切换,使用线程要好于进程。

③内存角度

同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间;

一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃会导致整个进程崩溃,所以多进程比多线程健壮。

每个独立的进程有一个程序的入口、程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。

线程内私有:线程栈,寄存器,程序寄存器

线程间共享:堆,地址空间,全局变量,静态变量

进程内私有:地址空间,堆,全局变量,栈,寄存器

④使用场景

需要频繁创建销毁的优先用线程。比如,Web 服务器来一个连接建立一个线程,连接断了就销毁线程

需要进行大量计算的优先使用线程。所谓大量计算,就是要耗费很多 CPU,切换频繁,这种情况下线程是最合适的。最常见的是图像处理、算法处理。

强相关的处理用线程,弱相关的处理用进程。

补充:进程内存空间的分布

进程的虚拟地址空间图示如下:

C++11的thread_local原理和应用范例

图片来自网络

栈段:

  1. 为函数内部的局部变量提供存储空间。

  2. 进行函数调用时,存储“过程活动记录”。

  3. 用作暂时存储区。如计算一个很长的算术表达式时,可以将部分计算结果压入堆栈。

堆段:

  程序通过malloc或new自己开辟的存储空间。

数据段:

  BSS段存储未初始化或初始化为0的全局变量、静态变量。数据段存储经过初始化的全局和静态变量。

代码段:

  又称为文本段。存储可执行文件的指令;也有可能包含一些只读的常数变量,例如字符串常量等。

继续阅读