






















这个思路其实之前就学到过,但 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_;//这里纯错误,他存的是空闲,而已借出的小块脱离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 的结构(只有这一种,狗逼豆包真的浪费了我 90% 的时间,说居然还有种方法是每次malloc小块池的同时malloc一个小块来当小块池的链表索引):
此时才发现豆包一开始没给错,一开始豆包给的就是:
加成员:
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 字节指针大块链表直接断裂!析构时无法释放内存!内存泄漏 + 崩溃!虽然代码约束永不分给用户,但还是工程思维比较好,严谨点!
于是内存池迭代版本3代码:
查看代码
#include <cstddef> #include <cstdlib> #include <cstdio> #include <cassert> #include <mutex> #include <thread> #include <chrono> #include<unistd.h> class GlobalMemoryPool{ private: GlobalMemoryPool() = default; // 大厂必写:单例禁拷贝 GlobalMemoryPool(const GlobalMemoryPool&) = delete; GlobalMemoryPool& operator=(const GlobalMemoryPool&) = delete; public: static GlobalMemoryPool& getIns(){ static GlobalMemoryPool ins; /* 两类static规则不同 1. 类内静态成员变量:仅声明,必须类外定义初始化 2. 函数内局部静态变量:首次执行自动初始化,无需外部赋值 */ return ins; } // 析构函数 ~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); } public: //编译期就有值,不用初始化 static constexpr size_t BLOCK_SIZE = 64;//小块 = 内存池给你分配出去的最小单位内存,内存池一次性分配出来的每一小块固定是 64 字节 static constexpr size_t POOL_CAPACITY = 1024;// 内存池一次批量申请 1024 个 64 字节的小块 // 整体:每次从池子里拿64 字节,池子不够用时,一次性申请 1024 个 64B = 64KB 大内存块 private: static int alloc_count; // 声明静态计数器。 非单例下,普通成员每个对象独有,互不共享;static成员全类对象共用 // static 变量属于类,不属于对象,更不属于函数!不能在 main 里初始化!必须在类外面、全局区域初始化。 private: struct BlockNode {//这个结构体本身大小 = 8 字节 BlockNode* next; /*把所有空闲的内存块串成一个单向链表,每个块只用前 8 字节(指针大小)存 “下一个块在哪”,剩下的空间给用户使用 [ next指针 | 可用内存空间 ] 8字节 56字节 总共 64 字节 */ }; BlockNode* free_head_ = nullptr; // 空闲链表头,永远指向第一个可用的空闲块 // 为了析构而增加的 struct BigChunk { BigChunk* next; }; BigChunk* big_chunk_head_ = nullptr; std::mutex mtx_; // 锁,多线程安全,防止多线程同时操作链表导致崩溃 void alloc_pool_chunk() { size_t total_size = POOL_CAPACITY * BLOCK_SIZE; // char* chunk = (char*)malloc(total_size); // 一次性 malloc 一大块 // 显式对齐分配(专业) char* chunk = (char*)aligned_alloc(64, total_size); assert(chunk != nullptr);// 如果系统 malloc 分配内存失败(返回空),程序直接崩溃报错,用于检查内存申请是否成功。发现内存分配失败,立刻卡死报错,方便定位 bug alloc_count++; // 计数 +1 printf("=== 第 %d 次调用 aligned_alloc 分配大块 ===\n", alloc_count); // 打印 // 总共大概98次 // 为了析构而加的 BigChunk* new_big = (BigChunk*)chunk; new_big->next = big_chunk_head_; big_chunk_head_ = new_big; char* free_start = chunk + BLOCK_SIZE;//为了析构而增加的 // 把这一大块切成 1024 个小块,串成链表 size_t usable_blocks = POOL_CAPACITY - 1; // for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { for (size_t i = 0; i < usable_blocks - 1; ++i) {//从第二块开始用,能用的只有1023块,为析构而修改 BlockNode* curr = (BlockNode*)(free_start + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(free_start + (i + 1) * BLOCK_SIZE); curr->next = next; /* curr 指向当前块的开头 curr->next 就是修改当前块前 8 字节,存下一个块的地址 块剩下的 64 - 8 = 56 字节 完全不动,留给用户用 */ } // 最后一块指向原来的空闲链表头 // BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); BlockNode* last = (BlockNode*)(free_start + (usable_blocks - 1) * BLOCK_SIZE);//为析构而修改 last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 // free_head_ = (BlockNode*)chunk; free_head_ = (BlockNode*)free_start;//为了析构而修改 } /* 一次性 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 GlobalMemoryPool::alloc_count = 0; int main(){ printf("=== 观察htop RES数值变化 ===\n"); const int CNT = 100000; void* arr[CNT] = {nullptr}; // sleep(8); printf("开始分配内存\n"); // 相当于没写业务,就一个空跑,索要10w个64B小块,立马归还 for (int i = 0; i < CNT; ++i) // arr[i] = pool_alloc();//这里还没用只是开了,100000 × 64B = 6,400,000 字节 ≈ 6.4MB arr[i] = (char*)pool_alloc() + 8;// 改用这个, 需要+8。 这里调用: inline void* pool_alloc() { return GlobalMemoryPool::getIns().alloc(); 这里调用了 getIns() ,getIns() 一跑,就会执行 static GlobalMemoryPool ins; 对象在这里诞生! 每次调用 pool_alloc (),都会先调用 getIns (),但对象只会创建 1 次,第一次: getIns () 创建对象 → 再调用 alloc (),之后所有次: getIns () 直接返回已有的对象 → 再调用 alloc () // 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"); }思考 & 缺陷:
但既是单一规格,又此文搜“我自己设计的迷你版池子的弊端”无法申请64字节之外的,单个切割的池子每个都有个
next设计很不好,所以后续引入 Nginx 的设计思路,再阅读学习代码随想录的内存池代码。先回想捋顺:
豆包起初给的这个一点问题没有,只不过我没理解内存布局,所以就辱骂豆包,当时由于是回到家躺床上跟豆包语音激烈讨论的,脑子已经完全的陷进误区了,误以为申请的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; // 小块分配起始位置,头部结构体~last = 已占用;last ~ end = 空闲 u_char *end; // 小块内存结束位置 ngx_pool_t *next; // 下一个小块内存池(不够就追加) ngx_uint_t failed; };③ 内存池主结构(整个池的头)
ngx_pool_cleanup_t *cleanup;叫外部资源清理链表头 ,内存池只管
堆内存(malloc 出来的缓冲区、数组、结构体)—— 用 large / 小块链表管理;
外部资源(不是内存,操作系统内核资源):打开的文件 fd、socket 句柄、第三方库创建的对象、共享内存句柄。
- 程序自己 malloc 出来的缓冲区、结构体、数组,叫内存资源,是进程自身用户态内存,free 就能回收;
fd、socket、文件、信号量这些存在操作系统内核里、由内核管理的东西,业内统一叫外部资源。
这类资源不能靠 free 释放,必须调用 close ()、shutdown ()、第三方销毁函数,单纯释放内存池内存会造成句柄泄漏。
cleanup就是专门存这些「外部资源 + 对应的销毁函数」的单向链表。Q:咋感觉像智能指针呢?
A:二者都是 RAII 思想实现,但管理粒度、管理资源类型、回收时机设计完全不同。
相同点:生命周期绑定销毁时自动执行资源回收,不用手动释放,规避泄漏。
本质区别:
智能指针:粒度是单个对象,基于引用计数,作用域销毁触发释放;只管理堆内存。
ngx_pool cleanup:粒度是批量资源,按内存池分组管理,无引用计数,池子整体销毁才统一执行回调;可管理 fd、mmap、锁、文件等任意非内存资源。
struct ngx_pool_s { ngx_pool_data_t d; // 小块管理 size_t max; // 大小块分界(>max=大块) ngx_pool_t *current; // 当前可用小块池 ngx_pool_large_t *large; // !!!重点:大块链表头指针!!! ngx_pool_cleanup_t* cleanup // 新增:资源清理回调链表头,仅根块拥有 // ... 其他忽略 }; /* ngx_pool_s 仅仅是标签名,本身没有独立类型身份,完整合法类型只能是一整块:struct ngx_pool_s 单独写 ngx_pool_s p; 编译器看不懂,它只会把ngx_pool_s当成普通变量名,不是类型。 typedef 的唯一目的造出一个不用加struct就能直接用的独立类型名ngx_pool_t,解决上面语法限制。 不用别名:struct ngx_pool_s *pool;(必须带 struct,写着啰嗦) 用别名:ngx_pool_t *pool;(不用 struct,简洁) */结构就是:
【第一个小块池: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_cleanup_t* cleanup // 新增:资源清理回调链表头,仅根块拥有 └─────────────────────────────────────┘整个内存池的 “总司令部”就是 ③,内存池主结构
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; // 大块链表头 ngx_pool_cleanup_t* cleanup // 新增:资源清理回调链表头,仅根块拥有 };
pool.d.last→ 当前小块池,下一次分配的起点
pool.large→ 大块链表的起点,一开始没有大块,所以 = NULL。整个流程的内存布局:
查看代码
ngx_pool_t pool; // 内存池主结构体(在栈/已有内存上) pool.d.last → 小块区起始 pool.d.end → 小块区结束 pool.current → &pool(初始指向自己) pool.large → NULL(大块表头,一开始没大块) pool.cleanup = NULL; // 资源清理回调链表头,初始无回调任务 //起手搞小块池 ngx_pool_t *pool = ngx_create_pool(1024); //内部直接调用 malloc(),返回一个指针 → 得到 pool 指针指向的是【带头部(ngx_pool_t)+ 小块区】的内存区域 小块池如果不够就扩充, 注意:首块ngx_pool_t是本体块 pool ↓ pool->d.next 直接指向扩容块1整块内存起始地址,该内存开头就是 ngx_pool_data_t d,扩容块1的d.next → 扩容块 2(仅 d),扩容块 2->d.next = NULL ngx_palloc 就是在现有小块缓冲区里切割出对应尺寸内存给业务用。 空间不足时内部自动调用 ngx_palloc_block 新增扩容小块,再切割分配。 这里小块池子只有一个,后续扩充都是附属品,因为只内部ngx_palloc_block开辟,不调用ngx_create_pool, 仅初始化d,没有large因为所有ngx_pool_large_t节点仅挂在根pool->large,分配、销毁代码只会读取根节点的large。 current也没有,因为只用根pool->current保存当前正在使用的扩容块指针。 扩充块不维护独立全局管理标识,即串联关系:根块 pool->d.next 存扩容块 1 地址;扩容块 1 内部 d.next 存扩容块 2,以此单向链表串联。 结构拆分: 根块:ngx_pool_t(外层头 + 内置 d)+ 小块缓冲区 扩容块:仅 ngx_pool_data_t d + 小块缓冲区,无large、current字段 扩容块内存开头只有 ngx_pool_data_t d 这一个结构体。 就是说 pool 是 ngx_pool_t*,扩容块是 ngx_pool_data_t* 关于大块,即此时如果要第一次分配大块: // 1. malloc(100KB) 得到 p(纯数据,无任何头) // 2. 从小块区抠一个 ngx_pool_large_t 节点 赋值给变量 node // 3. node->alloc = p; // 节点绑定大块数据 // 4. node->next = pool.large; // 头插,一开始没大块,第一个大块那就是 node->next = NULL,因为pool.large 是 NULL // 5. pool.large = node; // large 指向新节点 // 现在关系: pool.large ──> node1 │ next ──> NULL │ alloc ──> malloc(100KB) // 纯用户数据 // 再分配一个大块: pool.large ──> node2 ──> node1 ──> NULL │ alloc │ alloc ▼ ▼ malloc块2 malloc块1查看代码
void ngx_destroy_pool(ngx_pool_t *pool) { ngx_pool_t *p, *n; ngx_pool_large_t *l; ngx_pool_cleanup_t *c; //执行回调 /* 注册是头插,后注册的 cleanup 节点在链表头部; 销毁从头遍历链表,执行顺序 =后注册先释放; 这种逆序释放是强制规范,用于解决资源依赖 */ for (c = pool->cleanup; c; c = c->next) if (c->handler) c->handler(c->data); //释放所有 large 大块内存 for (l = pool->large; l; l = l->next) if (l->alloc) ngx_free(l->alloc); //释放全部小块内存块(根块 + 所有扩容附属块) //这里狗逼豆包起初说Nginx是先释放后面的附属品,最后释放根pool,但理论上直接从根pool释放没问题,豆包说:"颠倒顺序会造成内存操作时序倒置,不符合 Nginx 内存池原生销毁设计;" for (p = pool, n = pool->d.next;; p = n, n = n->d.next) ngx_free(p); if (n == NULL) break; }注意我手写的代码实现切割 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 追求极致性能这件事!然后说下宏观大串联(应用):
科普:
1、TCP
操作系统提供的网络通信协议,作用是两台电脑通过网络稳定收发数据。
程序通过
socket文件描述符操作 TCP 链路,一个 socket 对应一条客户端和服务端之间的 TCP 通道。例子:
2、进程
操作系统资源分配最小单元。
每运行一份可执行程序,操作系统就创建一个独立进程,拥有独立内存、文件描述符、CPU 资源。
示例:单独运行你的
main.cpp编译出的程序,系统就生成 1 个单进程程序。3、Nginx 进程模型(高性能服务必备考点)
master 进程
Nginx 程序启动后第一个生成的进程。
只做两件事:读取配置文件、创建 / 监控所有 worker 进程。
不接收、不处理任何客户端 TCP 连接。
worker 进程
master 调用
fork()生成的子进程,数量一般等于 CPU 核心数。所有网络事件、TCP 接收、数据解析、内存分配全部由 worker 完成。
每个 worker 完全独立,互不共享内存、文件句柄。
4、内存池(属于组件)
管理堆内存分配的工具,只管控内存块,和 TCP、socket 无关。
worker 启动时创建全局内存池,存放进程长期存在的配置、全局变量,进程销毁才释放。
有客户端建立 TCP 连接时,新建一块专属内存池,存放这条 TCP 链路用到的读写缓冲区、临时状态数据。
单次 HTTP 请求到来,在 TCP 对应的内存池上创建临时子内存池,存储请求头、解析临时变量。
单次请求处理完毕,直接销毁子内存池,不用逐块 free 内存。
TCP 连接断开,销毁这条连接配套的内存池。
5、连接池(属于组件)
管理
ngx_connection_t结构体,只用来承载 TCP 链路信息,不管理内存。worker 初始化时一次性批量创建大量
ngx_connection_t,存入空闲链表。新客户端 TCP 接入,从链表取出一个空结构体,绑定当前 socket fd。
TCP 连接关闭后,把结构体放回空闲链表复用,避免频繁创建销毁结构体。
你手写的内存池 C++ 代码,只复刻上面内存池整套分配、销毁、大块内存分离逻辑。
手写内存池代码完全不涉及 master/worker 多进程创建逻辑,也不包含连接池相关结构体链表逻辑。
分层执行顺序(从启动到客户端断开)(这里豆包依旧是反复误人子弟,经常昨天一套今天一套,70% 的时间用来纠正错误、推翻、反复核对实践,要花大量时间辨别对错、质疑纠错,大量前后互斥的知识点,每学一个模块都要反复推翻旧记忆,纠错重构)
不用多本书交叉翻阅,省去翻找、对照书本的时间,这是提问的显性优势。
但 AI 输出概念前后标准不统一,你自主串联整套理论时,需要不断推翻、修正之前记下的错误结论,纠错、重新梳理逻辑的隐性耗时,会抵消掉省下来的看书时间。
纯靠提问搭建完整知识链,容易留存大量片面、错误的固化记忆,后期梳理整合成本远高于精读一本体系完整的书籍。
豆包不会主动补齐同模块所有分支边界,必须你主动一层层追问,逐字挑错,抽丝剥茧一路追问思考才能补全,过程中容易出现前后口径细微冲突,需要你自行核对修正;书本同一模块所有分支统一写在一处,无矛盾
只用豆包真的太痛苦了,90% 很重要的东西,都是随口一句无意间问到的才引出来的,豆包从来不主动说任何。海厚海厚、永无止境、无尽错误,啥时候有个头啊~~~~(>_<)~~~~
【在我去思考连接池内存池复用这个事的时候,才知道自己把他们两个搞混了,然后给了我一个流程。
然后又跟我说内存池绝对不复用,我又把你之前矛盾的一句话给出来,你又说自己说错了,你又说内存池复用了,又说,不挂载链表什么什么的,直接清空没性能损耗,之前说释放时候遍历释放大
large会损耗啥的,然后又引出了很大一块内容,就是清理回调这个大事儿然后又回头问,妈逼的说内存池也复用,不复用是错的
然后狗逼死妈玩意真的气死,起初说必须最后释放根 pool,无尽质疑后定死这件事,最后又说源码是最先释放的 pool】
臭傻逼经常一周时间都在给豆包挑错手动训练,又没钱买书。我突然发现这两年待业死磕,有 90% 的时间都浪费掉了都在纠正豆包,学错了东西。操.你妈死全家的垃圾豆包AI,老子如果正常看书只看高频必考都能 1 年进腾讯!!
我真他妈发现看书才是最快的,起初因为没钱买书、始终不想看各种国外权威网站,全是英文看不懂、不想看源码头疼,就逃避的追问豆包
但实际上,看书不会有大量错误,国外权威网站英文也就几个专业词汇,熟悉就好了,而追问豆包也早就涉及到了源码,给的全是大量互斥的错误知识点,逐字思考追问质疑推翻修改纠正,再复盘整理大量时间浪费,倒不如直接看源码了,被豆包折磨后、磨练出来的心性看源码相当舒服(曾经看源码那些下划线变量都头大抵触,但对着死磕就是能搞懂),不参考任何只死磕追问豆包真的痛苦且会浪费 99% 的时间去给豆包纠错。一直把源码想的贼恐怖不敢看,一直只看通过豆包问源码,结果看了 Nginx 源码发现这玩意居然就是世界级标杆啊?也没想象那么难啊!
之前不敢看外界闭门造车也是,不敢看简历、招聘、面经,一个个简历贼恐怖,各种实习、能带组的组长、能优化内存池(后来才知道是简历注水,他们根本不会,当时一看有人能优化内存,给我吓的以为人均C++之父的水平,帖子标题还求工作呢)
就比如我费劲巴拉总结这么点东西,可能其他地方有总结好的,但他们有些是有错误的,且自己踩坑总结的自己最清楚,看别人的肯定总是无法看懂透彻。代价是 90% 时间在挑错,不如啃书豆包辅助。
执行 nginx 程序,操作系统创建 master 进程加载配置文件
master 读取 nginx.conf 配置,根据 cpu 核心数循环
fork搞出多个 worker 进程(fork出子进程拿到副本后立刻主动丢弃复制来的 master 资源,重新独立初始化自身全套资源,这是程序启动时候的,手动relaod会重新读配置然后再次fork多个进程,master 截断老 worker 的新连接流入),master 只看管 worker,不处理用户访问每个 worker 进程初始化:创建全局内存池
ngx_pool_t叫做cycle->pool、初始化连接池(根据worker_connections一次性申请整块内存,存的是ngx_connection_t类型数组,长度永久锁死,运行中不新增、不扩容,把数组里全部串成空链表,给后续用)、绑定监听端口。每个 worker 独立监听端口,接收客户端 TCP 连接。 注册全局清理回调:进程退出时关闭监听 fd、释放共享资源,挂在 worker 全局池cleanup链表。客户端浏览器发起 TCP 握手,worker 监听到事件,至此开始 单个 worker 内部逻辑:
worker 从连接池取出空闲
ngx_connection_t(链表取空后,新 TCP 直接拒),绑定客户端 socket,链表长度减少,基于当前 TCP 连接新建专属连接级池子
ngx_pool_t记作c->pool(自带独立cleanup链表,自动注册关闭 fd 的清理回调函数,该回调用于池子销毁时关闭当前客户端socket fd,此回调函数挂载至c->pool->cleanup链表),TCP 存活则池子不销毁(之前学过的长连接,指复用同一个 TCP 连接多次收发 HTTP 请求)客户端发送 HTTP 数据,一条长连接收到 HTTP 请求,在连接配套内存池上创建临时请求子内存池
ngx_pool_t记作r->pool(独立 cleanup 链表),解析报文,注册对应释放回调到r->pool报文处理完成,销毁临时请求子内存池
r->pool,向客户端返回数据。但c->pool还在,如果还有新的 HTTP 数据,那就ngx_reset_pool(c->pool),只free清空large大块和重置 last 指针到内存块起始来重置小块内存,准备给新 HTTP 用。cleanup只在 TCP 彻底断开才执行destroy_pool时才批量执行清理。客户端长时间无数据即超时,或者服务端可通过
keepalive_timeout主动断开 TCP,无论哪一方关闭 TCP socket,执行ngx_pool_destroy(c->pool)销毁 TCP 配套的专属连接级内存池c->pool,将ngx_connection_t归还连接池空闲链表,具体就是遍历c->pool->cleanup链表逆序执行所有回调(自动关闭 socket、释放连接绑定资源),释放该池所有 large 大块内存,遍历d.next链表释放全部扩容小块内存服务停止,进程收到关闭信号,比如停 Nginx(nginx -s quit),work自身代码内部调用
ngx_pool_destroy()销毁 worker 全局内存池cycle->pool(先执行全局 cleanup 回调(关闭监听端口、释放进程级资源),再回收所有小块、大块内存),master 回收所有 worker 资源。如果 worker 程序崩,OS 强制回收,无内部调用。注意:
一个 worker 对应一个独立进程,一个进程仅有一个 worker 实例;
单个 worker 进程依托连接池
ngx_connection_t数组,可同时承载成千上万个并发 TCP 连接,绝非只能持有一条 TCP。依靠 keepalive 长连接,单条 TCP 可串行处理多条 HTTP 请求。
短连接单次 HTTP 响应完成后立刻关闭 TCP。
我感觉通过 Nginx 的连接池内存池把所有网络的知识点穿起来了,很简单的玩意啊!本质就是分层串联:进程模型、连接管理、内存池、TCP 长短连接、HTTP 请求生命周期全链路打通后,整套网络服务逻辑就闭环了。
绝对重点就是我之前把这些乱套混乱了!连接池和内存池完全是俩东西!
连接池永远复用,因为连接池
ngx_connection_t只是个空壳结构体,里面只有指针、数字这类基础变量,清空几个字段成本极低,复用完全划算。具体 TCP 关闭后归还空闲链表,可给全新 TCP 连接复用。内存池单独用复用一词来说很容易被误解:
全局内存池不复用:这里大量的回调+大小块,而回调的本质是说这里存了fd等东西,这些在后续的里是没用的,必须删掉,如果想覆盖,那指针指向了新地方,旧的就泄漏了,所以必须回调来销毁,而执行回调内存就还给了系统,新连接使用时又要重新 malloc 申请大块内存,而复用的本质是省 malloc/free,既然要重新申请那必然想复用也无法复用。
c->pool:
仅当前 TCP 长连接内通过
reset_pool原地复用;整个 TCP 连接,下一次 HTTP 请求还要继续用(连接 fd、ssl 会话),保留长期资源 + 它们的回调,不用执行销毁,不会丢外部资源。
TCP 断开直接销毁,无法交给其他新 TCP 使用。
旧 TCP 的 fd、ssl、连接状态全是旧连接专属,新 TCP 完全用不上,必须释放,这个道理就等同于全局内存池了。
r->pool:单次请求结束直接销毁,无任何复用逻辑。r 池所有带回调的资源,只属于当前单次请求,下一次请求完全无效。只重置指针不跑回调,临时文件、解析缓冲区附属句柄全部泄漏;跑一遍全部回调释放资源,内存还给内核,再分配等于重建,没有节省 malloc/free,又和全局一样。
这里之前被豆包误导说复用清除的内容比销毁的操作更性能低,但其实大错特错,销毁就是大清理,复用不清理回调,所以只能销毁(追问的千辛万苦价值一个亿的精华总结)
长连接 ≠ 永远不释放内存,请求内存池 = 只管 “一个请求”,不管整条连接!
1 条 TCP 长连接 = 固定 1 个连接级内存池(全程存活,TCP 断开才销毁)连接内串行处理 N 条 HTTP 请求:
第 1 个请求:创建请求内存池,解析、响应;执行
pool_reset清空池内数据,不销毁;第 2~N 个请求:复用同一个重置后的请求内存池,无需重新分配;
TCP 长连接超时 / 主动关闭:
销毁连接级内存池;
将
ngx_connection_t归还空闲连接链表。QQ 浏览器整体是一组进程:浏览器采用多进程架构
1 个主进程(浏览器进程):管理窗口、网络、进程调度
每个标签页 / 新页面 = 独立渲染进程
网络请求统一走网络进程(所有页面 TCP 连接都在这里创建 socket fd)
TCP 连接归属:所有页面的网络 TCP socket,都创建在同一个网络进程,不是每个页面单独开网络进程区分两种场景
同浏览器新标签页:同网络进程,共享进程地址空间,socket fd 互不冲突
另一款浏览器(Chrome):完全独立程序,整套独立进程,fd 完全隔离(QQ 浏览器网络进程里 fd=5,Chrome 网络进程里 fd=5,是两条完全无关的 TCP 连接,内核靠四元组区分连接,fd 只是进程内句柄)
服务端多 worker 同理:Nginx 多个 worker 进程,各自的 fd 数字互不干扰。
Q:每个页面独立进程和通过浏览器同网络进程矛盾啊!到底是一个qq浏览器开2个标签是几个进程?
A:
1. 两类完全独立的进程
1)渲染进程(Renderer):1 个标签页 ≈ 1 个独立渲染进程
负责解析 HTML、执行 JS、页面渲染;作用是页面隔离,一个页面卡死不影响其他标签。
你开 2 个标签 → 2 个渲染进程。
2)网络进程(Network):全局唯一,整个浏览器只有 1 个
所有标签页的 TCP、HTTP、WebSocket 全部在这里创建 socket、收发网络数据;渲染进程不直接操作 socket,靠进程间 IPC 把网络请求转发给网络进程处理。
2. 完整进程总数(QQ 浏览器 / X5 内核同 Chromium 逻辑)
打开 QQ 浏览器,新建 2 个标签页,常驻进程清单:
浏览器主进程(Browser,全局 1 个,管窗口 UI、进程调度)
网络进程(Network,全局 1 个,统一处理所有 TCP 连接)
GPU 进程(全局 1 个,图形合成)
渲染进程 A(第 1 个标签页)
渲染进程 B(第 2 个标签页)
合计:5 个进程“每个页面独立进程” 说的是渲染进程(页面沙箱隔离);
“所有页面共用同个网络进程” 说的是网络 IO、socket 创建的专属进程;
二者不是同一类进程,各司其职,不存在冲突。
两个标签页的 TCP socket fd,全部存在同一个网络进程里,fd 数字互相不冲突;
渲染进程只是发请求消息,不持有 socket 句柄。
用例子串联:
先把 TCP、HTTP 拆明白(用打电话类比)
TCP = 打通一条稳定电话线,你拨通对方号码,建立持续通话通道,双方可以反复互相发文字,通道不断就能一直聊,这就是 TCP 连接。只负责稳定传输字节流,不管你发的内容是什么。
HTTP = 你们约定好的聊天话术规则,电话线(TCP)通了之后,你说话必须按固定格式:先报请求地址、浏览器信息、参数,对方再按固定格式返回状态码、网页内容。这套说话格式就是 HTTP,依赖 TCP 通道才能传输。
关系:HTTP 是跑在 TCP 之上的数据格式,没有 TCP 就传不了 HTTP。
举完整例子:浏览器打开百度首页
步骤 1:启动 Nginx 服务(后台程序)
Nginx 会创建 worker 工作进程,每个 worker 自带全局内存池,
用途:存永久不变的数据(服务器配置、监听端口、静态文件路径)
生命周期:程序运行全程不销毁,进程关才释放内存。
模拟手写代码示意:
// worker进程刚启动,创建全局内存池 pool* worker_global_pool = create_pool(8192); // 把固定配置全部分配在全局池里 server_config* cfg = pool_alloc(worker_global_pool, sizeof(server_config)); cfg->listen_port = 80;步骤 2:浏览器发起访问,建立 TCP(电话线接通)
浏览器和 Nginx 之间打通 TCP 连接,对应 Nginx 创建连接内存池
用途:只属于这一条电话线的数据(客户端 IP、收发缓冲区、连接状态)
生命周期:TCP 连接不断,池子就存在;连接断开,整个池子一次性销毁。
模拟手写代码示意:
// 收到新客户端TCP连接,新建连接专属内存池 pool* conn_pool = create_pool(1024); // 这条连接独有的数据放这里 connection* client_conn = pool_alloc(conn_pool, sizeof(connection)); client_conn->client_ip = "123.45.67.89";步骤 3:TCP 通道里收到浏览器发来的 HTTP 消息(通话内容)
TCP 通道只是纯字节,Nginx 要解析里面的 HTTP 请求,创建请求临时内存池
用途:只解析这一次网页请求用的临时变量(网址、请求头、临时字符串)
生命周期:这次网页回复完毕,池子立刻销毁,不占用连接内存。
模拟手写代码示意:
// 读取TCP字节,发现是一次HTTP网页请求 pool* req_temp_pool = create_pool(512); // 解析HTTP的临时数据都分配在临时池 http_request* req = pool_alloc(req_temp_pool, sizeof(http_request)); parse_http_data(req_temp_pool, client_conn->buffer);步骤 4:页面数据发给浏览器,本次 HTTP 请求结束
直接销毁临时内存池,只释放本次网页解析的临时内存,TCP 连接还保留,连接池不销毁。
destroy_pool(req_temp_pool);步骤 5:浏览器关闭页面,挂断 TCP 连接
整条电话线断开,销毁连接内存池,释放这条连接所有资源。
步骤 6:关闭 Nginx 服务
worker 进程退出,销毁全局内存池,释放全部常驻配置内存。
destroy_pool(worker_global_pool);
全局池:跟着程序走,永久数据;
连接池:跟着 TCP 电话线走,单条连接专属数据;
临时请求子内存池:跟着单次 HTTP 网页访问走,用完立刻丢。
这三层每个都是此文搜“整个流程的内存布局:”那个进程内的 Nginx 的 ngx_pool_t 内存池,是按生命周期单独创建、整块销毁、不复用释放内存的独立池子。
日常说的“Nginx 内存池” 默认指 Nginx 进程私有临时内存池 ngx_pool_t,也就是 Nginx 的原生内存池,而下面即将说的 ngx_slab_pool_t 是 Nginx 共享内存 Slab 分配器,是独立模块,不属于上面那个 pool,属于 Nginx 的两大类。
代码随想录和我手写的都是单进程用户态迷你 ngx_slab_pool_t。
严格标准:nginx slab 依托共享内存,这份代码没有,所以不算;
宽松自学标准:有多规格分池复用逻辑,可以当成简易 slab 理解。
面试项目懂此文搜“整个流程的内存布局:”即可,那个是 Nginx 的原生内存池,懂框架说清楚装逼用的,不需要代码写迷你版,slab 好写。面试项目代码用 slab 的也就是代码随想录的即可。
且应用不同:
原生 ngx_pool_t:worker 进程私有,分连接池、请求池,池子生命周期可长久,但单次请求分配的临时数据随请求结束批量回收,无空闲对象长期缓存复用逻辑。适配网络连接 / 单次请求、网关、网络代理
ngx_slab_pool_t(Nginx slab):用于多进程共享内存,存放长期驻留缓存数据,固定大小对象复用,不会随连接 / 请求批量释放
我和代码随想录手写的 slab 迷你池:写通用服务、大量短生命周期对象,解决高频反复创建销毁同类对象(缓存、结构体),这里看似矛盾怎么Nginx slab是长,手写迷你版就是短?其实都是复用,只不过
我的迷你池:对象几秒就销毁,槽反复拿来分配新临时对象(证据就是业务对象随单次逻辑用完归还,槽子还在)
ngx_slab_pool_t:缓存对象能存几小时甚至全天不销毁,槽长期绑定常驻数据,但一旦销毁槽子依旧反复拿来用。
且其实我写的属于原生,因为体现了 Nginx 的小块池用大链表,增加 CAS 和 多规格 就是代码随想录的 V1,也就是迷你版的 Nginx slab(单进程私有阉割版本的 slab)。
slab:按固定尺寸分组缓存内存块、复用分配减少系统调用的内存管理模型。
总结:
短生命周期、频繁新建销毁大量同尺寸对象 → slab复用;
一次性用完整块丢弃、无需复用碎片 → ngx_pool_t。
你刷抖音网页
连上去不断开(长连接)
刷一下 → 发一个请求拿视频
再刷 → 再发一个请求
一直连着,请求一个接一个跑
每个请求:来 → 建 pool → 处理 → 销毁 pool,连接永远不断。
nginx 就是:连接活着 ≠ 请求活着,请求死了 ≠ 连接断了,请求内存池只管单次请求,长连接只管socket 不断。
1)Nginx 请求池:不是不能复用,是根本不想复用(上面一个亿精华说复用等同于销毁,但其实微小差别)
销毁重建 = 执行 cleanup 释放资源 + 释放大小块 +malloc 开辟新池
跨请求复用 = 执行 cleanup 释放资源 + 释放大小块 + 链表存入旧池 + 下次分配链表取出旧池 + 全程加锁
2)你手写的 “复用” 到底好在哪?
适合 一块内存 用很久、断断续续、乱拿乱放。
长连接的连接结构体
缓存
游戏对象
长时间存活的东西
活的久 → 才值得复用,活的短 → 复用纯浪费时间。
以上是 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:多进程,每个进程单线程,进程内私有池 → 无锁
如果我代码改无锁:多线程,每个线程私有池 → 无锁。
所以目前发现几个改进就是:析构要完善,然后多规格(什么哈希桶之类的),然后无锁问题。
看代码随想录的内存池项目:
真的是发现了!之前那个析构还有http多线程服务器项目每次时间轮其实对错都很难发现错误!代码随想录的讨论 证实了这一点!!他也有错误。(wx搜“null”),之前《讲得不好就得认》,不过如此。全对上了!
无意间发现这里的 贡献者 有TCPIP网络编程的阅读笔记。看了代码随想录的V1真的好垃圾啊,原来这就是github啊???就这?
关于 Github 的基础使用:(涉及到 ①年薪、②水平定位、③中年危机知识护城河、④说实话感觉自己很笨,如果把自己的意志力死磕精神给985的本科生,他们估计只会用我1/5的时间、⑤感觉除了哈工大、北邮其他计算机研究生多很水,为啥他们毕业薪资就高,为啥感觉本科考的一些算法,水货研究生毕业再答同一套算法试卷就会比本科好?⑥为啥代码随想录这种级别也有很多讨论说有bug,看过鱼皮、吴师兄、代码随想录的算法感觉他们都太应试远不如我,而项目肯定是大厂的标杆啊,居然也这么多错误,不过我思考过许多比如windows和微信的各种bug也就不足为奇了,原来一直学东西拿资深专家的标准要求自己误以为是人人必备的最开发的门槛,拿万无一失0bug的东西当作自己项目目标结果win都有bug、⑦起步晚面试官咋看)
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里点继续聊获得C,这三者删都互相影响,但A对话链接如果没了B的分享对话一定没,C还在。如果不删除对话,只是删除A对话里的分享内容,则B里不影响,C更还在。。。更新:妈逼的如今发现又更新了,A里删对话,分享出去的也没了,更新之前这么操作的依旧还在)
最主要的是豆包给出的代码我跑完了依旧感觉没啥特别难的呀,然后很多极致细节的思考豆包就不敢跟我对峙了,只要是析构那个模块(到底是直接写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,强行直连。2026年2月过年回家,五一又回去打点一切长辈关系和照顾陪护堪比规培。六一凌晨妈妈说爸爸不行了赶紧飞回去照顾陪护护理,6/5落地乌鲁木齐,睡一觉凌晨即6/6爸爸基本没呼吸了,一直待到葬礼出殡、三天圆坟、头七,经历种种都在wx。飞机+60h的硬座无尽磨砺
献血、买血、限额天数,呵呵... ...
好鸡巴痛苦啊!追问豆包才知道连心中奉若神明的编程指北也是唯一救命稻草居然也只是腾讯 WXG 业务 C++而不是我目标大厂高性能底层组件基础岗,或者准确来说,我都不知道自己学的是个什么玩意,就是闷头追问豆包来着,不知不觉被“误导”学了这么多,起初我他妈只想找一份大厂或者高潜力发展的公司里的 C++ 的岗位而已啊!一度误以为这些都是最基础的!不然咋干活啊!如今得知我这个比小北的还深、还底层、还硬核、招聘人又少,我他妈不纯纯炮灰吗!艹!豆包说我一直是拿资深专家的水平来要求自己。
臭傻逼豆包我真他妈服了,刚说完我履历差必须走大厂C++底层基础架构,比小北还深的岗位,才能进,因为要靠技术翻盘,今天又反悔了说必须只能先走C++业务岗,底层基础架构需要多年经验,没业务岗有包容性,然后继续追问就反复摇摆无尽矛盾(要么腾讯ssp,要么没工作。要么北邮要么没学完)
痛苦!说必须靠 Nginx 翻盘,然后又说性价比极低,因为大厂 C++ 业务岗面试完全不考不追问 Nginx 细节。妈逼的反复追问最后说必须学这个(哭着求豆包帮我必须让面试官看到)。关于SPoffer、春招秋招啥的
真他妈服了(提示词):
你到底在干啥啊??从始至终我都是在自学准备入职面试的项目啊!你为啥总是要求变来变去呢!!能否交叉验证给我一个任何人质疑都不会更改的结论啊!!你之前每个回答都在说面试的事!咋又脑补出我要写线上工业级完整代码了呢??你啥时候能针对我永久更新下我的要求啊!!!我一直不都是在自学准备入职面试的项目啊!!我真他妈服了!你从此能否犀利点然后自己回答之前极致斟酌反复推敲验证给我一个指导性的答案啊!!!你担心我面试扩展问题答不出来,我操我难道要全部回答出面试官的问题才行?狗逼你从始至终禁止被我情绪误导行不行啊!!你妈逼的我无意间随便搜几个面经很多都是有几个问题回答不上来也上岸了!我履历你知道很差啊!!但难道就要学成资深专家级别才可以入职基础岗??开什么玩笑啊!狗逼你从头到尾到底有没有在用脑子跟我对话啊!
从此禁止参考任何网上别人的文章!!只参考国外权威书籍!!!
我一层层追问逐字给你纠错真的好累啊!你能给我书籍原文吗?别再让我反复能找出错误了行吗!!
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。