linux线程私有数据的原理

线程私有数据的应用

errno, pthread_self()都是线程私有数据。比如”名字”, 每个线程都有, 但却各不相同。muduo库使用了gcc提供__thread做到了这点(类似于c++11提供的thread_local, 它们基本等价)。此外pthread也提供了线程私有数据的支持。

线程私有数据原理

windowslinux实现的方式各不相同, 其中前者依赖了相关api, 见维基百科此处。本文只讨论linux下simple and stupid的做法。

linux内核特别配置了fs寄存器, 用来实现线程私有数据(Thread Specified Data即TSD)。线程初次访问fs:某个变量地址可以触发缺页异常, 然后内核将会映射线程私有数据的地址到fs:某个地址的变量。那到底是什么意思? 从下面的代码及其汇编可以找到答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct big {
int a;
int b;
int c;
};

__thread int val1 = 1;
__thread struct big val2 ;

void func1() {
// 写一个简单的变量
val1 = 20;
}

void func2() {
// 写更麻烦的struct变量
val2.a = 10;
val2.b = 20;
val2.c = 30;
}

对应汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val1:
.long 1
val2:
.zero 12
func1:
pushq %rbp
movq %rsp, %rbp
movl $20, %fs:val1@tpoff
nop
popq %rbp
ret
func2:
pushq %rbp
movq %rsp, %rbp
movl $10, %fs:val2@tpoff
movl $20, %fs:val2@tpoff+4
movl $30, %fs:val2@tpoff+8
nop
popq %rbp
ret

这里的val1相当于一个指针(说法不太恰当, 但只要对汇编有点理解就能明白这个比喻)。访问fs:val1的时候, 由于内核之前没有对这里做任何映射, 触发缺页异常产生进入内核态。

触发缺页异常后, 内核将建立一块TSD内存, 它的初始值就是val1的数据, TSD被存放在某个地址空间, 可以被用户态读写。接着, 内核将fs:val1映射到建立的TSD内存, 从异常处返回。然后用户态程序重新访问fs:val1这块数据就能正常拿到自己的私有数据了😃。

以后这个进程再次访问的时候, 由于fs:val1的映射关系已经做好了。直接读出来的就是映射过的TSD内存了。

对于程序员, 我们当然不需要自己用fs乱搞一通, 上面提到的__thread就是实现线程私有数据的一个方式。此外glibc提供了pthread_key相关的API也能实现线程私有数据(但是原理是一致的, 都是上面提到的访问fs进入缺页异常, 然后内核建立映射)。然而muduo使用了__thread而没有使用glibc提供的那些, 原因下面讨论。

pthread_key有何不妥?

glibc提供了pthread_key_create, pthread_getspecific, pthread_setspecific等api用来设置访问线程私有数据。然而因为以下几点, 它们不常用甚至不应该被使用:

  1. 慢, glibc维护了数据结构来管理这些键, 要createget时可能需要查表(我认为甚至可能有锁)。且设计函数调用, 这点也有开销
  2. 数量受限, glibc限制了能创建的键数目(取决于glibc, 一般是128个), 然而天知道第三方库会创建多少个pthread_key

__thread如何取地址/或者用地址进行读写?

上面的文本使得我们对线程私有数据有了最基本的认识。前面我们演示了读写, 看起来图景很清晰, 基本就是内核搞定了一切。然而却没有这么简单, 读写好说, 但取地址就略显麻烦了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__thread int val;

void fun1(void *) {

}

void fun2(int) {

}

void func() {
fun1(&val);
fun2(val);
val = 30;
}

对应的汇编:

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
34
35
36
37
38
39
val:
.zero 4
fun1:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
nop
popq %rbp
ret
fun2:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
nop
popq %rbp
ret
func:
pushq %rbp
movq %rsp, %rbp
// 忽略上面

// 下面是fun1的调用过程, 一个读地址
movq %fs:0, %rax
addq $val@tpoff, %rax
movq %rax, %rdi
call fun1

// 下面是fun2的调用过程, 一个读值
movl %fs:val@tpoff, %eax
movl %eax, %edi
call fun2

//
movl $30, %fs:val@tpoff

// 忽略下面
nop
popq %rbp
ret

读值/写值的过程很简单, 读写fs:地址即可, 这点我们前面以及提到过了。

但是读地址有点让人摸不着头脑, 它的逻辑是先从fs:0读出一个值, 然后加上val(注意我之前把val比作地址), 这就取得了它的地址。我们能通过这个地址修改此线程的这个私有数据, 它就是普通的内存地址, 没有任何魔法, 即之前提到的TSD内存区域的一块内存。

这么做的原因是线程需要先从fs:0读出它的TSD基地址(这也是线程的一个私有数据), 加上val才是映射的实际地址, 这就是fs:val映射的实际地址了。