好的,我们从第一个盲区开始补齐:内存分配器(Memory Allocator)。
这是无锁编程中最容易被忽视的"隐藏杀手"。你精心设计了一个无锁队列,所有 CAS 操作都完美,结果发现性能还是上不去——很可能是 new 和 delete 在背后拖了后腿。
1. 问题:malloc 是个"全局锁"
当我们调用 new / delete(底层是 malloc / free)时,背后是一个全局堆管理器在干活。
[线程A] new 对象 → 进入 malloc()
│
▼
[全局堆锁] ← 线程A 拿到锁,开始分配内存
│
[线程B] new 对象 → 进入 malloc() → 等待全局堆锁 → 阻塞!
[线程C] new 对象 → 进入 malloc() → 等待全局堆锁 → 阻塞!
硬件视角:
- 全局堆锁本质上是一个 Mutex(或自旋锁),多个线程争抢时会产生缓存行乒乓(MESI RFO)。
- 每次
malloc/free都可能触发系统调用(brk/mmap),陷入内核态。 - 频繁分配/释放时,锁开销可能超过业务逻辑本身。
结论:无锁数据结构本身很快,但 malloc 的全局锁会让你的多线程程序退化成串行执行。
2. 解决方案:Per-CPU / Per-Thread 内存池
核心思想:让每个线程/核心拥有自己独立的内存池,分配时无需加锁。
方案 A:jemalloc(Firefox / Redis / Rust 默认)
jemalloc 的核心设计:
+----------------------------------------------------------+
| jemalloc 架构 |
+----------------------------------------------------------+
| 每个线程有自己的 tcache(Thread Cache) |
| +--------------------------------------------------+ |
| | tcache:小型对象缓存(无锁,极快) | |
| | - 16B 对象列表 | |
| | - 32B 对象列表 | |
| | - 64B 对象列表 | |
| | - ... | |
| +--------------------------------------------------+ |
| ↓ (tcache 满了或对象太大) |
| 每个 CPU 核心有自己的 arena(竞技场) |
| +--------------------------------------------------+ |
| | arena:中型对象池(多线程共享,但每个核心一个) | |
| +--------------------------------------------------+ |
| ↓ (arena 也满了) |
| 全局堆(最终后备,需要加锁) |
+----------------------------------------------------------+
分配流程:
[线程A 申请 64B 对象]
│
▼
[检查 tcache] → 有 → 直接返回(~5ns,无锁)
│
▼ 无
[检查对应核心的 arena] → 有 → 返回(~20ns,可能需原子操作)
│
▼ 无
[全局堆] → 加锁分配(~100ns,有竞争)
性能对比:
| 分配器 | 单线程分配 (ns) | 多线程分配 (ns) | 锁竞争 |
|---|---|---|---|
glibc malloc |
~50 | ~300+(随线程数飙升) | 严重 |
jemalloc |
~15 | ~30(稳定) | 极小 |
tcmalloc |
~10 | ~25(稳定) | 极小 |
方案 B:tcmalloc(Google,TensorFlow / Go 早期使用)
tcmalloc 的设计与 jemalloc 类似,但更强调线程本地缓存(Thread-Local Cache):
[线程A] → [Thread-Local Cache] → 无锁分配,命中率 ~95%
│
↓ (未命中)
[中央堆(Central Heap)] → 加锁分配,但频率低
特点:
- 小对象(< 256KB)优先从 Thread-Local Cache 分配。
- 大对象直接用
mmap分配,绕过分级缓存。
3. 如果你需要定制:手写固定大小的对象池(Object Pool)
在极致性能场景(如网络收包的 Buffer 池),你可以自己实现一个无锁对象池。
template<typename T, size_t PoolSize>
class ObjectPool {
// 使用 SPSC 队列思想:生产者 = 归还对象,消费者 = 获取对象
// 但这里我们用 Lock-Free Stack 实现
struct Node {
T data;
std::atomic<Node*> next;
};
alignas(64) std::atomic<Node*> free_list{nullptr};
alignas(64) std::atomic<Node*> allocated_list{nullptr};
// 预分配池子(一次性分配,避免运行时 malloc)
Node pool[PoolSize];
public:
ObjectPool() {
// 将所有节点串成空闲链表
for (size_t i = 0; i < PoolSize - 1; ++i) {
pool[i].next = &pool[i + 1];
}
pool[PoolSize - 1].next = nullptr;
free_list.store(&pool[0], std::memory_order_relaxed);
}
// 从池子中取出一个对象(无锁)
T* allocate() {
Node* old_head = free_list.load(std::memory_order_acquire);
Node* new_head;
do {
if (!old_head) return nullptr; // 池子已空
new_head = old_head->next;
} while (!free_list.compare_exchange_weak(old_head, new_head,
std::memory_order_release, std::memory_order_relaxed));
return &old_head->data;
}
// 归还对象到池子(无锁)
void deallocate(T* ptr) {
Node* node = reinterpret_cast<Node*>(ptr);
Node* old_head = free_list.load(std::memory_order_acquire);
do {
node->next = old_head;
} while (!free_list.compare_exchange_weak(old_head, node,
std::memory_order_release, std::memory_order_relaxed));
}
};
硬件收益:
- 所有操作在用户态完成,无系统调用。
- 使用 CAS 无锁入栈/出栈,无全局锁。
- Pool 在构造时一次性
malloc大量内存,后续零分配。
4. 内存分配器与无锁队列的配合
在你的 ScoreMaster 系统中,每一帧可能都需要分配内存(如检测框列表、NPU 输入输出 Buffer)。如果每一帧都 new / delete,全局堆锁会成为瓶颈。
推荐架构:
[Decoder Thread] → 从 BufferPool 申请 Frame 对象
│
▼
[Pipeline Thread] → 处理完,归还到 BufferPool
│
▼
[NPU Workers] → 从 BufferPool 申请 Result 对象
│
▼
[Qt Main Thread] → 渲染完,归还到 BufferPool
全链路无锁 + 零 malloc。
5. 笔记存档:内存分配器核心概念速查
| 概念 | 解释 |
|---|---|
| 全局堆锁 | glibc malloc 的默认行为,多线程竞争时严重拖慢性能。 |
| tcache(Thread Cache) | jemalloc / tcmalloc 的核心:每个线程独立的小对象缓存,无锁分配。 |
| arena | jemalloc 中每个 CPU 核心的缓存区,减少跨核心竞争。 |
| 对象池(Object Pool) | 预先分配固定数量对象,用无锁栈(CAS)管理,完全绕开 malloc。 |
| 适用场景 | 频繁分配/释放的场景(网络包处理、游戏对象、帧数据)。 |
| 不适用场景 | 对象大小差异巨大、生命周期极长、总内存需求不可预估。 |
本节关键词:内存分配器、全局堆锁、jemalloc、tcmalloc、tcache、arena、对象池、无锁分配。 与前期知识的关联:SPSC/MPSC 队列 + 对象池 = 零 malloc、零锁、零系统调用的全链路数据管道。