2021SC@SDUSC
前面几篇博客分析完了postgreSQL源码在外存管理中的一些重点,后面的几篇博客将对内存管理的重点源码进行分析。
在DBMS中内存管理的目的就是尽量减少磁盘的I/O次数,因为每次I/O都会花费比较长的时间(相比于CPU来说),因此,如何尽可能地让所需的文件块在内存中,就是内存管理要解决的问题。
首先,来分析一下postgreSQL中的MemoryContext(内存上下文)机制。
src/backend/utils/mmgr/README
首先来阅读一下postgreSQL关于内存管理部分的README文件,了解一下memory context在设计上的相关细节。
在README文件中,说明了postgreSQL通过内存上下文进行大多数的内存分配,而内存上下文分配内存是通过src/backend/utils/mmgr/aset.c
文件中的AllocSets实现的,在下面我会进行分析。这意味着所有在内存上下文中分配的内存空间都会通过内存上下文进行记录,因此可以很容易的通过释放内存上下文来释放其中的所有内容,从而使得内存的分配和释放既可靠又快速。
内存上下文的基本操作:
根据上面的基本操作,我们可以看出这个内存上下文机制其实和操作系统的内存管理比较相似。操作系统为每个进程(用C语言程序举例)分配了执行的环境,进程可以调用上面提到的(malloc、free、realloc等函数),来对内存进行操作。内存上下文就相当于进程的执行环境,使得postgreSQL的后端进程能够通过其分配和释放内存。不过,内存上下文的释放相较于free函数来说,可以更容易地释放整个内存上下文分配地内存,而无需逐个块的操作。
有一个CurrentMemoryContext的全局变量,用于保存当前的上下文。设置这个全局变量的目的是:避免每次调用函数都需要向其传入一个上下文引用的开销。
postgreSQL的每个子进程都有多个私有的内存上下文,下面我们来看一下子进程内存上下文的构成。
仍然是在README文件中,首先子进程的所有内存上下文构成一个树形结构(并不是所有子进程都会有这样一个完整的树形结构,这里为了直观我就把完整的树形结构画上去了):
portal
翻译成中文是入口,在postgreSQL中,portal用来表示一个正在执行的或者是可执行的查询的执行状态。
portal记录了与执行相关的所有信息,比如查询树、计划树和执行状态。因此可以将Portal当作执行查询计划的入口。
通过这个树形结构,就可以跟踪进程中内存上下文的创建以及使用情况,当创建一个新的内存上下文时,可以将其添加到某个内存上下文的下面作为子节点。清除内存时可以从根节点开始DFS遍历上下文树从而将所有节点的内存占用释放。
MemoryContext本身是一个抽象的数据类型(ADT),可以有多种不同的实现方式,目的应该是实现上面提到的各种不同的上下文类型。
MemoryContext定义
源码位于src/include/utils/palloc.h
中。
typedef struct MemoryContextData *MemoryContext;//即MemoryContextData的指针类型,详细分析在下面。
MemoryContextData结构体
源码位于src/include/nodes/memnodes.h
中。
用于存放上下文的头部信息。
typedef struct MemoryContextData
{
NodeTag type; //内存上下文节点类型
/* 下面两个变量是为了减少内存对齐浪费: */
bool isReset; //在上次重置后是否分配内存空间
bool allowInCritSection; //是否允许在临界区使用palloc
const MemoryContextMethods *methods; //用于存放虚拟函数表(ADT的实现),会始终指向AllocSetMethods这个全局变量,原因放在下面的内存上下文操作中。
MemoryContext parent; //上下文树中该节点的父节点指针
MemoryContext firstchild; //上下文树中该节点的第一个孩子指针
MemoryContext prevchild; //上下文树中该节点的左边的兄弟节点的指针
MemoryContext nextchild; //上下文树中该节点的右边的兄弟节点的指针
const char *name; //上下文的名称(调试时使用)
const char *ident; //上下文的ID(调试时使用)
MemoryContextCallback *reset_cbs; //重置或删除的回调列表
} MemoryContextData;
关于isReset,PostgreSQL中提供了对内存上下文的重置,即释放内存上下文中所有分配的内存。在内存上下文被创建时,isReset变量会被设置为true,表示上次重置后还没有分配内存空间。一旦分配空间以后,这个变量就会被设置为false。按照这种规则,当重置时,检查isReset变量,如果为true则说明没分配空间,无需再进行重置操作,节省时间。
MemoryContext结构体
源码位于src/include/nodes/memnodes.h
中。
由一系列的函数指针构成,包含对内存上下文进行操作的函数,也就是ADT封装,可以有不同的方法集合。
typedef struct MemoryContextMethods
{
void *(*alloc) (MemoryContext context, Size size);//分配内存的函数指针
void (*free_p) (MemoryContext context, void *pointer);//释放内存的函数指针
void *(*realloc) (MemoryContext context, void *pointer, Size size);//重分配内存的函数指针
void (*reset) (MemoryContext context);//重置内存上下文的函数指针
void (*delete_context) (MemoryContext context);//删除内存上下文的的函数指针
Size (*get_chunk_space) (MemoryContext context, void *pointer);//检查内存片段大小的的函数指针
bool (*is_empty) (MemoryContext context);//检查内存上下位是否为空的的函数指针
void (*stats) (MemoryContext context,
MemoryStatsPrintFunc printfunc, void *passthru,
MemoryContextCounters *totals);//打印内存上下文状态的的函数指针
} MemoryContextMethods;
这里就是开头提到的内存上下文相关操作的实现形式,位于src/backend/utils/mmgr/aset.c
中。
类似于外存管理只实现了磁盘管理一种方式,postgreSQL关于上面的MemoryContext也只有AllocSetContext一种实现,因此PostgreSQL中只有针对AllocSetContext的一种操作函数集合,由全局变量AllocSetMethods表示。这也是MemoryContext中的methods变量会始终指向AllocSetMethods的原因。
AllocSet定义
typedef AllocSetContext *AllocSet;//类似与MemoryContext,AllocSet是AllocSetContext的指针
AllocSetContext结构体
typedef struct AllocSetContext
{
MemoryContextData header; //内存上下文的头信息,类似于外存管理中的头信息,即上面分析的MemoryContext
//关于内存分配的信息
AllocBlock blocks; //内存上下文中所有内存块的链表
AllocChunk freelist[ALLOCSET_NUM_FREELISTS]; //内存上下文中空闲内存片的数组
//内存上下文分配时用到的参数
Size initBlockSize; //初始内存块的大小
Size maxBlockSize; //最大内存块的大小
Size nextBlockSize; //下一个要分配的内存块的大小
Size allocChunkLimit; //分配内存片的尺寸阈值,在分配内存片时会遇到
AllocBlock keeper; //保存在这个变量中的内存块在内存上下文重置时会被保留,不被释放。
//可以将该上下文放入到context_freelists中
int freeListIndex; //即该上下文在context_freelists中的序号,如果未放入则为-1。
} AllocSetContext;
内存上下文分配参数解析
initBlockSize、maxBlockSize这两个变量会在内存上下文创建的时候就给定,然后会将nextBlockSize设置为和initBlockSize相同的值。在进行内存分配时,如果需要分配一个新的内存块,那么这个新内存块的大小就会采用nextBlockSize的值,因为nextBlockSize可能会变大,以变成当前大小2倍的形式,所以内存上下文重置时会将nextBlockSize重置为initBlockSize。
要想明白allocChunkLimit这个变量的作用,首先得知道什么是内存片:
内存片 内存块内会分成多个称为内存片的内存单元。
因此在分配内存片时,如果一个内存片的尺寸超过了allocChunkLimit时,将会为该内存片单独分配一个独立的内存块,从而避免以后进行内存回收时产生过多的碎片。
keeper
在内存上下文进行重置时不会对keeper中记录的内存块进行释放,而是对其内容进行清空。这样可以保证内存上下文重置结束后就已经包含一定的可用内存空间,而不需要通过malloc再申请。同时也可以避免在某个内存上下文被反复重置时,反复进行malloc带来的风险。 比如,在执行查询时,每一个元组被获得时,都需要有一个内存上下文用于存放与之相关的信息,而当获取下一个元组时,该内存上下文将被重置后重复使用。通过keeper变量保留一个内存块,可以避免每次重置都进行malloc操作。
AllocBlockData结构体(内存块链表节点)
typedef struct AllocBlockData
{
AllocSet aset; //该内存块所位于的AllocSet,即上面的数据结构
AllocBlock prev; //指向链表中上一个内存块的指针
AllocBlock next; //指向链表中下一个内存块的指针
char *freeptr; //指向该内存块中空闲区域的首地址
char *endptr; //指向该内存块的尾地址
} AllocBlockData;
AllocBlock是通过malloc函数(C标准库)分配的一个内存单元,包含一个或多个内存片。每个内存片由一个头部信息和数据区域组成,头部信息的结构体在下面,数据区域就是存储实际数据。
AllocChunkData结构体(内存片头部信息)
typedef struct AllocChunkData
{
Size size;//内存片中的空闲空间大小
void *aset;//该内存片所在的AllocSet,如果内存片为空闲,则用于链接其空闲链表
} AllocChunkData;
内存片的数据区域在头部信息之后分配,在内存中是连在一起的。通过postgreSQL自定义的palloc函数和pfree函数,可以自由地在内存上下文中申请和释放内存片,被释放的内存片将被加入到FreeList中以备重复使用。
FreeList数组
就是一个AllocChunkData类型(上面的结构体)的数组。用于维护在内存块中被回收的空闲内存片,这些空闲内存片将用于再分配。关于数组的长度,上面的代码中定义为AllocChunk freelist[ALLOCSET_NUM_FREELISTS]
,也就是说长度是由ALLOCSET_NUM_FREELISTS这个宏定义的, 我们在源码中可以找到定义:
#define ALLOCSET_NUM_FREELISTS 11
可以看到,这个宏定义大小为11,也就意味着这个数组的大小默认为11。
FreeList数组中的每一个元素都指向一个由特定大小空闲内存片组成的链表,大小和该元素在数组中的位置有关。假设元素序号为N,则该元素所指向的链表的每个空闲内存片的大小为 2 ( N + 3 ) 2^{(N+3)} 2(N+3) 字节。这意味着指向最小的空闲内存片大小为23 =8B,最大的空闲内存片大小为213 =8KB。然后去源码找一下关于空闲内存片大小的限制:
#define ALLOC_MINBITS 3 //这个即2的幂次,也就意味着数据片最小的大小为2^3,即8B
#define ALLOC_CHUNK_LIMIT (1 << (ALLOCSET_NUM_FREELISTS-1+ALLOC_MINBITS)) //限制内存片的最大大小。即1左移(11-1+3)=13位,也就是2^13,即8KB
正好和我们刚才推测出的大小所吻合。所以,想要改变数组的大小时,一定要从宏定义那里修改,不然这里限制空闲内存片最大大小的映射会出现问题。
因此,FreeList数组其实维护了11个空闲链表,每个链表对应一种大小的空闲内存片。而AllocChunkData种只有一个变量能用来维护这个空闲链表,那就是aset这个变量。那么它是如何和链表关联起来的呢?我们就来分析一下aset的作用:如果一个内存片正在使用,那么它的aset变量会指向所属的AllocSet。如果内存片为空闲的,这就意味着它会位于某个空闲链表种(根据大小确定),那么它的aset变量就会指向空闲链表中在它后面的内存片(void *类型,可以指向任意结构)。这样的话,从FreeList数组元素所指向的链表头开始,顺着aset变量(相当于next)就可以到达下一个内存片,从而找到该空闲链表中所有的空闲内存片。
当有一个大小为size的内存请求时,就会按照规则对其进行内存的分配:
这种内存分配方式对于内存片的分配来说是非常简单的,但也有缺点:需求的内存不可能总是2的幂次,这意味着分配到的空间总有一部分用不到,这就会像操作系统的固定分区分配一样,产生内部碎片。
抽象数据类型
MemoryContext采用了抽象数据类型的设计方式,极大的提高了封装性、通用性和灵活性。不过和外存管理只实现了磁盘管理一样,这里的内存上下文也只实现了AllocSet一种方式。
FreeList数组
关于FreeList数组的设计真的是非常巧妙,数组中藏着链表。而数组的长度又能和内存片的大小映射,这也跟内存片大小设计为2的。
aset变量
关于这个变量的设计我感觉很巧妙。借助void *类型,使得在内存片不空闲的时候能指向所属的AllocSet,又能在内存片空闲的时候变成next,指向空闲链表中在它后面的内存片。