0%

Windows TLS(线程局部存储):同一个变量名,每个线程各一份

TLS 解决什么问题

全局变量是所有线程共享的,多线程访问要么加锁,要么用原子操作。但有一类数据天生不该共享:错误码、当前用户上下文、日志缓冲区。为它们加锁纯属浪费——线程之间根本不想共享。

TLS(Thread Local Storage,线程局部存储)就是答案:同一个变量名下,每个线程持有独立副本。写法像全局变量,行为是线程私有。

最经典的例子是错误码:线程 A 调用失败设了错误码,线程 B 同时失败——如果错误码是普通全局变量,A 还没读就被 B 覆盖了。所以 errno 和 GetLastError() 的值都存在 TLS 里,每个线程读到的都是自己那次失败。

分类:静态 TLS 与动态 TLS

Windows 提供两套机制:

静态 TLS(__declspec(thread)) 动态 TLS(TlsAlloc 系列 API)
声明时机 编译期,写死在代码里 运行时申请,用几个领几个
能存什么 完整对象(有类型、有大小) 一个指针大小的格子,复杂数据自己 new 出来存指针
释放责任 线程退出时系统自动回收 槽位要 TlsFree,槽里 new 的对象也要自己 delete
典型使用者 自己写的 exe DLL 作者(DLL 不知道宿主环境,动态 TLS 最保险)

静态 TLS:一个关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <windows.h>
#include <cstdio>

// 每个线程都有自己独立的一份 g_counter.
__declspec(thread) int g_counter = 0;

DWORD WINAPI ThreadProc(LPVOID param) {
int id = (int)(INT_PTR)param;
for (int i = 0; i < 3; ++i) {
++g_counter; // 各加各的, 互不干扰, 不需要锁.
printf("线程 %d: g_counter = %d (地址 %p)\n", id, g_counter, &g_counter);
}
return 0;
}

int main() {
HANDLE h[3];
for (int i = 0; i < 3; ++i)
h[i] = CreateThread(NULL, 0, ThreadProc, (LPVOID)(INT_PTR)i, 0, NULL);
WaitForMultipleObjects(3, h, TRUE, INFINITE);
for (int i = 0; i < 3; ++i) CloseHandle(h[i]);
}

运行结果(地址因运行而异,关键是三个线程的地址各不相同、计数值各自从 0 涨到 3):

1
2
3
4
线程 0: g_counter = 1 (地址 0000007B1A2C0000)
线程 1: g_counter = 1 (地址 0000007B1A2C1000)
线程 2: g_counter = 1 (地址 0000007B1A2C2000)
...

如果 g_counter 是普通全局变量,三个线程抢同一块内存,最终值是几全看调度运气;加上 __declspec(thread) 后每个线程都是 3,一个锁字都不用写。

C++11 之后更推荐标准写法 thread_local int g_counter = 0;,跨平台,语义相同。

动态 TLS:API 四件套

模型是”槽位(slot)”:系统给每个进程一张槽位表(至少 64 个,最多 1088 个),每个线程在同一张表的同一索引下有自己独立的格子。TlsAlloc 领索引,TlsSetValue/GetValue 操作当前线程那一格。

1
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
#include <windows.h>
#include <cstdio>

DWORD g_tlsIndex = TLS_OUT_OF_INDEXES; // 全局只存"索引", 数据各线程私有.

DWORD WINAPI ThreadProc(LPVOID param) {
int id = (int)(INT_PTR)param;

// 每个线程 new 自己的缓冲区, 存进自己的格子.
int* p = new int(id * 100);
TlsSetValue(g_tlsIndex, p);

// 取出来的一定是自己存进去的那份.
int* q = (int*)TlsGetValue(g_tlsIndex);
printf("线程 %d: 取到 %d\n", id, *q);

delete p; // 格子里的对象线程自己负责释放.
return 0;
}

int main() {
g_tlsIndex = TlsAlloc(); // 进程级申请一次槽位.
if (g_tlsIndex == TLS_OUT_OF_INDEXES) return 1;

HANDLE h[2] = {
CreateThread(NULL, 0, ThreadProc, (LPVOID)0, 0, NULL),
CreateThread(NULL, 0, ThreadProc, (LPVOID)1, 0, NULL)
};
WaitForMultipleObjects(2, h, TRUE, INFINITE);
CloseHandle(h[0]); CloseHandle(h[1]);

TlsFree(g_tlsIndex); // 还回槽位.
}

注意分工:TlsAlloc/TlsFree 是进程级的(索引全局共享),TlsSetValue/TlsGetValue 是线程级的(各写各的格子)。

为什么 DLL 章必讲 TLS

TLS 是 DLL 作者的基础设施。DLL 被任意进程加载、被进程里任意多个线程同时调用,要保存”每个线程的上下文”(比如 Hook 库记录当前线程是否在钩子里、防重入),几乎只能靠 TLS。标准套路挂在 DllMain 的线程通知上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__declspec(dllexport) DWORD g_tlsIndex = TLS_OUT_OF_INDEXES;

BOOL WINAPI DllMain(HINSTANCE hinst, DWORD reason, LPVOID) {
switch (reason) {
case DLL_PROCESS_ATTACH:
g_tlsIndex = TlsAlloc(); // 进程加载时领槽位.
break;
case DLL_THREAD_ATTACH: // 每个新线程进来: 可在此分配线程上下文.
break;
case DLL_THREAD_DETACH: // 线程退出: 释放它格子里的对象, 防泄漏.
delete TlsGetValue(g_tlsIndex);
break;
case DLL_PROCESS_DETACH:
TlsFree(g_tlsIndex); // 进程卸载时还槽位.
break;
}
return TRUE;
}

一个要记住的坑

静态 TLS 变量如果带非平凡构造/析构(比如 __declspec(thread) SomeClass obj;),放在隐式加载的 DLL 里,在老系统(Vista 之前)上可能直接导致 DLL 加载失败。所以 DLL 里要稳妥就选动态 TLS,或者用 C++11 thread_local——编译器会为它生成特殊的延迟初始化代码,绕开这个坑。

小结

1
2
3
4
TLS = 同一名称, 每线程一份
├── 静态: __declspec(thread) / thread_local, 编译期声明, 存完整对象, 自动回收
└── 动态: TlsAlloc/SetValue/GetValue/Free, 运行期领槽位, 存指针, 自己释放
└── DLL 的标准姿势: DllMain 里 PROCESS_ATTACH 领槽, THREAD_DETACH 清数据

回到并发工具箱看,TLS 是”不共享”的极端,互斥锁是”共享但排队”,原子操作是”共享但一锤定音”——同一个问题的三种取舍,按数据该不该共享来选。