8、线程局部存储:__thread关键字的使用、线程局部存储的原理、TLS与全局变量的对比、实际应用场景

说到多线程编程,有个问题一直挺让人头疼——数据共享。全局变量吧,所有线程都能看到,得加锁。局部变量吧,又只在函数内部有效。那有没有一种变量,每个线程都有自己的独立副本,互不干扰?

有的。这就是线程局部存储(Thread Local Storage,简称TLS)。

我个人习惯把TLS叫做「线程的私有全局变量」。听起来有点矛盾?其实不矛盾。它作用域是全局的,但每个线程访问的是自己的那份拷贝。嗯,就像每个人都有自己的水杯,虽然都叫「水杯」,但里面的水各喝各的。

8.1 __thread关键字的使用

在C语言里,实现TLS最直接的方式就是用__thread关键字。这是GCC和Clang都支持的扩展,C11标准里也有_Thread_local,用法差不多。

先看个最简单的例子:

#include <stdio.h>
#include <pthread.h>

__thread int tls_var = 0;

void* thread_func(void* arg) {
    tls_var = (int)(long)arg;
    printf("线程 %ld: tls_var = %d\n", (long)arg, tls_var);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread_func, (void*)1);
    pthread_create(&t2, NULL, thread_func, (void*)2);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("主线程: tls_var = %d\n", tls_var);
    return 0;
}

运行结果:

线程 1: tls_var = 1
线程 2: tls_var = 2
主线程: tls_var = 0

看到了吗?三个线程各自操作tls_var,互不影响。主线程的值还是0,线程1是1,线程2是2。这就是TLS的魔力。

使用要点:
  • __thread只能修饰全局变量或静态局部变量,不能修饰普通局部变量
  • 只能修饰POD类型(基本类型、指针、结构体等),不能修饰有构造函数的C++对象
  • 声明时最好初始化,虽然不初始化也会默认清零,但显式初始化更安全

8.2 线程局部存储的原理

你可能会问:这玩意儿底层是怎么实现的?

说白了,每个线程在创建时,系统会为它分配一块独立的内存区域,专门存放TLS变量。这块区域通常叫「线程控制块」(TCB)或者「线程本地存储段」。

我画个图帮你理解:

主线程 全局变量: 0x1000 TLS副本: tls_var = 0 线程1 全局变量: 0x1000 TLS副本: tls_var = 1 线程2 全局变量: 0x1000 TLS副本: tls_var = 2 全局变量区(共享) int global_var = 42 (所有线程看到同一个值) TLS原理:每个线程拥有独立的变量副本 全局变量只有一个实例,TLS变量每个线程各有一份 访问TLS变量时,通过线程ID找到对应的副本地址

实现方式主要有两种:

  • 静态TLS:编译时就确定每个线程需要的TLS变量大小,分配固定偏移。访问速度快,但不够灵活。
  • 动态TLS:运行时通过系统调用(如pthread_key_create)动态分配。灵活,但每次访问都要查表,略慢。

__thread属于静态TLS。编译器在生成代码时,会把TLS变量的访问翻译成「基址+偏移量」的形式。基址是线程控制块的地址,偏移量是编译时确定的。所以访问速度几乎和普通全局变量一样快。

关键点:每个线程都有自己的栈空间,TLS变量就存放在线程的私有数据区里。线程切换时,TLS的基址寄存器也会跟着切换。这就是为什么不同线程访问同一个TLS变量名,却得到不同值的原因。

8.3 TLS与全局变量的对比

我整理了一张对比表,方便你直观理解:

对比维度 全局变量 TLS变量
存储位置 数据段(.data/.bss) 线程私有存储区
可见性 所有线程共享 每个线程独享
线程安全 不安全,需要加锁 天然安全,无需加锁
访问速度 最快(直接寻址) 略慢(需要间接寻址)
内存占用 固定一份 每线程一份,N线程占用N倍
初始化时机 程序启动时 线程创建时
适用场景 共享状态、配置信息 线程上下文、缓存数据

举个例子你就明白了。假设你写一个日志库,每个线程需要记录自己的日志缓冲区。用全局变量的话,所有线程往同一个缓冲区写,得加锁,性能差。用TLS的话,每个线程写自己的缓冲区,最后再合并,完全不用锁。

注意:TLS变量虽然线程安全,但要注意内存占用。如果创建了1000个线程,每个TLS变量占1KB,那就是1MB内存。我曾经在项目中遇到过,一个结构体TLS变量没注意大小,结果线程一多,内存直接爆了。嗯,从那以后我每次用TLS都会算一下内存开销。

8.4 实际应用场景

TLS在实际项目中用得挺多的。我挑几个典型的说说:

场景一:errno变量

你肯定用过errno吧?它就是TLS的经典案例。每个线程都有自己的errno,线程A调用函数出错设置errno = EINVAL,不会影响线程B的errno。如果errno是普通全局变量,多线程下根本没法用。

场景二:线程私有缓存

比如数据库连接池,每个线程可以缓存自己的连接。用TLS存储连接句柄,避免每次查询都去连接池取,减少锁竞争。

__thread DBConnection* tls_db_conn = NULL;

DBConnection* get_db_connection() {
    if (tls_db_conn == NULL) {
        tls_db_conn = create_new_connection();
    }
    return tls_db_conn;
}

场景三:日志系统

高性能日志库常用TLS做缓冲区。每个线程先把日志写到自己的缓冲区,满了再统一刷到文件。这样避免了全局锁,写入速度能提升好几倍。

场景四:随机数生成器

rand()函数内部有全局状态,多线程调用会有问题。用TLS给每个线程维护一个独立的随机数种子,既安全又高效。

__thread unsigned int tls_seed = 0;

int thread_safe_rand() {
    if (tls_seed == 0) {
        tls_seed = time(NULL) ^ pthread_self();
    }
    return rand_r(&tls_seed);
}
我的建议:TLS适合存储「线程私有的、频繁访问的、初始化成本高」的数据。比如线程ID、错误码、临时缓存等。但别滥用,每个TLS变量都会占用线程的私有内存空间,线程多了内存开销不小。

好了,关于线程局部存储就聊这么多。说白了,TLS就是给每个线程配了个「私人储物柜」,放什么自己说了算,别人碰不着。用好了,能省掉不少锁的麻烦。


公众号:蓝海资料掘金营,微信deep3321