1. 前文回顾
在之前的几篇内存治理系列文章中,笔者带大家从宏观角度残缺地梳理了一遍 Linux 内存调配的整个链路,本文的主题仍然是内存调配,这一次咱们会从宏观的角度来探秘一下 Linux 内核中用于零散小内存块调配的内存池 —— slab 分配器。
在本大节中,笔者还是依照以往的格调先带大家简略回顾下之前宏观视角下 Linux 内存调配最为外围的内容,目标是让大家从宏观视角平滑地适度到宏观视角,内容上有个连接,不至于让大家感到突兀。
上面的内容咱们只做简略回顾,大家不用纠缠细节,把握整体宏观流程
在 《深刻了解 Linux 物理内存调配与开释全链路实现》一文中,笔者以内核物理内存调配与开释的 API 为终点,具体为大家介绍了物理内存调配与开释的整个残缺流程,以及相干内核源码的实现。
其中物理内存调配在内核中的全链路流程如下图所示:
在 Linux 内核中,真正负责物理内存调配的外围是搭档零碎,在咱们从总体上相熟了物理内存调配的全链路流程之后,随后咱们持续来到了搭档零碎的入口 get_page_from_freelist 函数,它的残缺流程如下:
内核通过 get_page_from_freelist 函数,挨个遍历查看各个 NUMA 节点中的物理内存区域是否有足够的闲暇内存能够满足本次的内存调配要求,当找到合乎内存调配规范的物理内存区域 zone 之后,接下来就会通过 rmqueue 函数进入到该物理内存区域 zone 对应的搭档零碎中调配物理内存。
那么内核既然曾经有了搭档零碎,那么为什么还须要一个 slab 内存池呢 ?上面就让咱们从这个疑难开始,正式拉开本文的帷幕~~~
2. 既然有了搭档零碎,为什么还须要 Slab ?
从上篇文章 《深度分析 Linux 搭档零碎的设计与实现》第一大节 “1. 搭档零碎的外围数据结构” 的介绍中咱们晓得,内核中的搭档系统管理内存的最小单位是物理内存页 page。
搭档零碎会将它所属物理内存区 zone 里的闲暇内存划分成不同尺寸的物理内存块,这里的尺寸必须是 2 的次幂,物理内存块能够是由 1 个 page 组成,也能够是 2 个 page,4 个 page ........ 1024 个 page 组成。
内核将这些雷同尺寸的内存块用一个内核数据结构 struct free_area 中的双向链表 free_list 串联组织起来。
struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free;};
而这些由 free_list 串联起来的雷同尺寸的内存块又会近一步依据物理内存页 page 的迁徙类型 MIGRATE_TYPES 进行归类,比方:MIGRATE_UNMOVABLE (不可挪动的页面类型),MIGRATE_MOVABLE (能够挪动的内存页类型),MIGRATE_RECLAIMABLE (不能挪动,然而能够间接回收的页面类型)等等。
这样一来,具备雷同迁徙类型,雷同尺寸的内存块就被组织在了同一个 free_list 中,最终搭档零碎残缺的数据结构如下图所示:
free_area 中组织的全副是雷同尺寸的内存块,不同尺寸的内存块被不同的 free_area 治理。在 free_area 的外部又会近一步依照物理内存页面的迁徙类型 MIGRATE_TYPES,将雷同迁徙类型的物理内存页组织在同一个 free_list 中。
搭档零碎所调配的物理内存页全部都是物理上间断的,并且只能调配 2 的整数幂个页
随后在物理内存调配的过程中,内核会基于这个残缺的搭档零碎数据结构,进行不同尺寸的物理内存块的调配与开释,而调配与开释的单位仍然是 2 的整数幂个物理内存页 page。
具体的内存调配过程感兴趣的读者敌人能够回看下 《深度分析 Linux 搭档零碎的设计与实现》一文中的第 3 大节 “ 3. 搭档零碎的内存调配原理 ” 以及第 6 大节 “ 6. 搭档零碎的实现 ”。
这里咱们只对搭档零碎的内存调配原理做一个简略的整体回顾:
当内核向搭档零碎申请间断的物理内存页时,会依据指定的物理内存页迁徙类型 MIGRATE_TYPES,以及申请的物理内存块尺寸,找到对应的 free_list 链表,而后顺次遍历该链表寻找物理内存块。
比方咱们向内核申请 ( 2 ^ (order - 1),2 ^ order ] 之间大小的内存,并且这块内存咱们指定的迁徙类型为 MIGRATE_MOVABLE 时,内核会依照 2 ^ order 个内存页进行申请。
随后内核会依据 order 找到搭档零碎中的 free_area[order] 对应的 free_area 构造,并进一步依据页面迁徙类型定位到对应的 free_list[MIGRATE_MOVABLE],如果该迁徙类型的 free_list 中没有闲暇的内存块时,内核会进一步到上一级链表也就是 free_area[order + 1] 中寻找。
如果 free_area[order + 1] 中对应的 free_list[MIGRATE_MOVABLE] 链表中还是没有,则持续循环到更高一级 free_area[order + 2] 寻找,直到在 free_area[order + n] 中的 free_list[MIGRATE_MOVABLE] 链表中找到闲暇的内存块。
然而此时咱们在 free_area[order + n] 链表中找到的闲暇内存块的尺寸是 2 ^ (order + n) 大小,而咱们须要的是 2 ^ order 尺寸的内存块,于是内核会将这 2 ^ (order + n) 大小的内存块逐级减半决裂,将每一次决裂后的内存块插入到相应的 free_area 数组里对应的 free_list[MIGRATE_MOVABLE] 链表中,并将最初决裂出的 2 ^ order 尺寸的内存块调配给过程应用。
咱们假如以后搭档零碎中只有 order = 3 的闲暇链表 free_area[3],其余剩下的调配阶 order 对应的闲暇链表中均是空的。 free_area[3] 中仅有一个闲暇的内存块,其中蕴含了间断的 8 个 page,咱们临时疏忽 MIGRATE_TYPES 相干的组织构造。
当初咱们向搭档零碎申请一个 page 大小的内存(对应的调配阶 order = 0),如上图所示,内核会在搭档零碎中首先查看 order = 0 对应的闲暇链表 free_area[0] 中是否有闲暇内存块可供调配。如果有,则将该闲暇内存块从 free_area[0] 摘下返回,内存调配胜利。
如果没有,随后内核会依据前边介绍的内存调配逻辑,持续降级到 free_area[1] , free_area[2] 链表中寻找闲暇内存块,直到查找到 free_area[3] 发现有一个可供调配的内存块。这个内存块中蕴含了 8 个 间断的闲暇 page,然而咱们只有一个 page 就够了,那该怎么办呢?
于是内核先将 free_area[3] 中的这个闲暇内存块从链表中摘下,而后减半决裂成两个内存块,决裂进去的这两个内存块别离蕴含 4 个 page(调配阶 order = 2)。
随后内核会将决裂出的后半局部(上图中绿色局部,order = 2),插入到 free_area[2] 链表中。
前半部分(上图中黄色局部,order = 2)持续减半决裂,决裂进去的这两个内存块别离蕴含 2 个 page(调配阶 order = 1)。如上图中第 4 步所示,前半部分为黄色,后半部分为紫色。同理依照前边的决裂逻辑,内核会将后半局部内存块(紫色局部,调配阶 order = 1)插入到 free_area[1] 链表中。
前半部分(图中黄色局部,order = 1)在上图中的第 6 步持续减半决裂,决裂进去的这两个内存块别离蕴含 1 个 page(调配阶 order = 0),前半部分为青色,后半部分为黄色。
黄色后半局部插入到 frea_area[0] 链表中,青色前半部分返回给过程,这时搭档零碎分配内存流程完结。
咱们从以上介绍的搭档系统核心数据结构,以及搭档零碎内存调配原理的相干内容来看,搭档系统管理物理内存的最小单位是物理内存页 page。也就是说,当咱们向搭档零碎申请内存时,至多要申请一个物理内存页。
而从内核理论运行过程中来看,无论是从内核态还是从用户态的角度来说,对于内存的需求量往往是以字节为单位,通常是几十字节到几百字节不等,远远小于一个页面的大小。如果咱们仅仅为了这几十字节的内存需要,而专门为其调配一整个内存页面,这无疑是对贵重内存资源的一种微小节约。
于是在内核中,这种专门针对小内存的调配需要就应运而生了,而本文的主题—— slab 内存池就是专门应答小内存频繁的调配和开释的场景的。
slab 首先会向搭档零碎一次性申请一个或者多个物理内存页面,正是这些物理内存页组成了 slab 内存池。
随后 slab 内存池会将这些间断的物理内存页面划分成多个大小雷同的小内存块进去,同一种 slab 内存池下,划分进去的小内存块尺寸是一样的。内核会针对不同尺寸的小内存调配需要,事后创立出多个 slab 内存池进去。
这种小内存在内核中的应用场景十分之多,比方,内核中那些常常应用,须要频繁申请开释的一些外围数据结构对象:task_struct 对象,mm_struct 对象,struct page 对象,struct file 对象,socket 对象等。
而创立这些内核外围数据结构对象以及为这些外围对象分配内存,销毁这些内核对象以及开释相干的内存是须要性能开销的。
这一点咱们从 《深刻了解 Linux 物理内存调配与开释全链路实现》一文中具体介绍的内存调配与开释全链路过程中曾经十分分明的看到了,整个内存调配链路还是比拟长的,如果遇到内存不足,还会波及到内存的 swap 和 compact ,从而进一步产生更大的性能开销。
既然 slab 专门是用于小内存块调配与回收的,那么内核很天然的就会想到,别离为每一个须要被内核频繁创立和开释的外围对象创立一个专属的 slab 对象池,这些内核对象专属的 slab 对象池会依据其所治理的具体内核对象所占用内存的大小 size,将一个或者多个残缺的物理内存页依照这个 size 划分出多个大小雷同的小内存块进去,每个小内存块用于存储事后创立好的内核对象。
这样一来,当内核须要频繁调配和开释内核对象时,就能够间接从相应的 slab 对象池中申请和开释内核对象,防止了链路比拟长的内存调配与开释过程,极大地晋升了性能。这是一种池化思维的利用。
对于更多池化思维的介绍,以及对象池的利用与实现,笔者之前写过一篇对象池在用户态应用程序中的设计与实现的文章 《详解 Netty Recycler 对象池的精妙设计与实现》,感兴趣的读者敌人能够看一下。
将内核中的外围数据结构对象,池化在 slab 对象池中,除了能够防止内核对象频繁重复初始化和相干内存调配,频繁重复销毁对象和相干内存开释的性能开销之外,其实还有很多益处,比方:
- 利用 CPU 高速缓存进步访问速度。当一个对象被间接开释回 slab 对象池中的时候,这个内核对象还是“热的”,依然会驻留在 CPU 高速缓存中。如果这时,内核持续向 slab 对象池申请对象,slab 对象池会优先把这个刚刚开释 “热的” 对象调配给内核应用,因为对象很大概率依然驻留在 CPU 高速缓存中,所以内核拜访起来速度会更快。
- 搭档零碎只能调配 2 的次幂个残缺的物理内存页,这会引起占用高速缓存以及 TLB 的空间较大,导致一些不重要的数据驻留在 CPU 高速缓存中占用贵重的缓存空间,而重要的数据却被置换到内存中。 slab 对象池针对小内存调配场景,能够无效的防止这一点。
- 调用搭档零碎的操作会对 CPU 高速缓存 L1Cache 中的 Instruction Cache(指令高速缓存)和 Data Cache (数据高速缓存)有净化,因为对搭档零碎的长链路调用,相干的一些指令和数据必然会填充到 Instruction Cache 和 Data Cache 中,从而将频繁应用的一些指令和数据挤压进来,造成缓存净化。而在内核空间中越节约这些缓存资源,那么在用户空间中的过程就会越少的失去这些缓存资源,造成性能的降落。 slab 对象池极大的缩小了对搭档零碎的调用,避免了不必要的 L1Cache 净化。
- 应用 slab 对象池能够充分利用 CPU 高速缓存,防止多个对象对同一 cache line 的争用。如果对象间接存储排列在搭档零碎提供的内存页中的话(不受 slab 治理),那么位于不同内存页中具备雷同偏移的对象很可能会被放入同一个 cache line 中,即便其余 cache line 还是空的。具体为什么会造成具备雷同内存偏移地址的对象会对同一 cache line 进行争抢,笔者会在文章前面相干章节中为大家解答,这里咱们只是简略列出 slab 针对小内存调配的一些劣势,目标是让大家先从总体上把握。
3. slab 对象池在内核中的利用场景
当初咱们最起码从概念上分明了 slab 对象池的产生背景,以及它要解决的问题场景。上面笔者列举了几个 slab 对象池在内核中的应用场景,不便大家进一步从总体上了解。
本大节咱们仍然还是从总体上把握 slab 对象池,大家不用适度地陷入到细节当中。
- 当咱们应用 fork() 零碎调用创立过程的时候,内核须要应用 task_struct 专属的 slab 对象池调配 task_struct 对象。
static struct task_struct *dup_task_struct(struct task_struct *orig, int node){ ........... struct task_struct *tsk; // 从 task_struct 对象专属的 slab 对象池中申请 task_struct 对象 tsk = alloc_task_struct_node(node); ........... }
- 为过程创立虚拟内存空间的时候,内核须要应用 mm_struct 专属的 slab 对象池调配 mm_struct 对象。
static struct mm_struct *dup_mm(struct task_struct *tsk, struct mm_struct *oldmm){ .......... struct mm_struct *mm; // 从 mm_struct 对象专属的 slab 对象池中申请 mm_struct 对象 mm = allocate_mm(); ..........}
- 当咱们向页高速缓存 page cache 查找对应的文件缓存页时,内核须要应用 struct page 专属的 slab 对象池调配 struct page 对象。
struct page *pagecache_get_page(struct address_space *mapping, pgoff_t offset, int fgp_flags, gfp_t gfp_mask){ struct page *page;repeat: // 在 radix_tree(page cache)中依据缓存页 offset 查找缓存页 page = find_get_entry(mapping, offset); // 缓存页不存在的话,跳转到 no_page 解决逻辑 if (!page) goto no_page; .......省略.......no_page: // 从 page 对象专属的 slab 对象池中申请 page 对象 page = __page_cache_alloc(gfp_mask); // 将新调配的内存页退出到页高速缓存 page cache 中 err = add_to_page_cache_lru(page, mapping, offset, gfp_mask); .......省略....... } return page;}
- 当咱们应用 open 零碎调用关上一个文件时,内核须要应用 struct file专属的 slab 对象池调配 struct file 对象。
struct file *do_filp_open(int dfd, struct filename *pathname, const struct open_flags *op){ struct file *filp; // 调配 struct file 内核对象 filp = path_openat(&nd, op, flags | LOOKUP_RCU); .......... return filp;}static struct file *path_openat(struct nameidata *nd, const struct open_flags *op, unsigned flags){ struct file *file; // 从 struct file 对象专属的 slab 对象池中申请 struct file 对象 file = alloc_empty_file(op->open_flag, current_cred()); ..........}
- 当服务端网络应用程序应用 accpet 零碎调用接管客户端的连贯时,内核须要应用 struct socket 专属的 slab 对象池为新进来的客户端连贯调配 socket 对象。
SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr, int __user *, upeer_addrlen, int, flags){ struct socket *sock, *newsock; // 查找正在 listen 状态的监听 socket sock = sockfd_lookup_light(fd, &err, &fput_needed); // 为新进来的客户端连贯申请 socket 对象以及与其关联的 inode 对象 // 从 struct socket 对象专属的 slab 对象池中申请 struct socket 对象 newsock = sock_alloc(); ............. 利用监听 socket 初始化 newsocket ..........}
当然了被 slab 对象池所治理的内核外围对象不只是笔者下面为大家列举的这五个,事实上,但凡须要被内核频繁应用的内核对象都须要被 slab 对象池所治理。
比方:咱们在 《从 Linux 内核角度探秘 IO 模型的演变》 一文中为大家介绍的 epoll 相干的对象:
在《从 Linux 内核角度探秘 JDK NIO 文件读写实质》 一文中介绍的页高速缓存 page cache 相干的对象:
在 《深刻了解 Linux 虚拟内存治理》 一文中介绍的虚拟内存地址空间相干的对象:
当初咱们只是对 slab 对象池有了一个最外表的意识,那么接下来的内容,笔者会带大家深刻到 slab 对象池的实现细节中一探到底。
在开始介绍内核源码实现之前,笔者想和大家交代一下本文的行文思路,之前的系列文章中笔者都是采纳 “总——分——总” 的思路为大家讲述源码,然而本文要介绍的 slab 对象池实现比较复杂,一上来就把总体架构给大家展现进去,大家看的也是一脸懵。
所以这里咱们换一种思路,笔者会带大家从一个最简略的物理内存页 page 开始,一步一步地演进,直到一个残缺的 slab 对象池架构清晰地展示在大家的背后。
4. slab, slub, slob 傻傻分不清楚
在开始正式介绍 slab 对象池之前,笔者感觉有必要先向大家简略交代一下 Linux 零碎中对于 slab 对象池的三种实现:slab,slub,slob。
其中 slab 的实现,最早是由 Sun 公司的 Jeff Bonwick 大神在 Solaris 2.4 零碎中设计并实现的,因为 Jeff Bonwick 大神公开了 slab 的实现办法,因而被 Linux 所借鉴并于 1996 年在 Linux 2.0 版本中引入了 slab,用于 Linux 内核晚期的小内存调配场景。
因为 slab 的实现非常复杂,slab 中领有多种存储对象的队列,队列治理开销比拟大,slab 元数据比拟臃肿,对 NUMA 架构的反对臃肿繁冗(slab 引入时内核还没反对 NUMA),这样导致 slab 外部为了保护这些本身元数据管理构造就得破费大量的内存空间,这在配置有超大容量内存的服务器上,内存的节约是十分可观的。
针对以上 slab 的有余,内核大神 Christoph Lameter 在 2.6.22 版本(2007 年公布)中引入了新的 slub 实现。slub 简化了 slab 一些简单的设计,同时保留了 slab 的根本思维,摒弃了 slab 泛滥治理队列的概念,并针对多处理器,NUMA 架构进行优化,放弃了成果不太显著的 slab 着色机制。slub 与 slab 相比,进步了性能,吞吐量,并升高了内存的节约。成为当初内核中罕用的 slab 实现。
而 slob 的实现是在内核 2.6.16 版本(2006 年公布)引入的,它是专门为嵌入式小型机器小内存的场景设计的,所以实现上很精简,能在小型机器上提供很不错的性能。
而内核中对于内存池(小内存分配器)的相干 API 接口函数均是以 slab 命名的,然而咱们能够通过配置的形式来平滑切换以上三种 slab 的实现。本文咱们次要探讨被大规模使用在服务器 Linux 操作系统中的 slub 对象池的实现,所以本文上面的内容,如无非凡阐明,笔者提到的 slab 均是指 slub 实现。
5. 从一个简略的内存页开始聊 slab
从前边大节的内容中,咱们晓得内核会把那些频繁应用的外围对象对立放在 slab 对象池中治理,每一个外围对象对应一个专属的 slab 对象池,以便晋升外围对象的调配,拜访,开释相干操作的性能。
如上图所示,slab 对象池在内存管理系统中的架构档次是基于搭档零碎之上构建的,slab 对象池会一次性向搭档零碎申请一个或者多个残缺的物理内存页,在这些残缺的内存页外在逐渐划分出一小块一小块的内存块进去,而这些小内存块的尺寸就是 slab 对象池所治理的内核外围对象占用的内存大小。
上面笔者就带大家从一个最简略的物理内存页 page 开始,咱们一步一步的推演 slab 的整个架构设计与实现。
如果让咱们本人设计一个对象池,首先最直观最简略的方法就是先向搭档零碎申请一个内存页,而后依照须要被池化对象的尺寸 object size,把内存页划分为一个一个的内存块,每个内存块尺寸就是 object size。
事实上,slab 对象池能够依据状况向搭档零碎一次性申请多个内存页,这里只是为了不便大家了解,咱们先以一个内存页为例,为大家阐明 slab 中对象的内存布局。
然而在一个工业级的对象池设计中,咱们不能这么简略粗犷的搞,因为对象的 object size 能够是任意的,并不是内存对齐的,CPU 拜访一块没有进行对齐的内存比拜访对齐的内存速度要慢一倍。
因为 CPU 向内存读取数据的单位是依据 word size 来的,在 64 位处理器中 word size = 8 字节,所以 CPU 向内存读写数据的单位为 8 字节。CPU 只能一次性向内存拜访依照 word size ( 8 字节) 对齐的内存地址,如果 CPU 拜访一个未进行 word size 对齐的内存地址,就会经验两次访存操作。
比方,咱们当初须要拜访 0x0007 - 0x0014 这样一段没有对 word size 进行对齐的内存,CPU只能先从 0x0000 - 0x0007 读取 8 个字节进去先放入后果寄存器中并左移 7 个字节(目标是只获取 0x0007 ),而后 CPU 在从 0x0008 - 0x0015 读取 8 个字节进去放入长期寄存器中并右移1个字节(目标是获取 0x0008 - 0x0014 )最初与后果寄存器或运算。最终失去 0x0007 - 0x0014 地址段上的 8 个字节。
从下面过程咱们能够看出,CPU 拜访一段未进行 word size 对齐的内存,须要两次访存操作。
内存对齐的益处还有很多,比方,CPU 拜访对齐的内存都是原子性的,对齐内存中的数据会独占 cache line ,不会与其余数据共享 cache line,防止 false sharing。
这里大家只须要简略理解为什么要进行内存对齐即可,对于内存对齐的具体内容,感兴趣的读者能够回看下 《内存对齐的原理及其利用》 一文中的 “ 5. 内存对齐 ” 大节。
基于以上起因,咱们不能简略的依照对象尺寸 object size 来划分内存块,而是须要思考到对象内存地址要依照 word size 进行对齐。于是下面的 slab 对象池的内存布局又有了新的变动。
如果被池化对象的尺寸 object size 原本就是和 word size 对齐的,那么咱们不须要做任何事件,然而如果 object size 没有和 word size 对齐,咱们就须要填充一些字节,目标是要让对象的 object size 依照 word size 进行对齐,进步 CPU 拜访对象的速度。
然而下面的这些工作对于一个工业级的对象池来说还远远不够,工业级的对象池须要应答很多简单的诡异场景,比方,咱们偶然在简单生产环境中会遇到的内存读写访问越界的状况,这会导致很多莫名其妙的异样。
内核为了应答内存读写越界的场景,于是在对象内存的四周插入了一段不可拜访的内存区域,这些内存区域用特定的字节 0xbb 填充,当过程拜访的到内存是 0xbb 时,示意曾经越界拜访了。这段内存区域在 slab 中的术语为 red zone,大家能够了解为红色警戒区域。
插入 red zone 之后,slab 对象池的内存布局近一步演进为下图所示的布局:
- 如果对象尺寸 object size 自身就是 word size 对齐的,那么就须要在对象左右两侧填充两段 red zone 区域,red zone 区域的长度个别就是 word size 大小。
- 如果对象尺寸 object size 是通过填充 padding 之后,才与 word size 对齐。内核会奇妙的利用对象左边的这段 padding 填充区域作为 red zone。只须要额定的在对象内存区域的左侧填充一段 red zone 即可。
在有了新的内存布局之后,咱们接下来就要思考一个问题,当咱们向 slab 对象池获取到一个闲暇对象之后,咱们须要晓得它的下一个闲暇对象在哪里,这样不便咱们下次获取对象。那么咱们该如何将内存页 page 中的这些闲暇对象串联起来呢?
有读者敌人可能会说了,这很简略啊,用一个链表把这些闲暇对象串联起来不就行了嘛,其实内核也是这样想的,哈哈。不过内核奇妙的中央在于不须要为串联对象所用到的 next 指针额定的分配内存空间。
因为对象在 slab 中没有被调配进来应用的时候,其实对象所占的内存中寄存什么,用户基本不会关怀的。既然这样,内核罗唆就把指向下一个闲暇对象的 freepointer 指针间接寄存在对象所占内存(object size)中,这样防止了为 freepointer 指针独自再分配内存空间。奇妙的利用了对象所在的内存空间(object size)。
咱们接着对 slab 内存布局进行演变,有时候咱们冀望晓得 slab 对象池中各个对象的状态,比方是否处于闲暇状态。那么对象的状态咱们在哪里存储呢?
答案还是和 freepointer 的解决形式一样,奇妙的利用对象所在的内存空间(object size)。内核会在对象所占的内存空间中填充一些非凡的字符用来示意对象的不同状态。因为反正对象没有被调配进来应用,内存里存的是什么都无所谓。
当 slab 刚刚从搭档零碎中申请进去,并初始化划分物理内存页中的对象内存空间时,内核会将对象的 object size 内存区域用非凡字节 0x6b 填充,并用 0xa5 填充对象 object size 内存区域的最初一个字节示意填充结束。
或者当对象被开释回 slab 对象池中的时候,也会用这些字节填充对象的内存区域。
这种通过在对象内存区域填充特定字节示意对象的非凡状态的行为,在 slab 中有一个专门的术语叫做 SLAB_POISON (SLAB 中毒)。POISON 这个术语起的真的是只可意会不可言传,其实就是示意 slab 对象的一种状态。
是否毒化 slab 对象是能够设置的,当 slab 对象被 POISON 之后,那么会有一个问题,就是咱们前边介绍的寄存在对象内存区域 object size 里的 freepointer 就被会非凡字节 0x6b 笼罩掉。这种状况下,内核就只能为 freepointer 在额定调配一个 word size 大小的内存空间了。
slab 对象的内存布局信息除了以上内容之外,有时候咱们还须要去跟踪一下对象的调配和开释相干信息,而这些信息也须要在 slab 对象中存储,内核中应用一个 struct track 构造体来存储跟踪信息。
这样一来,slab 对象的内存区域中就须要在开拓出两个 sizeof(struct track)
大小的区域进去,用来别离存储 slab 对象的调配和开释信息。
上图展现的就是 slab 对象在内存中的残缺布局,其中 object size 为对象真正所须要的内存区域大小,而对象在 slab 中实在的内存占用大小 size 除了 object size 之外,还包含填充的 red zone 区域,以及用于跟踪对象调配和开释信息的 track 构造,另外,如果 slab 设置了 red zone,内核会在对象开端减少一段 word size 大小的填充 padding 区域。
当 slab 向搭档零碎申请若干内存页之后,内核会依照这个 size 将内存页划分成一个一个的内存块,内存块大小为 size 。
其实 slab 的实质就是一个或者多个物理内存页 page,内核会依据上图展现的 slab 对象的内存布局,计算出对象的实在内存占用 size。最初依据这个 size 在 slab 背地依赖的这一个或者多个物理内存页 page 中划分出多个大小雷同的内存块进去。
所以在内核中,都是用 struct page 构造来示意 slab,如果 slab 背地依赖的是多个物理内存页,那就应用在 《深度分析 Linux 搭档零碎的设计与实现》 一文中 " 5.3.2 设置复合页 compound_page " 大节提到的复合页 compound_page 来示意。
struct page { // 首页 page 中的 flags 会被设置为 PG_head 示意复合页的第一页 unsigned long flags; // 其余尾页会通过该字段指向首页 unsigned long compound_head; // 用于开释复合页的析构函数,保留在首页中 unsigned char compound_dtor; // 该复合页有多少个 page 组成,order 还是调配阶的概念,在首页中保留 // 本例中的 order = 2 示意由 4 个一般页组成 unsigned char compound_order; // 该复合页被多少个过程应用,内存页反向映射的概念,首页中保留 atomic_t compound_mapcount; // 复合页应用计数,首页中保留 atomic_t compound_pincount; }
slab 的具体信息也是在 struct page 中存储,上面笔者提取了 struct page 构造中和 slab 相干的字段:
struct page { struct { /* slub 相干字段 */ union { // slab 所在的治理链表 struct list_head slab_list; struct { /* Partial pages */ // 用 next 指针在相应治理链表中串联起 slab struct page *next;#ifdef CONFIG_64BIT // slab 所在治理链表中的蕴含的 slab 总数 int pages; // slab 所在治理链表中蕴含的对象总数 int pobjects; #else short int pages; short int pobjects;#endif }; }; // 指向 slab cache,slab cache 就是真正的对象池构造,里边治理了多个 slab // 这多个 slab 被 slab cache 治理在了不同的链表上 struct kmem_cache *slab_cache; // 指向 slab 中第一个闲暇对象 void *freelist; /* first free object */ union { struct { /* SLUB */ // slab 中曾经调配进来的独享 unsigned inuse:16; // slab 中蕴含的对象总数 unsigned objects:15; // 该 slab 是否在对应 slab cache 的本地 CPU 缓存中 // frozen = 1 示意缓存再本地 cpu 缓存中 unsigned frozen:1; }; }; };}
在笔者以后所在的内核版本 5.4 中,内核是应用 struct page 来示意 slab 的,然而思考到 struct page 构造曾经十分宏大且简单,为了缩小 struct page 的内存占用以及进步可读性,内核在 5.17 版本中专门为 slab 引入了一个治理构造 struct slab,将原有 struct page 中 slab 相干的字段全副删除,转移到了 struct slab 构造中。这一点,大家只做理解即可。
6. slab 的总体架构设计
在上一大节的内容中,笔者带大家从 slab 的宏观层面具体的介绍了 slab 对象的内存布局,首先 slab 会从搭档零碎中申请一个或多个物理内存页 page,而后依据 slab 对象的内存布局计算出对象在内存中的实在尺寸 size,并依据这个 size,在物理内存页中划分出多个内存块进去,供内核申请应用。
有了这个根底之后,在本大节中,笔者将持续带大家从 slab 的宏观层面上持续深刻 slab 的架构设计。
笔者在前边的内容中屡次提及的 slab 对象池其实就是上图中的 slab cache,而上大节中介绍的 slab 只是 slab cache 架构体系中的根本单位,对象的调配和开释最终会落在 slab 这个根本单位上。
如果一个 slab 中的对象全副调配进来了,slab cache 就会将其视为一个 full slab,示意这个 slab 此刻曾经满了,无奈在调配对象了。slab cache 就会到搭档零碎中从新申请一个 slab 进去,供后续的内存调配应用。
当内核将对象开释回其所属的 slab 之后,如果 slab 中的对象全副归位,slab cache 就会将其视为一个 empty slab,示意 slab 此刻变为了一个齐全闲暇的 slab。如果超过了 slab cache 中规定的 empty slab 的阈值,slab cache 就会将这些闲暇的 empty slab 从新开释回搭档零碎中。
如果一个 slab 中的对象局部被调配进来应用,局部却未被调配依然在 slab 中缓存,那么内核就会将该 slab 视为一个 partial slab。
这些不同状态的 slab,会在 slab cache 中被不同的链表所治理,同时 slab cache 会管制治理链表中 slab 的个数以及链表中所缓存的闲暇对象个数,避免它们无限度的增长。
slab cache 中除了须要治理泛滥的 slab 之外,还包含了很多 slab 的根底信息。比方:
- 上大节中提到的 slab 对象内存布局相干的信息
- slab 中的对象须要依照什么形式进行内存对齐,比方,依照 CPU 硬件高速缓存行 cache line (64 字节) 进行对齐,slab 对象是否须要进行毒化 POISON,是否须要在 slab 对象内存四周插入 red zone,是否须要追踪 slab 对象的调配与回收信息,等等。
- 一个 slab 具体到底须要多少个物理内存页 page,一个 slab 中具体可能包容多少个 object (内存块)。
6.1 slab 的根底信息管理
slab cache 在内核中的数据结构为 struct kmem_cache,以上介绍的这些 slab 的根本信息以及 slab 的治理构造全副定义在该构造体中:
/* * Slab cache management. */struct kmem_cache { // slab cache 的治理标记位,用于设置 slab 的一些个性 // 比方:slab 中的对象依照什么形式对齐,对象是否须要 POISON 毒化,是否插入 red zone 在对象内存四周,是否追踪对象的调配和开释信息 等等 slab_flags_t flags; // slab 对象在内存中的实在占用,包含为了内存对齐填充的字节数,red zone 等等 unsigned int size; /* The size of an object including metadata */ // slab 中对象的理论大小,不蕴含填充的字节数 unsigned int object_size;/* The size of an object without metadata */ // slab 对象池中的对象在没有被调配之前,咱们是不关怀对象里边存储的内容的。 // 内核奇妙的利用对象占用的内存空间存储下一个闲暇对象的地址。 // offset 示意用于存储下一个闲暇对象指针的地位间隔对象首地址的偏移 unsigned int offset; /* Free pointer offset */ // 示意 cache 中的 slab 大小,包含 slab 所须要申请的页面个数,以及所蕴含的对象个数 // 其中低 16 位示意一个 slab 中所蕴含的对象总数,高 16 位示意一个 slab 所占有的内存页个数。 struct kmem_cache_order_objects oo; // slab 中所能蕴含对象以及内存页个数的最大值 struct kmem_cache_order_objects max; // 当依照 oo 的尺寸为 slab 申请内存时,如果内存缓和,会采纳 min 的尺寸为 slab 申请内存,能够包容一个对象即可。 struct kmem_cache_order_objects min; // 向搭档零碎申请内存时应用的内存调配标识 gfp_t allocflags; // slab cache 的援用计数,为 0 时就能够销毁并开释内存回搭档零碎重 int refcount; // 池化对象的构造函数,用于创立 slab 对象池中的对象 void (*ctor)(void *); // 对象的 object_size 依照 word 字长对齐之后的大小 unsigned int inuse; // 对象依照指定的 align 进行对齐 unsigned int align; // slab cache 的名称, 也就是在 slabinfo 命令中 name 那一列 const char *name; };
slab_flags_t flags
是 slab cache 的治理标记位,用于设置 slab 的一些个性,比方:
- 当 flags 设置了 SLAB_HWCACHE_ALIGN 时,示意 slab 中的对象须要依照 CPU 硬件高速缓存行 cache line (64 字节) 进行对齐。
- 当 flags 设置了 SLAB_POISON 时,示意须要在 slab 对象内存中填充非凡字节 0x6b 和 0xa5,示意对象的特定状态。
- 当 flags 设置了 SLAB_RED_ZONE 时,示意须要在 slab 对象内存四周插入 red zone,避免内存的读写越界。
- 当 flags 设置了 SLAB_CACHE_DMA 或者 SLAB_CACHE_DMA32 时,示意指定 slab 中的内存来自于哪个内存区域,DMA or DMA32 区域 ?如果没有非凡指定,slab 中的内存个别来自于 NORMAL 间接映射区域。
- 当 flags 设置了 SLAB_STORE_USER 时,示意须要追踪对象的调配和开释相干信息,这样会在 slab 对象内存区域中额定减少两个
sizeof(struct track)
大小的区域进去,用于存储 slab 对象的调配和开释信息。
相干 slab cache 的标记位 flag,定义在内核文件 /include/linux/slab.h
中:
/* DEBUG: Red zone objs in a cache */#define SLAB_RED_ZONE ((slab_flags_t __force)0x00000400U)/* DEBUG: Poison objects */#define SLAB_POISON ((slab_flags_t __force)0x00000800U)/* Align objs on cache lines */#define SLAB_HWCACHE_ALIGN ((slab_flags_t __force)0x00002000U)/* Use GFP_DMA memory */#define SLAB_CACHE_DMA ((slab_flags_t __force)0x00004000U)/* Use GFP_DMA32 memory */#define SLAB_CACHE_DMA32 ((slab_flags_t __force)0x00008000U)/* DEBUG: Store the last owner for bug hunting */#define SLAB_STORE_USER
struct kmem_cache 构造中的 size 字段示意 slab 对象在内存中的实在占用大小,该大小包含对象所占内存中各种填充的内存区域大小,比方下图中的 red zone,track 区域,等等。
unsigned int object_size
示意单纯的存储 slab 对象所须要的理论内存大小,如上图中的 object size 蓝色区域所示。
在上大节咱们介绍 freepointer 指针的时候提到过,当对象在 slab 中缓存并没有被调配进来之前,其实对象所占内存中存储的是什么,用户基本不会去关怀。内核会奇妙的利用对象的内存空间来存储 freepointer 指针,用于指向 slab 中的下一个闲暇对象。
然而当 kmem_cache 构造中的 flags 设置了 SLAB_POISON 标记位之后,slab 中的对象会 POISON 毒化,被非凡字节 0x6b 和 0xa5 所填充,这样一来就会笼罩原有的 freepointer,在这种状况下,内核就须要把 freepointer 存储在对象所在内存区域的里面。
所以内核就须要用一个字段来标识 freepointer 的地位,struct kmem_cache 构造中的 unsigned int offset
字段干的就是这个事件,它示意对象的 freepointer 指针间隔对象的起始内存地址的偏移 offset。
上大节中,咱们也提到过,slab 的实质其实就是一个或者多个物理内存页,slab 在内核中的构造也是用 struct page 来示意的,那么一个 slab 中到底蕴含多少个内存页 ? 这些内存页中到底能包容多少个内存块(object)呢?
struct kmem_cache_order_objects oo
字段就是保留这些信息的,struct kmem_cache_order_objects 构造体其实就是一个无符号的整形字段,它的高 16 位用来存储 slab 所需的物理内存页个数,低 16 位用来存储 slab 所能包容的对象总数。
struct kmem_cache_order_objects { // 高 16 为存储 slab 所需的内存页个数,低 16 为存储 slab 所能蕴含的对象总数 unsigned int x;};
struct kmem_cache_order_objects max
字段示意 oo 的最大值,内核在初始化 slab 的时候,会将 max 的值设置为 oo。
struct kmem_cache_order_objects min
字段示意 slab 中至多须要包容的对象个数以及包容起码的对象所须要的内存页个数。内核在初始化 slab 的时候会 将 min 的值设置为至多须要包容一个对象。
内核在创立 slab 的时候,最开始会依照 oo 指定的尺寸来向搭档零碎申请内存页,如果内存缓和,申请内存失败。那么内核会降级采纳 min 的尺寸再次向搭档零碎申请内存。也就是说 slab 中至多会蕴含一个对象。
gfp_t allocflags
是内核在向搭档零碎为 slab 申请内存页的时候,所用到的内存调配标记位,感兴趣的敌人能够回看下 《深刻了解 Linux 物理内存调配全链路实现》 一文中的 “ 2.标准物理内存调配行为的掩码 gfp_mask ” 大节中的内容,那里有十分具体的介绍。
unsigned int inuse
示意对象的 object size 依照 word size 对齐之后的大小,如果咱们设置了SLAB_RED_ZONE,inuse 也会包含对象右侧 red zone 区域的大小。
unsigned int align
在创立 slab cache 的时候,咱们能够向内核指定 slab 中的对象依照 align 的值进行对齐,内核会综合 word size , cache line ,align 计算出一个正当的对齐尺寸。
const char *name
示意该 slab cache 的名称,这里指定的 name 将会在 cat /proc/slabinfo
命令中显示,该命令用于查看零碎中所有 slab cache 的信息。
cat /proc/slabinfo
命令的显示构造次要由三局部组成:
statistics 局部显示的是 slab cache 的根本统计信息,这部分是咱们最罕用的,上面是每一列的含意:
- active_objs 示意 slab cache 中曾经被调配进来的对象个数
- num_objs 示意 slab cache 中包容的对象总数
- objsize 示意 slab 中对象的 object size ,单位为字节
- objperslab 示意 slab 中能够包容的对象个数
- pagesperslab 示意 slab 所须要的物理内存页个数
tunables 局部显示的 slab cache 的动静可调节参数,如果咱们采纳的 slub 实现,那么 tunables 局部全是 0 ,
/proc/slabinfo
文件不可写,无奈动静批改相干参数。如果咱们应用的 slab 实现的话,能够通过# echo 'name limit batchcount sharedfactor' > /proc/slabinfo
命令动静批改相干参数。命令中指定的 name 就是 kmem_cache 构造中的 name 属性。tunables 这部分显示的信息均是 slab 实现中的相干字段,大家只做简略理解即可,与咱们本文主题 slub 的实现没有关系。- limit 示意在 slab 的实现中,slab cache 的 cpu 本地缓存 array_cache 最大能够包容的对象个数
- batchcount 示意当 array_cache 中缓存的对象不够时,须要一次性填充的闲暇对象个数。
- slabdata 局部显示的 slab cache 的总体信息,其中 active_slabs 一列展现的 slab cache 中沉闷的 slab 个数。nums_slabs 一列展现的是 slab cache 中治理的 slab 总数
在 cat /proc/slabinfo
命令显示的这些零碎中所有的 slab cache,内核会将这些 slab cache 用一个双向链表对立串联起来。链表的头结点指针保留在 struct kmem_cache 构造的 list 中。
struct kmem_cache { // 用于组织串联零碎中所有类型的 slab cache struct list_head list; /* List of slab caches */}
零碎中所有的这些 slab cache 占用的内存总量,咱们能够通过 cat /proc/meminfo
命令查看:
除此之外,咱们还能够通过 slabtop
命令来动静查看零碎中占用内存最高的 slab cache,当内存缓和的时候,如果咱们通过 cat /proc/meminfo
命令发现 slab 的内存占用较高的话,那么能够疾速通过 slabtop
迅速定位到到底是哪一类的 object 调配过多导致内存占用飙升。
6.2 slab 的组织架构
在上大节的内容中,笔者次要为大家介绍了 struct kmem_cache 构造中对于 slab 的一些根底信息,其中次要包含 slab cache 中所治理的 slabs 相干的容量管制,以及 slab 中对象的内存布局信息。
那么 slab cache 中的这些 slabs 是如何被组织治理的呢 ?在本大节中,笔者将为大家揭开这个谜底。
slab cache 其实就是内核中的一个对象池,而对于对象池的设计,笔者在之前的文章 《详解 Recycler 对象池的精妙设计与实现》 中具体的介绍过 Netty 对于对象池这块的设计,其中用了大量的篇幅重点着墨了多线程无锁化设计。
内核在对 slab cache 的设计也是一样,也充分考虑了多过程并发拜访 slab cache 所带来的同步性能开销,内核在 slab cache 的设计中为每个 cpu 引入了 struct kmem_cache_cpu 构造的 percpu 变量,作为 slab cache 在每个 cpu 中的本地缓存。
/* * Slab cache management. */struct kmem_cache { // 每个 cpu 领有一个本地缓存,用于无锁化疾速调配开释对象 struct kmem_cache_cpu __percpu *cpu_slab;}
这样一来,当过程须要向 slab cache 申请对应的内存块(object)时,首先会间接来到 kmem_cache_cpu 中查看 cpu 本地缓存的 slab,如果本地缓存的 slab 中有闲暇对象,那么就间接返回了,整个过程齐全没有加锁。而且拜访门路特地短,避免了对 CPU 硬件高速缓存 L1Cache 中的 Instruction Cache(指令高速缓存)净化。
上面咱们来看一下 slab cache 它的 cpu 本地缓存 kmem_cache_cpu 构造的具体设计细节:
struct kmem_cache_cpu { // 指向被 CPU 本地缓存的 slab 中第一个闲暇的对象 void **freelist; /* Pointer to next available object */ // 保障过程在 slab cache 中获取到的 cpu 本地缓存 kmem_cache_cpu 与以后执行过程的 cpu 是统一的。 unsigned long tid; /* Globally unique transaction id */ // slab cache 中 CPU 本地所缓存的 slab,因为 slab 底层的存储构造是内存页 page // 所以这里间接用内存页 page 示意 slab struct page *page; /* The slab from which we are allocating */#ifdef CONFIG_SLUB_CPU_PARTIAL // cpu cache 缓存的备用 slab 列表,同样也是用 page 示意 // 当被本地 cpu 缓存的 slab 中没有闲暇对象时,内核会从 partial 列表中的 slab 中查找闲暇对象 struct page *partial; /* Partially allocated frozen slabs */#endif#ifdef CONFIG_SLUB_STATS // 记录 slab 调配对象的一些状态信息 unsigned stat[NR_SLUB_STAT_ITEMS];#endif};
在本文 《5. 从一个简略的内存页开始聊 Slab》大节前面的内容介绍中,咱们晓得,slab 在内核中是用 struct page 构造来形容的,这里 struct kmem_cache_cpu 构造中的 page 指针
指向的就是被 cpu 本地缓存的 slab。
freelist
指针指向的是该 slab 中第一个闲暇的对象,在本文第五大节介绍 slab 对象内存布局的内容中,笔者提到过,为了充分利用 slab 对象所占用的内存,内核会在对象占用内存区域内开拓一块区域来寄存 freepointer 指针,而 freepointer 能够用来指向下一个闲暇对象。
这样一来,通过这里的 freelist 和 freepointer 就将 slab 中所有的闲暇对象串联了起来。
事实上,在 struct page 构造中也有一个 freelist 指针,用于指向该内存页中第一个闲暇对象。当 slab 被缓存进 kmem_cache_cpu 中之后,page 构造中的 freelist 会赋值给 kmem_cache_cpu->freelist,而后 page->freelist 会置空。page 的 frozen 状态设置为1,示意 slab 在本地 cpu 中缓存。
struct page { // 指向内存页中第一个闲暇对象 void *freelist; /* first free object */ // 该 slab 是否在对应 slab cache 的本地 CPU 缓存中 // frozen = 1 示意缓存再本地 cpu 缓存中 unsigned frozen:1;}
kmem_cache_cpu 构造中的 tid 是内核为 slab cache 的 cpu 本地缓存构造设置的一个全局惟一的 transaction id ,这个 tid 在 slab cache 分配内存块的时候次要有两个作用:
- 内核会将 slab cache 每一次分配内存块或者开释内存块的过程视为一个事物,所以在每次向 slab cache 申请内存块或者将内存块开释回 slab cache 之后,内核都会扭转这里的 tid。
- tid 也能够简略看做是 cpu 的一个编号,每个 cpu 的 tid 都不雷同,能够用来标识辨别不同 cpu 的本地缓存 kmem_cache_cpu 构造。
其中 tid 的第二个作用是最次要的,因为过程可能在执行的过程中被更高优先级的过程抢占 cpu (开启 CONFIG_PREEMPT 容许内核抢占)或者被中断,随后过程可能会被内核从新调度到其余 cpu 上执行,这样一来,过程在被抢占之前获取到的 kmem_cache_cpu 就与以后执行过程 cpu 的 kmem_cache_cpu 不统一了。
所以在内核中,咱们常常会看到如下的代码片段,目标就是为了保障过程在 slab cache 中获取到的 cpu 本地缓存 kmem_cache_cpu 与以后执行过程的 cpu 是统一的。
do { // 获取执行以后过程的 cpu 中的 tid 字段 tid = this_cpu_read(s->cpu_slab->tid); // 获取 cpu 本地缓存 cpu_slab c = raw_cpu_ptr(s->cpu_slab); // 如果两者的 tid 字段不统一,阐明过程曾经被调度到其余 cpu 上了 // 须要再次获取正确的 cpu 本地缓存 } while (IS_ENABLED(CONFIG_PREEMPT) && unlikely(tid != READ_ONCE(c->tid)));
如果开启了 CONFIG_SLUB_CPU_PARTIAL
配置项,那么在 slab cache 的 cpu 本地缓存 kmem_cache_cpu 构造中就会多出一个 partial 列表,partial 列表中寄存的都是 partial slub,相当于是 cpu 缓存的备用抉择.
当 kmem_cache_cpu->page (被本地 cpu 所缓存的 slab)中的对象曾经全副调配进来之后,内核会到 partial 列表中查找一个 partial slab 进去,并从这个 partial slab 中调配一个对象进去,最初将 kmem_cache_cpu->page 指向这个 partial slab,作为新的 cpu 本地缓存 slab。这样一来,下次调配对象的时候,就能够间接从 cpu 本地缓存中获取了。
如果开启了 CONFIG_SLUB_STATS
配置项,内核就会记录一些对于 slab cache 的相干状态信息,这些信息同样也会在 cat /proc/slabinfo
命令中显示。
slab cache 的架构演变到当初,笔者曾经为大家介绍了三种内核数据结构了,它们别离是:
- slab cache 在内核中的数据结构 struct kmem_cache
- slab cache 的本地 cpu 缓存构造 struct kmem_cache_cpu
- slab 在内核中的数据结构 struct page
当初咱们把这种三种数据结构联合起来,失去上面这副 slab cache 的架构图:
但这还不是 slab cache 的最终架构,到目前为止咱们的 slab cache 架构只演进到了一半,上面请大家持续追随笔者的思路咱们接着进行 slab cache 架构的演进。
咱们先把 slab cache 比作一个大型超市,超市里摆放了一排一排的商品货架,毫无疑问,顾客进入超市间接从货架上选取本人想要的商品速度是最快的。
上图中的 kmem_cache 构造就好比是超市,slab cache 的本地 cpu 缓存构造 kmem_cache_cpu 就好比超市的营业厅,营业厅内摆满了一排一排的货架,这些货架就是上图中的 slab,货架上的商品就是 slab 中划分进去的一个一个的内存块。
毫无疑问,顾客来到超市,间接去营业厅的货架上拿取商品是最快的,那么如果货架上的商品卖完了,该怎么办呢?
这时,超市的经理就会到超市的仓库中从新拿取商品填充货架,那么 slab cache 的仓库到底在哪里呢?
答案就在笔者之前文章 《深刻了解 Linux 物理内存治理》 中的 “ 3.2 非一致性内存拜访 NUMA 架构 ” 大节中介绍的内存架构,在 NUMA 架构下,内存被划分成了一个一个的 NUMA 节点,每个 NUMA 节点内蕴含若干个 cpu。
每个 cpu 都能够任意拜访所有 NUMA 节点中的内存,然而会有访问速度上的差别, cpu 在拜访本地 NUMA 节点的速度是最快的,当本地 NUMA 节点中的内存不足时,cpu 会跨节点拜访其余 NUMA 节点。
slab cache 的仓库就在 NUMA 节点中,而且在每一个 NUMA 节点中都有一个仓库,当 slab cache 本地 cpu 缓存 kmem_cache_cpu 中没有足够的内存块可供调配时,内核就会来到 NUMA 节点的仓库中拿出 slab 填充到 kmem_cache_cpu 中。
那么 slab cache 在 NUMA 节点的仓库中也没有足够的货物了,那该怎么办呢?这时,内核就会到搭档零碎中从新批量申请一批 slabs,填充到本地 cpu 缓存 kmem_cache_cpu 构造中。
搭档零碎就好比下面那个超市例子中的进货商,当超市经理发现仓库中也没有商品之后,就会分割进货商,从进货商那里批发商品,从新填充货架。
slab cache 的仓库在内核中采纳 struct kmem_cache_node 构造来示意:
struct kmem_cache { // slab cache 中 numa node 中的缓存,每个 node 一个 struct kmem_cache_node *node[MAX_NUMNODES];}
/* * The slab lists for all objects. */struct kmem_cache_node { spinlock_t list_lock; ....... 省略 slab 相干字段 ........#ifdef CONFIG_SLUB // 该 node 节点中缓存的 slab 个数 unsigned long nr_partial; // 该链表用于组织串联 node 节点中缓存的 slabs // partial 链表中缓存的 slab 为局部闲暇的(slab 中的对象局部被调配进来) struct list_head partial;#ifdef CONFIG_SLUB_DEBUG // 开启 slab_debug 之后会用到的字段 // slab 的个数 atomic_long_t nr_slabs; // 该 node 节点中缓存的所有 slab 中蕴含的对象总和 atomic_long_t total_objects; // full 链表中蕴含的 slab 全副是曾经被调配结束的 full slab struct list_head full;#endif#endif};
这里笔者省略了 slab 实现相干的字段,咱们只关注 slub 实现的局部,nr_partial
示意该 NUMA 节点缓存中缓存的 slab 总数。这些被缓存的 slabs 也是通过一个 partial 列表
被串联治理起来。
如果咱们配置了 CONFIG_SLUB_DEBUG
选项,那么 kmem_cache_node 构造中就会多出一些字段来存储更加丰盛的信息。nr_slabs
示意 NUMA 节点缓存中 slabs 的总数,这里会蕴含 partial slub 和 full slab,这时,nr_partial
示意的是 partial slab 的个数,其中 full slab 会被串联在 full 列表上。total_objects
示意该 NUMA 节点缓存中缓存的对象的总数。
在介绍完 struct kmem_cache_node 构造之后,咱们终于看到了 slab cache 的架构全貌,如下图所示:
上图中展现的 slab cache 本地 cpu 缓存 kmem_cache_cpu 中的 partial 列表以及 NUMA 节点缓存 kmem_cache_node 构造中的 partial 列表并不是无限度增长的,它们的容量收到上面两个参数的限度:
/* * Slab cache management. */struct kmem_cache { // slab cache 在 numa node 中缓存的 slab 个数下限,slab 个数超过该值,闲暇的 empty slab 则会被回收至搭档零碎 unsigned long min_partial;#ifdef CONFIG_SLUB_CPU_PARTIAL // 限定 slab cache 在每个 cpu 本地缓存 partial 链表中所有 slab 中闲暇对象的总数 // cpu 本地缓存 partial 链表中闲暇对象的数量超过该值,则会将 cpu 本地缓存 partial 链表中的所有 slab 转移到 numa node 缓存中。 unsigned int cpu_partial;#endif};
- min_partial 次要管制 NUMA 节点缓存 partial 列表 slab 个数,如果超过该值,那么列表中闲暇的 empty slab 就会被开释回搭档零碎中。
- cpu_partial 次要管制 slab cache 本地 cpu 缓存 kmem_cache_cpu 构造 partial 链表中缓存的闲暇对象总数,如果超过该值,那么 kmem_cache_cpu->partial 列表中缓存的 slab 将会被全副转移至 kmem_cache_node->partial 列表中。
当初 slab cache 的整个架构全貌曾经展示在了咱们背后,上面咱们基于 slab cache 的整个架构,来看一下它是如何调配和开释内存的。
7. slab 内存调配原理
同搭档零碎的内存调配原理一样,slab cache 在分配内存块的时候同样也分为疾速门路 fastpath 和慢速门路 slowpath,而且 slab cache 的组织架构比较复杂,所以在分配内存块的时候又会分为很多场景,在本大节中,笔者会为大家一一列举这些场景,并用图解的形式为大家论述 slab cache 内存调配在不同场景下的逻辑。
7.1 从本地 cpu 缓存中间接调配
咱们假如当初 slab cache 中的容量状况如上如图所示,slab cache 的本地 cpu 缓存中有一个 slab,slab 中有很多的闲暇对象,kmem_cache_cpu->page 指向缓存的 slab,kmem_cache_cpu->freelist 指向缓存的 slab 中第一个闲暇对象。
当内核向该 slab cache 申请对象的时候,首先会进入疾速调配门路 fastpath,通过 kmem_cache_cpu->freelist 间接查看本地 cpu 缓存 kmem_cache_cpu->page 中是否有闲暇对象可供调配。
如果有,则将 kmem_cache_cpu->freelist 指向的第一个闲暇对象拿进去调配,随后调整 kmem_cache_cpu->freelist 指向下一个闲暇对象。
7.2 从本地 cpu 缓存 partial 列表中调配
当 slab cache 本地 cpu 缓存的 slab (kmem_cache_cpu->page) 中没有任何闲暇的对象时(全副被调配进来了),那么 slab cache 的内存调配就会进入慢速门路 slowpath。
内核会到本地 cpu 缓存的 partial 列表中去查看是否有一个 slab 能够调配对象。这里内核会从 partial 列表中的头结点开始遍历直到找到一个能够满足调配的 slab 进去。
随后内核会将该 slab 从 partial 列表中摘下,间接晋升为新的本地 cpu 缓存。
这样一来 slab cache 的本地 cpu 缓存就被更新了,内核通过 kmem_cache_cpu->freelist 指针将缓存 slab 中的第一个闲暇对象调配进来,随后更新 kmem_cache_cpu->freelist 指向 slab 中的下一个闲暇对象。
7.3 从 NUMA 节点缓存中调配
随着工夫的推移, slab cache 本地 cpu 缓存的 slab 中的对象被一个一个的调配进来,变成了一个 full slab,于此同时本地 cpu 缓存 partial 链表中的 slab 也被全副摘除结束,此时是一个空的链表。
那么在这种状况下,slab cache 如何分配内存呢?依据前边 《6.2 slab 的组织架构》大节介绍的内容,此时 slab cache 就该从仓库中拿 slab 了,这个仓库就是上图中的 kmem_cache_node 构造中的 partial 链表。
内核会从 kmem_cache_node->partial 链表的头结点开始遍历,将遍历到的第一个 slab 从链表中摘下,间接晋升为新的本地 cpu 缓存 kmem_cache_cpu->page, kmem_cache_cpu->freelist 指针从新指向该 slab 中第一个闲暇独享。
随后内核会接着遍历 kmem_cache_node->partial 链表,将链表中的 slab 挨个摘下填充到本地 cpu 缓存 partial 链表中。最多只能填充 cpu_partial / 2
个 slab。这里的 cpu_partial
就是前边介绍的 struct kmem_cache 构造中的属性。
struct kmem_cache { // 限定 slab cache 在每个 cpu 本地缓存 partial 链表中缓存的所有 slab 中闲暇对象的总数 // cpu 本地缓存 partial 链表中闲暇对象的数量超过该值,则会将 cpu 本地缓存 partial 链表中的所有 slab 转移到 numa node 缓存中。 unsigned int cpu_partial;}
这样一来,slab cache 就从仓库 kmem_cache_node->partial 链表中从新填充了本地 cpu 缓存 kmem_cache_cpu->page 以及 kmme_cache_cpu->partial 链表。
随后内核间接从本地 cpu 缓存中,通过 kmem_cache_cpu->freelist 指针将缓存 slab 中的第一个闲暇对象调配进来,随后更新 kmem_cache_cpu->freelist 指向 slab 中的下一个闲暇对象。
7.4 从搭档零碎中从新申请 slab
当 slab cache 的本地 cpu 缓存 kmem_cache_cpu->page 是空的,kmem_cache_cpu->partial 链表中也是空,NUMA 节点缓存 kmem_cache_node->partial 链表中也是空的时候,比方,slab cache 在刚刚被创立进去时,就是上图中的架构,齐全是一个空的 slab cache。
这时,内核就须要到搭档零碎中从新申请一个 slab 进去,具体向搭档零碎申请多少内存页是由 struct kmem_cache 构造中的 oo
来决定的,它的高 16 位示意一个 slab 所须要的内存页个数,低 16 位示意 slab 中所蕴含的对象总数。
struct kmem_cache { // 示意 cache 中的 slab 大小,包含 slab 所申请的页面个数,以及所蕴含的对象个数 // 其中低 16 位示意一个 slab 中所蕴含的对象总数,高 16 位示意一个 slab 所占有的内存页个数。 struct kmem_cache_order_objects oo; // 当依照 oo 的尺寸为 slab 申请内存时,如果内存缓和,会采纳 min 的尺寸为 slab 申请内存,能够包容一个对象即可。 struct kmem_cache_order_objects min;}
当零碎中闲暇内存不足时,无奈取得 oo
指定的内存页个数,那么内核会降级采纳 min
指定的内存页个数,从新到搭档零碎中去申请。这些内容笔者曾经在本文 《6.1 slab 的根底信息管理》大节中具体介绍过了,遗记的读者敌人能够在回顾一下。
当内核从搭档零碎中申请出指定的内存页个数之后,就会依据笔者在 《5. 从一个简略的内存页开始聊 Slab》 大节中介绍的内容,初始化 slab ,最初将初始化好的 slab 间接晋升为本地 cpu 缓存 kmem_cache_cpu->page 。
当初 slab cache 的本地 cpu 缓存被从新填充了,内核间接从本地 cpu 缓存中,通过 kmem_cache_cpu->freelist 指针将缓存 slab 中的第一个闲暇对象调配进来,随后更新 kmem_cache_cpu->freelist 指向 slab 中的下一个闲暇对象。
8. slab 内存开释原理
slab cache 的内存开释正好和内存调配的过程相同,但内存开释的过程会比内存调配的过程简单一些,内存开释同样也蕴含疾速门路 fastpath 和慢速门路 slowpath,也会分为很多场景,在本大节中,笔者持续用图解的形式为大家论述 slab cache 在不同场景下的内存开释逻辑。
8.1 开释对象所属 slab 在 cpu 本地缓存中
如果将要开释回 slab cache 的对象所在的 slab 刚好是本地 cpu 缓存中缓存的 slab,那么内核间接会把对象开释回缓存的 slab 中,这个就是 slab cache 的疾速内存开释门路 fastpath。
随后修改 kmem_cache_cpu->freelist 指针使其指向刚刚被开释的对象,开释对象的 freepointer 指针指向原来 kmem_cache_cpu->freelist 指向的对象。
8.2 开释对象所属 slab 在 cpu 本地缓存 partial 列表中
当开释的对象所属的 slab 在 cpu 本地缓存 kmem_cache_cpu->partial 链表中时,内核也是间接将对象开释回 slab 中,而后批改 slab (struct page)中的 freelist 指针指向刚刚被开释的对象。开释对象的 freepointer 指向其下一个闲暇对象。
8.3 开释对象所属 slab 从 full slab 变为了 partial slab
本大节中介绍的开释场景是,以后开释对象所在的 slab 原来是一个 full slab,因为对象的开释刚好变成了一个 partial slab,并且该 slab 原来并不在 slab cache 的本地 cpu 缓存中。
这种状况下,当对象开释回 slab 之后,内核为了利用局部性的劣势须要把该 slab 在插入到 slab cache 的本地 cpu 缓存 kmem_cache_cpu->partial 链表中。
因为 slab 之前之所以是一个 full slab,恰好证实了该 slab 是一个十分沉闷的 slab,经常供不应求导致变成了一个 full slab,当对象开释之后,刚好变成 partial slab,这时须要将这个被频繁拜访的 slab 放入 cpu 缓存中,放慢下次调配对象的速度。
以上内容只是 slab 被开释回 kmem_cache_cpu->partial 链表的失常流程,然而通过本文 《6.2 slab 的组织架构》大节最初的内容介绍咱们晓得,slab cache 的本地 cpu 缓存 kmem_cache_cpu->partial 链表中的容量不可能是无限度增长的,它受到 kmem_cache 构造中 cpu_partial
属性的限度:
struct kmem_cache { // 限定 slab cache 在每个 cpu 本地缓存 partial 链表中所有 slab 中闲暇对象的总数 // cpu 本地缓存 partial 链表中闲暇对象的数量超过该值,则会将 cpu 本地缓存 partial 链表中的所有 slab 转移到 numa node 缓存中。 unsigned int cpu_partial;};
当每次向 kmem_cache_cpu->partial 链表中填充 slab 的时候,内核都须要首先查看以后 kmem_cache_cpu->partial 链表中所有 slabs 所蕴含的闲暇对象总数是否超过了 cpu_partial
的限度。
如果没有超过限度,则将 slab 插入到 kmem_cache_cpu->partial 链表的头部,如果超过了限度,则须要首先将以后 kmem_cache_cpu->partial 链表中的所有 slab 转移至对应的 NUMA 节点缓存 kmem_cache_node->partial 链表的尾部,而后能力将开释对象所在的 slab 插入到 kmem_cache_cpu->partial 链表中。
大家读到这里,我想肯定会有这样的一个疑难,就是内核这里为什么要把 kmem_cache_cpu->partial 链表中的 slab 一次性全副挪动到 kmem_cache_node->partial 链表中呢?
这样一来如果在 slab cache 的本地 cpu 缓存不够的状况下,不是还要在大老远从 kmem_cache_node->partial 链表中再次转移 slab 填充 kmem_cache_cpu 吗?这样一来门路就拉长了,内核为啥要这样设计呢?
其实咱们做任何设计都是要思考以后场景的,当 slab cache 演进到如上图所示的架构时,阐明内核以后所处的场景是一个内存开释频繁的场景,因为内存频繁的开释,所以导致 kmem_cache_cpu->partial 链表中的闲暇对象都快被填满了,曾经超过了 cpu_partial
的限度。
所以在内存频繁开释的场景下,kmem_cache_cpu->partial 链表太满了,而内存调配的申请又不是很多,kmem_cache_cpu 中缓存的 slab 并不会频繁的耗费。这样一来,就须要将链表中的所有 slab 一次性转移到 NUMA 节点缓存 partial 链表中备用。否则的话,就得频繁的转移 slab,这样性能耗费更大。
然而以后开释对象所在的 slab 依然会被增加到 kmem_cache_cpu->partial 表中,用以应答不那么频繁的内存调配需要。
8.4 开释对象所属 slab 从 partial slab 变为了 empty slab
如果开释对象所属的 slab 原来是一个 partial slab,在对象开释之后变成了一个 empty slab,在这种状况下,内核将会把该 slab 插入到 slab cache 的备用仓库 NUMA 节点缓存中。
因为 slab 之所以会变成 empty slab,表明该 slab 并不是一个沉闷的 slab,内核曾经良久没有从该 slab 中调配对象了,所以只能把它开释回 kmem_cache_node->partial 链表中作为本地 cpu 缓存的后备选项。
然而 kmem_cache_node->partial 链表中的 slab 不可能是有限增长的,链表中缓存的 slab 个数受到 kmem_cache 构造中 min_partial
属性的限度:
struct kmem_cache { // slab cache 在 numa node 中缓存的 slab 个数下限,slab 个数超过该值,闲暇的 empty slab 则会被回收至搭档零碎 unsigned long min_partial;}
所以内核在将 slab 插入到 kmem_cache_node->partial 链表之前,须要查看以后 kmem_cache_node->partial 链表中缓存的 slab 个数 nr_partial
是否曾经超过了 min_partial
的限度。
struct kmem_cache_node { // 该 node 节点中缓存的 slab 个数 unsigned long nr_partial;}
如果超过了限度,则间接将 slab 开释回搭档零碎中,如果没有超过限度,才会将 slab 插入到 kmem_cache_node->partial 链表中。
还有一种间接开释回 kmem_cache_node->partial 链表的情景是,开释对象所属的 slab 原本就在 kmem_cache_node->partial 链表中,这种状况下就是间接开释对象回 slab 中,无需扭转 slab 的地位。
总结
本文在搭档零碎的根底上又为大家具体介绍了一款内核专门应答小内存块治理的 slab 内存池,并列举了 slab 内存池在内核中的几种利用场景。
而后咱们从一个简略的内存页开始聊起,首先具体介绍了在 slab 内存池中所治理的内存块在内存中的布局:
在此基础上,笔者带大家持续采纳一步一图的形式,一步一步地推演出 slab cache 的整体架构:
在咱们失去了 slab cache 的整体架构之后,后续笔者基于此架构图,又为大家具体介绍了 slab cache 的运行原理,其中包含内核在多种不同场景下针对内存块的调配和回收逻辑。
在介绍 slab cache 针对小内存块调配原理的章节,咱们列举了如下四种场景:
- 从本地 cpu 缓存中间接调配
- 从本地 cpu 缓存 partial 列表中调配
- 从 NUMA 节点缓存中调配
- 从搭档零碎中从新申请 slab
slab cache 针对小内存块回收,又分为如下四种场景:
- 开释对象所属 slab 在 cpu 本地缓存中
- 开释对象所属 slab 在 cpu 本地缓存 partial 列表中
- 开释对象所属 slab 从 full slab 变为了 partial slab
- 开释对象所属 slab 从 partial slab 变为了 empty slab
好了,本文的内容就到这里了,slab cache 的机制的确比较复杂,波及到的场景又很多,后续的文章笔者会带大家到内核源码中去一一验证本文内容的正确性。咱们下篇文章见~~~