线程本地存储 TLS 详解 —— pthread_setspecific 用法、场景、原理
一个 C 库函数失败后设了 errno = EINVAL,多线程并发跑时,为什么每个线程都能拿到属于自己的错误码、不会互相覆盖?答案是 TLS(Thread Local Storage,线程本地存储)。这篇把 pthread_setspecific 这套 API 的用法、典型场景、底层机制讲清楚。
0. 引言:errno 之谜
C 标准库到处都是这种代码:
fd = open("/some/file", O_RDONLY);if (fd < 0) { perror("open failed"); /* 内部读 errno */ return -1;}
errno 是个全局变量。但多线程同时调 syscall 时它怎么不互相覆盖?
/* 线程 A */fd_a = open("file_a", O_RDONLY); /* 失败:errno = ENOENT *//* 线程 B(同一时刻) */fd_b = open("file_b", O_RDONLY); /* 失败:errno = EACCES *//* 之后 A 读 errno,会读到 ENOENT 还是 EACCES? */
如果 errno 是普通全局变量,那肯定撞车。但实测下来 A 读到的是 ENOENT、B 读到的是 EACCES,互不影响。
秘密在 glibc 的实现:
/* glibc 里 errno 实际上是这样的宏 */#define errno (*__errno_location())extern int *__errno_location(void);
__errno_location() 返回的是当前线程独有的一块内存地址 —— 这就是 TLS。
每个线程都有一份自己的 errno,互不干扰。这正是 pthread_setspecific 这套 API 要解决的通用问题。
1. TLS 是什么
全局变量 在所有线程间共享,栈变量 是每次函数调用一份,TLS 变量 是介于两者之间:每个线程独有,跨函数调用持久存在。
| | |
|---|
| | |
| | |
| TLS 变量 | 当前线程 | 当前线程的整个生命周期 |
malloc | | |
形象点说:每个线程都有一张"私人储物柜",柜子上有编号(key),所有线程的柜子编号是统一的(全局),但每个柜子里放什么东西完全独立。
2. pthread_setspecific 这套 API
2.1 四个核心函数
#include<pthread.h>/* 创建一个 key(全局唯一,所有线程共用同一个 key) */intpthread_key_create(pthread_key_t *key, void (*destructor)(void *));/* 销毁 key */intpthread_key_delete(pthread_key_t key);/* 写:把 value 关联到当前线程的这个 key 上 */intpthread_setspecific(pthread_key_t key, constvoid *value);/* 读:拿当前线程的这个 key 对应的 value */void *pthread_getspecific(pthread_key_t key);
记住关键不变量:
- key 是全局的
- value 是线程私有的:同一个 key,每个线程能读/写自己的 value
- value 默认是 NULL:新线程刚启动时
pthread_getspecific(key) 返回 NULL
2.2 最小例子:手写 my_errno
复刻一下 glibc 的 errno 思路:
#include<pthread.h>#include<stdio.h>#include<stdlib.h>static pthread_key_t my_errno_key;static pthread_once_t once = PTHREAD_ONCE_INIT;/* 线程退出时自动调,释放 malloc 的内存 */staticvoidmy_errno_destructor(void *value){ free(value);}staticvoidinit_key(void){ pthread_key_create(&my_errno_key, my_errno_destructor);}int *my_errno_location(void){ pthread_once(&once, init_key); int *p = pthread_getspecific(my_errno_key); if (!p) { /* 第一次访问:分配空间并存到 TLS */ p = malloc(sizeof(int)); *p = 0; pthread_setspecific(my_errno_key, p); } return p;}#define my_errno (*my_errno_location())void *worker(void *arg){ int id = *(int *)arg; my_errno = 100 + id; /* 每个线程写自己的 my_errno */ sleep(1); /* 故意让各线程交叉 */ printf("thread %d: my_errno = %d\n", id, my_errno); /* 拿到的是自己的 */ return NULL;}intmain(void){ pthread_t tids[4]; int ids[4] = {0, 1, 2, 3}; for (int i = 0; i < 4; i++) pthread_create(&tids[i], NULL, worker, &ids[i]); for (int i = 0; i < 4; i++) pthread_join(tids[i], NULL); return 0;}
输出(每个线程读到的都是自己写的值,没有任何 mutex):
3. destructor:自动清理资源
pthread_key_create 的第二个参数是个回调,线程退出时会被自动调用,参数是该线程对应这个 key 的 value:
static void my_destructor(void *value) { free(value); /* 或者 close(fd)、释放 socket、关闭 db handle... */}pthread_key_create(&key, my_destructor);
3.1 destructor 的调用时机
只在线程退出时被调,而不是 setspecific 覆盖旧 value 时:
char *p1 = malloc(100);pthread_setspecific(key, p1); /* 写入 p1 */char *p2 = malloc(100);pthread_setspecific(key, p2); /* 写入 p2,p1 没人 free,泄漏!*/
如果你打算在中间 setspecific 替换旧 value,得自己先 free 旧的:
void *old = pthread_getspecific(key);free(old);pthread_setspecific(key, p2);
3.2 destructor 的限制
- 只在线程正常退出或被
pthread_cancel 时被调 - 进程整体退出时(
exit / 主线程 return)不会被调(不是泄漏,因为进程已死) - 如果 destructor 里又调了
setspecific,会再次安排执行(POSIX 规定最多 PTHREAD_DESTRUCTOR_ITERATIONS 次,glibc 默认 4)
4. 标准模式:once + key 懒初始化
key 一定要在第一次使用前 pthread_key_create,但如果有多个线程同时跑过来,谁负责创建?答案是 pthread_once:
static pthread_once_t once = PTHREAD_ONCE_INIT;static pthread_key_t key;staticvoidinit_key(void){ pthread_key_create(&key, destructor);}void *get_thread_data(void){ pthread_once(&once, init_key); /* 多线程同时调,key_create 只跑一次 */ void *p = pthread_getspecific(key); if (!p) { p = malloc(...); pthread_setspecific(key, p); } return p;}
pthread_once 保证 init_key 只被执行一次,且其他并发调用方会被阻塞到第一次执行完。这是个能死记住的固定套路。
5. 实战场景:每线程一个 logger
每个 worker 线程要写自己的日志文件,避免锁日志文件:
#include<pthread.h>#include<stdio.h>#include<stdlib.h>#include<string.h>typedef struct { FILE *fp; char filename[64];} thread_logger_t;static pthread_once_t logger_once = PTHREAD_ONCE_INIT;static pthread_key_t logger_key;staticvoidlogger_destroy(void *value){ thread_logger_t *l = value; if (l) { if (l->fp) fclose(l->fp); free(l); }}staticvoidinit_logger_key(void){ pthread_key_create(&logger_key, logger_destroy);}staticthread_logger_t *current_logger(void){ pthread_once(&logger_once, init_logger_key); thread_logger_t *l = pthread_getspecific(logger_key); if (!l) { l = malloc(sizeof(*l)); snprintf(l->filename, sizeof(l->filename), "log_tid_%lu.txt", (unsigned long)pthread_self()); l->fp = fopen(l->filename, "w"); pthread_setspecific(logger_key, l); } return l;}/* 业务代码不用关心 logger 是哪个,直接调 LOG */#define LOG(fmt, ...) \ fprintf(current_logger()->fp, fmt "\n", ##__VA_ARGS__)void *worker(void *arg){ int id = *(int *)arg; LOG("worker %d started", id); do_some_work(); LOG("worker %d finished", id); /* 线程退出时 logger_destroy 自动 fclose + free */ return NULL;}
亮点:
- 业务函数(
do_some_work、LOG 宏)不用知道当前是哪个线程,也不用传 logger 参数 - 每个线程的 logger 是隔离的,写文件完全无锁
这就是 TLS 的"自动绑定 + 隐式上下文"威力。
6. 简化写法:C11 _Thread_local / GCC __thread
C11 标准提供了 _Thread_local 关键字(gcc / clang 也支持 __thread):
_Thread_local int my_errno = 0; /* C11 标准 */__thread int my_errno_gnu = 0; /* GCC 扩展,更老更通用 */void *worker(void *arg){ my_errno = 42; /* 自动每线程独立 */ printf("%d\n", my_errno); return NULL;}
底层实现:编译器和 linker 配合,每个线程的 TCB(Thread Control Block)里挂一段独立的 .tdata / .tbss 内存。访问通过特殊的段寄存器(x86-64 上是 %fs:)几条指令就完成,比 pthread_getspecific 快很多。
6.1 _Thread_local vs pthread_setspecific 对比
| _Thread_local | pthread_setspecific |
|---|
| | |
| | |
| | ~5-15 ns(函数调用 + table 查找) |
| | |
| | |
| ⚠️ 需要 __attribute__((tls_model("initial-exec"))) 等小坑 | |
| | |
6.2 怎么选
简单 POD 类型(int / 指针 / 小结构体),不需要自动清理 → _Thread_local
_Thread_local int recursion_depth = 0;_Thread_local char buf[1024]; /* 每线程自己的小缓冲 */
需要 destructor 自动清理(malloc / fopen / socket) → pthread_setspecific
/* logger 的例子,必须用 pthread_key + destructor */
两者结合:用 _Thread_local 存指针,配 pthread_key_create(&dummy_key, destructor) 注册清理 hook —— 但这玩法太花哨,初学不建议。
7. 反例:为什么不能用全局变量 + mutex 替代
可能有人想:
typedef struct { pthread_t tid; void *value;} entry_t;static entry_t entries[MAX_THREADS];static pthread_mutex_t lock;void *get_my_value(void){ pthread_mutex_lock(&lock); pthread_t self = pthread_self(); void *v = NULL; for (int i = 0; i < MAX_THREADS; i++) { if (pthread_equal(entries[i].tid, self)) { v = entries[i].value; break; } } pthread_mutex_unlock(&lock); return v;}
这种实现的问题:
- 每次访问都要加锁
- MAX_THREADS 写死
- 没有自动清理
- 重复实现 pthread 已经做好的事
TLS 解决的是这一类问题:“我想要每个线程都有一个独立的状态、且这个状态不通过参数传”。
几个真实工程里的 TLS 应用
| |
|---|
| errno |
| Web 服务器每个请求线程的 current_user() |
| request_id / trace_id 不用层层传参 |
| tcmalloc 每线程一个 small object cache |
| |
| 每个 attach 到 JVM 的线程的 JNIEnv* |
| |
8. 几个最常见的坑
8.1 setspecific 覆盖前不 free → 内存泄漏
char *p1 = malloc(100);pthread_setspecific(key, p1);char *p2 = malloc(100);pthread_setspecific(key, p2); /* p1 泄漏,destructor 不会调 */
8.2 destructor 在主线程退出时不调 → 看似泄漏
进程整体退出时,主线程的 TLS destructor 不会被调。这不是 bug,是 POSIX 设计 —— 进程都死了,OS 回收一切。但如果你在 valgrind 之类的工具里看,会标记。
如果非要清理(比如 destructor 里有持久化逻辑),手动在 main 末尾 free 主线程的 TLS:
intmain() { /* ... */ void *v = pthread_getspecific(key); if (v) my_destructor(v); pthread_setspecific(key, NULL); return 0;}
8.3 PTHREAD_KEYS_MAX 限制
POSIX 规定 PTHREAD_KEYS_MAX 至少是 128,glibc 实际是 1024。key 是全局有限资源,别在每个对象/类里随便 pthread_key_create,要复用。
8.4 destructor 链可能死循环
static void destructor(void *value) { pthread_setspecific(key, malloc(100)); /* ⚠️ 又写了一遍,下次还会调 */}
POSIX 规定最多迭代 PTHREAD_DESTRUCTOR_ITERATIONS(默认 4)次。但你不应该这么写。
8.5 NULL 既是合法 value,又是"没设过"的标记
pthread_setspecific(key, NULL); /* 合法,但之后 getspecific 返回 NULL, 无法区分"设了 NULL"和"没设" */
如果 NULL 是合法 value,得自己加标记位。一般用一个非 NULL 的 sentinel 替代。
9. TLS 生命周期一图流
10. 总结
TLS(Thread Local Storage)解决的核心问题:让某个状态/上下文在每个线程内部独立,但不需要通过函数参数到处传。
工具层级:
| | | | |
|---|
_Thread_local | | | | |
__thread | | | | |
pthread_setspecific | | | | 动态分配的资源(malloc/fopen/socket) |
| | | | 不推荐 |
经验法则:
- 能用
_Thread_local 就用 - 需要 destructor 才用
pthread_setspecific - 不要用全局 + mutex 模拟 TLS
掌握了 TLS,很多本来要"参数到处传"或"全局加锁"的多线程代码会瞬间变得干净。errno 这种从 80 年代就在用的设计,到今天依然是 C 工程师隐式上下文的最佳实践之一。