☰
TCMalloc 重启序列(rseq)机制全解析:per-CPU 缓存免锁分配的实现原理
2026/10/13 6:50:07 网站建设 项目流程
  • 内存管理
  • 系统编程

【免费下载链接】tcmalloc

项目地址:https://gitcode.com/gh_mirrors/tc/tcmalloc
点击查看免费下载

导读

本文基于 TCMalloc 官方设计文档 docs/rseq.md,深入剖析 TCMalloc 如何借助 Linux 内核的**重启序列(Restartable Sequences,简称 rseq)**实现 per-CPU 缓存(per-CPU caches):从TcmallocSlab的内存布局、分配/释放两条快路径的可重启代码序列,到 abort 跳板与签名校验、内核rseq系统调用的完整处理流程,再到 slabs 指针缓存与跨 CPU 操作的同步协议。读完本文,你将理解为什么 per-CPU 模式能在绝大多数分配/释放中完全避免原子指令与锁,掌握其"以重启换免锁"的底层设计权衡,并能顺着仓库源码逐行验证每一个关键机制。

per-CPU 缓存与重启序列:为什么选择"重启"而不是"加锁"

rseq 是什么

在 Linux 上,TCMalloc 的 per-CPU 缓存基于重启序列实现(可用man rseq(2)查看)。这一内核特性由 Google 的 Paul Turner 与 Andrew Hunter,以及 EfficiOS 的 Mathieu Desnoyers 联合推动开发。

重启序列的核心语义是:一个代码区域要么在同一 CPU 上原子地(相对于其他线程)完整执行到底,要么在被抢占、被中断或陷入信号处理时被内核中止并从头重新开始。换句话说,内核保证执行这段代码的线程要么一口气跑完,要么在"被切走"的那一刻被打回起点重来。

设计权衡:优化常见路径,容忍罕见路径

选择"跨核迁移或被抢占时重启",本质上是把优化押在最常见的情形——线程一直停留在同一个核上,因此这段代码完全不需要原子指令(atomics);代价则落在更罕见的情形——真的被抢占时,代码必须能从头重跑。这个取舍的代价是:代码路径本身必须支持被重启。整段序列中,除了最后一条"提交(commit)"状态变更的内存写操作外,前面的任何指令都可能被丢弃重来。

这带来了两个实现挑战:

  1. 需要对生成的汇编有细粒度控制,确保存储指令不会被编译器以不合适的方式重排;
  2. 必须保留足够的状态以支持重启。当内核检测到 PC 落在重启序列代码区间内时发生上下文切换,它不会从当前 PC 继续,而是将线程恢复到abort 序列;abort 序列负责识别被中断的是哪一段重启序列,然后把控制流交还给该序列的入口。因此,函数参数必须原样保留(以便用相同条件重启),诸如 CPU ID 之类的派生参数需要重新加载、相关值需要重新计算。

TcmallocSlab 的内存布局

在 per-CPU 模式下,TCMalloc 分配一个包含N个TcmallocSlab::Slabs的数组,所有操作都以逻辑 CPU ID为索引访问这个数组。

每个 slab 开头是一段控制数据区(每个 size-class 一个 8 字节 header),header 之后是存放 free-list 对象指针的槽位数组。

per-CPU 缓存数据结构的内存布局

在 tcmalloc/internal/percpu_tcmalloc.h 中,这两个部分分别对应:

struct Slabs { std::atomic<int32_t> header[NumClasses]; void* mem[((1ul << Shift) - sizeof(header)) / sizeof(void*)]; }; // Slab header(打包为原子更新的 32 位)。 // current 与 end 是从 per-CPU 区域起点算起的指针偏移。 // 槽位数组位于 [begin, end),已占用的槽位位于 [begin, current)。 // begin 单独存储,它是常量且对所有 CPU 相同。 struct Header { // 当前已占用槽位的结束偏移。 uint16_t current; // 该 size-class 槽位数组的结束偏移。 uint16_t end; };

几个值得注意的设计点:

  • 原子 header:header以原子类型存在,使得可以无未定义行为地(尤其在遥测目的下)读取某个核的状态。源码中Header通过absl::bit_cast与std::atomic<int32_t>互转(见 LoadHeader/StoreHeader),并有static_assert(sizeof(Header) == sizeof(AtomicHeader))保证两者等宽。
  • 按指针步长索引:Header中的字段以sizeof(void*)为步长索引到 slab 内。默认Shift=18时,每个 CPU 可缓存接近 32K 个对象。
  • 动态更新:进行中的工作把Slabs*与Shift编码进单一指针,从而允许运行时动态更新。这一设计已在仓库落地:TcmallocSlab::slabs_and_shift_使用std::atomic<SlabsAndShift>同时打包 slabs 指针与 shift 值(见 tcmalloc/internal/percpu_tcmalloc.h),ResizeSlabs借此在停止所有 CPU 后一次原子切换。

对于给定 size-class,为其分配的容量为end - begin个对象:

  • begin在初始化时通过静态分区确定(见 InitSlabs,按 size-class 依次累计consumed_bytes得到偏移,并写入begins_数组);
  • end则在更上层(tcmalloc::CPUCache)动态选择,目的是:避免撞进下一个 size-class 的begin;同时按指定的字节上限在各 size-class 之间均衡缓存对象容量。这与 docs/design.md 中"per-CPU 模式分配一大块内存、由MallocExtension::SetMaxPerCpuCacheSize限制单 CPU 可缓存内存总量"的描述一致。

分配路径(Pop)的 rseq 实现

分配是第一个要看的操作:它需要读取下标current-1处的指针、返回该对象,然后递减current。递减current就是提交(commit)操作。

伪 C++ 代码大致如下:

void* TcmallocSlab_Pop( void *slabs, size_t size_class, UnderflowHandler underflow_handler) { // 展开的 START_RSEQ 宏... restart: __rseq_abi.rseq_cs = &__rseq_cs_TcmallocSlab_Pop; start: // 真正的序列 Slab* slab = tcmalloc_slabs; if ((slabs & MASK) == 0) { return CacheSlab(); } Header* hdr = &slab.header[size_class]; uint64_t current = hdr->current; void* ret = *(&slab + current * sizeof(void*) - sizeof(void*)); // 数组之前的那一个元素特意被打上低位为 1 的标记。 if (ABSL_PREDICT_FALSE((uintptr_t)ret & 1)) { goto underflow; } void* next = *(&slab + current * sizeof(void*) - 2 * sizeof(void*)) --current; hdr->current = current; commit: prefetcht0(next); return ret; underflow: return nullptr; } // 实际由汇编实现,此处仅作讲解。 ABSL_CONST_INIT kernel_rseq_cs __rseq_cs_TcmallocSlab_Pop = { .version = 0, .flags = 0, .start_ip = &&start, .post_commit_offset = &&commit - &&start, .abort_ip = &&abort, };

仓库中的真实实现位于 tcmalloc/internal/percpu_tcmalloc.h(x86-64 内联汇编版TcmallocSlab<NumClasses>::Pop)。它与上述伪代码一一对应:

movq %[rseq_slabs_addr], %[scratch] ; scratch = tcmalloc_slabs(缓存指针) btrq $%c[cached_slabs_bit], %[scratch] ; 去掉标记位 jnc %l[underflow_path] ; 未缓存则走慢路径 movzwq (%[scratch], %[size_class], 4), %[current] ; current = header.current movq -8(%[scratch], %[current], 8), %[result] ; ret = 槽位 current-1 testb $%c[begin_mark_mask], %b[result] ; 检查 begin 标记(低位 1) jnz %l[underflow_path] ; 到达 begin 则下溢 movq -16(%[scratch], %[current], 8), %[next] ; next = 槽位 current-2 subl $0xffff0001, (%[scratch], %[size_class], 4) ; current--, remaining_capacity++ ; 提交(单次存储)

rseq_cs 元数据与内核如何触发重启

__rseq_cs_TcmallocSlab_Pop是一个只读数据结构,保存该重启序列的元信息。当内核抢占当前线程时,它会检查这个结构:如果当前指令指针落在[start, commit)区间内,就把控制流交还给每个序列专属的重启入口abort。

在 tcmalloc/internal/percpu_rseq_x86_64.S 中,DEFINE_UPSTREAM_CS宏定义了完整的__rseq_cs实例:start_ip(即.L##label##_start)、post_commit_offset(commit - start)、abort_ip(trampoline),并放入专门的__rseq_cssection(32 字节对齐)。x86-64 内联汇编的TCMALLOC_RSEQ_PROLOGUE(percpu_tcmalloc.h)负责:在__rseq_cs段定义元数据、把rseq_cs指针写入__rseq_abi.rseq_cs、然后进入临界区。

前向预取(prefetch)

由于next对象往往紧随当前对象被分配,分配路径会预取 next 指向的对象。为了避免预取一个野地址,TCMalloc 为每个 CPU/size-class 的slabs[cpu][begin]填充了一个指向自身的指针(该指针低位被置 1,同时兼作"到达数组起点"的哨兵)。这一逻辑在 InitCpuImpl 中实现:每个非空 size-class 区域之前写入elems | kBeginMark(kBeginMark = 1),既作为 Pop 判空的 begin 标记,又是合法可预取的地址。Pop 预取next(PrefetchNextObject),并把 slab 内存本身也纳入预取(PrefetchSlabMemory)。

内存序与"单次提交"

这段序列以唯一一条对hdr->current的提交存储结束。若发生迁移或中断,则重启准备步骤,因为cpu_id与current的值可能已经改变。由于这些操作只作用于单个核的数据且就在该核上执行,从内存序角度看,这些加载/存储只需在该核上按程序顺序呈现即可,无需跨核原子指令。docs/design.md的 Restartable Sequences and Per-CPU TCMalloc 一节对此有同样的表述:重启序列要么完整执行,要么反复重启直至无中断完成,全程不需要锁或原子指令。

重启处理:abort 跳板与签名

abort标签与restart是不同的。内核 rseq API 要求在重启处理器的前 4 个字节处放置一个"签名"(通常是一个故意非法的操作码)。TCMalloc 构造了一个小的、正确签名的跳板(trampoline),负责跳回restart。

x86 汇编中的样子:

// 用 RSEQ_SIGNATURE 编码 nop 的填充字节。 .byte 0x0f, 0x1f, 0x05 .long RSEQ_SIGNATURE .local TcmallocSlab_Push_trampoline .type TcmallocSlab_Push_trampoline,@function TcmallocSlab_Push_trampoline: abort: jmp restart

这保证abort之前的 4 字节与通过rseq系统调用配置的签名完全吻合。为什么内核要签名?为了防止把缓冲区溢出变成任意代码执行:若攻击者能写入内存,就可能控制rseq_cs::abort_ip(相当于往内存里写一条跳转指令),从而破坏 W^X 防护。签名的思路是:让调用方预先从可执行内存中注册一个魔数,内核假设攻击者很难在可执行内存中找到恰好以该魔数为前缀的可用"gadget"。

仓库中的签名定义在 tcmalloc/internal/percpu.h:x86-64 为0x53053053,AArch64 为0xd428bc00(其他平台为无效值0x0以允许编译)。在 percpu_rseq_x86_64.S 的SIGN_ABORT宏中,签名被编码为一条可安全执行的指令:nopl 0xSIGNATURE(%rip),从而可以与函数主体交错放置;而 AArch64 指令定长,签名通常选为非法/陷阱指令,因此必须与函数主体分离(放在独立的rseq_trampoline段,见 percpu_rseq_aarch64.S)。两处 trampoline 都先jmp .L##label##_abort回到restart入口。

ABI 约束也值得注意(见 percpu_rseq_x86_64.S 的 ABI note):由于 rseq 可在提交前的任意指令处重启,且 abort 时不恢复寄存器,所以不能修改输入参数寄存器(x86-64 下为%rdi、%rsi、%rdx),也不能依赖被调用者保存的寄存器(%rbx、%rsp、%rbp、%r12-%r15);只能用未用的参数寄存器、调用者保存的临时寄存器和返回值寄存器。同时,唯一允许"有意义地写内存"的指令必须是最后一条。

释放路径(Push)的 rseq 实现

释放使用两条存储:一条存被释放的对象,一条更新current。它依然与重启序列技术兼容,因为提交步骤只有一个——更新current。任何被抢占的序列只会反复覆盖被释放对象的值,直到某次成功的序列通过更新current将其提交。

int TcmallocSlab_Push( void *slab, size_t size_class, void* item, OverflowHandler overflow_handler) { // 展开的 START_RSEQ 宏... restart: __rseq_abi.rseq_cs = &__rseq_cs_TcmallocSlab_Push; start: // 真正的序列 Slab* slab = tcmalloc_slabs; if ((slabs & MASK) == 0) { return CacheSlab(); } Header* hdr = &slab.header[size_class]; uint64_t current = hdr->current; uint64_t end = hdr->end; if (ABSL_PREDICT_FALSE(current >= end)) { goto overflow; } *(&slab + current * sizeof(void*) - sizeof(void*)) = item; current++; hdr->current = current; commit: return; overflow: return overflow_handler(cpu_id, size_class, item); }

x86-64 的真实内联汇编实现(TcmallocSlab_Internal_Push)有一个精巧的细节:它读取 32 位 header(低 16 位current、高 16 位remaining_capacity),然后一条addl $0xffff0001, %[tmp]同时完成current++与remaining_capacity--,并利用进位标志检测remaining_capacity是否归零(溢出)。AArch64 版本(同文件 L769-L814)使用subs+b.cc实现相同检测。注意Header::kReservedIndexValue = 0xffff被特意保留为非法current值,以免破坏溢出检测逻辑(见 percpu_tcmalloc.h 注释)。

Slab 的懒初始化

为降低元数据开销,每个 CPU 的 slab 都是懒初始化的:当某个 CPU 首次缓存其 slab 指针(见下文"当前 CPU slabs 指针缓存")时,才为每个 size-class 初始化Header。仓库中的实现是 InitCpu / InitCpuImpl:通过ScopedSlabCpuStop先停掉该 CPU,再逐个 size-class 写入 begin 哨兵、初始化Header(current = begin、remaining_capacity = 0)。Init阶段则一次性分配CpuState数组、begins_数组并调用InitSlabs建立静态分区(percpu_tcmalloc.h)。

更复杂的操作:批量(Batch)

当缓存下溢或溢出时,需要从内层缓存(middle-end)成批取入或放回对象,以摊薄这些缓存的加锁/逻辑开销。批量操作采用与 Push/Pop 相同的思路:读/写一批N个对象,最后更新current提交整个操作。

批量操作的 C 链接入口是TcmallocSlab_Internal_PushBatch与TcmallocSlab_Internal_PopBatch(声明见 percpu.h,纯汇编实现在 percpu_rseq_x86_64.S 与 percpu_rseq_aarch64.S)。x86-64 版本用min(len, remaining_capacity)计算实际批量,循环把对象逐个压入/弹出,最后一条movl %ecx, (%r8, %rdi, 4)或movl %r10d, (%r8, %rdi, 4)作为唯一提交存储;AArch64 版本还用手写 128 位ldp/stp展开循环以提升吞吐。C++ 侧包装PushBatch/PopBatch(percpu_tcmalloc.h)会做 TSan 的 release/acquire 注解,因为汇编里的内存访问 TSan 不可见。

内核 rseq API 与实现

本节是原文档中关于内核侧 rseq API 的笔记——该 API 文档化程度较低,因此以源码路径为线索说明其行为。以下描述对应 Linux 内核kernel/rseq.c与include/linux/sched.h、include/uapi/linux/rseq.h中的实现。

rseq 系统调用

rseq系统调用(sys_rseq)的处理流程:

  1. 处理注销:若线程想注销 rseq,则清除当前 CPU 上该线程task_struct中的 rseq 信息;
  2. 返回错误:若线程已经注册过 rseq,则返回错误(EBUSY之类);
  3. 校验并保存:校验用户传入的输入并保存;
  4. 设置标志:为该线程设置TIF_NOTIFY_RESUME标志,指示"返回用户态前需要处理 rseq 相关恢复工作"。

TCMalloc 侧的系统调用位于 tcmalloc/internal/percpu.cc 的InitThreadPerCpu:

return 0 == syscall(__NR_rseq, &__rseq_abi, sizeof(__rseq_abi), 0, TCMALLOC_PERCPU_RSEQ_SIGNATURE);

它把__rseq_abi(线程局部、初始执行模型)注册给内核,并附上签名。初始化路径InitPerCpu(percpu.cc)还会校验 TLS 数据布局(__rseq_abi32 字节对齐、与tcmalloc_slabs重叠关系等,见下)以及注册membarrier的私有加速 rseq 命令(MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED_RSEQ),供跨 CPU fence 使用。

重启判定:rseq_ip_fixup

用户传入的输入被rseq_ip_fixup用于判定是否处于临界区、若是则在 abort 点重启。rseq_ip_fixup由__rseq_handle_notify_resume调用——后者被文档化为"在抢占或信号投递之后、返回用户空间之前必须被调用";它又被rseq_handle_notify_resume包装调用,这个包装器在线程未启用 rseq 时直接退出。

在 x86 上,触发这条路径的调用链大致是:

  1. rseq_signal_deliver
  2. setup_rt_frame
  3. handle_signal
  4. arch_do_signal_or_restart
  5. handle_signal_work
  6. exit_to_user_mode_loop
  7. exit_to_user_mode_prepare

可见收口点就是返回用户态的代码。重启逻辑如何随用户输入变化,有几条关键笔记:

  • rseq_ip_fixup每次都会调用rseq_get_rseq_cs:它每次从用户内存新鲜地读入rseq_cs指针并间接引用;随后检查无效情形(会导致用户进程段错误),再做下面要讲的 abort IP 签名校验。
  • 签名校验:要求rseq_cs::abort_ip指定的 abort 处理器前面必须有一个 32 位魔数,且与rseq系统调用最初提供并保存的魔数匹配。其安全动机正如前文所述——避免把缓冲区溢出升级为任意代码执行、破坏 W^X 防护。
  • 状态清理:信号与抢占在"出去的路上"总会把用户空间内存中的rseq::rseq_cs::ptr64清空(除非遇到导致段错误的错误情形)。这也解释了为什么 trampoline 要跳回abort(即restart入口)而非start:重启后需要重新初始化__rseq_abi.rseq_cs。

CPU ID 字段:cpu_id_start 与 cpu_id

rseq.c的另一项职责是把 CPU ID 写入用户空间内存。用户空间有两个字段承载此信息:

  • rseq::cpu_id_start:始终在有效范围内;
  • rseq::cpu_id:可能包含错误值。

内核同时提供两者,是为了支持"进入临界区之前就基于 CPU ID 计算派生值"的用法:用一个字段也能做到,但需要额外一个分支来区分"未初始化"与"取到 CPU ID 之后发生了变化"。而如果像 TCMalloc 这样只在临界区内取 CPU ID,就只需要一个字段——因为只有一个分支需要判断:"我初始化了没有"。不存在"CPU 不匹配"的概念:CPU ID 变了就直接重启。

两个字段的维护方式:

  • rseq_update_cpu_id把 CPU ID 写入两者,由__rseq_handle_notify_resume调用;
  • rseq_reset_rseq_cpu_id把cpu_id_start置零、把cpu_id置为RSEQ_CPU_ID_UNINITIALIZED(一个越界值),在注销路径中被调用。

TCMalloc 对这两个字段的使用还体现在VirtualCpu抽象上(percpu.h):VirtualCpu::Synchronize()(percpu.cc)根据RseqVcpuMode选择从__rseq_abi.mm_cid(内核虚拟 CPU,Linux 6.3+ 通过 auxv 的AT_RSEQ_FEATURE_SIZE探测)或GetRealCpuUnsafe()(即__rseq_abi.cpu_id)获取当前虚拟 CPU ID。

当前 CPU slabs 指针缓存

计算当前 CPU slabs 指针相对昂贵(涉及虚拟 CPU ID 支持与可变移位)。伪代码大致是:

cpu_offset = TcmallocSlab.virtual_cpu_id_offset_; cpu = *(&__rseq_abi + virtual_cpu_id_offset_); slabs_and_shift = TcmallocSlab.slabs_and_shift_; shift = slabs_and_shift & kShiftMask; shifted_cpu = cpu << shift; slabs = slabs_and_shift & kSlabsMask; slabs += shifted_cpu;

为了把这套计算移出快路径,TCMalloc 把当前 CPU 的 slabs 地址缓存在线程局部存储(thread-local storage)中。但线程被重调度到别的 CPU 后,如何知道缓存失效?答案是:把缓存地址的高 4 字节与__rseq_abi.cpu_id_start重叠。线程被重调度时,内核会用当前 CPU 号覆盖cpu_id_start,这正好成为"缓存地址失效"的信号。为了把缓存地址的高位部分与 CPU 号区分开,缓存地址的最高位(bit 63)被置 1,而真实 CPU 号(< 2^31)不会有这一位。

于是,分配/释放快路径上的 slabs 地址计算被化简为一次加载与一次检查:

slabs = __rseq_abi[-4]; if ((slabs & (1 << 63)) == 0) goto slowpath; slabs &= ~(1 << 63);

慢路径才做完整 slabs 地址计算并缓存它(CacheCpuSlabSlow,见 percpu_tcmalloc.h,其中还包含与ResizeSlabs并发时的stopped标志复核与slabs_and_shift一致性检查)。

这一重叠布局在常量与汇编中都有体现:

  • 常量:TCMALLOC_RSEQ_SLABS_OFFSET = -4、TCMALLOC_CACHED_SLABS_BIT = 63、TCMALLOC_CACHED_SLABS_MASK = 1ul << 63(percpu.h);
  • 数据布局:汇编文件 tcmalloc/internal/percpu_rseq_asm.S 在.tdata段中依次放置tcmalloc_sampler、tcmalloc_cached_vcpu、tcmalloc_slabs(8 字节,其高 4 字节即__rseq_abi.cpu_id_start)与__rseq_abi,并保证tcmalloc_slabs的地址等于rseq_abi_addr - 4、全部 TLS 数据落在同一 cache line 内。这些布局约束由InitPerCpu中的TC_CHECK_*断言在运行时验证(percpu.cc)。

一个需要强调的副作用:这种重叠使__rseq_abi.cpu_id_start不再能用于其原始用途(TCMalloc 因此改用cpu_id/mm_cid获取 CPU 号)。

跨 CPU 操作:FenceCpu 与同步协议

重启序列优化的是同 CPU 快路径,代价是跨 CPU 操作更昂贵——好在跨 CPU 操作罕见,因此这是合算的取舍。跨 CPU 操作包括:容量增长/收缩(GrowOtherCache/ShrinkOtherCache)、周期性排空(Drain)、空闲缓存收缩,以及 slabs 整体扩容/缩容(ResizeSlabs)和最大容量调整(UpdateMaxCapacities)。

FenceCpu:中断远端核上的重启序列

跨 CPU 操作依赖操作系统协助(包装为tcmalloc::tcmalloc_internal::subtle::percpu::FenceCpu)来中断远端核上任何正在运行的重启序列。当控制权交还给运行在该核上的线程时,可以保证:正在运行的重启序列要么已完成,要么已被抢占。

仓库中的实现(percpu.cc):

  • 若当前线程就在目标核上,FenceCpu直接返回(自己无需与自己同步);
  • 若启用了 rseq 且已注册上游membarrier,则调用MEMBARRIER_CMD_PRIVATE_EXPEDITED_RSEQ(配合MEMBARRIER_CMD_FLAG_CPU)精确中断目标核(UpstreamRseqFenceCpu);
  • 否则回退到SlowFence:利用sched_setaffinity依次把自己钉到每个 CPU 上实际跑一遍,从而保证"凡是之后运行的线程必然看到此前的所有写",并通过读/proc/self/cpuset确认 cpuset 更新循环已完成(percpu.cc)。

stopped 标志:两段式同步协议

同步协议通过CpuState::stopped标志与FenceCpu配合完成(percpu_tcmalloc.h),分两个阶段:

跨 CPU 操作开始(远程方)与本地分配/释放(本地方)之间:跨 CPU 操作先置位stopped_[cpu]标志,再调用FenceCpu;本地操作在 slabs 指针缓存阶段检查stopped_[cpu],若置位则不缓存指针。这保证FenceCpu完成后,所有本地操作要么已结束、要么已中止,且不会有新操作启动(因为新操作需要缓存好的 slabs 指针)。这一阶段对stopped_[cpu]只使用 relaxed 原子操作 + 编译器序(CompilerBarrier),因为FenceCpu已经提供了线程间所需的全部同步。

跨 CPU 操作结束与本地分配/释放之间:跨 CPU 操作以release 内存序清除stopped_[cpu];slabs 指针缓存以acquire 内存序观察到清除状态后才缓存指针、放行本地操作。release/acquire 保证:若某线程观察到stopped_[cpu]已清除,它必然同时看到跨 CPU 操作的全部副作用。

这两段协议合起来确保:跨 CPU 操作运行在完全静止的状态上,没有其他线程读写 slabs。唯一例外是Length/Capacity这类只读方法,它们仍可以读 slabs。

源码中的对应关系:StopCpu(置位 +FenceCpu)与StartCpu(release 清除)实现远程协议(percpu_tcmalloc.h),StopAllCpus/StartAllCpus是其全 CPU 版本(配合FenceAllCpus);CacheCpuSlabSlow在缓存后复核state_[vcpu].stopped(acquire 读)以避免与ResizeSlabs竞态;Drain、GrowOtherCache、ShrinkOtherCache等远程操作均以AssertCpuStopped断言前置条件。批量分配/释放的汇编实现(TcmallocSlab_Internal_PushBatch/PopBatch)则使用FETCH_SLABS检查缓存的 slabs 标记位作为快速失效手段。整个跨 CPU 操作集合的行为也通过 tcmalloc/internal/percpu_tcmalloc_fuzz.cc 的模糊测试与 tcmalloc/internal/percpu_tcmalloc_test.cc 的单元测试(覆盖CacheCpuSlab/UncacheCpuSlab、ResizeSlabs、UpdateMaxCapacities、Drain等)持续验证。

源码导航与进一步阅读

围绕本文主题,可直接在仓库中查阅的实现与文档入口:

  • 本文主体:docs/rseq.md(TCMalloc 官方 rseq 设计说明);
  • 架构背景:docs/overview.md(per-CPU 与 per-thread 两种模式的启用条件:RSEQ 自 Linux 4.18 合入,不可用时回退 per-thread);docs/design.md(per-CPU 模式内存划分与重启序列的总述,其图片与本文共用);
  • 常量与平台门控:tcmalloc/internal/percpu.h(TCMALLOC_RSEQ_SLABS_OFFSET、TCMALLOC_CACHED_SLABS_BIT、TCMALLOC_PERCPU_RSEQ_SIGNATURE、TCMALLOC_PERCPU_RSEQ_SUPPORTED_PLATFORM,目前仅 Linux 上的 x86-64 与 AArch64);
  • 核心数据结构与内联汇编:tcmalloc/internal/percpu_tcmalloc.h(TcmallocSlab、Header、SlabsAndShift、Push/Pop 内联汇编、批量入口、CacheCpuSlabSlow、StopCpu/StartCpu);
  • 初始化与 fence:tcmalloc/internal/percpu.cc(rseq系统调用注册、membarrier注册、FenceCpu/FenceAllCpus/SlowFence);
  • 纯汇编实现与 TLS 布局:tcmalloc/internal/percpu_rseq_asm.S、tcmalloc/internal/percpu_rseq_x86_64.S、tcmalloc/internal/percpu_rseq_aarch64.S;
  • 运行时观测:docs/stats.md(MALLOC: Bytes in per-CPU cache freelist等统计项说明),以及 tcmalloc/cpu_cache.cc、tcmalloc/cpu_cache.h 中CPUCache对 slab 容量分配、ResizeSlabs等上层调用的实现。

理解这条技术脉络后,再去看TcmallocSlab的每一行汇编与内核kernel/rseq.c的每个函数,都会是"按图索骥":它们共同回答同一个问题——如何让内存分配在多核高并发下既快又安全,且不付出锁的代价。

  • 内存管理
  • 系统编程

【免费下载链接】tcmalloc

项目地址:https://gitcode.com/gh_mirrors/tc/tcmalloc
点击查看免费下载

相关推荐

上一篇:kuba-car酷霸小车超声波入门:HC-SR04测距从首次读到精准避障
下一篇:让手柄“活”起来:源师兄手柄震动控制+蜂鸣器音调玩法(含代码)

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询