標(biāo)簽(空格分隔): Glibc, Thread, 線程棧
前言
前幾天自己寫了一段基于線程模型的網(wǎng)絡(luò)程序,即主線程對(duì)每個(gè)連接請(qǐng)求創(chuàng)建一個(gè)工作線程,工作線程處理接下來所有業(yè)務(wù)。主線程希望工作線程完成后自動(dòng)結(jié)束并釋放資源(請(qǐng)別問我這么 Low 的代碼用來干嘛,我就自己寫兩行來玩的 v)。這個(gè)時(shí)候容易犯一個(gè)錯(cuò)(大神請(qǐng)繞道),那就是既不調(diào)join操作(因?yàn)橹骶€程并不關(guān)注工作線程什么時(shí)候結(jié)束),也不將工作線程detach。這個(gè)錯(cuò)誤的后果就是導(dǎo)致資源泄露,當(dāng)然解決這個(gè)問題最簡單的方法是將工作線程設(shè)置為detach狀態(tài),系統(tǒng)就會(huì)自動(dòng)完成資源的回收。顯然如果本篇博客只是想描述怎么解決這個(gè)問題,那么顯然沒有寫一篇文章的必要。本文真正要描述的是線程的資源是怎么自動(dòng)釋放的,這毫無疑問涉及到線程有哪些資源以及是如何管理的問題。 在此之前,需要說明一下,本文中描述所描述的適用于 Linux 系統(tǒng),x86_64 平臺(tái),至于其它平臺(tái)是否適用我也不知道,哈哈。
背景
本節(jié)線程模型的內(nèi)容來自 Linux 線程模型的比較:LinuxThreads 和 NPTL。
對(duì) Linux 有所了解就會(huì)知道, Linux 內(nèi)核并不能真正支持線程,而是通過進(jìn)程間共享資源(內(nèi)存空間、文件等)的方式模擬線程,又被稱之為輕量級(jí)進(jìn)程(LWP)。最早 LinuxThreads 項(xiàng)目希望在用戶空間模擬對(duì)線程的支持。LinuxThreads 采用的是一對(duì)一的線程模型,為了解決信號(hào)處理、調(diào)度和進(jìn)程間同步原語方面的問題, LinuxThreads 引入了一個(gè)管理線程,以滿足響應(yīng)終止信號(hào)殺死整個(gè)進(jìn)程,完成線程結(jié)束后的內(nèi)存回收等任務(wù)。但是管理線程的引入也帶來系統(tǒng)伸縮性與性能的問題。并且, LinuxThreads 并不符合 POSIX 標(biāo)準(zhǔn)。
NPTL 的出現(xiàn)改變了 LinuxThreads 尷尬的現(xiàn)狀。不過,NPTL 不僅僅是一個(gè)用戶態(tài)的線程庫,同時(shí)它也對(duì)系統(tǒng)內(nèi)核做了一定的要求, 因此有時(shí)在談?wù)?Linux 內(nèi)核沒有線程概念時(shí)并不十分準(zhǔn)確,例如為了支持 nptl 線程內(nèi)核 task_struct 是引入了 pid 與 tgid 的區(qū)別, 因而準(zhǔn)確的說法應(yīng)該是內(nèi)核在調(diào)度的時(shí)候沒有線程的概念,這都是題外話了。NPTL 作為 Linux 線程的新的實(shí)現(xiàn),它移除了 LinuxThreads 中的管理線程,因而其在 NUMA 與 SMP 系統(tǒng)上更好的伸縮性與同步機(jī)制。此外,NPTL 是符合 POSIX 需求的, glibc2.3.5 開始就全面使用 NPTL 模型了,所在現(xiàn)在使用的 Linux 線程模型都是已經(jīng) NPTL 了。 本文中描述的資源管理都是指 NPTL 模型中的資源管理。更多的關(guān)于 LinuxThreads 與 NPTL 的內(nèi)容可以參考 Linux 線程模型的比較:LinuxThreads 和 NPTL。
此外需要說明一點(diǎn), 無論是 LinuxThreads 還是 NPTL, 它們都使用了一對(duì)一的線程模型,也即一個(gè)用戶態(tài)線程對(duì)應(yīng)一個(gè)內(nèi)核態(tài)LWP,線程的調(diào)度是由內(nèi)核完成的。
線程內(nèi)核資源
線程資源可以粗略地分為兩類,內(nèi)核資源(例如 task_struct)以及用戶態(tài)內(nèi)存資源(主要是線程棧)。在 Linux 平臺(tái)上,進(jìn)程的內(nèi)核資源釋放是通過父進(jìn)程使用 wait 系統(tǒng)調(diào)用完成的,如果父進(jìn)程沒有調(diào)用該操作,就會(huì)出現(xiàn)僵尸進(jìn)程,直到父進(jìn)程結(jié)束。對(duì)于線程而言,Linux 還提供了內(nèi)核自動(dòng)釋放的功能。參考 glibc-2.25 源碼描述 (sysdeps/unix/sysv/linux/createthread.c)
const int clone_flags =
(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
| CLONE_SIGHAND | CLONE_THREAD
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID
| 0);
上述代碼是 glibc 在調(diào)用 clone 創(chuàng)建線程時(shí)傳入的 flag 參數(shù),在本文中我們需要注意三個(gè)參數(shù): CLONE_THREAD, CLONE_PARENT_SETTID,CLONE_CHILD_CLEARTID。后面兩個(gè)參數(shù)與后面講述線程棧的釋放有關(guān)。 關(guān)于 CLONE_THREAD 參數(shù)的描述如下:
When a CLONE_THREAD thread terminates, the thread that created it using clone() is not sent a SIGCHLD (or other termination) signal; nor can the status of such a thread be obtained using wait(2).
這段說明,當(dāng)使用 CREATE_THREAD 參數(shù)創(chuàng)建線程后,此線程結(jié)束時(shí)不會(huì)發(fā)送 SIGCHLD 信號(hào),而且不能使用 wait 獲得其狀態(tài),其間接地說明了,內(nèi)核在某個(gè)時(shí)機(jī)自動(dòng)釋放了該線程的內(nèi)核資源,而至于是否有其它方式獲得該線程的狀態(tài),以后再討論這個(gè)問題。
線程棧的管理
對(duì)于多線程程序而言,堆資源是共享的,所有的線程都使用一個(gè)堆區(qū)。但是棧區(qū)是獨(dú)立的,每個(gè)線程都必須有自己的獨(dú)立的棧區(qū),那么這些棧區(qū)是如何管理的呢?
線程棧的布局
在討論線程棧的布局的時(shí)候,涉及到一個(gè)十分重要的數(shù)據(jù)結(jié)構(gòu) struct pthread。它存儲(chǔ)了線程的相關(guān)信息擔(dān)任線程的管理功能。其數(shù)據(jù)結(jié)構(gòu)比較復(fù)雜,再這里我們只展示幾個(gè)與本文討論內(nèi)容相關(guān)的變量,完整的內(nèi)容可以從 nptl/descr.h 文件中查看。
/* This descriptor's link on the `stack_used' or `__stack_user' list. */
list_t list;
/* Thread ID - which is also a 'is this thread descriptor (and
therefore stack) used' flag. */
pid_t tid;
list 用于將此結(jié)構(gòu)體掛于雙鏈表中,這也是 Linux 內(nèi)核中十分常見的一種數(shù)據(jù)結(jié)構(gòu)。 tid 存儲(chǔ)了線程的 ID 值。 從代碼中的注釋也可以看出 list 和 tid 都將用于線程棧的管理。
struct pthread 是用于用戶態(tài)描述線程的數(shù)據(jù)結(jié)構(gòu),那么顯然每個(gè) pthread 都唯一對(duì)應(yīng)一個(gè)線程。那么這個(gè)變量是存儲(chǔ)在哪里的,答案是線程棧內(nèi)存塊的高地址空間中的(這里以 x86 棧向下增長的方式為例)。也就是說,創(chuàng)建線程時(shí)為每個(gè)線程分配了一塊內(nèi)存,然后這塊內(nèi)存一部分存儲(chǔ)了 pthread 變量,剩下的內(nèi)存才是真正的線程棧。熟悉 Linux 內(nèi)核棧 結(jié)構(gòu)的人會(huì)對(duì)這種方式比較熟悉。下圖展示了 x86 上線程棧的簡要布局:

Talk is cheap, show me the code.
在創(chuàng)建線程的函數(shù) __pthread_create_2_1 中(nptl/pthrea_create.c),調(diào)用 ALLOCATE_STACK 宏用于分配線程棧,該宏即函數(shù) allcate_stack (nptl/allocatestack.c)。
struct pthread *pd;
...
/* The user provided some memory. Let's hope it matches the
size... We do not allocate guard pages if the user provided
the stack. It is the user's responsibility to do this if it is wanted. */
#if TLS_TCB_AT_TP
pd = (struct pthread *) ((uintptr_t) stackaddr
- TLS_TCB_SIZE - adj);
#elif TLS_DTV_AT_TP
pd = (struct pthread *) (((uintptr_t) stackaddr
- __static_tls_size - adj)
- TLS_PRE_TCB_SIZE);
#endif
這段代碼是用戶自己提供內(nèi)存塊用作線程棧時(shí)的代碼,此處 stackaddr 指向所分配內(nèi)存塊的高地址。因此,從代碼中可以看出來,無論從哪個(gè)分支編譯,pd 都指向該內(nèi)存塊高地址端一塊內(nèi)存。換句話說在線程棧內(nèi)存塊中存儲(chǔ)了一個(gè) pthread 對(duì)象。 至于這其中復(fù)雜的地址預(yù)留策略,例如對(duì)齊等,就不在此細(xì)說,有興趣可以直接去閱讀代碼。nptl 自動(dòng)分配線程棧的處理代碼是類似的,其注釋說明的已經(jīng)非常清楚了,如下所示:
/* Place the thread descriptor at the end of the stack. */
#if TLS_TCB_AT_TP
pd = (struct pthread *) ((char *) mem + size - coloring) - 1;
#elif TLS_DTV_AT_TP
pd = (struct pthread *) ((((uintptr_t) mem + size - coloring
- __static_tls_size)
& ~__static_tls_align_m1)
- TLS_PRE_TCB_SIZE);
#endif
線程棧的管理結(jié)構(gòu)
glibc 中使用了鏈表的形式來管理所有內(nèi)存棧,其中定義了兩個(gè)全局變量(nptl/allocatestack.c):
/* List of queued stack frames. */
static LIST_HEAD (stack_cache);
/* List of the stacks in use. */
static LIST_HEAD (stack_used);
而 LIST_HEAD 定義(include/list.h):
/* Define a variable with the head and tail of the list. */
# define LIST_HEAD(name) \
list_t name = { &(name), &(name) }
可以看出,上面的代碼定義了兩個(gè)鏈表頭, stack_cache 用于存放沒有使用的棧內(nèi)存,而 stack_used 是正在使用的棧內(nèi)存塊。
前面提到 pthread 是存儲(chǔ)在分配的棧內(nèi)存塊中的,同時(shí) pthread 中存在一個(gè)管理變量 list, 該變量即可將棧內(nèi)存塊掛載到不同的鏈表中。 如果內(nèi)存棧在使用過程中時(shí),則內(nèi)存塊被放入 stack_used 隊(duì)列中; 當(dāng)線程結(jié)束后,該內(nèi)存塊被移入 stack_cache 隊(duì)列中,可以供下次創(chuàng)建線程時(shí)直接使用。
線程棧的分配
創(chuàng)建線程時(shí),既可以由用戶自己分配內(nèi)存作為線程的棧區(qū),也可以由庫自動(dòng)為線程分配棧區(qū)。這里我們看一下線程分配棧內(nèi)存的過程。
在 allocatestack 函數(shù)中,當(dāng)用戶沒有傳入棧區(qū)內(nèi)存地址時(shí),庫首先會(huì)調(diào)用 get_cached_stack 函數(shù)嘗試從緩存中分配一塊內(nèi)存:
...
/* Search the cache for a matching entry. We search for the
smallest stack which has at least the required size. Note that
in normal situations the size of all allocated stacks is the
same. As the very least there are only a few different sizes.
Therefore this loop will exit early most of the time with an
exact match. */
list_for_each (entry, &stack_cache)
{
struct pthread *curr;
curr = list_entry (entry, struct pthread, list);
if (FREE_P (curr) && curr->stackblock_size >= size)
{
if (curr->stackblock_size == size)
{
result = curr;
break;
}
if (result == NULL
|| result->stackblock_size > curr->stackblock_size)
result = curr;
}
}
...
/* Dequeue the entry. */
stack_list_del (&result->list);
其中主要邏輯很簡單,就是從 stack_cache 中找到一個(gè)空閑的棧內(nèi)存, 其中 FREE_P 用于判斷是否空閑。事實(shí)上該宏就是判斷 pthread 結(jié)構(gòu)中 tid 值是否小于或等于 0, 若是則該塊地址是空閑的。 并將該 內(nèi)存塊從列表中取出來。
/* Check whether the stack is still used or not. */
#define FREE_P(descr) ((descr)->tid <= 0)
如果沒有空閑的內(nèi)存塊,那么就需要調(diào)用 mmap 去重新分配內(nèi)存了。
mem = mmap (NULL, size, prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
為線程成功獲得一塊內(nèi)存塊后,按前面分析會(huì)掛入 stack_used 列表中。這一步驟也是在 allocate_stack 函數(shù)中完成的,如下:
/* Prepare to modify global data. */
lll_lock (stack_cache_lock, LLL_PRIVATE);
/* And add to the list of stacks in use. */
stack_list_add (&pd->list, &stack_used);
lll_unlock (stack_cache_lock, LLL_PRIVATE);
如上,即完成了線程棧的分配。
線程棧的釋放
線程棧的釋放我們需要搞清楚下面兩個(gè)問題:
由誰釋放?
對(duì)于非 detach 的線程,這個(gè)問題答案十分明顯,線程的棧區(qū)將由調(diào)用 Join 操作的線程來完成釋放。但是對(duì)于 detach 線程,這個(gè)問題就不是那么清楚了。 沒有其它線程來顯式的釋放棧區(qū),那么這個(gè)棧區(qū)的釋放只能交由線程自己來完成。也就是說,一個(gè)線程需要自己釋放自己正在使用的棧內(nèi)存塊。這聽上去就是胡扯嘛,正在用怎么能釋放呢。但是仔細(xì)想一下,如果棧區(qū)沒有使用了,那么線程已經(jīng)結(jié)束,它更沒辦法去釋放自己的棧內(nèi)存了。這時(shí)就需要 Linux 內(nèi)核的支持了。怎么釋放?
事實(shí)上,線程釋放自己的棧區(qū)也并非真正意義上的釋放該內(nèi)存塊,而是將該內(nèi)存塊從 stack_used 移除,放入 stack_cache 鏈表中, 同時(shí)修改標(biāo)志位,而將內(nèi)存真正的釋放操作推遲到其它線程中完成。釋放過程被分為如下步驟:(1) 將棧內(nèi)存塊從 stack_used 取下放入 stack_cache 列表中。(2) 釋放 stack_cache 中已結(jié)束線程的棧內(nèi)存塊。這里是否已結(jié)束是根據(jù) pthread tid 位是否被清零來業(yè)判斷的。(3) 線程結(jié)束時(shí), 由內(nèi)核清除標(biāo)志位(tid), 這一步驟是由內(nèi)核完成的,當(dāng)線程結(jié)束時(shí),內(nèi)核會(huì)自動(dòng)將tid清零,這就意味著一旦 tid 被清零就意味著線程已經(jīng)結(jié)束。需要注意:前兩步是由線程完成,而每三步是由內(nèi)核來完成的。
可以看出,針對(duì)自己的棧內(nèi)存,每個(gè)線程只是將其放入 stack_cache 鏈表中,而該內(nèi)存塊真正的釋放操作是由別的線程來完成的。所以會(huì)存在這樣一個(gè)時(shí)間段,線程正在使用過程中卻已經(jīng)被放到 stack_cache 鏈表中了,而線程真正結(jié)束的標(biāo)志是由 Linux 內(nèi)核來完成的,只由 tid 被清零的棧內(nèi)存才可能被真正的釋放掉。
當(dāng)用戶執(zhí)行完用戶指定的函數(shù)后,進(jìn)入清理工作。整個(gè)線程的入口函數(shù)是 START_THREAD_DEFN(nptl/pthread_create.c)
,該宏定義為:
#def START_THREAD_DEFN \
static void __attribute__ ((noreturn)) start_thread(void)
所以,其實(shí)該宏其實(shí)是一個(gè)函數(shù)的簽名。在這個(gè)函數(shù)中,調(diào)用用戶提供的函數(shù)(pd->start_routine(pd->arg))。 下面 THREAD_SETMEM 宏的作用是執(zhí)行函數(shù)的結(jié)果存儲(chǔ)在 pthread 的 result 變量中。
/* Run the code the user provided. */
#ifdef CALL_THREAD_FCT
THREAD_SETMEM (pd, result, CALL_THREAD_FCT (pd));
#else
THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
#endif
當(dāng)用戶函數(shù)執(zhí)行完后, start_thread 函數(shù)會(huì)進(jìn)行清理工作。如果發(fā)現(xiàn)線程是 detach 狀態(tài),則會(huì)主動(dòng)進(jìn)行資源的釋放,否則將等待 join 操作來釋放:
...
/* If the thread is detached free the TCB. */
if (IS_DETACHED (pd))
/* Free the TCB. */
__free_tcb (pd);
...
真正的釋放操作發(fā)生在 __deallocate_stack 函數(shù)中,
void
internal_function
__deallocate_stack (struct pthread *pd)
{
lll_lock (stack_cache_lock, LLL_PRIVATE);
/* Remove the thread from the list of threads with user defined
stacks. */
stack_list_del (&pd->list);
/* Not much to do. Just free the mmap()ed memory. Note that we do
not reset the 'used' flag in the 'tid' field. This is done by
the kernel. If no thread has been created yet this field is
still zero. */
if (__glibc_likely (! pd->user_stack))
(void) queue_stack (pd);
else
/* Free the memory associated with the ELF TLS. */
_dl_deallocate_tls (TLS_TPADJ (pd), false);
lll_unlock (stack_cache_lock, LLL_PRIVATE);
}
/* Add a stack frame which is not used anymore to the stack. Must be called with the cache lock held. */
static inline void
__attribute ((always_inline))
queue_stack (struct pthread *stack)
{
/* We unconditionally add the stack to the list. The memory may
still be in use but it will not be reused until the kernel marks
the stack as not used anymore. */
stack_list_add (&stack->list, &stack_cache);
stack_cache_actsize += stack->stackblock_size;
if (__glibc_unlikely (stack_cache_actsize > stack_cache_maxsize))
__free_stacks (stack_cache_maxsize);
}
這一幕何其熟悉,首先將將內(nèi)存塊從 stack_used 鏈表中移除(stack_list_del (&pd->list););再調(diào)用 queue_stack 函數(shù)將其添加到 stack_cache 鏈表中。 如上完成了第一步了。
glibc 允許緩存一部分內(nèi)存塊,只有當(dāng)內(nèi)存塊的大小超過 stack_cache_maxsize 時(shí)才會(huì)釋放掉一部分內(nèi)存塊,這也就是為什么會(huì)有分配階段的 get_cached_stack 的操作了。 具體的釋放過程如下:
/* Free stacks until cache size is lower than LIMIT. */
void
__free_stacks (size_t limit)
{
/* We reduce the size of the cache. Remove the last entries until
the size is below the limit. */
list_t *entry;
list_t *prev;
/* Search from the end of the list. */
list_for_each_prev_safe (entry, prev, &stack_cache)
{
struct pthread *curr;
curr = list_entry (entry, struct pthread, list);
if (FREE_P (curr))
{
/* Unlink the block. */
stack_list_del (entry);
/* Account for the freed memory. */
stack_cache_actsize -= curr->stackblock_size;
/* Free the memory associated with the ELF TLS. */
_dl_deallocate_tls (TLS_TPADJ (curr), false);
/* Remove this block. This should never fail. If it does
something is really wrong. */
if (munmap (curr->stackblock, curr->stackblock_size) != 0)
abort ();
/* Maybe we have freed enough. */
if (stack_cache_actsize <= limit)
break;
}
}
}
該函數(shù)過程就是就是遍歷 stack_cache 鏈表,從中判斷使用該內(nèi)存的線程是否結(jié)束(FREE_P),即內(nèi)存塊中 pthread 的 tid 值是否被清零,并釋放掉一部分內(nèi)存(munmap)。其中包含了 TLS 內(nèi)存釋放的操作,本文中暫不做討論。
當(dāng)前線程結(jié)束時(shí)的 tid 操作是怎么完成的呢?希望你還記得前面說過的 clone 系統(tǒng)調(diào)用時(shí)傳入的 flag 參數(shù) CLONE_PARENT_SETTID 與 CLONE_CHILD_CLEARTID。這兩個(gè)參數(shù)的說明如下:
CLONE_CHILD_CLEARTID (since Linux 2.5.49)
Clear (zero) the child thread ID at the location ctid in child memory when the child exits, and do a wakeup on the futex at that address. The address involved may be changed by the set_tid_address(2) system call. This is used by threading libraries.
CLONE_PARENT_SETTID (since Linux 2.5.49)
Store the child thread ID at the location ptid in the parent's memory. (In Linux 2.5.32-2.5.48 there was a flag CLONE_SETTID that did this.) The store operation completes before clone() returns control to user space.
簡單來說, CLONE_PARENT_SETTID 參數(shù)要求內(nèi)核在 clone 操作完成前將父進(jìn)程空間的某個(gè)指定內(nèi)存位置填上子線程的 ID 值; CLONE_CHILD_CLEARTID 則要求內(nèi)核在線程結(jié)束后將子線程空間的某個(gè)指定內(nèi)存位置處的值清零。當(dāng)然,針對(duì)線程而言都是在同一個(gè)內(nèi)存空間中。那么 glibc 在調(diào)用 clone 傳入的參數(shù)是怎么樣的呢? 如下,
if (__glibc_unlikely (ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS,
clone_flags, pd, &pd->tid, tp, &pd->tid)
== -1))
這里 ARCH_CLONE 是 glibc 對(duì)底層做的一層封裝,它是直接使用的 ABI 接口,代碼是用匯編語言寫的,x86_64 平臺(tái)的代碼在 (sysdeps/unix/sysv/linux/x86_64/clone.S) 文件中, 感興趣可以自己去看。你會(huì)發(fā)現(xiàn)其實(shí)就是就是調(diào)用了 linux 提供的 clone 接口。所以也可以直接參考 Linux 手冊(cè)上對(duì) clone 函數(shù)的描述,此宏與 clone 參數(shù)是一樣的。 我們可以看出此處,函數(shù)兩次傳入的都子線程 pthread 中 tid 值,以讓內(nèi)核在線程開始時(shí)設(shè)置線程 ID 以及線程結(jié)束時(shí)清除其 ID 值。這樣此線程的棧內(nèi)存塊就可以被隨后的線程釋放了。
綜上,我們就分析完了線程棧的釋放過程。
除了本文描述的線程棧,線程資源還應(yīng)該包括 TLS 等。在本文中,我們并沒有分析這些資源是怎么管理的。這方面內(nèi)容留做以后的工作吧。