引入
笔者于2025年参与了2025年全国大学生计算机系统能力大赛-操作系统设计赛(全国)-OS内核实现赛道,在实现线程的过程中,深入了解了glibc和musl下pthread库的实现,现于此分享笔者对pthread_join()
的研究。
在多线程编程中,pthread_join()
函数用于等待指定线程的终止。其底层实现机制,尤其是在不同 C 库(如 GLIBC 和 MUSL)中,存在显著差异。本文将深入探究 pthread_join()
在 Linux 环境下的具体工作原理。
futex()
系统调用在 Linux 内核中扮演着多功能角色,能够实现多种同步操作。在 pthread
库的上下文中,它常与 clone()
系统调用协同,共同完成线程的创建与同步任务。
GLIBC 中的实现:基于内核协助的同步机制
GLIBC 在实现 pthread_join()
时,主要依赖 clone()
系统调用的特定标志位,将部分同步逻辑委托给内核处理。
strace
输出分析
通过 strace
工具对基于 GLIBC 的程序进行跟踪,可以观察到以下典型的系统调用序列:
clone(child_stack=0x7f4859865e30, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f48598669d0, tls=0x7f4859866700, child_tidptr=0x7f48598669d0) = 11383
futex(0x7f48598669d0, FUTEX_WAIT, 11383, NULLstrace: Process 11383 attached
<unfinished ...>
[pid 11383] set_robust_list(0x7f48598669e0, 24) = 0
[pid 11383] nanosleep({tv_sec=1, tv_nsec=0}, 0x7f4859865d50) = 0
[pid 11383] write(1, "1111\n", 51111) = 5
[pid 11383] madvise(0x7f4859066000, 8368128, MADV_DONTNEED) = 0
[pid 11383] exit(0) = ?
[pid 11383] +++ exited with 0 +++
<... futex resumed> ) = 0
nanosleep({tv_sec=1, tv_nsec=0}, 0x7ffd9d9d3460) = 0
在 pthread_create()
调用 clone()
时,会传递一系列标志,其中 CLONE_PARENT_SETTID
和 CLONE_CHILD_CLEARTID
是实现 pthread_join()
机制的关键:
CLONE_PARENT_SETTID
:此标志指示内核,在子线程创建后,将其线程 ID 存储在parent_tidptr
指向的内存地址(例如0x7f48598669d0
)。CLONE_CHILD_CLEARTID
:此标志更为核心,它指示内核在子线程终止时,将child_tidptr
指向的内存地址(同样是0x7f48598669d0
)清零,并对该地址执行一次futex
唤醒操作。
值得注意的是,这两个标志均指向同一内存地址 0x7f48598669d0
。这意味着线程 ID 在创建时被写入此地址,并在线程终止时由内核清零并触发唤醒。
pthread_join()
的等待机制
pthread_join()
函数正是利用了上述机制。它通过调用 futex()
系统调用并指定 FUTEX_WAIT
操作来实现等待。此操作会检查指定地址(uaddr
)上的值是否与期望值(val
)一致。只要地址 0x7f48598669d0
上的值仍为子线程的 ID(例如 11383
),调用 pthread_join()
的线程便会进入挂起状态。
一旦子线程执行 exit(0)
终止,内核将依据 CLONE_CHILD_CLEARTID
标志的指示,清零 0x7f48598669d0
上的值,并触发 FUTEX_WAKE
操作。此时,处于等待状态的 pthread_join()
调用将被唤醒,从而得知子线程已终止。这解释了 strace
输出中 FUTEX_WAIT
操作在子线程退出后立即恢复的现象。
MUSL 中的实现:用户空间协同与状态管理
与 GLIBC 不同,MUSL C 库在实现 pthread_join()
时,更多地依赖于用户空间的代码协同与内部状态管理。
strace
输出概览
在使用 MUSL 库时,strace
的输出示例如下:
clone(child_stack=0x7f9f63666af8, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|0x400000strace: Process 163 attached
, parent_tid=[163], tls=0x7f9f63666b38, child_tidptr=0x7f9f636fdf90) = 163
[pid 163] nanosleep({tv_sec=1, tv_nsec=0}, <unfinished ...>
[pid 162] futex(0x7f9f63666b70, FUTEX_WAIT_PRIVATE, 2, NULL <unfinished ...>
[pid 163] <... nanosleep resumed>0x7f9f63666aa0) = 0
[pid 163] ioctl(1, TIOCGWINSZ, {ws_row=39, ws_col=231, ws_xpixel=0, ws_ypixel=0}) = 0
[pid 163] writev(1, [{iov_base="1111", iov_len=4}, {iov_base="\n", iov_len=1}], 21111) = 5
[pid 163] futex(0x7f9f63666b70, FUTEX_WAKE_PRIVATE, 1 <unfinished ...>
[pid 162] <... futex resumed>) = 0
[pid 163] <... futex resumed>) = 1
[pid 162] futex(0x7f9f636fdf90, FUTEX_WAIT, 163, NULL <unfinished ...>
[pid 163] exit(0) = ?
[pid 162] <... futex resumed>) = 0
[pid 163] +++ exited with 0 +++
nanosleep({tv_sec=1, tv_nsec=0}, 0x7fff336faca0) = 0
尽管 clone()
调用时也传递了 CLONE_PARENT_SETTID
和 CLONE_CHILD_CLEARTID
,但 MUSL 并没有将它们作为 pthread_join()
核心逻辑的主要依赖。
基于 detach_state
的用户空间同步机制
MUSL 的 pthread_join()
机制围绕线程描述符中的 detach_state
字段展开:
- 创建时初始化状态:在线程创建时,其
detach_state
字段会被设置为DT_JOINABLE
(定义值为2
)。 - 线程终止时通知:当子线程的用户入口函数返回后,MUSL 内部的
__pthread_exit()
函数会被调用。在此函数中:detach_state
字段被更新为DT_EXITED
(定义值为0
)。- 随后,通过
__wake()
函数(该函数封装了futex()
调用),执行FUTEX_WAKE_PRIVATE
操作,唤醒一个等待在该字段上的线程。
pthread_join()
的等待过程:pthread_join()
服务(在__pthread_timedjoin_np
函数中实现)会调用futex()
,执行FUTEX_WAIT_PRIVATE
操作。它将持续等待,直到detach_state
字段的值不再是DT_JOINABLE
(即不再为2
)。
因此,当子线程终止并将其 detach_state
设为 0
并触发 FUTEX_WAKE_PRIVATE
信号后,pthread_join()
便会解除等待并返回。
结论:两种不同的实现策略
- GLIBC:主要依赖内核提供的
CLONE_CHILD_CLEARTID
标志,使内核在线程终止时自动清零特定地址并唤醒futex
,从而实现一种原子性的同步。 - MUSL:则在用户空间维护一个
detach_state
字段,并在线程终止时由用户代码显式地修改该字段并触发FUTEX_WAKE_PRIVATE
来通知等待者。
关于 GLIBC 使用 FUTEX_WAIT
而非 FUTEX_WAIT_PRIVATE
的探讨
尽管在同一进程内的线程间同步通常使用 FUTEX_WAIT_PRIVATE
更高效,因为它只唤醒当前进程内的等待者。对于 GLIBC 采用 FUTEX_WAIT
的原因,推测可能包括:
- 历史沿革:
FUTEX_PRIVATE
版本的futex
操作可能在 GLIBC 相关实现定型后才引入。 - 通用性考量:
CLONE_CHILD_CLEARTID
标志本身具有通用性,其设计可能不仅仅局限于线程间同步,也可能适用于进程间同步。在此情况下,使用非私有的FUTEX_WAKE
更具普适性,能够唤醒所有等待在同一地址上的futex
操作,无论它们是否来自同一进程。
通过对上述两种 C 库中 pthread_join()
实现机制的分析,我们得以深入了解 Linux 下多线程同步的底层原理及其在不同库中的具体实践和设计考量。
暂无评论内容