線程本地存儲及實現原理
本文是《go調度器源代碼情景分析》系列 第一章 預備知識的第十小節,也是預備知識的最後一小節。
線程本地存儲又叫線程局部存儲,其英文為Thread Local Storage,簡稱TLS,看似一個很高大上的東西,其實就是線程私有的全局變量而已。
有過多線程編程的讀者一定知道,普通的全局變量在多線程中是共享的,一個線程對其進行了修改,所有線程都可以看到這個修改,而線程私有的全局變量與普通全局變量不同,線程私有全局變量是線程的私有財產,每個線程都有自己的一份副本,某個線程對其所做的修改只會修改到自己的副本,並不會修改到其它線程的副本。
下面用例子來說明一下多線程共享全局變量以及線程私有全局變量之間的差異,並對gcc的線程本地存儲做一個簡單的分析。
首先來看普通的全局變量
#include <stdio.h> #include <pthread.h> int g = 0; // 1,定義全局變量g並賦初值0 void* start(void* arg) { printf("start, g[%p] : %d\n", &g, g); // 4,子線程中打印全局變量g的地址和值 g++; // 5,修改全局變量 return NULL; } int main(int argc, char* argv[]) { pthread_t tid; g = 100; // 2,主線程給全局變量g賦值為100 pthread_create(&tid, NULL, start, NULL); // 3, 創建子線程執行start()函數 pthread_join(tid, NULL); // 6,等待子線程運行結束 printf("main, g[%p] : %d\n", &g, g); // 7,打印全局變量g的地址和值 return 0; }
簡單解釋一下,這個程序在註釋1的地方定義了一個全局變量g並設置其初值為0,程序運行後主線程首先把g修改成了100(註釋2),然後創建了一個子線程執行start()函數(註釋3),start()函數先打印出g的值(註釋4)確定在子線程中可以看到主線程對g的修改,然後修改g的值(註釋5)後線程結束運行,主線程在註釋6處等待子線程結束後,在註釋7處打印g的值確定子線程對g的修改同樣可以影響到主線程對g的讀取。
編譯並運行程序:
[email protected]:~/study/c$ gcc thread.c -o thread -lpthread [email protected]:~/study/c$ ./thread start, g[0x601064] : 100 main, g[0x601064] : 101
從輸出結果可以看出,全局變量g在兩個線程中的地址都是一樣的,任何一個線程都可以讀取到另一個線程對全局變量g的修改,這實現了全局變量g的多個線程中的共享。
了解了普通的全局變量之後我們再來看通過線程本地存儲(TLS)實現的線程私有全局變量。這個程序與上面的程序幾乎完全一樣,唯一的差別就是在定義全局變量 g 時增加了 __thread 關鍵字,這樣g就變成了線程私有全局變量了。
#include <stdio.h> #include <pthread.h> __thread int g = 0; // 1,這裏增加了__thread關鍵字,把g定義成私有的全局變量,每個線程都有一個g變量 void* start(void* arg) { printf("start, g[%p] : %d\n", &g, g); // 4,打印本線程私有全局變量g的地址和值 g++; // 5,修改本線程私有全局變量g的值 return NULL; } int main(int argc, char* argv[]) { pthread_t tid; g = 100; // 2,主線程給私有全局變量賦值為100 pthread_create(&tid, NULL, start, NULL); // 3,創建子線程執行start()函數 pthread_join(tid, NULL); // 6,等待子線程運行結束 printf("main, g[%p] : %d\n", &g, g); // 7,打印主線程的私有全局變量g的地址和值 return 0; }
運行程序看一下效果:
[email protected]:~/study/c$ gcc -g thread.c -o thread -lpthread [email protected]:~/study/c$ ./thread start, g[0x7f0181b046fc] : 0 main, g[0x7f01823076fc] : 100
從輸出結果可以看出:首先,全局變量g在兩個線程中的地址是不一樣的;其次main函數對全局變量 g 賦的值並未影響到子線程中 g 的值,而子線程對g都做了修改,同樣也沒有影響到主線程中 g 的值,這個結果正是我們所期望的,這說明,每個線程都有一個自己私有的全局變量g。
這看起來很神奇,明明2個線程都是用的同一個全局變量名來訪問變量但卻像在訪問不同的變量一樣。
下面我們就來分析一下gcc到底使用了什麽黑魔法實現了這個特性。對於像這種由編譯器實現的特性,我們怎麽開始研究呢?最快最直接的方法就是使用調試工具來調試程序的運行,這裏我們使用gdb來調試。
[email protected]:~/study/c$ gdb ./thread
首先在源代碼的第20行(對應到源代碼中的 g = 100)處下一個斷點,然後運行程序,程序停在了斷點處,反匯編一下main函數:
(gdb) b thread.c:20 Breakpoint1at0x400793:filethread.c, line 20. (gdb) r Startingprogram:/home/bobo/study/c/thread Breakpoint1, at thread.c:20 20 g=100; (gdb) disass Dumpofassemblercodeforfunctionmain: 0x0000000000400775<+0>:push %rbp 0x0000000000400776<+1>:mov %rsp,%rbp 0x0000000000400779<+4>:sub $0x20,%rsp 0x000000000040077d<+8>:mov %edi,-0x14(%rbp) 0x0000000000400780<+11>:mov %rsi,-0x20(%rbp) 0x0000000000400784<+15>:mov %fs:0x28,%rax 0x000000000040078d<+24>:mov %rax,-0x8(%rbp) 0x0000000000400791<+28>:xor %eax,%eax => 0x0000000000400793 <+30>:movl $0x64,%fs:0xfffffffffffffffc 0x000000000040079f<+42>:lea -0x10(%rbp),%rax 0x00000000004007a3<+46>:mov $0x0,%ecx 0x00000000004007a8<+51>:mov $0x400736,%edx 0x00000000004007ad<+56>:mov $0x0,%esi 0x00000000004007b2<+61>:mov %rax,%rdi 0x00000000004007b5<+64>:callq 0x4005e0 <[email protected]> 0x00000000004007ba<+69>:mov -0x10(%rbp),%rax 0x00000000004007be<+73>:mov $0x0,%esi 0x00000000004007c3<+78>:mov %rax,%rdi 0x00000000004007c6<+81>:callq 0x400620 <[email protected]> 0x00000000004007cb<+86>:mov %fs:0xfffffffffffffffc,%eax 0x00000000004007d3<+94>:mov %eax,%esi 0x00000000004007d5<+96>:mov $0x4008df,%edi 0x00000000004007da<+101>:mov $0x0,%eax 0x00000000004007df<+106>:callq 0x400600 <[email protected]> ......
程序停在了g = 100這一行,看一下匯編指令,
=> 0x0000000000400793 <+30>:movl $0x64,%fs:0xfffffffffffffffc
這句匯編指令的意思是把常量100(0x64)復制到地址為%fs:0xfffffffffffffffc的內存中,可以看出全局變量g的地址為%fs:0xfffffffffffffffc,fs是段寄存器,0xfffffffffffffffc是有符號數-4,所以全局變量g的地址為:
fs段基址 - 4
前面我們在講段寄存器時說過段基址就是段的起始地址,為了驗證g的地址確實是fs段基址 - 4,我們需要知道fs段基址是多少,雖然我們可以用gdb命令查看fs寄存器的值,但fs寄存器裏面存放的是段選擇子(segment selector)而不是該段的起始地址,為了拿到這個基地址,我們需要加一點代碼來獲取它,修改後的代碼如下:
#include <stdio.h> #include <unistd.h> #include <pthread.h> #include <asm/prctl.h> #include <sys/prctl.h> __thread int g = 0; void print_fs_base() { unsigned long addr; int ret = arch_prctl(ARCH_GET_FS, &addr); //獲取fs段基地址 if (ret < 0) { perror("error"); return; } printf("fs base addr: %p\n", (void*)addr); //打印fs段基址 return; } void* start(void* arg) { print_fs_base(); //子線程打印fs段基地址 printf("start, g[%p] : %d\n", &g, g); g++; return NULL; } int main(int argc, char* argv[]) { pthread_t tid; g = 100; pthread_create(&tid, NULL, start, NULL); pthread_join(tid, NULL); print_fs_base(); //main線程打印fs段基址 printf("main, g[%p] : %d\n", &g, g); return 0; }
代碼中主線程和子線程都分別調用了print_fs_base()函數用於打印fs段基地址,運行程序看一下:
fs base addr: 0x7f36757c8700 start, g[0x7f36757c86fc] : 0 fs base addr: 0x7f3675fcb700 main, g[0x7f3675fcb6fc] : 100
可以看到:
-
子線程fs段基地址為0x7f36757c8700,g的地址為0x7f36757c86fc,它正好是基地址 - 4
-
主線程fs段基地址為0x7f3675fcb700,g的地址為0x7f3675fcb6fc,它也是基地址 - 4
由此可以得出,gcc編譯器(其實還有線程庫以及內核的支持)使用了CPU的fs段寄存器來實現線程本地存儲,不同的線程中fs段基地址是不一樣的,這樣看似同一個全局變量但在不同線程中卻擁有不同的內存地址,實現了線程私有的全局變量。
這裏我們簡要的分析了AMD64 Linux平臺下gcc對線程本地存儲的實現,後面的章節我們還會看到go的runtime是如何利用線程本地存儲來把正在運行的goroutine和工作線程關聯在一起的。
線程本地存儲及實現原理