线程本地存储空间

ELF Thread Local Storage ABI (TLS) 是一种存储模型,用于存储 允许每个线程拥有全局变量的唯一副本。此模型 用于实现 C++ 的 thread_local 存储模型。在创建线程时, 变量将使用初始 TLS 映像中的初始值。TLS 变量在线程安全代码中可用作缓冲区 会话记录。也可以处理 errno 或 dlerror 等 C 样式错误 。

TLS 变量与任何其他全局/静态变量非常相似。已实施 其初始数据最终在 PT_TLS 细分中。PT_TLS 细分 位于只读 PT_LOAD 段内(尽管 TLS 变量可写入)。 然后,此细分会复制到每个线程的 可写入位置。将 PT_TLS 细分复制到的位置会受到影响 由分段的对齐方式决定,以确保 TLS 变量的对齐方式 尊重。

ABI

编译器、链接器和动态链接器必须遵循的实际接口 尽管实施的细节较为复杂, 复杂。编译器和链接器必须发出 使用 4 种访问模式之一(如下一部分所述)。动态 然后,链接器和线程实现必须完成所有设置, 效果。不同的架构具有不同的 ABI,但它们是相似的 足够宽泛的表达方式,我们大多就能像 只需要一个 ABI本文档假定使用 x86-64 或 AArch64 并且会在出现差异时指出这一点

TLS ABI 使用几个术语:

  • 线程指针:这是每个线程中的唯一地址,通常存储为 放在寄存器中线程局部变量位于线程指针的偏移量处。 在本文档中,线程指针将缩写为 $tp$tp__builtin_thread_pointer() 在 AArch64 上返回的内容。在 AArch64 $tp 上 由名为 TPIDR_EL0 的特殊寄存器提供,可使用 mrs <reg>, TPIDR_EL0。在 x86_64 上,使用 fs.base 细分库, 可通过 %fs: 访问,且可通过 %fs:0rdfsbase 加载 指令。
  • TLS 部分:这是每个模块中的数据图片,由 每个模块中的 PT_TLS 程序头文件。并非每个模块都有 PT_TLS 程序标头,因此并非所有模块都有 TLS 段。每个单元 最多有一个 TLS 段,相应地最多一个 PT_TLS
  • 静态 TLS 集:这是 动态链接器。它由主可执行文件组成 以及 DT_NEEDED 以传递方式提及的每个库。 需要在静态 TLS 集中设置 DF_STATIC_TLS 动态表中的 DT_FLAGS 条目(由 PT_DYNAMIC 细分指定)。
  • TLS 区域:这是每个 IP 地址专属的连续内存区域 线程。$tp 将指向此区域中的某个点。它包含 静态 TLS 集中每个模块的 TLS 段 实现私有数据,有时也称为 TCB(线程 控制区块)。在 AArch64 上,以 $tp 开头的 16 字节预留空间为 有时也称为 TCB我们将该空间称为“ABI TCB” 。
  • TLS 分块:这是各个线程的 TLS 段副本。还有 每个线程的每个 TLS 段对应一个 TLS 块。
  • 模块 ID:除了主模块 ID,模块 ID 不是静态已知的 可执行文件的模块 ID(始终为 1)。其他模块的模块 ID 为 由动态链接器选择的网址它只是每个服务器中 模块。从理论上讲,它可以是 例如哈希或其他内容实际上,它只是一个简单的计数器 由动态链接器维护的代码
  • 主可执行文件:这是包含起始地址的模块。它 访问模型也会以特殊方式进行处理。它始终 其模块 ID 为 1。这是唯一可以使用固定偏移量的模块 通过下文所述的 Local Exec 模型从 $tp 检索这些 API。

为了符合 ABI,必须支持所有访问模型。

访问模式

ABI 指定了 4 种访问模型:

  • global-dynamic
  • local-dynamic
  • initial-exec
  • local-exec

这些值可用于 -ftls-model=...__attribute__((tls_model("...")))

所用的模型与以下方面相关:

  1. 执行访问权限的模块: <ph type="x-smartling-placeholder">
      </ph>
    1. 主可执行文件
    2. 静态 TLS 集中的模块
    3. 在启动后加载的模块,例如上传者:dlopen
  2. 要访问的变量是在哪个模块中定义的: <ph type="x-smartling-placeholder">
      </ph>
    1. 在同一模块内(即 local-*
    2. 在其他模块中(即 global-*
  • global-dynamic 可在任何位置用于任何变量。
  • local-dynamic 可由任何模块使用,用于在该模块中定义的任何变量 同一个模块中。
  • initial-exec 可供任何模块用于静态变量中定义的任何变量 已设置 TLS。
  • local-exec 可由主可执行文件用于 main 可执行文件。
全球动态

全局动态是最常用的访问格式。也是最慢的。 任何线程局部全局变量都应可通过此方法访问。这个 如果动态库访问在 另一个模块(请参阅“初始执行”部分中的例外情况)。已定义的符号 则不需要使用此访问模型。主可执行文件可以是 也不应使用这种访问模式执行上述操作时,这是默认的访问模式, 使用 -fPIC 进行编译是共享库的标准。

此访问模式通过调用动态链接器中定义的一个函数来发挥作用。 调用函数的方式有两种:通过 TLSDESC 或通过 __tls_get_addr

对于 __tls_get_addr,系统会向其传递一对 GOT 条目 与此符号相关联的字词。具体来说,它会将指针传递到第一个 第二个条目紧跟在它之后对于给定的符号 S,第一个 条目(表示为 GOT_S[0])必须包含以下模块的模块 ID: 已定义 S。第二个条目(表示为 GOT_S[1])必须包含到 TLS 分块,与 PT_TLS 段中符号的偏移量相同 相关联然后,使用S __tls_get_addr(GOT_S)__tls_get_addr 的实现将 稍后会讨论的。

TLSDESC 是访问 global-dynamic(以及 local-dynamic)的替代 ABI 其中,使用另一对 GOT 槽,其中第一个 GOT 槽 包含一个函数指针。第二个则包含已定义的 辅助数据。这样,动态链接器就可以选择 视情况调用。

在这两种情况下,对这些函数的调用都必须由特定的 代码序列和一组特定的 reloc 代码。这样,链接器就能识别 并将这些访问权限放宽为 local-dynamic 访问模式。

(注意:以下段落详细介绍了编译器如何支持 ABI 结尾。如果您无关紧要,请跳过此段。)

为了让编译器发出此访问模型的代码,需要发出一个调用 __tls_get_addr(由动态链接器定义)和对 符号名称。具体而言,编译器会为(提示 GOT 本身需要迁居其他地点)__tls_get_addr(GOT_S)。通过 然后链接器在生成 GOT 时发出两个动态重定位。在 x86_64 它们是R_X86_64_DTPMODR_X86_64_DTPOFF。在 AArch64 上 R_AARCH64_DTPMODR_AARCH64_DTPOFF。这些重定位会引用符号 无论模块是否通过该名称定义符号。

本地动态

局部动态与全局动态相同,但针对的是局部符号。它可以 视为对此模块的 TLS 块的单一 global-dynamic 访问。 由于模块中定义的每个变量都与 TLS 块,编译器可以将多个 global-dynamic 调用优化为一个。 编译器会放宽对 local-dynamicglobal-dynamic 访问权限 每当变量为局部/静态或隐藏可见性时。链接器 有时可以放宽对 local-dynamic 的一些 global-dynamic 访问权限 。

以下示例说明了编译器可以如何针对此 访问模式:

static thread_local char buf[buf_cap];
static thread_local size_t buf_size = 0;
while(*str && buf_size < buf_cap) {
  buf[buf_size++] = *str++;
}

可能会降至

// GOT_module[0] is the module ID of this module
// GOT_module[1] is just 0
// <X> denotes the offset of X in this module's TLS block
tls = __tls_get_addr(GOT_module)
while(*str && *(size_t*)(tls+<buf_size>) < buf_cap) {
  (char*)(tls+<buf>)[*(size_t*)(tls+<buf_size>)++] = *str++;
}

如果此代码使用全局动态,则必须至少进行 2 次调用,一次调用 另一个用于获取 buf 的指针,另一个用于获取 buf_size 的指针。

初始执行

只要编译器知道 将加载 可执行文件,而不是使用 dlopen 打开。这种访问模式 仅在主可执行文件访问默认选项的全局符号时使用, 可见性。这是因为编译可执行文件是 编译器知道生成的所有代码都将位于初始可执行集中。如果 系统会编译 DSO,以使线程局部访问使用此模型,然后再使用 DSO 无法使用 dlopen 安全地打开。这在性能方面是可以接受的 而如果您知道二进制文件永远无法 dlopen-ed 处理,例如使用 libc。以这种方式编译/链接的模块 其 DF_STATIC_TLS 标志集。

在不使用 -fPIC 的情况下进行编译时,初始 Exec 为默认执行。

编译器会发出代码,甚至不会调用 __tls_get_addr 进行这种访问 模型。它使用单个 GOT 条目来实现,我们将用 GOT_s 表示符号 s,编译器会发出重定位

extern thread_local int a;
extern thread_local int b;
int main() {
  return a + b;
}

会降低为如下所示的内容:

int main() {
  return *(int*)($tp + GOT[a]) + *(int*)($tp + GOT[b]);
}

请注意,在 x86 架构上,GOT[s] 实际上将解析为负 值。

本地执行

这是最快的访问模式,仅当符号位于 第一个 TLS 块,也就是主可执行文件的 TLS 块仅限实际应用 主要可执行文件可以使用此访问模式,因为任何共享库 (通常不需要)知道它是否正在从主应用访问 可执行文件。链接器会将 initial-exec 放宽为 local-exec。编译器 在没有通过 -ftls-model__attribute__((tls_model("..."))),因为编译器无法得知 要链接到一个主可执行文件或 共享库。

有关如何计算此偏移量的精确细节会略有变化 从架构到架构。

示例代码:

static thread_local int a;
static thread_local int b;

int main() {
  return a + b;
}

将降至

int main() {
  return (int*)($tp+TPOFF_a) + (int*)($tp+TPOFF_b));
}

在 AArch64 TPOFF_a == max(16, p_align) + <a> 上,其中 p_alignp_align 字段,<a>PT_TLS 从主可执行文件的 TLS 段开头的 a 的偏移量。

x86_64 TPOFF_a == -<a> 上,其中 <a>a 相对于 end 的偏移量 位于主可执行文件的 TLS 段中。

链接器了解任何给定 XTPOFF_X,并填充此 值。

实现

本部分介绍了在 Fuchsia 上实现的实现方式。这个 表示这里的粗略笔画在不同的 libc 中非常相似 包括 musl 和 glibc 的实现。

在实际实现这一切时,会引入一些更多详细信息。名字 即所谓的“DTV”(动态线程矢量)(本文档中称为 dtv),它 按模块 ID 将 TLS 块编入索引。下图显示了 就是这个样子在 Fuchsia 的实现中,我们实际存储了一个 线程描述符结构体中的大量元信息以及 ABI TCB(以下标记为 tcb)。在我们的实现中,我们使用前 8 个字节 使其指向 DTV前 tcb 指向dtv,如下所示: 如下图所示,但在 dlopen 之后,情况可能会发生变化。

arm64:

*------------------------------------------------------------------------------*
| thread | tcb | X | tls1 | ... | tlsN | ... | tls_cnt | dtv[1] | ... | dtv[N] |
*------------------------------------------------------------------------------*
^         ^         ^             ^            ^
td        tp      dtv[1]       dtv[n+1]       dtv

此处 X 的大小为 min(16, tls_align) - 16,其中 tls_align 是最大值 静态 TLS 集中加载的所有 TLS 段的对齐方式。这是由 静态链接器,因为静态链接器会解析 TPOFF_* 值。这个 设置内边距,以便在需要时将 $tp 与主内边距对齐 可执行文件的 PT_TLS 段的 p_align 值,则 tls1 - $tp 将为 max(16, p_align)。这可以确保始终留有至少 16 字节的空间 (在上图中以 tcb 表示)。

x86:

*-----------------------------------------------------------------------------*
| tls_cnt | dtv[1] | ... | dtv[N] | ... | tlsN | ... | tls1 | tcb |  thread   |
*-----------------------------------------------------------------------------*
^                                       ^             ^       ^
dtv                                  dtv[n+1]       dtv[1]  tp/td

其中,td 表示“线程描述符指针”。在这两种实现中 指向线程描述符。在这些视频中 tcb 实际上是线程描述符结构体 这两种情况,但在 AArch64 上是最后一个成员,在 x86_64 上则是第一个 成员。

打开

此图说明了初始可执行文件会发生什么情况,但实际并非如此 解释 dlopen 案例中会发生什么。调用 __tls_get_addr 时 首先检查 tls_cnt 是否与模块 ID(由 GOT_s[0] 提供)一致 ) 在 dtv 内。如果是,则查询 dtv[GOT_s[0]] + GOT_s[1] 但如果不是更复杂一些请参阅 dynlink.c 中的 __tls_get_new

简而言之,已经为更大的 dtv 分配了足够大的空间 调用 dlopen。有足够的空间, 将始终存在于已分配的某个位置系统会将较大的空间设置为 是适当的 dtv。然后,将 tcb 设置为指向这个更大的新 dtv。未来 然后,访问将使用更简单的代码路径,因为 tls_cnt 将非常大 。