`pthread_join()` 实现机制探析:Linux 线程等待的底层原理

引入

笔者于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_SETTIDCLONE_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_SETTIDCLONE_CHILD_CLEARTID,但 MUSL 并没有将它们作为 pthread_join() 核心逻辑的主要依赖。

基于 detach_state 的用户空间同步机制

MUSL 的 pthread_join() 机制围绕线程描述符中的 detach_state 字段展开:

  1. 创建时初始化状态:在线程创建时,其 detach_state 字段会被设置为 DT_JOINABLE(定义值为 2)。
  2. 线程终止时通知:当子线程的用户入口函数返回后,MUSL 内部的 __pthread_exit() 函数会被调用。在此函数中:
    • detach_state 字段被更新为 DT_EXITED(定义值为 0)。
    • 随后,通过 __wake() 函数(该函数封装了 futex() 调用),执行 FUTEX_WAKE_PRIVATE 操作,唤醒一个等待在该字段上的线程。
  3. 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 的原因,推测可能包括:

  1. 历史沿革FUTEX_PRIVATE 版本的 futex 操作可能在 GLIBC 相关实现定型后才引入。
  2. 通用性考量CLONE_CHILD_CLEARTID 标志本身具有通用性,其设计可能不仅仅局限于线程间同步,也可能适用于进程间同步。在此情况下,使用非私有的 FUTEX_WAKE 更具普适性,能够唤醒所有等待在同一地址上的 futex 操作,无论它们是否来自同一进程。

通过对上述两种 C 库中 pthread_join() 实现机制的分析,我们得以深入了解 Linux 下多线程同步的底层原理及其在不同库中的具体实践和设计考量。

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容