最早的時候是在程序初始化過程中開啟了一個timer(timer_create
),這個timer第一次觸發(fā)的時間較短時就會引起程序core掉,core的位置也是不定的。使用valgrind可以發(fā)現(xiàn)有錯誤的內(nèi)存寫入:
==31676== Invalid write of size 8
==31676== at 0x37A540F852: _dl_allocate_tls_init (in /lib64/ld-2.5.so)
==31676== by 0x4E26BD3: pthread_create@@GLIBC_2.2.5 (in /lib64/libpthread-2.5.so)
==31676== by 0x76E0B00: timer_helper_thread (in /lib64/librt-2.5.so)
==31676== by 0x4E2673C: start_thread (in /lib64/libpthread-2.5.so)
==31676== by 0x58974BC: clone (in /lib64/libc-2.5.so)
==31676== Address 0xf84dbd0 is 0 bytes after a block of size 336 alloc'd
==31676== at 0x4A05430: calloc (vg_replace_malloc.c:418)
==31676== by 0x37A5410082: _dl_allocate_tls (in /lib64/ld-2.5.so)
==31676== by 0x4E26EB8: pthread_create@@GLIBC_2.2.5 (in /lib64/libpthread-2.5.so)
==31676== by 0x76E0B00: timer_helper_thread (in /lib64/librt-2.5.so)
==31676== by 0x4E2673C: start_thread (in /lib64/libpthread-2.5.so)
==31676== by 0x58974BC: clone (in /lib64/libc-2.5.so)
google _dl_allocate_tls_init
相關(guān)發(fā)現(xiàn)一個glibc的bug Bug 13862 和我的情況有點(diǎn)類似。本文就此bug及tls相關(guān)實現(xiàn)做一定闡述。
需要查看glibc的源碼,如何確認(rèn)使用的glibc的版本,可以這樣:
$ /lib/libc.so.6
GNU C Library stable release version 2.5, by Roland McGrath et al.
...
為了方便,還可以直接在(glibc Cross Reference)[http://osxr.org/glibc/source/?v=glibc-2.17]網(wǎng)頁上進(jìn)行查看,版本不同,但影響不大。
BUG描述
要重現(xiàn)13862 BUG作者提到要滿足以下條件:
The use of a relatively large number of dynamic libraries, loaded at runtime using dlopen.
The use of thread-local-storage within those libraries.
A thread exiting prior to the number of loaded libraries increasing a significant amount, followed by a new thread being created after the number of libraries has increased.
簡單來說,就是在加載一大堆包含TLS變量的動態(tài)庫的過程中,開啟了一個線程,這個線程退出后又開啟了另一個線程。
這和我們的問題場景很相似。不同的是我們使用的是timer,但timer在觸發(fā)時也是開啟新的線程,并且這個線程會立刻退出:
/nptl/sysdeps/unix/sysv/linux/timer_routines.c
timer_helper_thread(...) // 用于檢測定時器觸發(fā)的輔助線程
{
...
pthread_t th;
(void) pthread_create (&th, &tk->attr, timer_sigev_thread, // 開啟一個新線程調(diào)用用戶注冊的定時器函數(shù)
td);
...
}
要重現(xiàn)此BUG可以使用我的實驗代碼 thread-tls,或者使用Bug 13862 中的附件
TLS相關(guān)實現(xiàn)
可以順著_dl_allocate_tls_init
函數(shù)的實現(xiàn)查看相關(guān)聯(lián)的部分代碼。該函數(shù)遍歷所有加載的包含TLS變量的模塊,初始化一個線程的TLS數(shù)據(jù)結(jié)構(gòu)。
每一個線程都有自己的堆棧空間,其中單獨(dú)存儲了各個模塊的TLS變量,從而實現(xiàn)TLS變量在每一個線程中都有單獨(dú)的拷貝。TLS與線程的關(guān)聯(lián)關(guān)系可以查看下圖:

應(yīng)用層使用的pthread_t
實際是個pthread
對象的地址。創(chuàng)建線程時線程的堆棧空間和pthread
結(jié)構(gòu)是一塊連續(xù)的內(nèi)存。但這個地址并不指向這塊內(nèi)存的首地址。相關(guān)代碼:/nptl/allocatestack.c allocate_stack
,該函數(shù)分配線程的堆棧內(nèi)存。
pthread
第一個成員是tcbhead_t
,tcbhead_t
中dtv
指向了一個dtv_t
數(shù)組,該數(shù)組的大小隨著當(dāng)前程序載入的模塊多少而動態(tài)變化。每一個模塊被載入時,都有一個l_tls_modid
,其直接作為dtv_t
數(shù)組的下標(biāo)索引。tcbhead_t
中的dtv
實際指向的是dtv_t
第二個元素,第一個元素用于記錄整個dtv_t
數(shù)組有多少元素,第二個元素也做特殊使用,從第三個元素開始,才是用于存儲TLS變量。
一個dtv_t
存儲的是一個模塊中所有TLS變量的地址,當(dāng)然這些TLS變量都會被放在連續(xù)的內(nèi)存空間里。dtv_t::pointer::val
正是用于指向這塊內(nèi)存的指針。對于非動態(tài)加載的模塊它指向的是線程堆棧的位置;否則指向動態(tài)分配的內(nèi)存位置。
以上結(jié)構(gòu)用代碼描述為,
union dtv_t {
size_t counter;
struct {
void *val; /* point to tls variable memory */
bool is_static;
} pointer;
};
struct tcbhead_t {
void *tcb;
dtv_t *dtv; /* point to a dtv_t array */
void *padding[22]; /* other members i don't care */
};
struct pthread {
tcbhead_t tcb;
/* more members i don't care */
};
dtv是一個用于以模塊為單位存儲TLS變量的數(shù)組。
實際代碼參看 /nptl/descr.h 及 nptl/sysdeps/x86_64/tls.h。
實驗
使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp
編譯代碼,即在創(chuàng)建線程前加載了一個.so:
Breakpoint 1, dump_pthread (id=1084229952) at thread.cpp:40
40 printf("pthread %p, dtv %p\n", pd, dtv);
(gdb) set $dtv=pd->tcb.dtv
(gdb) p $dtv[-1]
$1 = {counter = 17, pointer = {val = 0x11, is_static = false}}
(gdb) p $dtv[3]
$2 = {counter = 18446744073709551615, pointer = {val = 0xffffffffffffffff, is_static = false}}
dtv[3]
對應(yīng)著動態(tài)加載的模塊,is_static=false
,val
被初始化為-1:
/elf/dl-tls.c _dl_allocate_tls_init
if (map->l_tls_offset == NO_TLS_OFFSET
|| map->l_tls_offset == FORCED_DYNAMIC_TLS_OFFSET)
{
/* For dynamically loaded modules we simply store
the value indicating deferred allocation. */
dtv[map->l_tls_modid].pointer.val = TLS_DTV_UNALLOCATED;
dtv[map->l_tls_modid].pointer.is_static = false;
continue;
}
dtv
數(shù)組大小之所以為17,可以參看代碼 /elf/dl-tls.c allocate_dtv
:
// dl_tls_max_dtv_idx 隨著載入模塊的增加而增加,載入1個.so則是1
dtv_length = GL(dl_tls_max_dtv_idx) + DTV_SURPLUS; // DTV_SURPLUS 14
dtv = calloc (dtv_length + 2, sizeof (dtv_t));
if (dtv != NULL)
{
/* This is the initial length of the dtv. */
dtv[0].counter = dtv_length;
繼續(xù)上面的實驗,當(dāng)調(diào)用到.so中的function
時,其TLS被初始化,此時dtv[3]
中val
指向初始化后的TLS變量地址:
68 fn();
(gdb)
0x601808, 0x601804, 0x601800
72 return 0;
(gdb) p $dtv[3]
$3 = {counter = 6297600, pointer = {val = 0x601800, is_static = false}}
(gdb) x/3xw 0x601800
0x601800: 0x55667788 0xaabbccdd 0x11223344
這個時候還可以看看dtv[1]
中的內(nèi)容,正是指向了pthread
前面的內(nèi)存位置:
(gdb) p $dtv[1]
$5 = {counter = 1084229936, pointer = {val = 0x40a00930, is_static = true}}
(gdb) p/x tid
$7 = 0x40a00940
結(jié)論:
so模塊加載
這里也并不太需要查看dlopen
等具體實現(xiàn),由于使用__thread
來定義TLS變量,整個實現(xiàn)涉及到ELF加載器的一些細(xì)節(jié),深入下去內(nèi)容較多。這里直接通過實驗的手段來了解一些實現(xiàn)即可。
上文已經(jīng)看到,在創(chuàng)建線程前如果動態(tài)加載了.so,dtv數(shù)組的大小是會隨之增加的。如果是在線程創(chuàng)建后再載入.so呢?
使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp -DTEST_DTV_EXPAND -DSO_CNT=1
編譯程序,調(diào)試得到:
73 load_sos();
(gdb)
0x601e78, 0x601e74, 0x601e70
Breakpoint 1, dump_pthread (id=1084229952) at thread.cpp:44
44 printf("pthread %p, dtv %p\n", pd, dtv);
(gdb) p $dtv[-1]
$3 = {counter = 17, pointer = {val = 0x11, is_static = false}}
(gdb) p $dtv[4]
$4 = {counter = 6299248, pointer = {val = 0x601e70, is_static = false}}
在新載入了.so時,dtv
數(shù)組大小并沒有新增,dtv[4]
直接被拿來使用。
因為dtv
初始大小為16,那么當(dāng)載入的.so超過這個數(shù)字的時候會怎樣?
使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp -DTEST_DTV_EXPAND
編譯程序:
...
pthread 0x40a00940, dtv 0x6016a0
...
Breakpoint 1, dump_pthread (id=1084229952) at thread.cpp:44
44 printf("pthread %p, dtv %p\n", pd, dtv);
(gdb) p dtv
$2 = (dtv_t *) 0x6078a0
(gdb) p dtv[-1]
$3 = {counter = 32, pointer = {val = 0x20, is_static = false}}
(gdb) p dtv[5]
$4 = {counter = 6300896, pointer = {val = 0x6024e0, is_static = false}}
可以看出,dtv
被重新分配了內(nèi)存(0x6016a0 -> 0x6078a0)并做了擴(kuò)大。
以上得出結(jié)論:
- 創(chuàng)建線程前dtv的大小會根據(jù)載入模塊數(shù)量決定
- 創(chuàng)建線程后新載入的模塊會動態(tài)擴(kuò)展dtv的大小(必要的時候)
pthread堆棧重用
在allocate_stack
中分配線程堆棧時,有一個從緩存中取的操作:
allocate_stack(..) {
...
pd = get_cached_stack (&size, &mem);
...
}
/* Get a stack frame from the cache. We have to match by size since
some blocks might be too small or far too large. */
get_cached_stack(...) {
...
list_for_each (entry, &stack_cache) // 根據(jù)size從stack_cache中取
{ ... }
...
/* Clear the DTV. */
dtv_t *dtv = GET_DTV (TLS_TPADJ (result));
for (size_t cnt = 0; cnt < dtv[-1].counter; ++cnt)
if (! dtv[1 + cnt].pointer.is_static
&& dtv[1 + cnt].pointer.val != TLS_DTV_UNALLOCATED)
free (dtv[1 + cnt].pointer.val);
memset (dtv, '\0', (dtv[-1].counter + 1) * sizeof (dtv_t));
/* Re-initialize the TLS. */
_dl_allocate_tls_init (TLS_TPADJ (result));
}
get_cached_stack
會把取出的pthread
中的dtv重新初始化。注意 _dl_allocate_tls_init
中是根據(jù)模塊列表來初始化dtv數(shù)組的。
實驗
當(dāng)一個線程退出后,它就可能被當(dāng)做cache被get_cached_stack
取出復(fù)用。
使用g++ -o thread -g -Wall -lpthread -ldl thread.cpp -DTEST_CACHE_STACK
編譯程序,運(yùn)行:
$ ./thread
..
pthread 0x413c9940, dtv 0x1be46a0
...
pthread 0x413c9940, dtv 0x1be46a0
回顧BUG
當(dāng)新創(chuàng)建的線程復(fù)用了之前退出的線程堆棧時,由于在_dl_allocate_tls_init
中初始化dtv數(shù)組時是根據(jù)當(dāng)前載入的模塊數(shù)量而定。如果在這個時候模塊數(shù)已經(jīng)超過了這個復(fù)用的dtv數(shù)組大小,那么就會出現(xiàn)寫入非法的內(nèi)存。使用valgrind檢測就會得到本文開頭提到的結(jié)果。
由于dtv數(shù)組大小通常會稍微大點(diǎn),所以在新加載的模塊數(shù)量不夠多時程序還不會有問題。可以通過控制測試程序中SO_CNT
的大小看看dtv中內(nèi)容的變化。
另外,我查看了下glibc的更新歷史,到目前為止(2.20)這個BUG還沒有修復(fù)。
參考文檔