[Hical] 你的协程内存去哪里了?三层 PMR 内存池设计#
本专栏文章:拆开 Hical · 第 6 篇
前几篇我们都在聊"怎么做",这篇聊"内存去哪了"。
HTTP 服务器处理一个请求时,至少有这些内存分配:读缓冲(8KB)、请求头解析、URL decode、body 字符串、JSON 对象、响应序列化缓冲、中间件属性注入。每秒 10 万请求 → 每秒数百万次的 malloc/free。
C++17 的 PMR(多态内存资源)给了我们一个机会——换掉默认的 new/delete,用分层的分配器策略。Hical 把这个机会用到了极致。
1. PMR 的最简入门#
如果你没用过 PMR,这里 30 秒快速理解:
1
2
3
4
5
6
7
8
| // 传统方式:全局 new/delete(不知道这内存在哪、持续多久)
std::string s = "hello"; // malloc → free
// PMR 方式:你指定分配器的"上游"
char buf[1024]; // 栈上的一块内存
std::pmr::monotonic_buffer_resource pool(buf, sizeof(buf));
std::pmr::string s("hello", &pool); // 分配在栈上的 buf 里!
// pool 析构 → buf 回收 → 不需要 free!
|
PMR 的核心思想:分离"我要内存"和"从哪拿内存"。 不同的分配器适合不同的场景:
monotonic_buffer_resource:一次分配一大块,从这块里切小份出去,只增不减。适合请求级临时数据(请求处理完就全丢)unsynchronized_pool_resource:内存池,单线程用——池里的内存块可回收复用synchronized_pool_resource:同上,但线程安全(有全局锁)new_delete_resource:就是普通的 new/delete
Hical 的灵感:把三个 PMR 分配器叠成三级,分别对应进程、线程、请求三个生命周期。
2. 三级 PMR 架构#
1
2
3
4
5
6
7
8
9
10
11
12
13
| 请求处理中(~100μs 生命周期):
jsonObj → monotonic_buffer_resource ─┐
url decode → 同上 ├─ 只增不减,请求结束整块丢弃
body copy → 同上 ┘
线程生命周期(~分钟到小时):
PMR string → unsynchronized_pool_resource ─┐ 无锁池,回收复用
小 vector → 同上 ┘
进程生命周期:
synchronized_pool_resource → 上游(线程安全,但加锁)
└─ 上游的 TrackedResource → 统计追踪
└─ 最终上游:new_delete_resource(或 mimalloc)
|
3. 第一级:全局池(synchronized_pool_resource)#
1
2
3
4
5
6
7
8
9
10
11
12
| // 启动时
MemoryPool::instance().configure({
.globalMaxBlocksPerChunk = 128,
.globalLargestPoolBlock = 1024 * 1024 // 1MB
});
// 全局池的 pool_options 控制:
// - max_blocks_per_chunk: 一次从上游申请多少块(128)
// - largest_required_pool_block: 最大单块大小(1MB)
//
// 超过 1MB 的分配直接透传到上游 new_delete_resource
// 免得大对象的短期占用撑大了全局池
|
全局池是"兜底"的——所有不在线程本地池或请求级缓冲中的 PMR 分配最终都经过它。它是 synchronized 的(多线程安全),所以被它保护的内存操作有锁开销。这就是为什么大多数分配要进一步下沉到线程池和请求级缓冲中。
4. 第二级:线程本地池(unsynchronized_pool_resource)#
1
2
3
4
5
6
7
8
9
| // 每个 IO 线程自动创建自己的线程本地池
auto* pool = MemoryPool::threadLocalPool();
// 单线程使用 → unsynchronized → 无锁!
// pool_options:
// - max_blocks_per_chunk: 64
// - largest_required_pool_block: 512KB
//
// 因为单线程用,块可以少点(不用为竞争预留)
|
线程本地池的 GC 机制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| struct ThreadPoolEntry
{
std::unique_ptr<std::pmr::unsynchronized_pool_resource> pool;
std::atomic<std::chrono::steady_clock::time_point> lastAllocTime;
std::atomic<bool> needsRelease{false};
};
// 创建时注册到全局列表
threadPools_.push_back(std::move(entry));
// GC 时(定期触发):
void gc(std::chrono::seconds maxIdleSeconds)
{
for (auto& entry : threadPools_)
{
if (now - entry->lastAllocTime > maxIdleSeconds)
{
// 给拥有者线程发信号"需要 release"
entry->needsRelease.store(true);
}
}
// 下次拥有者线程调用 threadLocalPool() 时:
// cache->pool->release(); ← 由拥有者自己释放!线程安全
}
|
💡 GC 的标记-延迟释放模式:GC 线程不直接 delete 其他线程的池,只设置 needsRelease 标志。下一次拥有者线程路过时会自己调 release()——因为只有拥有者才能安全销毁单线程池。
generation 代际计数器:
1
2
3
4
5
6
7
8
9
10
11
| // configure() 重建全局池时:
globalPool_.~synchronized_pool_resource();
new (&globalPool_) ...; // placement new 重建
generation_.fetch_add(1); // 代际 +1
// 线程路过时:
if (cache.generation != currentGen)
{
// 全局池重建了 → 我的 thread_local 池也要重建
// 因为它引用了旧的 globalPool_ 作为上游(已析构!)
}
|
5. 第三级:请求级单调缓冲(monotonic_buffer_resource)#
1
2
3
4
5
6
7
8
9
| auto requestPool = MemoryPool::createRequestPool(4096);
// └─ 初始大小 4KB,上游用线程本地无锁池
// 使用:
std::pmr::string body = req.readBody(pmrAlloc());
// ↑ body 的内存由 requestPool 分配
// 请求结束:
// requestPool 析构 → 整块内存还给线程本地池
|
不可释放的特征是特性,不是 bug:
单调缓冲只增不减——分配出去的块不能单独 free。但这在请求场景下恰好是优点:请求处理完整个缓冲就丢了,不需要逐个释放。从 4KB 开始如果不够用,自动扩容——上游是线程本地池,比全局 new/delete 快得多。
💡 关键约束:请求级单调缓冲分配的对象不得逃逸请求作用域——因为缓冲在请求结束后就析构了。这在协程中是自然保证的:请求开始→分配→请求结束→析构,全在同一协程作用域内。
6. 配套的 StringPool:字符串对象的回收复用#
三级 PMR 解决了"原始内存如何分配"的问题。但 std::string 本身作为对象容器也需要复用。HTTP 服务器中的字符串分配特征:(1) 很多小字符串(500-2000 字节响应 body),(2) 频繁创建/销毁,(3) 大小基本可预测。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| class StringPool
{
// 5 档大小:256 / 512 / 1K / 2K / 4K
static constexpr size_t kClassSizes[5] = {256, 512, 1024, 2048, 4096};
// 每档最多缓存 32 个
static constexpr size_t kMaxPooled = 32;
// thread_local 池
static Pool& threadPool()
{
thread_local Pool pool;
return pool;
}
// 拿:找刚好能装下 requiredSize 的档位
static std::shared_ptr<std::string> acquire(size_t requiredSize)
{
auto classIdx = findClass(requiredSize);
if (classIdx == kNumClasses) return std::make_shared<std::string>(); // >4K 不池化
auto& pool = threadPool();
auto& cls = pool.classes[classIdx];
std::string* raw;
if (cls.count > 0)
{
raw = cls.slots[--cls.count]; // 池里还有 → 复用
}
else
{
raw = new std::string();
raw->reserve(kClassSizes[classIdx]); // 新建 → pre-reserve
}
return std::shared_ptr<std::string>(raw,[classIdx](std::string* str) { StringPool::release(str, classIdx); });
}
// 还:清空后回到对应的档位
static void release(std::string* str, size_t classIdx)
{
auto& pool = threadPool();
auto& cls = pool.classes[classIdx];
str->clear();
if (cls.count < kMaxPooled)
{
cls.slots[cls.count++] = str; // 池还没满 → 回收
}
else
{
delete str; // 满了 → 真的 delete
}
}
};
|
跨线程释放降级为 new/delete:StringPool 的 acquire/release 都是 thread_local 的。如果 string 在 IO 线程分配、在另一个线程释放——custom deleter 里 release() 回到另一个线程的池——这是安全的(因为不涉及跨线程的池状态修改),但 string 回不到了原线程的池,降级为普通 delete。不过 HTTP server 的主流场景是同一个线程上 send → writeLoop,跨线程的情况很少。
驻留内存上限:32 × 5 × 4KB = 640KB/线程。
7. ReadBufferPool:8KB 缓冲的请求级借还#
这个池在第 1 篇已经讲过,这里简要回顾:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| class ReadBufferPool
{
static constexpr size_t kBufferSize = 8192; // 8KB
static constexpr size_t kMaxReturnSize = 65536; // 超过 64KB 不归还
static constexpr size_t kMaxPooled = 32; // 最多 32 个
// 借
static BufferHandle acquire()
{
if (thread_local 池非空) return 入池的 buffer;
else return new string(reserve 8KB);
}
// 还(BufferHandle 的析构函数自动调)
static void release(string* buf)
{
buf->clear();
if (thread_local 池还没满 && buf->size() <= kMaxReturnSize)
{
放回池;
}
else
{
delete buf; // 异常大的请求不污染池子
}
}
};
|
kMaxReturnSize = 65536 的保护:偶尔有请求触发了 8KB→64KB 的扩容(比如超大的请求体),归还时检查容量——超标的直接 delete,不撑大池子。
回顾一下你学会了什么#
- 三级 PMR 对应三级生命周期:进程 → 线程 → 请求
- 全局池是有锁的兜底,大多数分配应下沉到线程池或请求级缓冲
- 线程本地池无锁(unsynchronized),带 GC 延迟释放——GC 不直接 delete 别的线程的池
- 请求级单调缓冲只增不减——请求结束整块丢弃,恰是 HTTP 请求的特征优势
- StringPool 5 级分档回收 string 对象,跨线程释放降级为普通 delete
- ReadBufferPool 的 kMaxReturnSize 保护——异常大的请求不撑大池子
- generation 代际计数器让 configure() 重建全局池时所有 thread_local 缓存自动失效
下一篇:一套 API,两套实现:C++26 反射双轨是怎么设计的