体系编程课上遇到的一个问题:Linux下,假如一个 pthread_create
创立的线程没有被 pthread_join
收回,是否会和僵尸进程相同,发生“僵尸线程”,并一直占用一个 pid/tid?
猜测
僵尸进程
关于进程与子进程来说,假如子进程退出了,可是父进程不对子进程进行 reap (即运用 wait/waitpid 对子进程进行收回),则子进程的 PCB(内核中的 task_struct)依然会保留,用于记载回来状况直到父进程获取,而且状况将被设置成 ZOMBIE,即发生“僵尸线程”。
#include <unistd.h>
int main()
{
if(fork() == 0) {
return 0; // child exits immediately
}
while(1); // parent loops
}
运转后能够看到子进程 607727 的状况为 Zombie,而且在最终有 <defunct>
标志。
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
miigon 607726 97.5 0.0 2640 988 pts/0 R+ 21:55 0:28 ./child
miigon 607727 0.0 0.0 0 0 pts/0 Z+ 21:55 0:00 [child] <defunct>
僵尸线程?
#include <stdlib.h>
#include <pthread.h>
void *child_thread(void *args) {
// child thread exits immediately.
return (void*)666;
}
int main() {
pthread_t t1;
pthread_create(&t1, NULL, child_thread, NULL);
while(1);
// parent and child never join.
// pthread_join(t1, NULL);
}
子线程发动后立刻回来,而父线程无限循环,而且不 join 子线程,不查看其回来状况。
Linux 内核中(至少在调度上)并不区别线程和进程,都视为 task,故合理猜测:或许这儿的 pthread_create 和 pthread_join 也能够类比 fork 和 wait,假如一个线程被创立后,不进行 pthread_join,那在子线程履行结束后,或许子线程也会进入 Zombie 状况,直至被父线程收回?(猜测)
验证
对上述猜测进行验证,编译运转上述线程代码:
$ gcc pt.c -o pt -lpthread -g
$ ./pt
假如咱们的猜测正确,当查看 pt 的一切线程的时分,理论上应该能够看到一个主线程,还有一个 defunct 状况的子线程 task。
运用 ps 查看 pt 的一切线程:
$ ps -T -C pt
PID SPID TTY TIME CMD
610281 610281 pts/1 00:00:09 pt
发现只有一个主线程(PID == SPID
),没有观察到 defunct 状况的子线程,子线程退出后尽管主线程没有 pthread_join 读取其回来值,可是子线程 pid/tid 依然被收回了,并没有进入僵尸状况。
验证一下假如把子线程函数换成死循环,运转后能够观察到子线程存在,阐明测验方法没有问题,扫除子线程没有创立成功或许观测方法有误的或许性:
void *child_thread(void *args) {
while(1);
}
$ ps -T -C pt
PID SPID TTY TIME CMD
610762 610762 pts/1 00:00:05 pt
610762 610763 pts/1 00:00:05 pt
阐明咱们的猜测是不精确的,并没有观察到子线程退出后变为僵尸状况。
探求
因为已知在 Linux 上,创立线程和创立进程实际上走的是同一套机制,本质上都是 fork/clone,仅仅调用者指定的资源共享程度不同,所以差异出现的诱因只能是位于 fork/clone 的调用者,即位于 pthread 的代码中。
pthread 在 Linux 上一般是由 libc 完结的,最常见的 libc 是 glibc(另一个 Linux 上常用的 libc 的比如是 musl,更轻量,不打开)。glibc 的 pthread 完结叫做 NPTL(替换掉之前的远古完结叫 LinuxThreads,也不打开),能够在 codebrowser.dev/glibc/glibc… 很方便地在线浏览相关代码。
本文环境 ubuntuserver 22.04.1 + linux5.15.0 + glibc2.35;一切源代码文件以这些版本为准。
假如发现 glibc/NPTL 部分代码的锁进很乱,那是因为本来的代码便是这么锁进的,不是文章格式化错误。
线程等候 pthread_join()
首先查看 pthread_join 的源码,因为根据咱们的猜测,假如是会发生“僵尸线程”的话,pthread_join 要收回这个“僵尸线程”,必然要调用 wait/waitpid 系的体系调用。
codebrowser.dev/glibc/glibc…
codebrowser.dev/glibc/glibc…
// pthread_join.c:21
int
___pthread_join (pthread_t threadid, void **thread_return)
{
return __pthread_clockjoin_ex (threadid, thread_return, 0 /* Ignored */,
NULL, true);
}
核心部分:
// pthread_join_common.c:35
int
__pthread_clockjoin_ex (pthread_t threadid, void **thread_return,
clockid_t clockid,
const struct __timespec64 *abstime, bool block)
{
struct pthread *pd = (struct pthread *) threadid;
// ......
if (block) // true
{
// 等候线程履行完结
pthread_cleanup_push (cleanup, &pd->joinid);
pid_t tid;
while ((tid = atomic_load_acquire (&pd->tid)) != 0) // 获取锁
{
int ret = __futex_abstimed_wait_cancelable64 ( // 经过等候一个 futex 来等候线程履行完结,仅仅 futex syscall 的封装,内部并没有调用 wait/waitpid
(unsigned int *) &pd->tid, tid, clockid, abstime, LLL_SHARED);
if (ret == ETIMEDOUT || ret == EOVERFLOW)
{
result = ret;
break;
}
}
pthread_cleanup_pop (0);
}
void *pd_result = pd->result; // 获取线程的回来值,阐明线程现已履行完结
if (__glibc_likely (result == 0)) // 等候成功
{
/* We mark the thread as terminated and as joined. */
pd->tid = -1;
/* Store the return value if the caller is interested. */
if (thread_return != NULL)
*thread_return = pd_result;
/* Free the TCB. */
__nptl_free_tcb (pd); // 释放 TCB,即 pthread 结构体
}
// ......
}
经过 JOIN 的这部分关键代码,能够推测出这几个重要信息:
- glibc 上 pthread_join 等候子线程完结,并不是经过传统的 wait/waitpid 完结的,而是由 pthread 自己再保护了一个 futex (在这儿作为「线程履行结束」的条件变量 condition variable),经过等候这个 futex 完结。
- 经过
__nptl_free_tcb(pd)
能够知道,所谓的 “TCB” 这个概念实际上便是 pthread 结构体自身(pthread_t 是指向其的指针),而且是存储在用户态的,由 glibc/nptl 办理,而不是在内核态办理。
- pthread_join 只担任释放用户态 pthread 结构体(pd),而和释放线程在内核中占用的资源没有关系。
综合「pthread_join 不担任收回(reap)内核态线程」以及「观察到子线程在履行完结后,在主线程什么都没有做的状况下自己消失了」这两个信息,进一步猜测子线程是退出后被内核主动 reap 掉了。
可是按照正常进程来说,除非是父进程设置了 signal(SIGCHLD, SIG_IGN);
,否则操作体系是不会主动 reap 掉子进程的,假定内核不区别进程和线程,对线程而言应该也是这个行为(需求等候父进程 reap,否则就处于 ZOMBIE 状况)才对。
由此猜测有或许是两种或许性中的一种:
- 内核或许对线程 task 有一定的特别照顾/特别处理,使得线程的 task 会在退出时主动 reap,而进程则等候父进程收回。
- 也有一种或许性是 pthread 自己在子线程履行结束做了特别处理,让操作体系 reap 掉自己(真的或许做到吗?)
后面的内容和探求都是环绕测验检验这两个猜测打开的。
线程创立 pthread_create()
因为已知线程 task 不是由 pthread_join 收回的,必然是内核或许 pthread 在什么其他当地进行了收回,故追寻整个线程 pthread 从创立开端的生命周期:
codebrowser.dev/glibc/glibc…
// pthread_create.c:619
int
__pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg)
{
void *stackaddr = NULL;
size_t stacksize = 0;
// ......
// 分配 TCB (pthread 结构体)和线程栈空间
struct pthread *pd = NULL;
int err = allocate_stack (iattr, &pd, &stackaddr, &stacksize);
int retval = 0;
// ......
// 初始化 TCB
pd->start_routine = start_routine; // 子线程进口
pd->arg = arg; // 子线程进口参数
pd->c11 = c11;
// ......
/* Setup tcbhead. */
tls_setup_tcbhead (pd);
/* Pass the descriptor to the caller. */
*newthread = (pthread_t) pd;
// ......
// .......some more signal related stuff
/* Start the thread. */
if (__glibc_unlikely (report_thread_creation (pd))) // 假如需求陈述 TD_CREATE 工作
{
// ......在咱们的比如中不会走到这儿,疏忽
// event 是 debug 时用的,例如用来告诉 gdb 线程现已创立
}
else
// !!创立内核态线程 task!!
retval = create_thread (pd, iattr, &stopped_start, stackaddr,
stacksize, &thread_ran);
// ......
if (__glibc_unlikely (retval != 0))
{
// ......错误处理,疏忽
}
else
{
// 放开 pd 上的锁,让子线程自在运转
if (stopped_start) lll_unlock (pd->lock, LLL_PRIVATE);
// ......
}
out:
if (destroy_default_attr)
__pthread_attr_destroy (&default_attr.external);
return retval;
}
pthread_create 做的工作并不复杂:
- 为子线程分配栈空间和 TCB(pd)
- 预备/配置好 TCB 各项参数,包括子线程进口和进口参数
- 调用
create_thread()
发动子线程 task
线程 task 创立 create_thread()
这个函数在 pthread_create()
中担任为子线程创立实际的内核态 task,经过调用 clone 完结(__clone_internal()
是 clone/clone2/clone3 的简略封装)。
// pthread_create.c:231
static int create_thread (struct pthread *pd, const struct pthread_attr *attr,
bool *stopped_start, void *stackaddr,
size_t stacksize, bool *thread_ran)
{
// ......
/* We rely heavily on various flags the CLONE function understands:
CLONE_VM, CLONE_FS, CLONE_FILES
These flags select semantics with shared address space and
file descriptors according to what POSIX requires.
...... 篇幅原因缩略,查看原始文件或 `man clone` 查询每个 flag 效果
The termination signal is chosen to be zero which means no signal
is sent. */
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
| CLONE_SIGHAND | CLONE_THREAD // !!留意到这个 CLONE_THREAD flag
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID
| 0);
TLS_DEFINE_INIT_TP (tp, pd);
struct clone_args args =
{
.flags = clone_flags, // clone flags
.pidfd = (uintptr_t) &pd->tid,
.parent_tid = (uintptr_t) &pd->tid, // CLONE_PARENT_SETTID
.child_tid = (uintptr_t) &pd->tid,
.stack = (uintptr_t) stackaddr,
.stack_size = stacksize,
.tls = (uintptr_t) tp, // CLONE_SETTLS
};
// clone,子线程从 start_thread() 开端履行
int ret = __clone_internal (&args, &start_thread, pd);
if (__glibc_unlikely (ret == -1))
return errno;
// ......
return 0;
}
这儿留意到,调用 clone 的时分,传递给内核的 flags 中含有 CLONE_THREAD 这个 flag。
这个 flag 意味着用户态显式地告知了内核,克隆出来的 task 应该被作为一个线程看待。先不论这个 flag 的详细影响是什么,传递这个 flag 这件工作自身足以阐明,内核实际上对一般进程 task 和线程 task 还是有专门的区别的,并不是除了资源共享程度不同以外其他都彻底一模相同。
咱们前面针对子线程的 task 会被主动 reap 掉这件事,做出了两种猜测:或许是内核特别处理了线程 task,也或许是 pthread 自己在子线程结束收回了子线程。
CLONE_THREAD 这个 flag 的存在加大了第一种猜测正确的或许性,不过并不彻底扫除第二种猜测,要扫除第二种猜测,需求看子线程的用户例程履行结束后,在 pthread 中都做了什么,有没有收回掉子线程 task。
子线程 task 履行进口 start_thread()
create_thread()
创立的子线程的履行进口固定为 start_thread()
,这个函数再从 pd->start_routine
和 pd->args
获得用户函数的地址和参数,并跳转到用户函数开端履行。
/* Local function to start thread and handle cleanup. */
static int _Noreturn
start_thread (void *arg)
{
struct pthread *pd = arg;
// ......
// ......thread local storage and stuff
// ......unwinders and stuff, for cancellation and/or exception handling
// ......
if (__glibc_likely (! not_first_call))
{
/* Store the new cleanup handler info. */
THREAD_SETMEM (pd, cleanup_jmp_buf, &unwind_buf);
__libc_signal_restore_set (&pd->sigmask);
LIBC_PROBE (pthread_start, 3, (pthread_t) pd, pd->start_routine, pd->arg);
/* Run the code the user provided. */
void *ret;
if (pd->c11)
{
// ......c11 规范下多了一个类型转化问题,咱们不运用 c11 规范,不走到这儿,略
}
else
ret = pd->start_routine (pd->arg); // 运用用户供给的参数,调用用户函数,得到回来值 ret
THREAD_SETMEM (pd, result, ret); // 用户函数回来值 ret 存入到 pd->result
}
// ======= 到这儿,用户函数现已履行结束,子线程使命完结,进入结束阶段 ======
// thread local storage、thread local data 析构
#ifndef SHARED
if (&__call_tls_dtors != NULL)
#endif
__call_tls_dtors ();
/* Run the destructor for the thread-local data. */
__nptl_deallocate_tsd ();
/* Clean up any state libc stored in thread-local variables. */
__libc_thread_freeres ();
/* Report the death of the thread if this is wanted. */
if (__glibc_unlikely (pd->report_events))
{
// ......在需求时,陈述 TD_DEATH 工作,略
// event 是 debug 时用的,例如用来告诉 gdb 线程现已退出
}
// ......
if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
/* This was the last thread. */
exit (0); // 假如这个线程是最终一个线程,退出整个线程组(thread group)
// ......signal and mutex and stuff
// ......假如栈空间是 pthread 分配的(而不是用户创立线程时供给的),则收回栈空间
// ......
// 假如线程被 pthread_detach 过,则趁便收回 TCB(留意 TCB 不是 task_struct,是用户态 pthread 结构体)
// 默许行为是不在这儿收回 TCB,咱们的比如中也是默许状况,即不会在这儿履行 `__nptl_free_tcb(pd)`,而是需求等到 pthread_join() 时才会收回 TCB
// 这是因为 pd 结构体中含有用户态回来值 pd->result 或许会被父线程需求;pthread_detach 则意味着父线程不关心子线程的履行成果。
if (IS_DETACHED (pd))
/* Free the TCB. */
__nptl_free_tcb (pd);
out:
// 子线程彻底结束,最终一步:调用 sys_exit 体系调用,结束【子线程】(不是整个进程)
while (1)
INTERNAL_SYSCALL_CALL (exit, 0);
/* NOTREACHED */
}
留意到最终一步 sys_exit
和常见的 exit()/_exit()
不同,前者是体系调用,后者是由 libc 供给的用户态包装方法。
两者的效果效果也不相同:
-
sys_exit
是退出当前【调度单元】,即 task,在这儿是指当前【线程】 - 而
exit()/_exit()
实际上包装的是sys_exit_group
体系调用,代表退出整个【线程组】,即整个进程的一切线程。
命名的紊乱是前史原因,因为一开端 Linux 只支撑多进程,最初
exit()/_exit()
也的确封装的是 sys_exit。而后来加入多线程后,Linux 在内核态内引入了一个新概念:thread group。本来的进程变成了 task,
task_struct->pid
变成了线程 id(gettid()
回来task_struct->pid
),而现在常说的进程 id,则是新的task_struct->tgid
(thread group id,getpid()
回来task_struct->tgid
)。同时,libc 的exit()/_exit()
也被改为调用新的 sys_exit_group,即结束整个线程组。本来的 sys_exit 的效果不变,依然是结束一个调度单元 task,仅仅「调度单元」的概念改变了而已。这个比较 hack 的方法,使得 linux 以较小的改动,完结了对线程的支撑,害处便是导致用户态的 “pid” 的概念和内核态中的 “pid” 的意义不一致,不留意的话简单混淆。
能够看到,子线程的进口函数 start_thread()
,在履行完用户函数后,销毁了栈和 thread local storage,然后履行了 sys_exit 结束子线程 task。
这个进程和多进程模型中一个子进程调用 exit()
退出线程是相似的,并不保证一定清理掉 task_struct,而是理论上有或许使 task 进入 ZOMBIE 状况。而且这儿能够看到,pthread 也没有让子进程做(诸如自己 wait 自己?)之类的魔法来显式清理掉子进程的 task。
这阐明咱们之前的猜测2是不正确的,子线程的内核 task_struct 并不是 pthread 在子线程退出后进行特别处理 reap 收回的。只能是 sys_exit 体系调用中,退出子线程 task 的时分,内核自己决议要直接 reap 掉这个 task。
实际上猜测2自身也不或许,一个 task 不或许自己收回自己的资源,因为只有现已结束的 task 才干被收回,可是现已结束的 task 就无法履行任何代码了,也就没法收回自己。
退出子线程 sys_exit 体系调用
前面经过扫除,将咱们子线程的 task 被 reap 的精确方位定位到了 sys_exit 中了。咱们从用户态的 glibc 以及 pthread,持续进入到内核态代码的范围中。
前一末节结束说到,咱们发现子线程的 task 在用户态是正常 sys_exit 退出的,可是 sys_exit 后 pid 以及 task_struct 被立刻收回掉,而不是像一般进程相同进入僵尸状况,这儿看到内核 do_exit()
方法(sys_exit 体系调用的内核态处理函数):
elixir.bootlin.com/linux/v5.15…
// kernel/exit.c:727
// 不用仔细看这个函数的每一步,这儿全放出来仅仅为了表现步骤有多么多而已
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
// ......
profile_task_exit(tsk);
kcov_task_exit(tsk);
ptrace_event(PTRACE_EVENT_EXIT, code);
validate_creds_for_do_exit(tsk);
// ......
io_uring_files_cancel();
exit_signals(tsk); /* sets PF_EXITING */
/* sync mm's RSS info before statistics gathering */
if (tsk->mm)
sync_mm_rss(tsk->mm);
acct_update_integrals(tsk);
group_dead = atomic_dec_and_test(&tsk->signal->live);
if (group_dead) {
/*
* If the last thread of global init has exited, panic
* immediately to get a useable coredump.
*/
if (unlikely(is_global_init(tsk)))
panic("Attempted to kill init! exitcode=0x%08x\n",
tsk->signal->group_exit_code ?: (int)code);
#ifdef CONFIG_POSIX_TIMERS
hrtimer_cancel(&tsk->signal->real_timer);
exit_itimers(tsk->signal);
#endif
if (tsk->mm)
setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm);
}
acct_collect(code, group_dead);
if (group_dead)
tty_audit_exit();
audit_free(tsk);
tsk->exit_code = code;
taskstats_exit(tsk, group_dead);
exit_mm();
if (group_dead)
acct_process();
trace_sched_process_exit(tsk);
exit_sem(tsk);
exit_shm(tsk);
exit_files(tsk);
exit_fs(tsk);
if (group_dead)
disassociate_ctty(1);
exit_task_namespaces(tsk);
exit_task_work(tsk);
exit_thread(tsk);
perf_event_exit_task(tsk);
sched_autogroup_exit_task(tsk);
cgroup_exit(tsk);
flush_ptrace_hw_breakpoint(tsk);
exit_tasks_rcu_start();
exit_notify(tsk, group_dead);
proc_exit_connector(tsk);
mpol_put_task_policy(tsk);
#ifdef CONFIG_FUTEX
if (unlikely(current->pi_state_cache))
kfree(current->pi_state_cache);
#endif
/*
* Make sure we are holding no locks:
*/
debug_check_no_locks_held();
if (tsk->io_context)
exit_io_context(tsk);
if (tsk->splice_pipe)
free_pipe_info(tsk->splice_pipe);
if (tsk->task_frag.page)
put_page(tsk->task_frag.page);
validate_creds_for_do_exit(tsk);
check_stack_usage();
preempt_disable();
if (tsk->nr_dirtied)
__this_cpu_add(dirty_throttle_leaks, tsk->nr_dirtied);
exit_rcu();
exit_tasks_rcu_finish();
lockdep_free_task(tsk);
do_task_dead();
}
EXPORT_SYMBOL_GPL(do_exit);
能够看到一个 task 退出时需求清理/释放的资源种类非常之多,主流程do_exit()
里的子流程函数调用就有好几十个了。
咱们想要从中找到这个逻辑:exit 的时分,内核根据什么决议是否直接 reap 掉 task。
利用已知常识帮助快速定位到想要找的逻辑的代码:已知关于一般进程来说,假如父进程设置了疏忽 SIGCHLD 信号(signal(SIGCHLD, SIG_IGN)
),则子进程 exit 的时分会【reap 掉 task】,否则子进程会【进入 ZOMBIE 状况】。这实际上正是咱们要找的「exit 决议是否直接 reap 掉 task」的决议计划进程的一部分。猜测关于线程 task 是否主动 reap 的决议计划逻辑也是在相同的方位或附近。
故以【进入 ZOMBIE 状况】为头绪反查,直接搜索 = EXIT_ZOMBIE
测验找一切将 task 状况设置为 ZOMBIE 的当地,快速定位到 exit_notify()
中:
/*
* Send signals to all our closest relatives so that they know
* to properly mourn us..
*/
static void exit_notify(struct task_struct *tsk, int group_dead)
{
bool autoreap; // 是否主动 reap 掉 task
// ......
tsk->exit_state = EXIT_ZOMBIE; // 默许将 task 置入 EXIT_ZOMBIE 状况
if (unlikely(tsk->ptrace)) { // 假如启用了 ptrace(一般用于 debug)
int sig = // ......
autoreap = do_notify_parent(tsk, sig);
} else if (thread_group_leader(tsk)) { // 假如是线程组组长,即主线程
// 而且整个线程组(进程)中一切线程都现已退出
// 则发送 SIGCHLD 给 parent,假如父进程 SIG_IGN 掉了 SIGCHLD,则主动 reap
autoreap = thread_group_empty(tsk) &&
do_notify_parent(tsk, tsk->exit_signal);
} else { // 其他任何状况,即:不是线程组组长(例如子线程)
autoreap = true; // 则均主动 reap
}
if (autoreap) { // 主动 reap 的进程直接进入 EXIT_DEAD 状况
tsk->exit_state = EXIT_DEAD;
list_add(&tsk->ptrace_entry, &dead);
}
// ......
}
这儿证实了猜测1:当一个 task 不是一个线程组的组长的时分,内核会在 exit 的时分直接 reap 掉子线程的 task。所以在子线程履行结束可是未 join 之间,ps 会看不到子线程,因为子线程 task 现已被内核收回了。
也便是说,只有一个线程组(也便是进程)的主线程能够进入僵尸状况,一切的子线程都不或许会有僵尸状况。子线程的 task 在用户程序代码履行结束后,就立刻退出并被收回了。
而子线程的履行成果(回来值等)则保存在用户态 pthread 结构体中,供后续或许的 JOIN 运用。
定论
关于 Linux 平台上的 pthread 线程,在子线程比父线程先退出且没被 JOIN 的状况下,不会发生和传统意义上的僵尸进程相似的“僵尸线程”(即 ps 不会看到有 defunct 的线程 task,子线程 task 会在 exit 时被内核直接收回掉,不等父进程 JOIN)。
可是并不意味着没有被 pthread_join 的线程彻底不会占用资源。
没被收回的线程尽管不会占用内核的 task 资源,可是会在用户态留下 pthread 结构体(TCB)以及线程的栈(因为 pthread 结构体和线程栈是一同分配一同释放的),假如未被 JOIN 的线程累积过多,依然或许会导致用户态资源耗尽而导致该进程无法创立新的线程。
与僵尸进程不同的是,“僵尸线程”堆积的影响只约束在一个进程之内,理论上不会导致体系上其他进程创立失利(因为不占用 task_struct 和 pid/tid)。
留意到该定论只适用于 Linux,因为 Linux 完结线程的方法为内核轻改动,大多数线程相关的功用完结都在用户态中完结(glibc)。不扫除其他 POSIX 体系或许内核级原生支撑 pthread 线程。
pthread_detach()
过的线程,则 pthread 会在线程履行完结后主动释放 pthread 结构体以及栈,所以对不关心履行成果的线程,应当运用 pthread_detach()
进行脱离。若不进行脱离,则必须确保一切线程在合适的时间都能被 pthread_join()
收回。
Reference:
manpages.debian.org/bullseye/ma…
codebrowser.dev/glibc/glibc…
elixir.bootlin.com/linux/v5.15…