openGauss 内存管理

文摘   2024-11-19 17:31   广东  

openGauss 中内存的分配与释放是基于内存上下文(MemoryContext)管理的,这种管理方式使得开发者可以不手动释放内存,能减少内存泄漏问题。

总览

一般来说,上下文是会与一个相对独立的处理流程绑定起来。
比方说执行引擎中 Portal 执行的流程会对应一个 
PortalHeapMemory 的上下文。

首先考虑一个最简单场景:只有一个流程。

  • 上下文会在流程开始前实例化。

  • 流程执行过程中调用 palloc 分配出的内存都会被这个上下文跟踪。

  • 流程结束后释放上下文时,就把该上下文跟踪的所有内存释放掉。

在流程处理过程中,有一个线程局部变量 CurrentMemoryContext 用于存储当前流程的上下文。
palloc 宏展开之后其实也是通过这个变量分配的内存。

然后我们再考虑比较复杂的场景:流程之间会存在嵌套关系,一个流程可能会由多个子流程组成。
比方说 Portal 执行流程(
PortalHeapMemory)是会包含 Executor 子流程(ExecutorState)的。

对于这种场景,我们可以把流程看作是节点,把父子关系看作是边,就构成了一个树形结构。
这些流程对应的各个上下文也同样会形成一棵结构相同的上下文树。
这棵上下文树的根节点是 
ThreadTopMemoryContext,一般我们可以用线程局部变量 t_thrd.top_mem_cxt 访问它。
进入子流程的时候只需要实例化一个新的子上下文,然后用它来替换掉 
CurrentMemoryContext 即可。
当子流程退出时那就把 
CurrentMemoryContext 换回原本的上下文。
其实这个过程有点像函数调用栈,
CurrentMemoryContext 就类似于栈顶指针。

最后我们考虑实际实现中的场景:一个流程中除了子流程,还会包含多个子模块。
这些子模块需要分别使用不同的上下文,且它们的生命周期可能存在重叠。

这个场景其实也还好,因为上下文的生命周期也是开发者手动管理的。
一种简单的思路就是开发者直接在需要的地方给它手动实例化或者释放,但这种搞法看起来比较乱。
我看代码里还有一种做法是让子模块的生命周期跟它的父节点大致相同:跟父节点一起实例化,并与父节点一同释放。

MemoryContext

上下文本身是一个 MemoryContextData 结构体,代码中常见的 MemoryContext 其实是用这个结构体 typedef 出来的指针。
这个结构体是一个基类,里面维护了一些树形结构要用的边以及虚函数表。

简单列举一下 MemoryContext 的成员:

  • type:保存类型信息,可用于 downcast。

  • methods:保存在本上下文中内存基础分配释放等函数指针的虚函数表。

  • alloc_methods:给内存分配的虚函数加一层封装,常用于加塞调试断言。

  • mcxt_methods:上下文自身管理的虚函数表,比如释放子节点、析构函数等。

AllocSetContext

MemoryContext 的一个常用子类就是 AllocSetContext,是一种链表的内存池实现。

Block

跟大部分内存管理一样,它每次向操作系统分配内存都是申请一大块内存,称这块内存为 Block,结构体指针类型是 AllocBlock
而且每次内存池容量不足需要扩容时,分配的下一块 Block 容量会比上一块的多一倍。
这种分配策略可以降低时间复杂度,与 
std::vector 的扩容思路是相同的。
不过这个容量不会无限翻倍,会有一个最大的分配容量上限(
maxBlockSize)。
最初的 Block 容量由 
initBlockSize 指定。
所有分配出的 Block 由一个链表维护,对应的成员是 
AllocSetContext::blocks,它保存的是链表尾指针。

上下文生命周期结束时,只要把所有 Block 全部干掉就完事了。

Chunk

然后上层应用最开始通过内存池分配内存时(比如 palloc),内存池会从 Block 中切割出合适容量的内存块 Chunk,返回给上层应用。
这个 Chunk 的结构体指针类型是 
AllocChunk
跟其他内存池一样,当用户程序释放内存时,它会把用户归还的 Chunk 塞回一个 freelist 里面,对应的成员是 
AllocSetContext::freelist
以后用户程序再希望分配内存时,
AllocSetContext 就可以先去 freelist 里面翻有没有合适容量的 Chunk。
有的话就直接返回;没有的话再去从 Block 里面切割新的 Chunk。

注意,在运行过程中 AllocSetContext 会分配出很多个 Block,它们以链表的方式存储。
除链表末尾的 Block 外,其他 Block 都是被实现为利用完全,无法继续切割的。
切割 Chunk 的时候我们只能用链表末尾的 Block 来切。
如果这个末尾的 Block 容量不足以切割出合适容量的 Chunk,那就只能开新的 Block。
但是这个容量不足的 Block 可能还有剩余容量没用完,不能浪费。
所以就直接把它切成一堆尽可能大的 Chunk,全部塞 freelist 里面。
最后再开一个新的 Block,再从里面去切割 Chunk 就行了。

Freelist

有一个细节需要提一下,就是如何通过 freelist 快速获取容量合适的 Chunk。
所有 Chunk 的容量都是二的整数幂,每次切割 Chunk 时会选择恰能容纳待分配内存的最小容量。
比方说用户程序需要分配 1023 字节的内存,内存池会返回给它一个 2 ^ 10 = 1024 字节的 Chunk。
当然这个 Chunk 肯定是有一个 Header 的,要考虑偏移量。
Freelist 实际上维护的是一个数组,这个数组每个元素都是一个 Chunk 链表,每个链表里的 Chunk 容量都是相同的。
相当于不同的链表分别存储不同容量的 Chunk。
因此分配内存时我们只需要找到容量合适的链表就行。
一种简单的思路就是给这个链表数组按照容量从小到大维护一个固定顺序,直接 
std::lower_bound 就能二分找到。
然后又因为我们规定 Chunk 的容量是二的整数幂,就二分都不用了,直接用神秘的指令统计前导零就能直接把容量换算成数组下标。
实际的实现是数组下标 
i 里的链表维护容量为 1 << (i + ALLOC_MINBITS) 的 Chunk。
这个 
ALLOC_MINBITS 是为了让 Chunk 容量不要太小,同时还可以保证内存对齐,实际代码里是 #define ALLOC_MINBITS 3,也就是约束最小的 Chunk 容量为 8 字节。
举个例子,比方说需要分配 1023 字节的内存,先向上对齐为 1024 字节,然后统计 1024 的前导零发现它是 2 的 10 次方,然后减去 
ALLOC_MINBITS,所以它的数组下标就是 7。
具体可以去看 
AllocSetFreeIndex 的实现。

同理,Chunk 释放的时候也是计算出索引值就塞到对应的链表里。
需要注意一点是 Chunk 需要先找到它的所属的上下文才能访问到 freelist。
所属上下文指针存储在 header 里面,需要将用户传入的待释放内存的指针往回偏移一个 header。

看起来这个内存池是不方便处理内存碎片的。

结构体

列举一下 AllocSetContext 的成员:

  • header:父类结构体。

  • blocks:实际分配出的 Block 链表,实际存的是 Block 链表尾指针。

  • freelist:内存池的一种自由表实现,用于存放闲置的内存块 Chunk,Chunk 的容量分配基于倍增思想实现。

  • initBlockSize:第一个 Block 的容量。

  • maxBlockSize:每次分配 Block 时最大的分配容量。

  • nextBlockSize:下一次分配的 Block 的容量,每分配一次 Block 这个变量就会倍增,但它不能超过 maxBlockSize

  • keeper:保存最初的 Block 指针,也就是 Block 链表首指针。

  • totalSpace:内存池向操作系统总共分配出的容量。

  • freeSpace:扣掉 Block 的 Header 后可用的容量,每次向用户返回 Chunk 后,会扣掉 Chunk 带上 Header 的实际容量。

AllocChunk 其实是个 Header,列举一下它的成员:

  • aset:是个被复用的 void *,当 Chunk 在 freelist 里面的时候用作链表 next 指针,当 Chunk 被返回给上层用户时用作指向所属 AllocSetContext 的指针。

  • size:Chunk 容量。

AllocBlockData 就没什么好说的了,成员变量名已经自注释了:

  • aset

  • prev

  • next

  • freeptr

  • endptr

  • allocSize

PortalHeapMemory

这里以 PortalHeapMemory 为例,用调试器观察内存上下文的生命周期以及一些实现细节。

上下文创建

PortalHeapMemory 上下文实现是 AllocSetContext
上下文结构体的内存也是通过上下文进行分配的,实际调试观察到它会通过线程 root 上下文 
ThreadTopMemoryContext 分配出来的。
它分配内存时候还会顺带分配出虚函数表 
MemoryContextMethods *methods 的内存。
具体参考函数 
MemoryContextCreate
顺带一提 
ThreadTopMemoryContext 的实现也是 AllocSetContext

创建上下文时需要指定上下文的父节点与名称,观察到 PortalHeapMemory 的父节点是 portal_mem_cxt
具体参考函数 
CreatePortal

上下文节点各种初始值、大部分虚函数表以及树形结构中边的初始化都在 MemoryContextCreate 里面。

虚函数表 MemoryContextData::methods 是由 AllocSetContextSetMethods 进行初始化的。
这些虚函数提供内存分配与释放等接口,在这个例子中它们的内部实现就是上文提到的 
AllocSetContext 链表内存池。

然后内存池最初的 Block 可能会在 AllocSetContextCreate 里面直接分配,需要入参 minContextSize 传入一个比较大的值。

提供一下主流程的调用栈:

#0  GenericMemoryAllocator::AllocSetAlloc<true, false, false> (context=0xfffaf2930000, align=0, size=465, file=0x710d2c0 "aset.cpp", line=521) at aset.cpp:946#1  0x000000000118abf0 in MemoryAllocFromContext (context=0xfffaf2930000, size=449, file=0x710d2c0 "aset.cpp", line=521) at mcxt.cpp:1196#2  0x0000000001189d64 in MemoryContextCreate (tag=T_AllocSetContext, size=352, parent=0xfffaf29504b0, name=0x71137b0 "PortalHeapMemory", file=0x710d2c0 "aset.cpp", line=521) at mcxt.cpp:1058#3  0x0000000001171608 in GenericMemoryAllocator::AllocSetContextCreate (parent=0xfffaf29504b0, name=0x71137b0 "PortalHeapMemory", minContextSize=0, initBlockSize=1024, maxBlockSize=8192, maxSize=0, isShared=false, isSession=false) at aset.cpp:521#4  0x0000000001171144 in AllocSetContextCreate (parent=0xfffaf29504b0, name=0x71137b0 "PortalHeapMemory", minContextSize=0, initBlockSize=1024, maxBlockSize=8192, contextType=STANDARD_CONTEXT, maxSize=0, isSession=false) at aset.cpp:377#5  0x000000000119254c in CreatePortal (name=0x735bbb0 "", allowDup=true, dupSilent=true, is_from_spi=false, is_from_pbe=false) at portalmem.cpp:239#6  0x00000000019d3db4 in exec_simple_query (query_string=0xfffaf1f44060 "CREATE TABLE sales_table (\n    sales_date DATE NOT NULL\n) with (TTL = '7 days', PERIOD = '1 day')\nPARTITION BY RANGE(sales_date) (\n    PARTITION start\n    VALUES\n        LESS THAN('2021-01-01 00:00:00"..., messageType=QUERY_MESSAGE, msg=0xfffaf3fcb990) at postgres.cpp:2959

内存的分配

这里以最常见的 palloc 为例,这个宏展开后会调用 MemoryAllocFromContext 并传入保存当前上下文的线程局部变量 CurrentMemoryContext
这里由于是调试构建所以会调多一层 
MemoryContextAllocDebug

提供一下调用栈:

#0  GenericMemoryAllocator::AllocSetAlloc<true, false, false> (context=0xfffaf1b175f0, align=0, size=16, file=0x435a340 "datetime.cpp", line=4439) at aset.cpp:810#1  0x000000000118abf0 in MemoryAllocFromContext (context=0xfffaf1b175f0, size=16, file=0x435a340 "datetime.cpp", line=4439) at mcxt.cpp:1196#2  0x000000000118ae3c in MemoryContextAllocDebug (context=0xfffaf1b175f0, size=16, file=0x435a340 "datetime.cpp", line=4439) at mcxt.cpp:1233#3  0x0000000000ded8e4 in char_to_interval (str=0xfffaf26f98f0 "7 days", typmod=-1, can_ignore=false) at datetime.cpp:4439

上下文释放

逻辑很清晰,主要分为两步:第一步是清空内存池;第二步是释放上下文结构体本身。

清空内存池

入口函数是 MemoryContextDeleteInternal
注意它的一个出参 
context_list,这个出参会返回被递归清空内存池的所有上下文结构体指针,用于后续的释放。

MemoryContextDeleteChildren(context, context_list) 把子节点递归全部清空,实际调用函数可以参考 std_MemoryContextDeleteChildren

MemoryContextSetParent(context, NULL) 把树形结构的父子边断开,参考 std_MemoryContextSetParent

(*context->methods->delete_context)(context) 是实际的清空动作,实际调用参考 GenericMemoryAllocator::AllocSetDelete
内部实现其实就是循环遍历 
set->blocks 链表,把里面的 Block 挨个 free 掉。

给一个主流程调用栈:

#0  MemoryProtectFunctions::gs_memprot_free<(MemType)0> (ptr=0xfffaf0d66000, sz=8192) at memprot.cpp:854#1  0x000000000117c368 in GenericMemoryAllocator::AllocSetDelete<true, false, false> (context=0xfffaf1b1f3c8) at aset.cpp:787#2  0x0000000001186958 in MemoryContextDeleteInternal (context=0xfffaf1b1f3c8, parent_locked=false, context_list=0xfffaf3fcb3b8) at mcxt.cpp:377#3  0x0000000001186d64 in std_MemoryContextDelete (context=0xfffaf1b1f3c8) at mcxt.cpp:418#4  0x0000000001193880 in PortalDrop (portal=0xfffaf1a54060, isTopCommit=false) at portalmem.cpp:647

释放上下文结构体

上个小节提到 MemoryContextDeleteInternal 清空内存池的同时会顺带把被清空的上下文节点以一个链表出参 context_list 的形式返回出来。
注意到这个时候原本树形结构已经被破坏了,所以只能用这个链表,不然是拿不到子节点上下文指针的。

入口函数是 FreeMemoryContextList,它会遍历链表挨个用 pfree_ext 释放,调试器进去看到是进入了 pfree 函数。

因为这里的上下文结构体是通过 root 上下文分配的。
所以 
pfree 函数要先通过偏移 header 把内存块的所属上下文指针找到。
然后调用释放的虚函数 
MemoryContextData::methods::free_p
这里它的实现是 
GenericMemoryAllocator::AllocSetFree
内部实现大致上就是找到对应的 freelist 往里面摁塞。

给一下调用栈:

#0  GenericMemoryAllocator::AllocSetFree<true, false, false> (context=0xfffaf2930000, pointer=0xfffaf1b1f3c8) at aset.cpp:1287#1  0x000000000118be48 in pfree (pointer=0xfffaf1b1f3c8) at mcxt.cpp:1486#2  0x000000000118dd4c in FreeMemoryContextList (context_list=0xfffaf3fcb3b8) at mcxt.cpp:2033#3  0x0000000001186dcc in std_MemoryContextDelete (context=0xfffaf1b1f3c8) at mcxt.cpp:427#4  0x0000000001193880 in PortalDrop (portal=0xfffaf1a54060, isTopCommit=false) at portalmem.cpp:647

点击阅读原文跳转作者文章

openGauss
开源关系型数据库
 最新文章