

























这个思路其实之前就学到过,但 IO 层
read→fread这套封装无需手动模拟,标准库封装成熟、场景通用、性能足够,业务层直接用即可,自研无收益。而
malloc必须自研模拟 / 手写内存池:
glibc 的 malloc 通用适配全场景,但问题是:
并发(malloc内部自带锁,同一时间只允许一个线程执行内存分配释放,多线程一起 malloc 抢同一把锁直接卡死)、
碎片(频繁小块分配内存越用越碎最后没空间)、
分配速度(每次都要进内核)
服务端高并发场景:频繁小对象分配释放,原生 malloc 锁竞争重、碎片多
可控性:自定义内存对齐、内存大小规格、内存生命周期、内存泄漏统计
性能碾压:一次性向内核申请大块内存,用户态无系统调用、无锁 / 细粒度锁分配,远超原生 malloc,以后所有小分配都从这块大的里切,再也不调用 malloc。
平时:
malloc(16)、malloc(32)、malloc(64)→ 调用 N 次内核,极慢。内存池:先malloc(1MB)一次要一大块 → 后面所有小内存都从这里切,切 1000 次都不再进内核,速度爆炸快!
业务定制:适配私有协议、固定大小对象池、线程本地内存池等专属场景,通用 malloc 做不到
本质区别:IO 封装解决磁盘读写频次,通用场景够用;内存分配解决服务端高并发内存吞吐 + 碎片 + 锁开销,高性能服务端必须自研简化分配逻辑。
早期设计仅单条空闲链表,所有堆操作都要操作该链表,只能全局上锁互斥,如今的
malloc底层用的是ptmalloc实现,拆分多个空闲链表与内存分区,仅锁定操作对应的局部链表分区,不全局锁住整个堆空间(也叫分区细粒度锁),但线程多了,依旧不行。注意几个问题:
进程虚拟地址空间全局共享,所有线程可见
线程栈、线程局部存储 TLS,线程私有隔离,其他线程无法随意访问篡改
堆、全局变量、静态变量,多线程共享,无天然隔离
多线程是一个cpp文件里,主main线程之外开的子线程,他们这些线程调用
malloc拿到的堆内存数据互通共享。分析:
如果,每个线程都
malloc,容易碎片,改进是说,理论上直接malloc大块也行,但无管理逻辑就是裸内存,没法规整分配、回收、对齐,这会越用越乱,所以手写内存池主线程预先申请一大块连续内存,所有线程统一从这块内存申领使用,每次申请也不再用malloc。但申请大块内存≠直接无锁,分两种设计
线程私有内存池(真正无锁):每个线程单独提前申请一大块独立内存,线程只取用自己池子里的内存。线程之间内存区域完全隔离,互不干涉,不需要加任何锁,不存在争抢越界问题。
全局共用内存池(必须加锁):所有线程共用同一块大内存,依旧要加锁保护分配位置、空闲链表,只是锁粒度更小(即把锁的范围缩小到「我们自己管理的这一小块连续内存」,只锁这一小块内部的分配指针 / 空闲链表;而系统
malloc哪怕细粒度,锁的是「整个进程堆的分区 / 桶」,锁的范围、逻辑复杂度,都远大于我们自己的极简全局内存池)、竞争频率远低于malloc全局大锁,性能远优于原生malloc。关于方便程度:malloc 是 C 标准库,所有编译器 / 系统默认自带,直接写
malloc(100)就能跑。tcmalloc/jemalloc 需要额外安装、编译、链接。Q:如此大的需求为啥没现成的?
A:现成成熟内存池库早已普及,线上项目几乎没人裸用原生 malloc;手写内存池,只为极致定制化、剔除冗余、贴合专属业务模型,通用场景直接用 tcmalloc/jemalloc 即可。
Q:那还手写个JB?
A:
考底层原理吃透:不是让你造工业级库,是考察你懂:进程内存布局、brk/mmap 申请、内存对齐、碎片成因、glibc 分配瓶颈,能讲清原生 malloc 慢、锁重、碎内存的根因。考数据结构功底:手写要写空闲链表、内存块切割、偏移管理、回收复用,直接考察链表、内存布局、地址运算基本功,是服务端开发基础硬功底。
考并发优化思维:手写过程必须区分:全局池加细粒度锁、线程本地池无锁两种写法,面试官看你懂锁粒度优化、线程隔离思想,这是高并发核心思路。
考工程优化思想:体现你具备批量申请、批量复用、减少系统调用的性能优化思维,这是高性能服务端必备思维,不止局限内存,网络 IO、连接池、线程池全是这套逻辑。
项目落地佐证:简历写高性能服务器、多线程框架、网络框架,必须用手写内存池做技术落地佐证,证明你真做过性能优化,不是只会调用 API。
区分普通选手与后端核心选手:普通开发只会调用
new/malloc,大厂要的是懂内存分配底层、能自研优化、能解决线上内存碎片与并发分配瓶颈的人,手写就是筛选门槛。多希望有钱可以 买书,唉!没钱买书
《Linux 高性能服务器编程》游双 第 15 章 内存池,原文要点:
频繁调用
malloc和free存在两大缺陷:一是系统调用开销大,每次分配都可能陷入内核;二是容易产生大量内存碎片,降低内存利用率。内存池思想:预先向操作系统申请一块连续的大内存,后续内存分配均从这块内存中划分,释放时也统一归还给内存池,而非直接归还系统。
《深入理解计算机系统》CSAPP 第 9 章 堆内存管理,原文核心:
C 标准库
malloc并非系统调用,其底层依靠brk调整堆顶位置、mmap映射匿名内存两种方式向内核申请虚拟内存。早期 glibc
malloc采用全局唯一互斥锁保护堆分配操作,多线程并发分配时,所有线程串行等待锁,并发性能极差。后期 arena 优化拆分多内存域,但依旧存在域内锁竞争,无法彻底解决高频小对象分配阻塞问题。
内存碎片本质:无序随机大小内存分配释放,造成大量离散空闲小块内存,无法满足大块分配需求。
好吧,先简单的开.开胃压压.惊,学个全局锁的内存池 —— 内存池迭代版本1:
查看代码
#include <cstddef> #include <cstdlib> #include <cstdio> #include <cassert> #include <mutex> #include <thread> #include <chrono> #include<unistd.h> class GlobalMemoryPool{ public: static constexpr size_t BLOCK_SIZE = 64;//小块 = 内存池给你分配出去的最小单位内存,内存池一次性分配出来的每一小块固定是 64 字节 static constexpr size_t POOL_CAPACITY = 1024;// 内存池一次批量申请 1024 个 64 字节的小块 // 整体:每次从池子里拿64 字节,池子不够用时,一次性申请 1024 个 64B = 64KB 大内存块 private: struct BlockNode {//这个结构体本身大小 = 8 字节 BlockNode* next; /*把所有空闲的内存块串成一个单向链表,每个块只用前 8 字节(指针大小)存 “下一个块在哪”,剩下的空间给用户使用 [ next指针 | 可用内存空间 ] 8字节 56字节 总共 64 字节 */ }; BlockNode* free_head_ = nullptr; // 空闲链表头,永远指向第一个可用的空闲块 std::mutex mtx_; // 锁,多线程安全,防止多线程同时操作链表导致崩溃 void alloc_pool_chunk() { size_t total_size = POOL_CAPACITY * BLOCK_SIZE; char* chunk = (char*)malloc(total_size); // 一次性 malloc 一大块 assert(chunk != nullptr);// 如果系统 malloc 分配内存失败(返回空),程序直接崩溃报错,用于检查内存申请是否成功。发现内存分配失败,立刻卡死报错,方便定位 bug // 把这一大块切成 1024 个小块,串成链表 for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { BlockNode* curr = (BlockNode*)(chunk + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(chunk + (i + 1) * BLOCK_SIZE); curr->next = next; /* curr 指向当前块的开头 curr->next 就是修改当前块前 8 字节,存下一个块的地址 块剩下的 64 - 8 = 56 字节 完全不动,留给用户用 */ } // 最后一块指向原来的空闲链表头 BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 free_head_ = (BlockNode*)chunk; } /* 一次性 malloc 64KB(1024×64B) 把这一大块切成 1024 个小 64B 块 用链表把它们串起来 让 free_head_ 指向第一个块 这就是内存池的核心:预分配 + 链表管理。 */ public: void* alloc(){ std::lock_guard<std::mutex> lock(mtx_); // 加锁,线程安全 if (!free_head_) // 没有空闲块了 alloc_pool_chunk(); // 再预分配 1024 块 BlockNode* ret = free_head_; // 拿第一个空闲块 free_head_ = free_head_->next; // 头指针后移 return ret; // 返回给用户使用 /* 分配逻辑超级简单: 加锁 没空块 → 再申请一大块 把链表头节点取出来返回 头指针指向下一个块 可以看出这里内存池只改指针,malloc 要全局找空间、加锁、管理碎片,所以内存池快得多 */ } void free(void* ptr) { if (!ptr) return; std::lock_guard<std::mutex> lock(mtx_); BlockNode* node = (BlockNode*)ptr; node->next = free_head_; // 插回链表头部 free_head_ = node; // 新空闲头 = 刚释放的块 }/* 把块重新插回空闲链表头部,并没有真正还给系统! 这就是内存池高效的原因 —— 复用内存,不反复调用系统 free。*/ }; static GlobalMemoryPool g_pool; inline void* pool_alloc() { return g_pool.alloc(); } inline void pool_free(void* p) { g_pool.free(p); } int main(){ printf("=== 观察htop RES数值变化 ===\n"); const int CNT = 100000; void* arr[CNT] = {nullptr}; sleep(10); printf("开始分配内存\n"); for (int i = 0; i < CNT; ++i) arr[i] = pool_alloc(); std::this_thread::sleep_for(std::chrono::seconds(3)); printf("开始释放内存\n"); for (int i = 0; i < CNT; ++i) pool_free(arr[i]); std::this_thread::sleep_for(std::chrono::seconds(3)); printf("测试结束\n"); }科普语法:
1、无大小强制转换限制,任意指针类型均可互转,只是大转小指针,解引用会越界破坏数据,有内存越界风险。
char*单字节寻址访问(即按照1字节解引用),int*按 4 字节寻址访问。2、
inline:
不加
inline:函数写在.h头文件里,A.cpp 包含 → 定义一次,B.cpp 包含 → 又定义一次,编译器报错:重复定义 (multiple definition)加
inline:编译器允许这个函数在多个 .cpp 文件里重复定义。因为链接器会合并为一份实体,规避重复定义报错。作用就是把这个函数的代码,直接复制粘贴到调用它的地方,不进行函数调用,正常函数:调用 → 跳过去执行 → 跳回来,有跳转开销。
inline函数调用时不跳转,直接把函数里的代码展开在当前位置。没有函数调用开销,更快。但代码量大的函数不建议加,会成倍增大程序体积,反而拖慢运行,递归、多分支复杂逻辑函数,inline 基本失效。现代这玩意基本没用,编译器内置自动判断。3、关于有参无参:
查看代码
//---------------对象创建调用构造函数----------------- // 无参构造创建对象 class Demo{ public: Demo(){ cout << "无参构造" << endl} }; Demo obj1; // Demo obj1(); // ❌ 错误!这是函数声明,不是对象 // 有参构造创建对象 class Test{ public: Test(int a){cout << "有参构造 a = " << a << endl;} }; Test obj2(10); //--------------------函数调用---------------- // 无参函数调用 void fun1(){} fun1(); // 有参函数调用 void fun2(int x){} fun2(10); //Pool ins();叫定义一个函数声明类是模板,对象是实例,创建对象触发构造函数调用,本质是也是函数调用,无参构造创建对象语法省略括号避免歧义。
重点说下很绕的新链衔接,为啥是新表要衔接到旧的开头而不是尾巴:
初始空闲链:
A→B→C→nullptr,free_head_ = A比如要申请
malloc个小内存,那就不用malloc,直接用我们这个手动写的内存池,先分配一个,即:第一次分配:取头节点
A,free_head_更新为B,剩余空闲:B→C→nullptr第二次分配:取头节点
B,free_head_更新为C,剩余空闲:C→nullptr,仍有可用块,不扩容第三次分配:取头节点
C,free_head_变为nullptr,无空闲块,触发扩容,扩容新增块尾节点指向原空表头D->E->F>null,更新free_head_ = D第四次分配:取头节点
D,free_head_更新为E,剩余空闲:E->F→nullptr,释放归还
A:A->next = free_head_,即A->next = E,即A->E->F->null再把free_head_设为A。真的好神奇!!这就是新搞的永久衔接旧的头,内存池分配永远只拿链表头,新的 chunk 放前面 / 放后面,效果完全一样,都能分配到,但设想下,比如目前有
A → B → C → D → nullptr,你想申请 5 块,显然不够,就要新申请,但注意如果新申请的是64块,你用到现在只有ABCD这 4 块了,那你直接就新申请的 64 个找尾巴接到A上即可,你如果想接到旧的尾巴上,关键问题是,你根本不知道自己用了多少呀?注意:常规内存池默认单块分配,无批量一次性申领多个的场景。
比如:假设开64块后,你已经要走了 60 个内存块,又陆续释放 1 个,此时剩下的是 5 块,那好你如果想找到这个54块的末尾可不是能直接算
新申请是连续的一块,可以找到尾巴,但用了就一定会打乱,
比如地址:001 -> 002 -> 003 -> 04 -> 005...、-> 064,
你用了只剩下最后 4 块,061 -> 062 -> 063 -> 064,此时释放了 001 块,链变成:001 -> 061 -> 062 -> 063 -> 064,这 5 块,这你就必须用
null做末尾终止符来遍历才能找到 064 。理解了“整块 malloc 申请的连续内存,必须整体释放,无法单独释放其中局部高低地址片段,链表拆分整块内存为独立节点,可单独回收复用任意节点”。但注意,如果
free回收给操作系统,依旧同malloc,必须要大块整个的还。但好处:
减少系统调用开销,malloc/free 内核交互成本高
规避内存碎片,切块统一管理
指针操作分配释放,速度远快原生接口
线程批量复用内存,并发性能更优
内存边界可控,便于做校验防护
究竟比
malloc快在哪里?其实说一套流程就能捋顺处这个优劣势:
两者都做分片分区上锁,底层上锁逻辑形式一致,但内存池提前定好统一块规格,靠头指针直接取用,无需筛选匹配。只锁住记录空闲开头的单个标记,仅改动两处地址指向,操作瞬间结束立刻开锁。上锁时间极短,几乎不会排队等待。
一次性从系统申请一大块内存,后续反复内部取用归还,极少再麻烦系统,省去沟通延迟。
不需要额外信息,只用地址指针串联内存块,几乎没有额外记录信息,读写负担极低。
malloc无固定尺寸标准,大家申请的内存大小乱七八糟,系统只能挨个翻找合适大小的空位,翻找过程消耗时间,只能逐个检索匹配对应大小内存块做挑选、拆分、拼接内存动作,全部做完才开锁。多个线程抢同片区域,排队等候时间长,拖慢速度。频繁找系统要内存(时不时就跟操作系统申请、退还内存,跨层级沟通本身就有延迟,次数多就变慢)
额外信息记录(每块内存都额外记下大小、是否在用等信息,读写更新这些内容,增加性能损耗)
创建多个固定大小池(4B、8B、16B、32B…),分配时 → 找最接近的池取块。这叫多尺寸内存池,不是通用
malloc。依然不支持任意尺寸,只支持预设尺寸。真正比malloc快的内存池,一定是固定大小 / 预设尺寸,不支持任意动态尺寸。能支持任意动态尺寸的内存池,本质就是重新造了一个malloc,不会更快。引出:
锁等待慢、找空间费时间、反复切拼内存、频繁找系统要内存,
关于单例模式:
一个类只有一个对象:
查看代码
class Pool{ private: Pool(){} public: static Pool& getIns(){ static Pool ins; /* 第一次调用函数:创建 ins 之后所有调用:不创建新对象,直接用第一次那个 程序结束前,这个对象永远活着 永远只存在这一个实例 如果不加static,每次调用函数,创建一个新对象,函数结束,对象立刻销毁,每次返回的都是新的、不同的对象 */ return ins;//返回类型写 Pool& 就代表返回引用,直接 return ins; 会自动返回对象的引用,不需要写 &ins } }; //调用方式,这么获取,次次都是同一个对象 Pool& p1 = Pool::getIns(); Pool& p2 = Pool::getIns(); // p1和p2指向同一个实例,无法造出第二个独立对象
static Pool ins;只会创建一次,终身就这一个对象,第一次调用getIns(),创建static Pool ins;,返回它的引用,第二次、第三次…… 以后任何调用,直接返回已经存在的那同一个ins,不会再创建新对象。构造函数是 private,外面写 Pool p; → 直接编译报错, 根本不允许你创建第二个对象。是私有构造 = 强制单例。全局 static:仅当前文件可见,全局唯一一份
类内 static:所有对象共享一份,不属于任何对象
而手写内存池里用的是:
static GlobalMemoryPool g_pool;、
inline void* pool_alloc() { return g_pool.alloc(); }、(这是一个全局快捷函数,它内部固定、写死、永远只调用上面那个g_pool)
inline void pool_free(void* p) { g_pool.free(p); },别人可以手动再写一个
GlobalMemoryPool pool2;造出第二个池,逻辑崩溃:通用全局内存池,强制单例,分配释放同源,多规格内存池需拆分专属接口,互不混用内存块。
说人话就是:
终极要点都是单例,那些非单例的就是多规格,必须写好隔离逻辑【从哪里出的,回到哪里】:
池 A → 接口 A alloc / A free
- 池 B → 接口 B alloc / B free
绝对不能混用!
为啥如此?
你内存池每次调用
pool_alloc(),返回的 64 字节小内存块,每块就是一块内存void* p = pool_alloc(); // 这就是 ↓ 一块内存 // 64字节,属于内存池管理重点:每一块内存,只属于 1 个内存池!最致命的崩溃场景:
场景 1:正常情况(只有一个池 ✅)
// 全局唯一池 static GlobalMemoryPool g_pool; void* p = pool_alloc(); // 从 g_pool 分配 pool_free(p); // 还给 g_pool完全正确,不崩。
场景 2:有人乱创建 第二个池(🔥 必崩)
// 全局池 1 static GlobalMemoryPool g_pool; // 坏人创建 池2 GlobalMemoryPool pool2;void* p = pool2.alloc(); // p 这块内存 → **属于 pool2**看函数绑定关系
inline void pool_free(void* p) { g_pool.free(p); }调用
pool_free,代码固定只会调用全局唯一的g_pool的释放方法,从pool2取出的内存块,强行挂载进g_pool空闲链表,即 p 属于 pool2,你却把它还给g_pool,g_pool收到一块不属于自己的内存 → 直接数据错乱。比如:
池 1 = 你的钱包
池 2 = 我的钱包
64 字节内存块 = 100 块钱
我从我的钱包拿出 100 块,你把这 100 块 放回你的钱包,你的钱包记账直接乱掉!
于是迭代版本2:
查看代码
#include <cstddef> #include <cstdlib> #include <cstdio> #include <cassert> #include <mutex> #include <thread> #include <chrono> #include<unistd.h> class GlobalMemoryPool{ private: GlobalMemoryPool() = default; public: static GlobalMemoryPool& getIns(){ static GlobalMemoryPool ins; return ins; } public: static constexpr size_t BLOCK_SIZE = 64;//小块 = 内存池给你分配出去的最小单位内存,内存池一次性分配出来的每一小块固定是 64 字节 static constexpr size_t POOL_CAPACITY = 1024;// 内存池一次批量申请 1024 个 64 字节的小块 // 整体:每次从池子里拿64 字节,池子不够用时,一次性申请 1024 个 64B = 64KB 大内存块 private: struct BlockNode {//这个结构体本身大小 = 8 字节 BlockNode* next; /*把所有空闲的内存块串成一个单向链表,每个块只用前 8 字节(指针大小)存 “下一个块在哪”,剩下的空间给用户使用 [ next指针 | 可用内存空间 ] 8字节 56字节 总共 64 字节 */ }; BlockNode* free_head_ = nullptr; // 空闲链表头,永远指向第一个可用的空闲块 std::mutex mtx_; // 锁,多线程安全,防止多线程同时操作链表导致崩溃 void alloc_pool_chunk() { size_t total_size = POOL_CAPACITY * BLOCK_SIZE; char* chunk = (char*)malloc(total_size); // 一次性 malloc 一大块 assert(chunk != nullptr);// 如果系统 malloc 分配内存失败(返回空),程序直接崩溃报错,用于检查内存申请是否成功。发现内存分配失败,立刻卡死报错,方便定位 bug // 把这一大块切成 1024 个小块,串成链表 for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { BlockNode* curr = (BlockNode*)(chunk + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(chunk + (i + 1) * BLOCK_SIZE); curr->next = next; /* curr 指向当前块的开头 curr->next 就是修改当前块前 8 字节,存下一个块的地址 块剩下的 64 - 8 = 56 字节 完全不动,留给用户用 */ } // 最后一块指向原来的空闲链表头 BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 free_head_ = (BlockNode*)chunk; } /* 一次性 malloc 64KB(1024×64B) 把这一大块切成 1024 个小 64B 块 用链表把它们串起来 让 free_head_ 指向第一个块 这就是内存池的核心:预分配 + 链表管理。 */ public: void* alloc(){ std::lock_guard<std::mutex> lock(mtx_); // 加锁,线程安全 if (!free_head_) // 没有空闲块了 alloc_pool_chunk(); // 再预分配 1024 块 BlockNode* ret = free_head_; // 拿第一个空闲块 free_head_ = free_head_->next; // 头指针后移 return ret; // 返回给用户使用 /* 分配逻辑超级简单: 加锁 没空块 → 再申请一大块 把链表头节点取出来返回 头指针指向下一个块 可以看出这里内存池只改指针,malloc 要全局找空间、加锁、管理碎片,所以内存池快得多 */ } void free(void* ptr) { if (!ptr) return; std::lock_guard<std::mutex> lock(mtx_); BlockNode* node = (BlockNode*)ptr; node->next = free_head_; // 插回链表头部 free_head_ = node; // 新空闲头 = 刚释放的块 }/* 把块重新插回空闲链表头部,并没有真正还给系统! 这就是内存池高效的原因 —— 复用内存,不反复调用系统 free。*/ }; // 用了单例 getIns () → 全局 g_pool 彻底多余、必须删掉! inline void* pool_alloc() { return GlobalMemoryPool::getIns().alloc(); } inline void pool_free(void* p) { GlobalMemoryPool::getIns().free(p); } int main(){ printf("=== 观察htop RES数值变化 ===\n"); const int CNT = 100000; void* arr[CNT] = {nullptr}; sleep(8); printf("开始分配内存\n"); for (int i = 0; i < CNT; ++i) arr[i] = pool_alloc();//这里还没用只是开了,100000 × 64B = 6,400,000 字节 ≈ 6.4MB std::this_thread::sleep_for(std::chrono::seconds(5));//是 C++ 的,装逼/规范写法,sleep() 是 Linux 系统函数(Windows 没有), printf("开始释放内存\n"); for (int i = 0; i < CNT; ++i) pool_free(arr[i]); std::this_thread::sleep_for(std::chrono::seconds(3)); printf("测试结束\n"); }实操:
![]()
刚开始:RES 很小,分配 10 万块后:RES 明显上涨(约 6.4MB),释放后:RES 不会下降!
Q:为什么释放了内存不还给系统?
A:因为内存池的设计思想:我先拿着,下次你还要用,我直接给你,不用再找系统要。这就是内存池高效、无碎片的原因。
具体来说
pool_free()只是把块插回空闲链表,没有调用free(chunk)释放malloc出来的大块内存。但就算free之前说过也不回落,C 库内存管理器会缓存空闲内存,free只是 内存还给 C 库缓存arena,不会立刻还给内核。误区:我一直以为底层不用brk了,但
malloc申请大块,glibc是Linux下标准C语言运行库,封装内存、IO等系统接口,他的内部小内存默认调用brk拓展堆空间,大块内存才走mmap,内存池没绕开brk/mmap,只是一次性批量调用系统接口拿一大片内存,只不过一次拿的比正常多。关于内存对齐:
CPU 读内存喜欢按 8 字节 / 16 字节 整齐地址读,不喜欢乱的地址。如果返回的块是奇数地址、非对齐地址,跨内存行读取,额外多一次访存操作,业务一用就低效,代码设置 BLOCK_SIZE = 64,是 8 字节对齐的。64 位 Linux 默认最大对齐是 8 字节,所以分配出去的所有内存地址天然满足对齐要求,不会出现未定义行为,也不会影响 CPU 访问效率。
如果没对齐咋搞?
首先科普掩码:用来筛选、抹掉数字部分位数的固定二进制数。以 8 对齐为例:8 二进制
1000,减 1 得 7=0111,这 7 就是掩码。用掩码按位取反后,清空低位实现对齐。科普即将用到的变量:
sz待对齐数值,
align_up向上内存对齐函数
static:类内全局共享,所有实例共用一份
constexpr:编译期确定值,运行时不可修改
size_t:无符号整数类型,专门表示内存大小
ALIGN_SIZE = 8:定义 8 字节内存对齐标准
const仅只读,编译不一定求值,constexpr强制编译期算出常量,内存尺寸、数组下标这类编译期取值场景,只能用constexpr必须编译期算出,因为运行零开销。
32 位常用 4 字节对齐,64 位主流 8 字节对齐。
把数字凑成8 的整倍数思路是:
① 8-1=7,任何数除以 8,余数最大只会是 7
② 数字 + 7,不足 8 倍数的都会往上进一档
③ 抹掉多出的零头,就得到最近的偏大 8 倍数
数字 65,65+7=72,刚好凑成 8 倍数。
数字 66,66+7=73,去掉零散尾数,得 72
具体怎么去掉零散尾数,用的就是上面提到的掩码,上代码:
static constexpr size_t ALIGN_SIZE = 8;static constexpr size_t align_up(size_t sz, size_t align) { return (sz + align - 1) & ~(align - 1); }
align - 1:得到掩码,8-1=7 → 二进制000...0111
~(align - 1):按位取反,掩码变成111...1000,这一步也是其实就是准备用&运算符来搞没尾数的
sz + align - 1:向上进位,避免刚好是整数倍时多算
&运算:清零低 3 位,强制对齐到 8 的倍数static constexpr size_t BLOCK_SIZE = align_up(67, ALIGN_SIZE);继续优化:
这里虽然已经对齐,但可以增加更显眼的函数,
void* chunk = aligned_alloc(64, total_size);,C11 标准库函数,显式指定内存对齐字节数,你原来用 malloc 系统默认给你对齐,但不写出来,面试官看不到;改成 aligned_alloc 就是你主动告诉面试官:我懂内存对齐、我懂 CPU 访存规则、我写高性能代码。(aligned_alloc= 带内存对齐的malloc)因为单例全局只能有一个实例,如果允许拷贝 / 赋值,就会凭空造出第二个内存池,直接导致双重释放、链表错乱、程序崩溃,所以必须
delete禁用,这是大厂单例的安全底线,彻底禁止对象被复制、被赋值:// 🔥 大厂必写:单例禁拷贝 GlobalMemoryPool(const GlobalMemoryPool&) = delete;//是禁用拷贝构造函数,编译器看到 = 就知道:任何人尝试用「已有的内存池对象去创建新对象」,直接报错,不允许 GlobalMemoryPool& operator=(const GlobalMemoryPool&) = delete;//是禁用赋值运算符重载,意思是:任何人尝试把「一个内存池对象赋值给另一个内存池对象」,直接编译失败继续优化(其实是严重错误):
alloc函数错误:返回值直接返回了BlockNode*类型的ret,即内存块起始地址,该地址存放next指针,用户写入数据会覆盖指针,破坏链表。应该为: 返回节点地址 + 结构体大小,跳过
next指针return (char*)ret + sizeof(BlockNode); }
BlockNode地址是内存块最起始、存放next指针的地址。那对应自定义的
free也要改:BlockNode* node = (BlockNode*)((char*)ptr - sizeof(BlockNode));,因为alloc时往后跳 8 字节给用户,free时往前跳 8 字节找回节点,不跳回去,你就找不到链表节点,无法回收内存。那我有思考,既然创建销毁都改,麻烦,就直接都
main里用的时候 +8:arr[i] = (char*)pool_alloc() + 8;,alloc、free不用动。继续优化(这才是真正踩了无数坑的地方):
加析构:
1、进程退出系统会自动回收全部内存,程序跑完不写析构也不会内存泄漏,所以单进程跑完直接退出:析构可省略
2、但代码规范、复用、动态销毁实例、单元测试场景必须手写析构释放
3、内存池缓存的内存不会主动还给内核,不手动释放会常驻堆空间,程序长期运行、反复创建销毁池实例:析构必写,避免内存持续膨胀
首先有两类
static规则不同1. 类内静态成员变量:仅声明,必须类外定义初始化
2. 函数内局部静态变量:首次执行自动初始化,无需外部赋值
而
static constexpr编译期就有值,不用初始化。这里我先想简单的看下到底想操作系统申请了多少次内存
可以
main里定义计数器,然后(写法一)查看代码
void alloc_pool_chunk(int &count) { char* chunk = (char*)aligned_alloc(64, total_size); count++; // 直接累加 printf("第%d次分配\n", count); }也可以类内搞,但必须
main外的全局初始化,因为属于整个类,所有对象共享一份。C++ 规定:声明和定义必须分开!(写法二)(我用的这个)//类里 private: static int alloc_count; // 声明静态计数器。 非单例下,普通成员每个对象独有,互不共享;static成员全类对象共用 // static 变量属于类,不属于对象,更不属于函数!不能在 main 里初始化!必须在类外面、全局区域初始化。 //main之前写: int GlobalMemoryPool::alloc_count = 0;或者
static const int alloc_count = 0;。(写法三)或者类内的函数局部变量(不用外部赋值,因为它是局部变量)(写法四):
void alloc_pool_chunk() { // 就放这里!!!函数内部!静态局部变量! static int alloc_count = 0; // 然后正常用 alloc_count++; printf("第 %d 次分配\n", alloc_count); // ... 其他代码不变 }我发现总共申请98次,因为
const int CNT = 100000; for (int i = 0; i < CNT; ++i) arr[i] = pool_alloc();一次 pool_alloc 拿 1 块 64B。
一次 alloc_pool_chunk 给 1024 块
所以:100000 / 1024 = 97.656,意味着:你会调用
aligned_alloc一共 98 次!所以必须
free98次!但根本不用循环 98 次,只需要一个 while 循环,从头拆到尾就行,但还要注意,但关于
free函数:名字一样,归属不一样:
库函数
free:全局自带函数,作用是把内存还给操作系统。类里的
free:属于自定义类的成员方法,只做链表回收,不归还系统内存。调用写法区分:
写
free(变量),直接调用系统库释放内存。
对象.free(变量),执行自己写的回收逻辑,不走库函数。注意:操作系统记录的是单次分配的整块起始地址与占用大小,内部只是逻辑切割,物理仍是连续一块内存,依据首地址单次释放就能回收全部空间,即
free一次不是释放一块的64字节,而是起初申请的 1024 个块,但我们想要的是释放回池子里,只是放这一个,所以必须自己写free函数来做回收一次的功能。
pool_free()里用的就是自定义的free只回收 1 个小块,不还给系统;系统库函数
free()只释放当初malloc/aligned_alloc申请的那一大整块(1024 块);但豆包给的是(体现了我和AI豆包的,通过意志力死磕、追问、辱骂、质疑、反复实践思考、用自己独创的提示词做约束、互相相启发、合作、配合思考,我自己想不到这么写,但豆包给完“垃圾代码”我一个字一个字死磕追问,我就可以判断哪里有漏洞,如果语法思路学完,就不用一个字一个字死磕追问):
查看代码
//哎!!这个代码豆包给的!!可是这他妈每次free是1024个小块,没小块64字节,他妈的豆包这个代码实现记录的next早都被释放了 ~GlobalMemoryPool() { BlockNode* current = free_head_; while (current != nullptr) { BlockNode* next = current->next; free(current); // 释放每一块 current = next; } free_head_ = nullptr; printf("=== 内存池已释放所有内存(%d 次分配全部回收)===\n", alloc_count); }其实这里有个插曲,我思考是,既然小块的
next不行那我直接存第 1024 个块的节点的next不就可以了吗,因为这个next一定是指向了下一个不连续malloc得来的 1024 小块池啊?其实还是那个问题!此文搜 “关键问题是,你根本不知道自己用了多少呀?”,你如果有释放,一定打乱小块池内部有序连续地址!系统的free却不怕打乱!因为是物理层面的。(这里简称数 1024 个问题)应该搞个大块的链表指针:
第一次申请 3 块:a b c,内存池 只记录起始地址 a,链表变成:
a → b → c → nullptr都用完了再次申请第二次申请 3 块:d e f,内存池 只记录起始地址 d,新链表变成:
d → e → f -> nullptr释放时(pool_free):你释放谁,谁就插回链表头部,比如你释放 a,链表变成:
a → d → e → f → nullptr,最后析构释放:只释放两个大起始地址:
a和d
free (a) → 一次性回收
a+b+c整块free (d) → 一次性回收
d+e+f整块具体实现:
前言思考:
不用动态数组用链表理由:
链表想申请多少个大块都能串起来,永远不用扩容,这叫零开销,内存池追求的就是这个!
仅顺序遍历,任意位置增删只需改指针,效率更高
动态数组满了必须扩容、拷贝、释放旧数组(又慢又麻烦),
随机访问快,增删末尾简单,中间删改移位耗时。
内存池场景频繁新增大块、仅收尾遍历释放,链表适配度更高。
梳理已有代码逻辑:
目前是对象里存指针,指向
BlockNode结构体类型,这个结构体就是接下来申请大块切1024个小块的这个小块的内存格式,每个小块都是前8个字节存next,后56个存用户数据。比如开1024个大块,每个块都是 64 字节(设计成前 8 个字节存
next指针,后面字节存数据),main里创建对象:[管理单例] ↓只持有指针,指向 小块0 → 小块1 → 小块2 → 小块3 → ... 每个小块:[指针域(存下一块地址)] + [64字节可用内存区]初始化:向系统申请一整片连续大内存,按 64 字节规格切分成若干标准小块,
// 🔥 就是这里:刚申请完一大块内存,立刻循环切割所有小块! for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { BlockNode* curr = (BlockNode*)(chunk + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(chunk + (i + 1) * BLOCK_SIZE); curr->next = next; }初始化 = 申请大内存 + 立即全部切割 + 串成空闲链表。分配只是摘链,回收只是挂链。
先插入个语法细节:假设有
void* chunk = aligned_alloc(64, 64 * 1024);、BigChunk* new_big = (BigChunk*)chunk;,我理解内存被new_big 接管了,那chunk这个变量啥时候销毁?答案是chunk只是个临时指针变量,存的是地址数字,不占堆内存,函数结束自动销毁,不需要释放,不会泄漏。new_big 仅保存内存地址,堆内存不会随函数结束释放,程序退出系统才回收。整个这代码就是
main里做的事就是搞 CNT 个 64 字节大小的内存块。
main里流程:
for (int i = 0; i < CNT; ++i) arr[i] = (char*)pool_alloc() + 8;这里调用:inline void* pool_alloc() { return GlobalMemoryPool::getIns().alloc();,进而调用getIns(),getIns()一跑,就会执行static GlobalMemoryPool ins;对象在这里诞生!每次调用
pool_alloc (),都会先调用getIns(),但对象只会创建 1 次,第一次getIns ()创建对象再调用alloc(),之后所有次getIns()直接返回已有的对象再调用alloc()。然后基于这些现有的逻辑,豆包说要增加存大链表的结构体,开胃思路(依旧有错误):
更新:
代码逻辑落地会形成big_chunk_head_ 和 free_head_ 指向同一块 64KB 内存的同一个起始地址的指向关系。
free_head_把这块地址当 BlockNode,写next指针
big_chunk_head_把这块地址当 BigChunk,写next指针同一块 8 字节内存,不能同时存两个指针!会覆盖!会炸!会断链!
以下这部分关于:内存分区、单例存储位置、指针作用、大小块职责、性能原理、static 特性这些认知,全部无误。
但漏洞落在落地实现逻辑:
你计划将 64KB 大块首地址既赋值给
free_head_作为小块链表起点,又强转为BigChunk存入大块链表节点,物理首地址复用,8 字节指针区域会被两套链表读写篡改,不存在额外独立空间存放大块链表节点,这是隐患根源。对应你提出的疑问:小块已占满整块内存空间,无富余区域存放大块链表信息,原生写法无法同时承载两条互不干扰链表。
以下原文:
BlockNode 管 64 字节小块,BigChunk 管 64KB 大块,两个链表,
搞个
BigChunk专门记录每次向系统申请的 64KB 大块,即struct BigChunk { BigChunk* next; };8字节指针,专门用来把所有64KB大块串成链表,方便最后一次性释放。
BigChunk* big_chunk_head_ = nullptr;,big_chunk_head_:链表头,把所有大块串起来,也只有它能让你最后一次性释放所有系统内存。
static问题:
struct BigChunk加static是为了所有类通用,不新建第二个,但我代码是单例就可有可无了。
BigChunk* big_chunk_head_ = nullptr;没有 static 是因为他是指向必须释放的内存头,指向系统分配的大块内存(全部指向堆内存,malloc/aligned_alloc从操作系统堆区分配空间,不在栈、不在全局静态区),必须让他属于对象,随着单例一起释放,不然会泄漏。完美闭环!!通透!!Q:但我的质疑是,正常申请 1024 个块,每个块前 8 个存
next,后几个字节存数据,也即是说申请的完全利用起来了不多也不少!那问题就在于,小链用了,你大块的链表用啥存? (其实这么追问思考,我才刚理解内存池了细节)A:这个问题马上后面说,这里先不解释,先科普语法等基础概念。
之前误以为用了内存池,就都是堆开辟东西,其实错了依旧会有其他虚拟地址的东西,首先,给用户用的区别于malloc的内存池函数,是在堆上开内存放数据,而内存池依旧有自己的成员变量,存在单例对象里(全局静态区,不是栈也不是堆),我一直误以为内存池全程就只有内存池开的1024个大块呢,
其实是一个
内存池.cpp文件运行来处理比如 3 个任务,内存池会造个管理内存的工具对象,单例对象存在数据段的静态存储区(全局/静态区),既不在栈也不在堆,它不存用户数据,它原本是存一个指向BlockNode类型的指针(free_head_记录空闲小块链表在堆),现在增加的是指向BigChunk类型的指针(big_chunk_head_记录所有从系统申请的大块在堆)。你用malloc还是内存池都在堆搞数据,差别就是申请一大块,后续不用总跟内核交互,提高性能。所以不要以为只有申请的堆数据空间,本身这个堆是由处在静态虚拟内存区的对象所指向。空闲链表头指针存于内存池对象成员中!
那这里单例对象,就是只存了两个指针:
big_chunk_head_、free_head_初始都指向空,比如free_head是空闲链表头指针,也就是说他就是个箭头,就是个指针,初始null,挂上一个第一个块,就first_heat=第一个块, 挂第二个就free_head->next=第二个块,但注意所谓的“当前头”不是对象里的东西了,对象只持有一个指针,指向的就是malloc开的大块,这些大块都是分成了同级别的小块,别以为对象里还有个什么块头、表头啥的,对象里只有指针(即箭头),在开辟1024大块的时候才指向具体块(BlockNode类型)。纠正自己的误区(后来发现也就是错的!!~~~~(>_<)~~~~):
big_chunk_head_、free_head_都指向 A,那 A 必然指向小块 B,如果第一次开 ABC 三小块,第二个次开 DEF 上小块,大块链表应该指向的是A->D,小的应该是A->B->C->D->E->F,可是堆里数据块又不会分身术,他到底指向谁啊!!本身只能开一个链表,咋可能开两个链表啊,因为我理解这里1024个小块就充当了链表,用的就是实打实的申请的堆小块,根本不可能随便像栈上写结构体那样搞next块啊,所以没有地方在搞个链表啊。其实豆包没错,我一直以为豆包在跟我胡扯,说什么分时复用,覆盖即可,感觉是误人子弟,但其实豆包没错,
首先我想的就有问题,都已经说了两种结构体,可我还按照之前的逻辑,当作1024个小块都是
BlockNode类型的,所以就进误区,觉得big_chunk_head_、free_head_都指向BlockNode类型的 A 块,那 A 块的next必然是 B,又存大块的 D 呢?其实人家不可能指向同一个
BlockNode类型的 A 块,都说了free_head_指向BlockNode类型的 A,big_chunk_head_应该指向的是另一个每个块都是BigChunk类型的链表,只不过由于我觉得无法再次搞个链表所以就跟豆包打起来了,豆包就服软说自己错了,但其实都豆包根本没错!我没考虑到强制类型转换这件事!!客观存在两个独立的结构体:
你用来切小块的结构体
FreeNode,big_chunk_head_= A(大块头,永远记着第一个大块首地址)你用来串大块链表的结构体
BigChunk,free_head_= A(小块空闲头)这两个是完全不同的类型定义,内存布局不一样,用途不一样。你申请的是一整块物理内存(64KB),这块内存本身没有结构体,是你人为赋予它类型:
你用
(BigChunk*)chunk把这块内存的头部解释为BigChunk,用来串大块链表你把整块内存剩余部分解释为
FreeNode数组,切成小块给分配器用总共俩链表:
第一个链表:
big_chunk_head_驱动的大块链表,节点类型是BigChunk,每个节点对应一整个 64KB 物理内存,作用是记录所有向系统申请的大块内存,方便析构时统一释放。第二个链表:你内存池原本的空闲小块链表,节点类型是
FreeNode,每个节点是从 64KB 里切出来的小内存块,作用是给用户分配、回收空闲的小块内存。物理上只有一块内存,但代码层面做了两次不同类型的强转:一次转成
BigChunk做链表管理、一次转成FreeNode做内存切块。全错,这是内存践踏,不存在 “分时复用”!不存在 “类型转换解决冲突”!
操.你妈又浪费一天时间,吃饭走路拉屎都在跟豆包对骂思考这事。结果这玩意还他妈是错的!其实追问豆包的劣势是效率低,反复没个正确的东西,需要自己追问思考摸索,但好处是,就像刷算法题一样,想破脑袋,有收获,深刻,且我跟豆包激烈争辩,堪比踩坑无数,且所有写法的利弊都了如指掌)
真正正确思路:
之前的错误版:
// 结构体1 struct BlockNode { BlockNode* next; }; // 结构体2 struct BigChunk { BigChunk* next; }; char* chunk = 申请64KB; // 你把【同一块内存开头】 // 既当成 BigChunk BigChunk* big = (BigChunk*)chunk; big->next = ...; // 又当成 BlockNode BlockNode* free = (BlockNode*)chunk; free->next = ...;同一块 8 字节内存,被你同时当成两个指针用!写一个,另一个直接被覆盖!内存池一动链表 → 大块链表直接报废!
记住,内存布局别覆盖,之前错在误以为类型转换就没事了,其实依旧有事,依旧不可以覆盖,之前把小块链表,强转大块类型然后存大块的链表地址!
先看内嵌式的,类似 Nginx:
此时才发现豆包一开始没给错,一开始豆包给的就是:
加成员:
BigChunk链表struct BigChunk { BigChunk* next; }; BigChunk* big_chunk_head_ = nullptr;
BigChunk:专门记录每次向系统申请的 64KB 大块
big_chunk_head_:链表头,把所有大块串起来作用:只有它能让你最后一次性释放所有系统内存
alloc_pool_chunk开头加代码BigChunk* new_big = (BigChunk*)chunk; new_big->next = big_chunk_head_; big_chunk_head_ = new_big;
chunk是 aligned_alloc 申请的 64KB 大块首地址
(BigChunk*)chunk:把这块大内存开头当成BigChunk节点(只用前 8 字节存指针)
new_big->next = big_chunk_head_:把新大块串到链表前面
big_chunk_head_ = new_big:链表头指向新大块内存布局:
假设 1 个大块 = 固定 3 个 64 字节块→ 必须扣 1 个当表头,只剩 2 个给用户
第一次分配:大块 1 → 用户块 AB 注意:0x100 到 0x140 相差 64 字节
大块1 起始:0x100 [0x100] 表头块(BigChunk)next = NULL [0x140] A(用户) [0x180] B(用户) 大块链:big_chunk_head_ → 0x100 小块链:free_list → A → B大块2 起始:0x200 [0x200] 表头块(BigChunk)next = 0x100 [0x240] C(用户) [0x280] D(用户) 大块链:big_chunk_head_ → 0x200 → 0x100 小块链:free_list → C → D → A → B然后从第二块开始给用户使用,空闲链表从第二个块开始(跳过第一个管理块):
char* free_start = chunk + BLOCK_SIZE;真正能给用户用的块数量 = 1024 - 1:
size_t usable_blocks = POOL_CAPACITY - 1;,然后把for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) {改成切链表只切可用的 1023 块:for (size_t i = 0; i < usable_blocks - 1; ++i) { BlockNode* curr = (BlockNode*)(free_start + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(free_start + (i + 1) * BLOCK_SIZE); curr->next = next; }然后后两句的
// 最后一块指向原来的空闲链表头 BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 free_head_ = (BlockNode*)chunk; //修改为: BlockNode* last = (BlockNode*)(free_start + (usable_blocks - 1) * BLOCK_SIZE); last->next = free_head_; free_head_ = (BlockNode*)free_start;析构变成:
查看代码
~GlobalMemoryPool() { // 遍历所有大块,全部释放 BigChunk* curr = big_chunk_head_; while (curr) { BigChunk* next = curr->next; free(curr); curr = next; } big_chunk_head_ = nullptr; free_head_ = nullptr; printf("=== 内存池已释放所有内存(%d 次分配全部回收)===\n", alloc_count); }
从链表头开始遍历所有 64KB 大块
free(curr):真正释放系统内存一个不漏,全部还给操作系统
申请下来的1024个块的第一个块用来存大块链表指针,仅用前8字节存指针,剩余56字节空间不使用,属于内存池固定管理开销。
Q:既然你说新建个!可我看新建的也是一样的结构前8,那为啥不直接用小块的结构体?也就是直接只有一种类型结构体,然后1024个小块的第一个当作大块的链表格式,后面的当作小块的链表格式。
A:此文搜“完美闭环!!通透!! ”是同一个内存管理逻辑,两个链表结构体虽然结构相同,但职责完全隔离,生命周期完全不同,BlockNode(小块)随时会被分配给用户、被回收、在链表上跳来跳,它是不稳定的!BigChunk(大块头)一旦申请,直到整个内存池销毁才释放,它是永久固定的!
万一这个块被分配给用户了!用户改写了前 8 字节指针大块链表直接断裂!析构时无法释放内存!内存泄漏 + 崩溃!虽然代码约束永不分给用户,但还是工程思维比较好,严谨点!
回想捋顺:
豆包起初给的这个一点问题没有,只不过我没理解内存布局,所以就辱骂豆包,当时由于是回到家躺床上跟豆包语音激烈讨论的,脑子已经完全的陷进误区了,误以为申请的1024个块就是用来充当一个链表,然后无法搞第二个链表,豆包却一直在说是俩独立链表,其实豆包说的没任何问题,被我一直辱骂后豆包才被“污染”,给出所谓的覆盖一事,然后又引出多种写法混在一起,其实一切都是我没理解,我的错。
继续说上面的写法也可以搞成一个结构体里俩指针。好至此实现了大块链表的链接,也就可以正确析构了,进一步说点事,先引入个科普:
关于 Nginx:
实际就是:用户 → Nginx → 你写的 C++ HTTP 服务(多线程 /epoll)
Nginx 是 Linux 下高性能网络服务程序。它用多进程 + epoll做高并发。内部用内存池管理每个连接 / 请求的内存,提升性能。
之前手写的http服务器,也是web服务器也叫Web Server服务器
Web 本义:指万维网,基于 HTTP/HTTPS 协议通信。你用
epoll监听端口、处理 HTTP 报文,只要走 HTTP 协议对外提供服务,就是 Web 服务器 / HTTP 服务器,名称等价。客户端不限 Chrome:curl、Postman、其他程序发 HTTP 请求,它依旧是 Web 服务器。
只有不用 HTTP 协议(如自定义 TCP 协议),才不属于 Web 服务器。
关于正向代理 vs 反向代理:
客户端请求不直接访问后端服务,先发送到Nginx,由Nginx代为转发请求、接收响应再回传给客户端,这个中转行为就是反向代理。 正向代理面向客户端,替客户端访问外网;反向代理面向服务端,替服务端接收外部请求。
反向代理(自动,不用改浏览器):你输入
www.xxx.com,浏览器自动走直连域名对应的 Nginx,Nginx 再转给内部业务服务,你全程无操作不知情。目标是后端业务服务,部署在服务端侧,做流量转发、负载均衡,是你工作、面试的常见内容。站点所有者架起 Nginx。你正常输网址访问,你没做任何设置。你想找的是网站业务服务,后端真实程序被藏起来,你只见到 Nginx。例:逛电商网站,背后多个业务服务都被 Nginx 挡住。正向代理(手动,必须改浏览器):你先在浏览器填写代理地址和端口,再打开任意外网网站,请求才会先走代理服务器。目标是公网网站,多为客户端侧使用,企业统一上网、匿名访问、FQ都属于这类,C++ 服务端开发基本不接触。是你自己修改浏览器配置,指定走代理。你想访问外网站点,你的真实地址被藏起来,网站只看到代理。例:电脑设代理上网、FQ,都是这种。
Nginx 自己为啥不搞非要转发?
Nginx 擅长的只有 3 件事:读硬盘文件(图片、HTML、JS、CSS)、接收请求、转发请求、高并发扛流量
Nginx 不会干的事:算你账户里有多少钱、查你的用户名、处理登录、处理订单、处理游戏逻辑、处理聊天消息、处理视频流、处理复杂业务,这些复杂业务,必须由 你写的 C++ 服务 / Java 服务 来干!所以它必须转发给 8080 端口:你写的 C++ 服务→ 这个服务才会查库、计算、返回结果
Nginx 作为 Web 服务器(自身返回资源):
Nginx 是个【软件】!就像:
微信 = 聊天软件
浏览器 = 上网软件
Nginx = 监听端口、处理请求的软件
你必须先启动 Nginx 这个软件,它才会出现!
你必须【配置】Nginx!
你要写一个 nginx.conf 配置文件!
我给你看最简单的配置,小白也能看懂:
server { listen 80; #:listen 80 → 监听 80 端口 root /home/ubuntu/html; #:root /home/ubuntu/html → 文件存在这个文件夹里 }写完配置 → 启动 NginxNginx 就活了!
你在浏览器输入地址
http://192.168.1.100/pic.jpg发生了什么:
浏览器 → 访问 80 端口
Nginx 正在监听 80 端口 → 收到请求
Nginx 看配置:root 是
/home/ubuntu/htmlNginx 去硬盘找:
/home/ubuntu/html/pic.jpg找到 → 直接发给浏览器
Nginx 作为转发:
你必须【配置】Nginx!你要写一个 nginx.conf 配置文件!
server { listen 80; # 监听 80 端口(1号门) location /getUserName { proxy_pass http://127.0.0.1:8080; # 这句话意思: # 遇到 /getUserName 请求 # 转发给 8080 端口(2号门) } }写完配置 → 启动 Nginx,Nginx 就站在 80 门看门了!你还要启动你自己写的服务器!你写的服务器 → 站在 8080 门看门!
你在浏览器输入地址:
浏览器 → 访问 80 端口(1 号门)
Nginx 正在监听 80 端口 → 收到请求
Nginx 看配置:哦,这个要转发给 8080
Nginx 把请求带到 8080 端口
你写的服务器在 8080 看门 → 收到请求
你写的服务器处理业务
你写的服务器把结果发给 Nginx
Nginx 再把结果发给浏览器
Nginx 为啥要内存池?
因为 Nginx 要处理几百万请求!每来一个请求:要创建结构体、要存头信息、要存包体、要临时缓冲区、要各种小对象。
如果每个都
malloc/free太慢、内存碎片爆炸、高并发直接卡死,所以 Nginx 用内存池解决:一次申请,批量释放,不碎片,不卡顿。Nginx 内存池 和 网络连接 怎么结合?一个连接 = 一个内存池!流程:
客户端连上来
Nginx 创建一个内存池
这次连接需要的所有内存:包头、包体、临时 buffer、各种小对象,全部从这个内存池里拿!
- 连接断开直接销毁整个内存池!不用一个个 free!一秒释放所有内存!
我手写内存池跟Nginx有JB关系?
Nginx 内存池 = 管门外的事(转发、接收、协议)
接收数据、解析 HTTP 头、转发请求、发回响应,这些临时数据、临时结构→ 用 Nginx 自己的内存池,它只管转发,不管业务!
你的 C++ 服务内存池管什么(门内业务、计算、逻辑)
Nginx 把请求转发给你(8080),你要干:查数据库、计算逻辑、处理订单、拼接返回数据、业务对象、缓存、缓冲区,这些真正做事的内存,用 你自己写的内存池
突然想起之前写的线程池:
门外 Nginx:
内存池:分配转发数据内存
线程池:处理网络连接、转发
只管收发、转发,不干活
门内 你的 C++ 服务:
内存池:分配业务数据内存
线程池:处理业务逻辑
- 真正计算、处理、干活
科普结束,说 Nginx 是咋样?
其实目前这种写法严格来说既不像 Nginx 也不像 tcmalloc,只是自定义的手写定长而已,但更好的搞清楚了所有涉及到内存池的底层逻辑。
目前我自己手写的版本,这种直接第一块当大块链表,是因为就只有这一个大块,或者严格具体说我没考虑用户要大块(大对象)的事情,所以不需要大对象析构,直接搞这无数个申请的、用于分割小块的,主块大块即可。
而真正的 Nginx 里的内存池叫
ngx_pool,核心类型是ngx_pool_t,一次
malloc后,手写是分 1024 块,这里不提前分割,保留一整块,如果用户想要的块小于内置max,视为小块(小对象),内存池先把申请的整个大块比如 4096 字节,前 40 个字节当作头部存储管理信息(ngx_pool_data_t),后面 4056 字节用于给用户分割,用多少就从空闲的 4056 字节里线性切割多少,这个貌似被很多教程说成单规格或者叫线性分割,而如果用户申请的大小,大于
max视为大块(ngx_pool_large_t),也就是大对象,直接单独执行malloc(size)得到一块独立大内存,不在池子里,然后注意从刚刚的 4056 空闲里分割一块出来,作为ngx_pool_large_t节点,相当于大块链表。struct ngx_pool_large_s { ngx_pool_large_t *next; // 串成链表 void *alloc; // 指向刚才 malloc 的大块 };large->next = pool->large; pool->large = large;ngx_pool_large_t *large; // 大块链表头所有大块管理节点,靠
next连成一串:pool->large → large1 → large2 → large3 → NULL ↓ ↓ ↓ malloc1 malloc2 malloc3要遍历 / 释放时:
for (large = pool->large; large; large = large->next) { if (large->alloc) free(large->alloc); }挺简单,但我注意到这里大块的结构体为啥是俩指针,
struct ngx_pool_large_s { ngx_pool_large_t *next; // ① 链表指针 void *alloc;// ② 指向真正的大块内存 };直接用next就行啊,豆包说你设计的结构体(致命错误)
// 你想的:只用 next,又串节点,又指数据 struct ngx_pool_large_s { ngx_pool_large_t *next; };
malloc 大块数据 →
void *data = malloc(1024*1024);从小块池抠节点 →
node = 小块内存你想:
node->next = data;// ❌ 直接类型错误!崩溃点 1:类型不兼容
node->next是ngx_pool_large_t*
data是void*崩溃点 2:链表直接断了
pool->large → node1 → data(不是节点) → 崩溃
next必须指向下一个节点,你让它指数据,链表直接炸,再也找不到 node2!崩溃点 3:永远无法释放
for (node = pool->large; node; node = node->next)第一次循环拿到 node1,第二次
node = node->next→ 拿到的是数据地址,不是节点!没有 next 字段!直接越界、崩溃、内存泄漏!除非把「大块数据的头部」强行拿来当链表节点应该:
① 大块节点
struct ngx_pool_large_s { ngx_pool_large_t *next; // 链表:指向下一个大块节点 void *alloc; // 指针:指向真正 malloc 的大块数据 };每个大块内存,都对应一个此结构体节点来管理。
② 内存池数据头(小块用)
struct ngx_pool_data_t { u_char *last; // 小块分配起始位置 u_char *end; // 小块内存结束位置 ngx_pool_t *next; // 下一个小块内存池(不够就追加) ngx_uint_t failed; };③ 内存池主结构(整个池的头)
struct ngx_pool_s { ngx_pool_data_t d; // 小块管理 size_t max; // 大小块分界(>max=大块) ngx_pool_t *current; // 当前可用小块池 ngx_pool_large_t *large; // !!!重点:大块链表头指针!!! // ... 其他忽略 };结构就是:
【第一个小块池:ngx_pool_t】 ┌─────────────────────────────────────┐ │ ngx_pool_data_t d; ← ② 内嵌 │ │ ├─ last │ │ ├─ end │ │ ├─ next → 下一个小块池 │ │ └─ failed │ │ │ │ size_t max; │ │ ngx_pool_t *current; │ │ ngx_pool_large_t *large; → 大块链表头 │ └─────────────────────────────────────┘整个内存池的 “总司令部”就是你定义的 ③ 内存池主结构
ngx_pool_sngx_pool_s === 全称 ngx_pool_t === 别名(typedef)所以:
ngx_pool_t pool;和struct ngx_pool_s pool;等价,是小块池的头结点,里面装的就是你写的这一整套:struct ngx_pool_s { ngx_pool_data_t d; // 小块管理头部 size_t max; // 大小块分界线 ngx_pool_t *current; // 当前正在用的小块池 ngx_pool_large_t *large; // 大块链表头 };
pool.d.last→ 当前小块池,下一次分配的起点
pool.large→ 大块链表的起点,一开始没有大块,所以 = NULL。整个流程的内存布局:
ngx_pool_t pool; // 内存池主结构体(在栈/已有内存上) pool.d.last → 小块区起始 pool.d.end → 小块区结束 pool.current → &pool(初始指向自己) pool.large → NULL(一开始没大块) //起手搞小块池 ngx_pool_t *pool = ngx_create_pool(1024); //内部直接调用 malloc(),返回一个指针 → 得到 pool 指针指向的是【带头部(ngx_pool_t)+ 小块区】的内存区域 此时如果要第一次分配大块: // 1. malloc(100KB) → 得到 p(纯数据,无任何头) // 2. 从小块区抠一个 ngx_pool_large_t 节点 → node // 3. node->alloc = p; // 节点绑定大块数据 // 4. node->next = pool.large; // 头插 // 5. pool.large = node; // large 指向新节点 // 现在关系: pool.large ──> node1 │ next ──> NULL │ alloc ──> malloc(100KB) // 纯用户数据 // 再分配一个大块: pool.large ──> node2 ──> node1 ──> NULL │ alloc │ alloc ▼ ▼ malloc块2 malloc块1void ngx_destroy_pool(ngx_pool_t *pool) { ngx_pool_large_t *large; // ====================== // 第一步:遍历所有大块,free 每一个 malloc 的数据 // ====================== for (large = pool->large; large; large = large->next) { if (large->alloc) { // 如果有大块数据 free(large->alloc); // 🔥 直接 free 它 } } // ====================== // 第二步:小块池 & 整个内存 一次性全部释放 // ====================== ngx_free(pool); }注意我手写的代码实现切割 1024 块那个,
free回收一块会地址乱所以不可以直接+1024个找next,即此文搜 “关键问题是,你根本不知道自己用了多少呀?”,必须用大块链表,也就是小块池(我总说成大块,因为是第一次起手malloc用来给小块用的)。那这里 Nginx 也是搞了个
d.last这个小块池的链表,那可否用直接数 1024 的思路呢?他妈的更不行啊!因为人家 Nginx 没
next这玩意!就算有next也找不到啊,都说了释放小块回池子里会乱序,首先我手写的内存池其实没考虑场景,每个小块都有
next,人家 Nginx 是只有last指针后移,我的写法每次free还可以用,人家 Nginx 是last一直往前跑,不会回头释放,因为场景是 http 这种连接,很快完事,所以 Nginx 如果搞个next就增大开销了,因为next的意义在于复用(放回的时候靠next头插),而 Nginx 不考虑小块复用。所以,Nginx 不需要复用,就不用
next,就没有释放小块这件事,必须大链表连接各个池子头,但如果你死命就是硬刚这个理论问题,觉得不考虑客观已经发生的
next巨大开销这件事,觉得不复用、不放回,不会乱序,不用小块池头链表,再记录下每次申请用了多少个小块,完全可以通过next增加count,找到一个小块池的最末尾的然后找他的next。但违背 Nginx 追求极致性能这件事!然后就是关于复用这件事,无尽追问才扯出发现完全有很多层面复用,本质是我对这玩意依旧稀里糊涂。
Q:为啥?妈逼的你打个游戏、看导管子视频,一会断开一下子,你受得了?必须要长连接吧?
A:长连接 ≠ 永远不释放内存,请求内存池 = 只管 “一个请求”,不管整条连接!
一条长连接 = 可以跑 N 个请求,不断开,但请求是一来一回的。
每个请求 单独一个 ngx_pool_t,请求来了 → 创建 pool,请求响应完 → 销毁 pool,但连接不断开!
连接本身的内存 是 slab,长期复用,请求的内存 用完就扔,快得很。
长连接一直活着,但请求内存照样频繁释放。
你刷抖音网页
连上去不断开(长连接)
刷一下 → 发一个请求拿视频
再刷 → 再发一个请求
一直连着,请求一个接一个跑
每个请求:来 → 建 pool → 处理 → 销毁 pool,连接永远不断。
nginx 就是:连接活着 ≠ 请求活着,请求死了 ≠ 连接断了,请求内存池只管单次请求,长连接只管socket 不断。
1)Nginx 请求池:不是不能复用,是根本不想复用
因为复用 = 麻烦 = 变慢 = 容易出 bug,请求就活 1ms~10ms,用完直接整个扔掉,比回收、标记空闲、管理链表快 10 倍都不止,复用对这种短命内存,纯多余,会变成:
用完一块内存,不能扔
要标记 “我空了”
要放进链表
下次分配要去链表里找
多线程还要加锁
2)你手写的 “复用” 到底好在哪?
适合 一块内存 用很久、断断续续、乱拿乱放。
长连接的连接结构体
缓存
游戏对象
长时间存活的东西
活的久 → 才值得复用,活的短 → 复用纯浪费时间。
Q:关于到底为啥这么设计,想不通,感觉复用和不复用没啥区别?Nginx 到底涉不涉及到复用这件事啊?而且说活的越久越需要复用?可以活的久和复用有啥关系? 复用之前不都清空数据了吗?清空标记空闲释放小块这些动作很烂啊,为啥不直接像短命的http那样一路last指针,然后满了申请?
A:假设同样小内存,然后同样需要反复申请啊,一个http连接需要反复申请,你说可以不复用,用完直接扔,重新malloc小块池。
那我需要复用的不也是需要反复的申请吗?不也是可以一直last往前,然后满了扔掉吗?
就在这里,他妈的的才发现豆包没讲全,反复辱骂后豆包又被污染了,幸亏我点进豆包给的链接看了眼,发现自己少学个应用。
以上是 Nginx 的单个 worker 进程内部用的,这里有仨链表:
内存块链表管小块的、
大块链表可以单独释放回收复用、
清理回调链表用于放文件、socket 等资源的析构函数,在pool 销毁时逐个调用。
slab是一整页切成的固定小块集合,slot是里面单个固定小块。这里小块链表只有一个
last指针往后挪,不记录每块的大小、不记录每块是否空闲,不能单独释放某一块,只能整池销毁。每个客户端发来的一次访问即 HTTP请求,就一个
pool,请求结束(200/404/500 / 断开),整个池干掉,keepalive 长连接下,同一个连接可以反复来请求,每个请求仍然是自己独立pool,互不干扰。这里都是进程内部,但放到多进程共享内存里直接暴死(引出进程共享的):
进程 A 分配一块,进程 B 分配一块
进程 A 想释放自己那块 → 做不到!因为没记录、没标记
内存永远只增不减,瞬间爆掉
多进程同时改
last指针,直接数据错乱所以 Nginx 还有一套设计 —— 共享内存 ngx_slab:
专门为缓存、限流、共享字典使用(之前的进程内部的是 HTTP 请求用完就丢),多进程一起用:
先
mmap映射一大块连续物理内存做成共享内存,再在里面自己切块、建链表(不是malloc),把一大块共享内存切成 N 个一样大的块(比如全部 64B、或全部 128B,这里的 64B 就是完全我手写内存池的逻辑)用链表把所有空闲块串起来,哈哈
一次性 mmap 拿到 1MB 大内存 → 提前切成 64B、128B、256B... 固定大小块(准确说还是要的时候才切割,先划分的是页) → 用链表串起来 → 谁要就给谁,释放就放回链表分配 / 释放 = 只改链表指针,不碰系统调用
可以随时放回空闲链表马上被下次复用,Nginx 认为:共享内存池一旦创建,就一直用到进程退出才会还给OS,因为还回去要 munmap,系统调用慢。
总结就是:
进程内线性池
小块:不 free、不复用、指针只往前走
大块:单独链表,可以 free
生命周期:请求结束整池销毁
共享内存 slab
小块:可 free、可复用、链表管理
大块:可 free、可复用、按页管理
生命周期:worker 重启 / 退出 才真正还给系统
设计目的不同:
进程内内存池(ngx_pool_t)只为 “一次请求” 服务
生命极短:请求来了 → 建池 → 分配 → 请求走 → 销毁
目标:快、快、快,不做任何复杂管理
不需要回收,因为用完就扔
共享内存 slab(ngx_slab_t)为 “全进程生命周期” 服务。
小块管理方式天差地别:
进程内池只有一个往后推的指针:last(不提前分割)
不记录谁用了、谁没用
不记录块大小
不记录空闲位置,完全裸奔
共享
slab必须有完整的空闲链表 + 位图(提前分割页,具体 64B、8B 这些用的时候再切割)
- 总共享内存比如:64KB,系统内存页默认 4KB / 页,64KB 拆成 16 个物理页
单个申请内存 ≤4KB:页内按 64B、128B 等规格切小块(slab)分配、回收复用
单个申请内存 >4KB:直接拿完整页,不切割
每一种大小(16B、32B、64B…)都有一条空闲链表
分配 = 从链表摘一个、释放 = 放回链表
精准知道每一块是空闲还是使用
多进程安全(你不知道但最重要):
进程内池完全不考虑多进程
没有锁
- 没有原子操作
多个进程同时写 → 直接内存错乱、崩溃
- 共享 slab天生为多进程设计
有自旋锁
有原子操作
多个进程同时分配 / 释放 → 安全、不会互相踩
至此总结下 Nginx:
进程内部:
是先一次性
ngx_create_pool(底层封装的是malloc)大块,然后用户边用边线性切割小块(线性推进,last指针往后推),不提前切割,如果需要超过4KB的块就不切割现有的大块,直接重新ngx_palloc_large(封装malloc),然后large链表节点ngx_pool_large_t是该大对象的管理节点,他是从当前内存池的小块区里切出来的,挂到pool->large链表。不够就再次执行上述扩容如此往复。大块的咋链接?首先说下,这里其实也是我和豆包沟通中的歧义,先看我自己的手写的内存池代码,其实是每次64B,没判断大小都是直接一次性锁定固定64B,Nginx 区别是小对象(≤64B)走你现在的 64B 池,大对象(>64B)直接
malloc/mmap+ 挂large链表,而我写的其实豆包说是教科书版本定长内存池,但存在诸多问题,第一次要 64B拿走free_head_,free_head_自动指向下一块,给你了,第二次要 64B直接拿新的free_head_,再把free_head_往后挪。只支持:无数次分配,每次大小 ≤ 64 字节,超过 64 字节比如你想要100字节,直接越界崩溃。然后比如申请 2 次,每次是一个1024大块,那这里大块之间的连接就用第一小块,而我没有非64B的用户需求级别的大块,所以没法讨论!这个问题后续不自己瞎改了,直接看代码随想录的 Github 项目然后一句一句死磕追问豆包改进吧。先把所有现有设计模式搞懂,然后再看代码随想录的,那就继续回到这里,Nginx的进程内部大块咋链接这件事涉及到俩概念:
一个是每次
malloc大块准备线性给小块用的这个大块咋链接?也就是用来线性切小块的 “主内存块” 怎么链接?pool -> 当前块 -> 上一个块 -> 更老的块 -> NULL每个块都是
ngx_pool_t/ngx_pool_data_t,用自身的next指针链接,全部来自 malloc 一次一大块,内部切,不额外 malloc另一个是每次用户级别的,需要很大的块,也就是大对象,需要重新malloc的又是咋搞?这个容易如下:
// 再分配一个大块: pool.large ──> node2 ──> node1 ──> NULL │ alloc │ alloc ▼ ▼ malloc块2 malloc块1Nginx 的进程外:
共享是一次性
ngx_shm_alloc(mmap)一个大块,然后ngx_slab_pool_t提前划分页,然后用的时候再根据规格动态切割,专业术语叫内部管理用ngx_slab_pool_t(slab 池),然后第一次有人申请8B才拿一页,切割512个8B的slot,然后后续搞16B...,如果用户需要比较大的内存,是从空闲页链表拿连续4KB页,不去找那些8B、64B的小规格。进程内部是不够就再次执行上述扩容如此往复,但这里Nginx 共享内存池(slab)默认不动态扩容,配置多大就是多大,运行期不会再mmap新的共享块 Nginx。这里的大块又是咋链接?
用来切小对象的「4KB 页」(对应进程内的 “主块切小块”):
由于:
双向链表节点自带
prev前驱指针,直接定位前驱,无遍历,O (1)。单向链表只有
next,找不到前驱,必须从头遍历,O(n)。所以这里,
ngx_slab_pool_t { ngx_slab_page_t *pages; // 所有页的数组(连续) ngx_slab_page_t free; // 空闲页链表头 ... } ngx_slab_page_t { uintptr_t slab; // 位图 / 规格标记 ngx_slab_page_t *next; uintptr_t prev; }双向便于找到前一个这种然后做删除啥的,然后所有 4KB 页 → 存在一个连续数组
pages[](不是分散 malloc)。空闲页:用pool->free做头,串成 双向链表:pool->free ↔ page1 ↔ page2 ↔ page3 ↔ NULL一旦某一页被切为 8B/16B/… → 从「空闲页链表」摘下,挂到对应规格的 slot 链表:
slots[8B] ↔ pageA ↔ pageB ↔ ... slots[16B] ↔ pageC ↔ ...pages [] 数组存所有物理上连续的 4KB 页,目的:知道第几页在哪、分配连续多页(比如 3 页给大对象)必须靠数组、没有数组,你根本找不到连续页。类似整个书架(所有格子都在,连续不乱)
双向链表,只串空闲的页,目的:快速拿 / 删空闲页(O(1))、不遍历、不卡性能。只把 “空格子” 串起来,方便随手拿、随手放
大对象(> 页 / 特定阈值)(对应进程内
large链表):整个共享内存只有一块:
mmap出来的连续大数组,再也不扩、再也不新mmap。没有 “独立 malloc 的数据块”,所有数据都在这一块里。直接拿连续的 4KB 页(1 页或多页)。页的管理结构
ngx_slab_page_t→ 全部在共享内存的pages[]数组里(连续、预分配)。链表:只有页的双向链表,没有 “大对象链表”:pages[] 数组(连续) 空闲页:free ↔ page1 ↔ page2 ↔ ... 大对象:page3+page4(标记 NGX_SLAB_BIG)→ 地址返回给用户小对象:
slots[32B] → page1 → page2 → NULL (同尺寸页链表) ↓(位图切) 32B小块1、2、3…小块之间:不靠指针连,用页内位图管理空闲 / 已用。
页之间:同尺寸挂
slots[slot]双向链表。所有页:都在共享内存
pages[]数组里,无独立 malloc。关于进程外的东西再说详细点,整体架构思路 & 内存布局具体情况(其实我自己根据豆包一直搞到析构发现没标准答案就无意间查到了内存池、然后代码随想录的内存池,然后回头梳理进程外这件事的时候,发现此文搜“所以这里,”误以为每个块都有个空闲头,后就死磕理解之前其实不够理解的进程外的这个架构原理,死后才返现代码随想录写的V1就是在模拟这个):
当你一次mmap内存就给你这么块连续物理内存,用作你进程外使用共享这件事,里面按顺序切4段:
低地址 ─────────────────────────────────────────高地址 [ngx_slab_pool_t] [slots数组] [pages数组] [4KB数据页区] 总管理头 各种尺寸链表头 每页的管理员 真正存数据的内存 │ ├─ 记录 slots[] 在哪 ├─ 记录 pages[] 在哪 └─ 记录 4KB数据页区 在哪
4KB数据页区 = 全量真实内存,mmap一次性申请一大块内存后,全部先划分成固定的4KB页,统一放在4KB数据页区里。slots是结构体数组,里面存指针,用来串链表。
slots[0]管8B→ 拿一整页4KB切成无数个8B小块,要8B就从slots[0]链表里拿。
slots[1]管16B→ 再拿一页4KB切成无数个16B小块,要16B从slots[1]拿。
page就是桥接,一边连接slots链表,一边管理对应的4KB数据页。
ngx_slab_pool_t就是整个内存池的大总管,记着所有区域的起始位置、空闲页链表、总大小和互斥锁,统一调度一切。
ngx_slab_page_t:
ngx= Nginx 的专属前缀(所有结构体都带这个,无意义,标记是 Nginx 自己的)
slab= Slab 内存池(专门管理小块内存的机制)
page= 一页内存(这里固定是 4KB 一页)
slot为啥自己不管理?非要个桥接?slot[0]就直接记录page[0]记录的东西啊?因为一个slot要管很多很多页,一个slot装不下所有页的信息,并不是1个slot对应1个page,1个slot对应10个、100个page,必须用page做桥接单独记录每一页的状态,slot[0]只管8B大小,但系统可能需要100页4KB都切成8B来满足需求:
slot[0]= 队长(只负责:我是 8B 队,我有一条链表)
page[0]= 队员 1(管第 1 页 4KB)
page[1]= 队员 2(管第 2 页 4KB)
page[2]= 队员 3(管第 3 页 4KB)……
page[99]= 队员 100(管第 100 页 4KB)队长只有 1 个,队员可以有很多个,pages [i] 是 4KB 页 i 的管理员,记录:这页属于哪个 slot、切成多大、空闲块在哪、串在哪个链表。
slot[0](8B) ↓ (一条链表) page[0] → page[2] → page[5] ↓ ↓ ↓ 4KB页0 4KB页2 4KB页5 (全8B) (全8B) (全8B) slot[1](16B) ↓ (一条链表) page[1] → page[3] → page[4] ↓ ↓ ↓ 4KB页1 4KB页3 4KB页4 (全16B) (全16B) (全16B)
mmap申请内存,得到是一个pool指针,指向的就是申请下来的整片的内存起始地址,pool是一个指针,类型为ngx_slab_pool_t*,它的值等于整片内存的起始地址,通过 pool 可以访问ngx_slab_pool_t结构体、slots 数组、pages 数组、4KB 数据页。那完整就是:初始状态:
整块 4KB数据页区:【全部100页 4KB 物理内存】(永远不动、永远全量在这) pages[]: 【page0 ~ page99】(100个管理员,一一对应) ngx_slab_pool_t free 链表: free → page0 → page1 → page2 → ... → page99 → NULL slots[]: slots[0] = NULL slots[1] = NULL ...全是空plaintext 低地址 ────────────────────────────────────────────────────────────────────────────→ 高地址 [ngx_slab_pool_t] [slots[]] [pages[]] [4KB数据页区] │ │ │ │ │ │ │ │ ├─ slots ─────────────▶ 指向这里 │ │ │ │ │ │ ├─ start ──────────────────────────────┼───────────────▶ 指向这里 │ │ │ │ │ │ │ │ └─ free ──────┐ │ │ │ │ │ │ │ ▼ │ │ │ pages[0]─────┼───────────────▶ 对应───────▶ 4KB页0 │ │ │ ▼ │ │ pages[1]─────┼───────────────▶ 对应───────▶ 4KB页1 │ │ │ ▼ │ │ pages[2]─────┼───────────────▶ 对应───────▶ 4KB页2 │ │ │ ▼ │ │ NULL │ │ │ │ │ │ slots[0] ◀──────────────────┘ │ (链表头) │ │ │ ▼ │ pages[3] ────────────────────────────────▶ 对应───────▶ 4KB页3图里
[ngx_slab_pool_t]就是ngx_slab_pool_t类型的数据,即ngx_slab_pool_t结构体实例。每个 page 自动对应 1 个 4KB 页,不用指针,靠位置计算对应,
ngx_slab_pool_t是结构体实例,里存 3 个关键地址
指向 slots []
指向 4KB 数据页区起始地址
free 是全局空闲页链头
slots[]也串pages[],但串的是已经分配、已经切分的页:
free 串:空闲、未分配、完整的 page
slot 串:已分配、已切割、在用的 page
同一个 page,同一时间只能在 free 或者 1 个 slot 里,不能同时在两边
从 free 拿走 → 进 slot
用完释放 → 回 free
4KB数据页区 = 全部真实物理内存。
至此说完 Nginx 的全部!完全没看任何源码、教程、书籍等任何资源,全靠追问豆包、死命和豆包语音。
再说另一波东西:
TCMalloc:Thread-Caching Malloc,Google 写的通用内存分配器。
用来替换系统 malloc 的,但只替换「当前这一个进程」里的
malloc,不是全局替换整台机器,也不跨进程。先说下TCMalloc的三级架构:
ThreadCache(线程本地缓存,属于上层),每个线程一个,私有,无锁(和 Nginx 的共享模式完全一样,此文搜“用链表把所有空闲块串起来,哈哈”,只不过不是给多个进程用,是给单个线程私用自己玩)
- 管小对象 ≤32KB
内部按
size-class分档:8B、16B、32B、…、32KB(几十种)每个
size一条 空闲链表(freelist)分配:直接从自己的
freelist摘一个,无锁、极快释放:塞回自己的
freelist比如线程申请 20B (≤32KB 小对象),线程查自己的 ThreadCache(无锁),ThreadCache 有 20B 空闲块直接拿走,分配完成,如果 ThreadCache 没有,去 CentralCache 批量取一批
对应 Nginx:Nginx 没有线程本地缓存,worker 是单线程,直接在 pool 里 last 指针往后推。
CentralHeap(中央堆,也叫CentralCache),全局共享,所有线程共用,带锁。
ThreadCache 没货了,批量从 Central 拿一批(比如 64 个同尺寸块)
ThreadCache 空闲太多,批量还给 Central(Central = 线程本地缓存的 “仓库”,不碰 OS,所有线程共享的 “内存中转站”)
- 内部也是按 size-class 分桶 + 链表
用 自旋锁 保护,竞争比系统 malloc 小很多
对应 Nginx:Nginx 没有全局中央堆,每个请求 pool 完全独立,互不共享。
PageHeap(页堆),最底层,向 OS 要内存,按页管理。
- 页默认8KB / 页
管理单元叫
Span:一段连续的页(1 页、2 页、…、最多 256 页)申请大小 ≥256KB:直接分配若干整页,不走 Thread/Central
≤256的:把一整页(或几页)切成很多同样大小的小块,挂在 CentralCache 的 freelist 上,等待 ThreadCache 来取。
负责:
向 OS
mmap要内存空闲页合并、碎片整理
长期空闲的页还给 OS(munmap)
最后,咋用这玩意?
你代码里写的
malloc(size),就是 TCMalloc 暴露给上层的入口函数,它内部自动走你理解的那三级架构:判断大小 → 走 ThreadCache → CentralCache → PageHeap → OS,全程替你包办。至此总结下:
之前浪费时间走进的误区,我写的手写内存池叫块头内嵌法,最前面一段当链表节点(next 指针),后面全给用户,然后扩展下就得到了Nginx进程内的写法,也叫元数据与数据分离(双结构体)
链表节点(管理结构)和用户内存是两块独立内存
节点单独存在,用户内存单独
malloc结构:
[节点结构体] → [用户内存]总结梳理:Nginx懂了、TCMalloc 懂了,
然后普通的malloc是底层走ptmalloc,管理头
header嵌在同一块内存里,malloc(100)就是一块100+header,每次跟操作系统交换,很慢。网上好多博客写的哈希桶就是多规格,只用在 Nginx 共享内存 slab,进程内内存池完全没有!
我现在写的代码 = 教科书版「单规格定长内存池」
如果你想加:判断大小 → ≤64B 走池、>64B 直接 malloc = 这就是 Nginx 进程内内存池(ngx_pool_t)的逻辑!但又不完全是,因为人家进程内是线性的,不提前分割固定字节
如果你想加:多规格 + 按页 (4KB) 切割 + 空闲链表 + 哈希桶 = 这是 Nginx 共享内存 slab(跨进程用)
tcmalloc咋用?
g++ abc.cpp -o abc -ltcmalloc && ./abc即可,看不到,除非多线程,就可以看出替换的效果了。对外:TCMalloc 就是
malloc/free/new/delete的替代品,它自己实现了标准库的malloc函数,链接后程序里所有malloc都会走到 TCMalloc 的代码,不再走系统 glibc 的malloc,因为各种底层库都用malloc,因为 C++ 的标准容器 (vector/string) → 用allocator→ 调用::operator new→ 默认实现,就是调用malloc。那为啥底层不搞高效的?因为malloc是 Linux 系统内核 + 标准库唯一提供的、跨动态库兼容的、全局统一的堆内存入口。所有更快的(tcmalloc、jemalloc)都必须伪装成malloc,才能被底层使用,底层不可能直接调用它们。所有你写的
malloc→ 进 TCMalloc → 走 ThreadCache/CentralCache/PageHeap → 最终用mmap/sbrk向系统要页,全程不调用系统 malloc。所有管理结构(Span、ThreadCache、CentralCache、SizeClass 元数据)全部从 PageHeap 来的内存页里分配,也就是:先向 OS 拿大页(mmap),从这些页里切出管理节点、切出小对象。
所以我的手写内存池是:是全局单例 + 多线程安全 + 带锁,是目前我代码只有一个切割1024这件事,后续作为接口对外使用的话,类似于cpp文件就是同一个进程,该进程内的所有线程通过单例共享同一个内存池,多线程抢的是同一个全局内存池的锁和空闲块,所以线程B想干活就得等A申请内存这件事完事,即:
单例
GlobalMemoryPool全局唯一,仅限当前进程内所有线程共享;多线程调用
pool_alloc/pool_free会竞争mutex,是线程间抢夺进程之间内存独立
Nginx:多进程,每个进程单线程,进程内私有池 → 无锁
如果我代码改无锁:多线程,每个线程私有池 → 无锁。
所以目前发现几个改进就是:析构要完善,然后多规格(什么哈希桶之类的),然后无锁问题。
于是内存池迭代版本3代码:
看代码随想录的内存池项目:
真的是发现了!之前那个析构还有http多线程服务器项目每次时间轮其实对错都很难发现错误!代码随想录的讨论 证实了这一点!!他也有错误。(wx搜“null”),之前《讲得不好就得认》,不过如此。全对上了!
无意间发现这里的 贡献者 有TCPIP网络编程的阅读笔记。看了代码随想录的V1真的好垃圾啊,原来这就是github啊???就这?
VScode有时候白屏,必须搞下窗口。
github复制需要点好几下,延迟好大。
github的左侧一会悬浮一会嵌入需要把页面拖出来再放回去
感觉比 Java 爽多了,就是照着豆包死磕底层代码就行,直接追问到底,妈逼的 Java 各种依赖包记得大学IDEA/eclipse搞的头大,写个helloworld倒腾软件半天都无法编译构建不起环境,本科课设不咋学习抄个别人的项目都不知道咋运行妈逼的!一屁眼子 jar 包。
为啥想到手写内存池?
开始malloc看不到回落,硬头皮啃这一节,但都是文字、概念,学完感觉学会了、背会了malloc和内存池的对比、背会了底层走的brk/mmap,但没啥东西心里不踏实,回想之前看到过什么优化内存,决定手写个内存池。
又回忆起,取关了的、感觉算法很烂又有点营销号风格的代码随想录,但关于培训 C++ 项目他应该很行,记得之前看到过公众号文章,但没钱买书、没钱买星球,可以参考他的 github,其实我一直都是完全闭门造车,各种戾气劝退C++,我就想那些清北华五就不会被劝退吗?他们走一个我就能站住坑位,爸爸的病没法再治疗了,我找份C++的工作真的这么难吗?
黄国平博士致谢论文、罗斯纪录片、艾弗森纪录片、当幸福来敲门... ...
叶问看X光学武功
为啥打算看github参考别人?
一直觉得自己基础语法不行,然后之前刚学的时候,编程指北的知乎文章里的 github 服务器的项目地址打不开,找其他的怕项目不与时俱进、运行不了增加负担、很多怕学深了、偏了、怕收费、怕自己有更好的思路和作者不同却受别人大厂头衔束缚不敢改,公众号文章一直只顾着收藏没看,但现在突然发现自己有基础、有功底、有能力去参考他们那些人的定位于进大厂的项目了,先看 代码随想录 的 内存池 吧!
一直知道鱼皮的Java+AI,AI+C++又是啥?(他妈的!发现个事!!A对话链接分享出B链接,B里点继续聊,这三者删都互相影响,但A对话链接如果没了B的分享对话一定没,如果不删除对话,只是删除A对话里的分享内容,则B里不影响)
最主要的是豆包给出的代码我跑完了依旧感觉没啥特别难的呀,然后很多极致细节的思考豆包就不敢跟我对峙了,只要是析构那个模块(到底是直接写next还是咋),全网说的内存池代码看不懂,豆包说 Nginx 代码也这么写,尽管让豆包参考极致权威,但反复质疑,豆包也依旧总是顺从我,哪怕豆包说的是堆的。感觉没啥难度,所以才想去看github。
代码随想录的项目选哪个?结合豆包自己的思考:
代码随想录说服务器项目烂大街,但我的思考是虽说烂大街,但我也必须搞!因为可以通过这个有一个基本框架宏观的认知!!!不应该跳过这个不搞,去直接搞内存池RCP网络库那些,就像开车技术烂大街但不会开车的人必须先学开车不能直接去学咋修发动机。
对比维度 烂大街基础 HTTP 服务器 你的 HTTP 服务器 网络模型 × 基础 socket(套接字基础收发)、单线程串行处理 √ epoll 事件驱动(Linux 高并发 IO 模型) 连接形态 × 短连接(请求完成立刻断开连接) √ 长连接(单次链路可多次交互) 资源管理 × 无连接池(每次请求新建销毁套接字) √ 自研连接池(复用空闲连接减少损耗) 超时管控 × 无定时器(无法清理僵死无效连接) √ 手写时间轮(定时回收超时连接) 并发能力 × 单线程,同一时间只能处理一个请求 √ 多线程并发,可同时应对多路客户端请求 报文处理 √ 基础 HTTP 请求解析 √ 具备基础解析,支撑文件访问业务 业务功能 √ 简单文字页面返回 √ 目录浏览、本地图片读取返回 代码封装 × 逻辑堆砌,无模块化拆分 × 未封装成通用网络库 API,功能内嵌服务内 技术含金量 入门入门级,同质化严重 网络核心考点全覆盖,脱离平庸水准 我的 HTTP 服务器完整手写实现了网络库的核心高并发架构,包括 epoll、长连接、连接池、时间轮等全套底层网络能力,技术深度对标独立网络库项目,只是没有封装成通用 API,而是直接集成在服务内部使用。
Q:那代码随想录的那些独立项目又是啥?对比我之前写的
A:你的 HTTP 服务器 = 一个完整大包,它内部自带、自己手写了一个简易版【网络库】,但内部没有手写 协程库 / 内存池 / 缓存 / RPC
顶层:直播 / 商城 / 社交 等业务应用 ↓ 实际你现在跑的就是:腾讯云服务器上的后台服务 ===================================================================== 【 你的 自研 HTTP 多线程服务器 】 内部 = 业务逻辑 + 手写基础组件 ===================================================================== ┌─────────────────────────────────────────────────────────────┐ │ 【模块1:网络库 —— 数据进出唯一通道】 ✅ 你自己手写实现 70%~80% │ ├─ epoll → 高并发网络监听 │ ├─ 长连接 → 保持连接,减少开销 │ ├─ 连接池 → 复用连接,提升效率 │ └─ 时间轮 → 检测超时,关闭闲置连接 └───────────────────┬─────────────────────────────────────────┘ ↓ 收到海量用户请求,发给协程处理 ┌─────────────────────────────────────────────────────────────┐ │ 【模块2:协程库 —— 并发调度,同时处理上万请求不阻塞】 ❌ 完全没手写实现 │ └─ 只用了系统多线程,没有自己造协程轮子 └───────────────────┬─────────────────────────────────────────┘ ↓ 运行过程靠内存池全程管控 ┌─────────────────────────────────────────────────────────────┐ │ 【模块3:内存池 —— 统一分配回收内存,防碎片、提速度】 ❌ 完全没手写实现 │ └─ 直接用系统 malloc/new,没有自己管理内存 └───────────────────┬─────────────────────────────────────────┘ ↓ 业务查询数据时使用 ┌─────────────────────────────────────────────────────────────┐ │ 【模块4:缓存系统 —— 优先读热门数据,减轻底层数据库压力】 ❌ 完全没手写实现 │ └─ 没有LRU、没有内存缓存,图片直接读磁盘 └───────────────────┬─────────────────────────────────────────┘ ↓ 跨机器调用其他服务时使用 ┌─────────────────────────────────────────────────────────────┐ │ 【模块5:RPC框架 —— 跨机器服务互相调用】 ❌ 完全没手写实现 │ └─ 只是单机服务,不跨机器调用别的服务 └───────────────────┬─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 【顶层业务逻辑】 ✅ 你写了最基础业务 │ └─ HTML目录展示、点击查看本地图片 └─────────────────────────────────────────────────────────────┘ ↓ ===================================================================== 底层:系统内核 + 本地磁盘文件 / 数据库 (未来计划:加 Redis、POJO 奶牛派对算法、最短路径) (目前:只存了一个 .jpg 图片在腾讯云服务器里)
中层通用组件 你的 HTTP 服务器里有没有? 代码随想录里咋写的? 你写了啥? 网络库 ✅ 有!自己手写了 epoll 反应堆、事件循环、TCP 连接管理、长连接、连接池、定时器 / 时间轮、数据收发缓冲区、异常断开处理、高并发模型
纯库,给别人调用
epoll、长连接、连接池、时间轮
把库嵌进了 HTTP 服务器里一起跑协程库 ❌ 没有 自己实现:协程创建、切换、保存上下文、调度器、无栈 / 有栈协程,完全自己造轮子
手写协程!
只用了系统多线程,没手写协程 你用的是操作系统原生线程(pthread)
内存池 ❌ 没有 我啃到编程指北C++到这打算加的,就算有吧 直接用系统内存,没自己造池
目前是直接用mallocnew,完全没自己写内存管理缓存系统 ❌ 没有 LRU 淘汰、键值存储、内存高速存取、类似 Redis
无内存缓存,直接读磁盘 RPC 框架 ❌ 没有 序列化、网络通信、服务注册发现、跨机器函数调用
单机服务,不跨机器调用 业务逻辑 ✅ 有 目录展示、图片访问 通透!!刚对整个服务端岗位业务+技术有个宏观认识!!
哎别人速成背诵高频题目,抓重点考点,而我是全程所有知识点自己手写代码实践、自研项目、啃底层、挖原理、抠细节。
内存池感言:
说实话内存池真的很容易理解,像手写智能指针一样,真的很通透,通过一个项目顺便整个串联梳理捋顺出了整个流程逻辑(个人还是那句话,竞赛的算法题无非就是数组、大厂LinuxC++高性能服务端开发岗位无非就是指针、类、赋值拷贝移动等这些头大的基础语法而已),但却属于高加分项的东西,但难度很小很小,对着每行代码死磕问豆包,真的很轻松就搞通透了!
而 epoll 的 http 多线程服务器,豆包说烂大街,很容易,代码复杂冗余但难度不高,但说实话,我真的感觉 http 服务器项目当初很痛苦,代码量大,一个前端HTML 显示本地目录的简单功能,如何获取数据、页面怎么跟后端各种模块传输联调的、代码都是咋流转的,我 tm 就追问了好几个星期。
当然了吃七个烧饼不是第七个饱的,是有前六垫底,如果先学内存池估计也认为内存池很难,因为我走到现在 C++ 语法基本透彻了,当初 C++ 语法真的脱层皮!!
豆包必须前台盯着,如果最小化,那新聊天会把历史定位顶上去,直接跳到最新
奶奶个腿,硬头皮啃下来了,发现真的好基础,之前基本都知道,只不过很朦胧,这次把朦胧的点能用术语说出来了。
为什么做C++,wx搜“不看视频”
心路历程,wx搜“出发点”
*****、广告助手:绕过大陆=白名单,列表里的不走该app的功能应用(直连、不跳广告)
黑名单一直以为是里面的不走app功能,但其实是里面的走该app的功能,豆包一些广告或者啥插件估计在国外,所以走直连估计走的是豆包自己的转换,很快,而开v2豆包有海外 DNS / 海外 IP,在某些规则库里会被误判为 “境外站”,所以开v2就很慢。
所以走绕过大陆模式,然后路由设置,新增一行规则:
outbound:选direct、domain:填doubao.com,强行直连。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。