23.5 线程局部存储
线程局部存储 (Thread-Specific Storage, TSS) 让每个线程持有自己的私有数据副本。它适合存放“线程上下文”这类不应在线程间共享的对象。
本节接口来自 <threads.h>,因此也受实现支持度影响;不支持时需要在平台层提供等价封装。
1. 核心接口
tss_create 创建键,tss_set 绑定当前线程的数据指针,tss_get 读取当前线程绑定值,tss_delete 删除键。创建键时可提供析构函数,在线程退出时自动清理该线程绑定的数据。
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>
static tss_t key;
void cleanup(void *ptr) {
free(ptr);
}
int worker(void *arg) {
int id = *(int *)arg;
int *slot = malloc(sizeof *slot);
if (slot == NULL) {
return 1;
}
*slot = id * 10;
tss_set(key, slot);
printf("thread %d -> %d\n", id, *(int *)tss_get(key));
return 0;
}
int main(void) {
thrd_t t1, t2;
int id1 = 1, id2 = 2;
if (tss_create(&key, cleanup) != thrd_success) {
return 1;
}
thrd_create(&t1, worker, &id1);
thrd_create(&t2, worker, &id2);
thrd_join(t1, NULL);
thrd_join(t2, NULL);
tss_delete(key);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
两行的先后顺序不固定:
thread 1 -> 10
thread 2 -> 202
thread 2 -> 20
thread 1 -> 102
2. 适用场景
日志上下文、错误码、解析器状态等每个线程各自需要一份的数据,都可以放入线程局部存储,这样就不必把上下文指针逐层作为参数传递,也不需要为这些数据加锁。
3. 生命周期
tss_delete 只释放键本身,不调用任何析构函数,也不释放各线程关联的数据;各线程的数据靠线程退出时的析构函数释放。因此,应在所有使用该键的线程都已退出之后再调用 tss_delete。
4. 与 _Thread_local 的区别
声明为 _Thread_local(C23 起可写作关键字 thread_local)的对象,每个线程都有它的一个实例,访问方式与普通对象相同,开销也小,但不能指定析构函数。TSS 则在运行期通过 tss_create 创建键,能为每个线程的数据指定析构函数,适合在库中按需创建、并需要在线程退出时释放资源的数据。
5. 习题
5.1 巩固
下面的程序创建 4 个线程,编号为偶数的线程各绑定一个整数,编号为奇数的线程只读不写:
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>
static tss_t key;
static atomic_int cleaned;
static void cleanup(void *p) {
atomic_fetch_add(&cleaned, 1);
printf("cleanup: %d\n", *(int *)p);
fflush(stdout);
free(p);
}
static int worker(void *arg) {
int id = *(int *)arg;
void *before = tss_get(key);
printf("thread %d: before set -> %s\n", id, before == NULL ? "NULL" : "value");
if (id % 2 == 0) {
int *p = malloc(sizeof *p);
*p = id;
tss_set(key, p);
printf("thread %d: after set -> %d\n", id, *(int *)tss_get(key));
}
return 0;
}
int main(void) {
thrd_t t[4];
int ids[4];
tss_create(&key, cleanup);
for (int i = 0; i < 4; ++i) {
ids[i] = i;
thrd_create(&t[i], worker, &ids[i]);
}
for (int i = 0; i < 4; ++i) {
thrd_join(t[i], NULL);
}
printf("cleaned = %d\n", cleaned);
tss_delete(key);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
要求:
- 先不看运行结果,回答:4 个线程的
before set各是什么?会打印几行cleanup,各带什么值?cleaned是多少? - 编译运行,核对预测。
cleanup的行与对应线程的before set行之间隔着其他线程的输出,这是否说明析构函数在别的线程上执行? - 把
tss_create(&key, cleanup)的第二个实参改成NULL,再跑一遍。cleanup还调用吗?两个奇数编号线程绑定的值本来就不存在,这对结果有影响吗? - 把
atomic_int cleaned换成普通int,说明为什么这里的累加必须用原子类型或互斥量。
验收:第 1 步预测为 4 个 NULL、两行 cleanup(值为 0 和 2)、cleaned = 2;第 2 步输出与之相符,各行交错是线程调度的结果,析构函数仍在各自线程退出时执行;第 3 步 cleanup 一次也不打印;第 4 步说明 cleanup 在不同线程上并发执行,普通 int 的读-改-写会丢失更新。
下面的程序想给每个线程一份“上下文”,写的时候加锁,读的时候也加锁,但 4 个线程最后都打印 thread 3:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <threads.h>
static mtx_t lock;
static char shared[64];
static int made;
static int freed;
static char *ctx_get(void) {
if (!made) {
made = 1;
}
return shared;
}
static int worker(void *arg) {
int id = *(int *)arg;
mtx_lock(&lock);
char *buf = ctx_get();
snprintf(buf, sizeof shared, "thread %d", id);
mtx_unlock(&lock);
thrd_sleep(&(struct timespec){ .tv_nsec = 100000000 }, NULL);
mtx_lock(&lock);
printf("%s\n", ctx_get());
mtx_unlock(&lock);
return 0;
}
int main(void) {
thrd_t t[4];
int ids[4];
mtx_init(&lock, mtx_plain);
for (int i = 0; i < 4; ++i) {
ids[i] = i;
thrd_create(&t[i], worker, &ids[i]);
}
for (int i = 0; i < 4; ++i) {
thrd_join(t[i], NULL);
}
printf("made = %d, freed = %d\n", made, freed);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
要求:
- 连续运行 5 次,记录 4 行输出与最后一行的计数值。每次都是 4 个
thread 3,说明锁尽到了哪一部分责任,又漏掉了哪一部分? - 把
ctx_get改成 TSS 实现:用tss_get取本线程的缓冲区,没有就malloc一块 64 字节并用tss_set绑定。验收:4 行分别是thread 0到thread 3(顺序不限)。 - 加上析构函数用
free释放缓冲区,并用atomic_int统计创建与释放次数,在线程全部thrd_join之后打印。验收:created = 4, freed = 4。4 个线程几乎同时退出,析构函数在各自的线程上并发执行;若把计数器换成普通int会怎样?再换成用一把全局互斥量保护,说明这把互斥量最早可以在什么时候mtx_destroy。 - 在修复版里让工作线程绑定缓冲区之后先睡 200 ms 再退出,
main在这段睡眠期间调用tss_delete(key)(工作线程此后再也不碰这个键)。运行并记录freed。按本节第 3 小节说明析构函数为什么没有执行,以及键应该推迟到哪一步之后再删除。 - 用
_Thread_local char buf[64]改写修复版:不需要析构函数,也不需要malloc,但同一个线程第二次拿缓冲区时,里面还留着上一次写入的内容。给出实现并说明为什么_Thread_local数组不需要释放,而 TSS 版本必须释放。最后再把第 4 步的“提前删除”做法用到这个版本上,说明为什么_Thread_local没有对应的失效问题。
验收:第 1 步 5 次都输出 4 个 thread 3,made = 1, freed = 0;第 2 步每个线程打印自己的编号;第 3 步 created = 4, freed = 4,普通 int 会丢失更新,互斥量版本必须在 4 个线程全部 thrd_join 之后才能销毁;第 4 步 freed = 0、created = 4:键在 thrd_exit 之前已被删除,线程退出时已经没有这个键可清理,删除必须放在全部 thrd_join 之后;第 5 步的 _Thread_local 版本每线程各有一份数组,随线程结束自动消失,下一次使用前用 buf[0] = '\0' 或重新 snprintf 覆盖;它没有运行期创建的键,也就没有“提前删除”这回事。
5.2 编程
为库函数实现“每线程一份”的日志上下文,要求调用方不传上下文指针,也不加锁:
typedef struct {
char buf[96];
size_t len;
} log_ctx;
void log_write(const char *text);
const char *log_text(void);2
3
4
5
6
7
每个线程第一次调用时自动创建 log_ctx,绑定到内部静态键上;线程退出时由析构函数释放。log_write 把 text 追加到本线程的缓冲区,log_text 返回本线程缓冲区的首地址。
要求:
- 用
tss_get判断是否需要创建,用tss_set绑定;创建失败时log_write什么都不做,log_text返回空串。所有函数都不加锁。 main启动 3 个线程,每个线程写两条记录,形如t1:m0;、t1:m1;,然后打印自己看到的整串。验收:每行只含本线程的两条记录,顺序固定,不含其他线程的内容。- 用
atomic_int统计“创建过几个上下文”“析构函数执行了几次”,在线程全部thrd_join之后打印。验收:两个数都等于 3。 - 把同样的逻辑改用
static _Thread_local char *buf实现。除了两处函数内部,还要在哪一处加代码才能释放内存?分别在两种实现上运行-fsanitize=address,记录各自的报告。结合第 3 步的计数说明:判断这类“线程退出时没释放”的问题,为什么要以计数为准,而不能只看泄漏检测工具有没有报告。 - 让某个线程在写日志之前先调用一次
tss_set(key, NULL),再按原逻辑写入。程序还能工作吗?结合tss_get返回值的约定解释。
验收:第 2 步每个线程的输出只含自己的两条记录;第 3 步 created = 3, freed = 3;第 4 步 _Thread_local 版本需要在线程函数返回前手写 free 并把指针置空,TSS 版本由析构函数完成;在 Linux 上用 -fsanitize=address 实测时,两版都可能不报告泄漏(泄漏检测在进程退出时扫描,工作线程结束后其栈区往往尚未归还,残留的指针足以让那块内存看起来仍然可达),因此不能把工具的沉默当作通过,结论应来自第 3 步的计数;第 5 步仍能工作,因为 tss_get 返回 NULL 与“尚未绑定”无法区分,下一次调用会重新创建并覆盖绑定值,代价是先前那块内存无人释放。
5.3 综合与挑战
线程退出时要清空的往往不止一块内存。下面的程序把 8 个节点挂成链表,整条链表作为该线程的 TSS 值:
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>
static tss_t key;
static int freed;
typedef struct node {
int value;
struct node *next;
} node;
static void drain(void *p) {
node *n = p;
if (n == NULL) {
return;
}
node *next = n->next;
free(n);
++freed;
tss_set(key, next);
}
static int worker(void *arg) {
(void)arg;
node *list = NULL;
for (int i = 8; i >= 1; --i) {
node *n = malloc(sizeof *n);
n->value = i;
n->next = list;
list = n;
}
tss_set(key, list);
return 0;
}
int main(void) {
thrd_t t;
tss_create(&key, drain);
thrd_create(&t, worker, NULL);
thrd_join(t, NULL);
printf("freed = %d of 8\n", freed);
tss_delete(key);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
要求:
- 连续运行 5 次,记录
freed。析构函数每次只释放一个节点,为什么最终停在 4 而不是 1 或 8?查阅实现里TSS_DTOR_ITERATIONS的取值,说明这个数字与循环次数的关系。 - 把析构函数改成用一个
while一次走完整条链表,并在结束前tss_set(key, NULL)。验收:freed = 8 of 8。 - 在析构函数里打印
tss_get(key),与传入的参数比较。验收:析构函数被调用时,该键在本线程的值已经是NULL,所以清理必须依赖形参,不能重新从键里取值。 - 让
main设置一个线程局部值后从main返回(而不是调用thrd_exit),在析构函数里打印一行。运行后有没有这一行?再把return 0;换成thrd_exit(0);重试。说明两种退出方式的差别,以及这给“用 TSS 保存必须落盘的数据”带来什么要求。 - 把节点数改成 400,观察
freed。这说明靠“析构函数里重新挂回剩余节点”来分次清理会遇到什么限制?要保证全部释放,析构函数应当怎样写?
验收:第 1 步 freed = 4 of 8(glibc 上 TSS_DTOR_ITERATIONS 为 4);第 2 步 freed = 8 of 8;第 3 步两条输出分别为 arg=... 与 tss_get=NULL;第 4 步 return 0 时没有析构输出,thrd_exit(0) 时有;第 5 步仍为 4,说明每次调用最多执行 TSS_DTOR_ITERATIONS 轮,靠重新绑定把工作分摊到多轮会在上限处静默丢失,正确做法是在一次调用里把整条链清完。