標簽(空格分隔): Glibc, Thread, 線程棧
前言
前幾天自己寫了一段基于線程模型的網絡程序,即主線程對每個連接請求創建一個工作線程,工作線程處理接下來所有業務。主線程希望工作線程完成后自動結束并釋放資源(請別問我這么 Low 的代碼用來干嘛,我就自己寫兩行來玩的 v)。這個時候容易犯一個錯(大神請繞道),那就是既不調join操作(因為主線程并不關注工作線程什么時候結束),也不將工作線程detach。這個錯誤的后果就是導致資源泄露,當然解決這個問題最簡單的方法是將工作線程設置為detach狀態,系統就會自動完成資源的回收。顯然如果本篇博客只是想描述怎么解決這個問題,那么顯然沒有寫一篇文章的必要。本文真正要描述的是線程的資源是怎么自動釋放的,這毫無疑問涉及到線程有哪些資源以及是如何管理的問題。 在此之前,需要說明一下,本文中描述所描述的適用于 Linux 系統,x86_64 平臺,至于其它平臺是否適用我也不知道,哈哈。
背景
本節線程模型的內容來自 Linux 線程模型的比較:LinuxThreads 和 NPTL。
對 Linux 有所了解就會知道, Linux 內核并不能真正支持線程,而是通過進程間共享資源(內存空間、文件等)的方式模擬線程,又被稱之為輕量級進程(LWP)。最早 LinuxThreads 項目希望在用戶空間模擬對線程的支持。LinuxThreads 采用的是一對一的線程模型,為了解決信號處理、調度和進程間同步原語方面的問題, LinuxThreads 引入了一個管理線程,以滿足響應終止信號殺死整個進程,完成線程結束后的內存回收等任務。但是管理線程的引入也帶來系統伸縮性與性能的問題。并且, LinuxThreads 并不符合 POSIX 標準。
NPTL 的出現改變了 LinuxThreads 尷尬的現狀。不過,NPTL 不僅僅是一個用戶態的線程庫,同時它也對系統內核做了一定的要求, 因此有時在談論 Linux 內核沒有線程概念時并不十分準確,例如為了支持 nptl 線程內核 task_struct 是引入了 pid 與 tgid 的區別, 因而準確的說法應該是內核在調度的時候沒有線程的概念,這都是題外話了。NPTL 作為 Linux 線程的新的實現,它移除了 LinuxThreads 中的管理線程,因而其在 NUMA 與 SMP 系統上更好的伸縮性與同步機制。此外,NPTL 是符合 POSIX 需求的, glibc2.3.5 開始就全面使用 NPTL 模型了,所在現在使用的 Linux 線程模型都是已經 NPTL 了。 本文中描述的資源管理都是指 NPTL 模型中的資源管理。更多的關于 LinuxThreads 與 NPTL 的內容可以參考 Linux 線程模型的比較:LinuxThreads 和 NPTL。
此外需要說明一點, 無論是 LinuxThreads 還是 NPTL, 它們都使用了一對一的線程模型,也即一個用戶態線程對應一個內核態LWP,線程的調度是由內核完成的。
線程內核資源
線程資源可以粗略地分為兩類,內核資源(例如 task_struct)以及用戶態內存資源(主要是線程棧)。在 Linux 平臺上,進程的內核資源釋放是通過父進程使用 wait 系統調用完成的,如果父進程沒有調用該操作,就會出現僵尸進程,直到父進程結束。對于線程而言,Linux 還提供了內核自動釋放的功能。參考 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 在調用 clone 創建線程時傳入的 flag 參數,在本文中我們需要注意三個參數: CLONE_THREAD, CLONE_PARENT_SETTID,CLONE_CHILD_CLEARTID。后面兩個參數與后面講述線程棧的釋放有關。 關于 CLONE_THREAD 參數的描述如下:
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).
這段說明,當使用 CREATE_THREAD 參數創建線程后,此線程結束時不會發送 SIGCHLD 信號,而且不能使用 wait 獲得其狀態,其間接地說明了,內核在某個時機自動釋放了該線程的內核資源,而至于是否有其它方式獲得該線程的狀態,以后再討論這個問題。
線程棧的管理
對于多線程程序而言,堆資源是共享的,所有的線程都使用一個堆區。但是棧區是獨立的,每個線程都必須有自己的獨立的棧區,那么這些棧區是如何管理的呢?
線程棧的布局
在討論線程棧的布局的時候,涉及到一個十分重要的數據結構 struct pthread。它存儲了線程的相關信息擔任線程的管理功能。其數據結構比較復雜,再這里我們只展示幾個與本文討論內容相關的變量,完整的內容可以從 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 用于將此結構體掛于雙鏈表中,這也是 Linux 內核中十分常見的一種數據結構。 tid 存儲了線程的 ID 值。 從代碼中的注釋也可以看出 list 和 tid 都將用于線程棧的管理。
struct pthread 是用于用戶態描述線程的數據結構,那么顯然每個 pthread 都唯一對應一個線程。那么這個變量是存儲在哪里的,答案是線程棧內存塊的高地址空間中的(這里以 x86 棧向下增長的方式為例)。也就是說,創建線程時為每個線程分配了一塊內存,然后這塊內存一部分存儲了 pthread 變量,剩下的內存才是真正的線程棧。熟悉 Linux 內核棧 結構的人會對這種方式比較熟悉。下圖展示了 x86 上線程棧的簡要布局:
Talk is cheap, show me the code.
在創建線程的函數 __pthread_create_2_1 中(nptl/pthrea_create.c),調用 ALLOCATE_STACK 宏用于分配線程棧,該宏即函數 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
這段代碼是用戶自己提供內存塊用作線程棧時的代碼,此處 stackaddr 指向所分配內存塊的高地址。因此,從代碼中可以看出來,無論從哪個分支編譯,pd 都指向該內存塊高地址端一塊內存。換句話說在線程棧內存塊中存儲了一個 pthread 對象。 至于這其中復雜的地址預留策略,例如對齊等,就不在此細說,有興趣可以直接去閱讀代碼。nptl 自動分配線程棧的處理代碼是類似的,其注釋說明的已經非常清楚了,如下所示:
/* 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
線程棧的管理結構
glibc 中使用了鏈表的形式來管理所有內存棧,其中定義了兩個全局變量(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) }
可以看出,上面的代碼定義了兩個鏈表頭, stack_cache 用于存放沒有使用的棧內存,而 stack_used 是正在使用的棧內存塊。
前面提到 pthread 是存儲在分配的棧內存塊中的,同時 pthread 中存在一個管理變量 list, 該變量即可將棧內存塊掛載到不同的鏈表中。 如果內存棧在使用過程中時,則內存塊被放入 stack_used 隊列中; 當線程結束后,該內存塊被移入 stack_cache 隊列中,可以供下次創建線程時直接使用。
線程棧的分配
創建線程時,既可以由用戶自己分配內存作為線程的棧區,也可以由庫自動為線程分配棧區。這里我們看一下線程分配棧內存的過程。
在 allocatestack 函數中,當用戶沒有傳入棧區內存地址時,庫首先會調用 get_cached_stack 函數嘗試從緩存中分配一塊內存:
...
/* 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 中找到一個空閑的棧內存, 其中 FREE_P 用于判斷是否空閑。事實上該宏就是判斷 pthread 結構中 tid 值是否小于或等于 0, 若是則該塊地址是空閑的。 并將該 內存塊從列表中取出來。
/* Check whether the stack is still used or not. */
#define FREE_P(descr) ((descr)->tid <= 0)
如果沒有空閑的內存塊,那么就需要調用 mmap 去重新分配內存了。
mem = mmap (NULL, size, prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
為線程成功獲得一塊內存塊后,按前面分析會掛入 stack_used 列表中。這一步驟也是在 allocate_stack 函數中完成的,如下:
/* 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);
如上,即完成了線程棧的分配。
線程棧的釋放
線程棧的釋放我們需要搞清楚下面兩個問題:
由誰釋放?
對于非 detach 的線程,這個問題答案十分明顯,線程的棧區將由調用 Join 操作的線程來完成釋放。但是對于 detach 線程,這個問題就不是那么清楚了。 沒有其它線程來顯式的釋放棧區,那么這個棧區的釋放只能交由線程自己來完成。也就是說,一個線程需要自己釋放自己正在使用的棧內存塊。這聽上去就是胡扯嘛,正在用怎么能釋放呢。但是仔細想一下,如果棧區沒有使用了,那么線程已經結束,它更沒辦法去釋放自己的棧內存了。這時就需要 Linux 內核的支持了。怎么釋放?
事實上,線程釋放自己的棧區也并非真正意義上的釋放該內存塊,而是將該內存塊從 stack_used 移除,放入 stack_cache 鏈表中, 同時修改標志位,而將內存真正的釋放操作推遲到其它線程中完成。釋放過程被分為如下步驟:(1) 將棧內存塊從 stack_used 取下放入 stack_cache 列表中。(2) 釋放 stack_cache 中已結束線程的棧內存塊。這里是否已結束是根據 pthread tid 位是否被清零來業判斷的。(3) 線程結束時, 由內核清除標志位(tid), 這一步驟是由內核完成的,當線程結束時,內核會自動將tid清零,這就意味著一旦 tid 被清零就意味著線程已經結束。需要注意:前兩步是由線程完成,而每三步是由內核來完成的。
可以看出,針對自己的棧內存,每個線程只是將其放入 stack_cache 鏈表中,而該內存塊真正的釋放操作是由別的線程來完成的。所以會存在這樣一個時間段,線程正在使用過程中卻已經被放到 stack_cache 鏈表中了,而線程真正結束的標志是由 Linux 內核來完成的,只由 tid 被清零的棧內存才可能被真正的釋放掉。
當用戶執行完用戶指定的函數后,進入清理工作。整個線程的入口函數是 START_THREAD_DEFN(nptl/pthread_create.c)
,該宏定義為:
#def START_THREAD_DEFN \
static void __attribute__ ((noreturn)) start_thread(void)
所以,其實該宏其實是一個函數的簽名。在這個函數中,調用用戶提供的函數(pd->start_routine(pd->arg))。 下面 THREAD_SETMEM 宏的作用是執行函數的結果存儲在 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
當用戶函數執行完后, start_thread 函數會進行清理工作。如果發現線程是 detach 狀態,則會主動進行資源的釋放,否則將等待 join 操作來釋放:
...
/* If the thread is detached free the TCB. */
if (IS_DETACHED (pd))
/* Free the TCB. */
__free_tcb (pd);
...
真正的釋放操作發生在 __deallocate_stack 函數中,
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);
}
這一幕何其熟悉,首先將將內存塊從 stack_used 鏈表中移除(stack_list_del (&pd->list););再調用 queue_stack 函數將其添加到 stack_cache 鏈表中。 如上完成了第一步了。
glibc 允許緩存一部分內存塊,只有當內存塊的大小超過 stack_cache_maxsize 時才會釋放掉一部分內存塊,這也就是為什么會有分配階段的 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;
}
}
}
該函數過程就是就是遍歷 stack_cache 鏈表,從中判斷使用該內存的線程是否結束(FREE_P),即內存塊中 pthread 的 tid 值是否被清零,并釋放掉一部分內存(munmap)。其中包含了 TLS 內存釋放的操作,本文中暫不做討論。
當前線程結束時的 tid 操作是怎么完成的呢?希望你還記得前面說過的 clone 系統調用時傳入的 flag 參數 CLONE_PARENT_SETTID 與 CLONE_CHILD_CLEARTID。這兩個參數的說明如下:
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 參數要求內核在 clone 操作完成前將父進程空間的某個指定內存位置填上子線程的 ID 值; CLONE_CHILD_CLEARTID 則要求內核在線程結束后將子線程空間的某個指定內存位置處的值清零。當然,針對線程而言都是在同一個內存空間中。那么 glibc 在調用 clone 傳入的參數是怎么樣的呢? 如下,
if (__glibc_unlikely (ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS,
clone_flags, pd, &pd->tid, tp, &pd->tid)
== -1))
這里 ARCH_CLONE 是 glibc 對底層做的一層封裝,它是直接使用的 ABI 接口,代碼是用匯編語言寫的,x86_64 平臺的代碼在 (sysdeps/unix/sysv/linux/x86_64/clone.S) 文件中, 感興趣可以自己去看。你會發現其實就是就是調用了 linux 提供的 clone 接口。所以也可以直接參考 Linux 手冊上對 clone 函數的描述,此宏與 clone 參數是一樣的。 我們可以看出此處,函數兩次傳入的都子線程 pthread 中 tid 值,以讓內核在線程開始時設置線程 ID 以及線程結束時清除其 ID 值。這樣此線程的棧內存塊就可以被隨后的線程釋放了。
綜上,我們就分析完了線程棧的釋放過程。
除了本文描述的線程棧,線程資源還應該包括 TLS 等。在本文中,我們并沒有分析這些資源是怎么管理的。這方面內容留做以后的工作吧。