+ -
当前位置:首页 → 问答吧 → [翻译]PostgreSQL内存分配系统的重新设计

[翻译]PostgreSQL内存分配系统的重新设计

时间:2010-12-13

来源:互联网

最近开始学习PostgreSQL的源代码。
以下是本人翻译的一篇文档。
我的博客地址http://blog.csdn.net/Bumanji/archive/2010/11/04/5988370.aspx

$PostgreSQL: pgsql/src/backend/utils/mmgr/README,v 1.15 2008/04/09
01:00:46 momjian Exp $

内存分配系统的重新设计
      直到7.0版,Postgres在处理传引用数据的大的查询过程中,存在严重的内存泄露问题.无法在查询结束之前为内存的循环使用预先做准备.这个问题必须得到解决,特别是TOAST的到来.因为TOAST允许非常大的数据片(chunk)在系统中传递.这篇文档描述了7.1版中实现的新的内存管理系统.

背景
      已经有很多内存分配的操作都是在"内存环境"中进行的.所谓的"内存环境",就是指在backend/utils/mmgr/aset.c中实现的AllocSets.我们所要做的是创建更多的内存环境,定义这些内存环境何时可以释放的恰当的规则.
      对一个内存环境的基本操作有:
       创建内存环境
       在内存环境中分配一个内存片(等同于C标准库中的malloc()函数)
       删除一个内存环境(包括释放内存环境中分配的所有内存)
       重设内存环境(释放在内存环境中分配的所有内存,但不释放内存环境对象本身)
      对于在内存环境中分配的一个内存片,可以将其释放,也可以将其扩大或缩小(对应标准函数库的free和realloc函数).分配或释放内存的操作是在原先分配此内存片的内存环境中进行的.
      任何时候都有一个"当前"内存环境,由CurrentMemoryContext全局变量表示.palloc宏在这个内存环境中分配内存空间.MemoryContextSwitchTo操作选择一个新的当前内存环境(并且返回前一个内存环境,这样调用者可以在退出之前保存前一个内存环境).
      内存环境,相对于对malloc/free函数的直接使用,主要好处就是,整个内存环境的内容可以很容易地释放,而不需要一个一个地释放其中单独的内存片.这比跟踪单独的内存块更快更可靠.我们已经在事务结束时的清理工作中使用了:通过重设所有活跃的内存环境,我们释放了所有的内存.我们需要另外的可以在查询中的关键时候被重设或删除的内存环境,比如在对一个元组的访问之后,重设或删除某一个内存环境.

关于palloc API与标准C库的一些说明
      palloc之类函数的行为与malloc之类函数的行为类似.但也有一些经过考虑而故意造成的不同点.这里对其进行说明.
如果内存用完,palloc和repalloc通过elog(ERROR)退出函数,不会返回NULL,没有必要在调用处判断返回值是否为NULL.
palloc(0)是一个有效操作.它不返回NULL指针,而是会返回一个有效的内存片,不过这个内存片没有字节可以使用.(然而,这个内存片可以通过调用repalloc函数增大,也可以通过调用pfree函数释放而不返回错误.)(注意,这是8.0版的新的行为,早些版本不允许palloc(0)操作.不管怎样,允许这个操作更好些).类似的,repalloc函数可以重新分配内存大小为0字节.
pfree和repalloc不会接受一个NULL指针作为参数.这是有意这样做的.
pfree/repalloc不再依赖于CurrentMemoryContext
      在这样的方案下,可以对任意内存片调用pfree和repalloc,无论这个内存片是否属于CurrentMemoryContext.内存片所属的内存环境会提供这些操作.在使用pfree/repalloc之前,必须将CurrentMemoryContext设置为内存片分配的内存环境的要求已经改变了.旧有的编码要求很明显是容易出错的,而且会引入越来越多的内存环境切换.所以我想只需要在调用palloc时使用CurrentMemoryContext.我们可以通过下面讨论的内存环境管理器来避免调用pfree/repalloc时使用CurrentMemoryContext.
      我们甚至可以考虑整个地就避免使用CurrentMemoryContext.在分配内存的时候,显示地指定内存分配所在的内存环境.但我想这样就需要使用过多的符号--我们要在很多地方的调用中传入同一个内存环境.例如,需要给copyObject例程传入一个内存环境,正像函数执行例程返回一个传引用数据类型一样.那么在函数内部临时分配内存但不返回给其调用者的例程又如何呢?我们当然不想杂乱地让系统中的每一个调用都"使用一个任何临时内存分配都可以用的内存环境".所以,这就需要有一个全局变量,以表示一个合适的可以分配临时内存的内存环境.这就是CurrentMemoryContext.

内存环境机制的附加说明
      如果我们准备更多的内存环境,我们需要更多的机制去跟踪这些内存环境.否则的话,就有将所有的内存环境置于错误条件之下的风险.
      我们通过创建"父"内存环境和"子"内存环境组成的树结构来达到跟踪内存环境的目的.当创建一个内存环境的时候,新内存环境被指定为一些已存在的内存环境的"孩子".一个内存环境可以有多个"孩子",但只能有一个"父"内存环境.这样的话,所有的内存环境就形成了一个"森林".(不是一个独立的"树",因为有可能有不止一个的最高层的内存环境).
      所谓的重设或删除任何特定的内存环境,也会重设或删除所有它的直接的或非直接的"子"内存环境.这个特性让我们可以管理很多内存环境,而不用担心它们会有内存泄露的情况.我们只需要跟踪一个最高层的内存环境,在事务结束的时候将其删除,并且确保任何我们创建的短生命周期的内存环境都是其子孙.因为内存环境树可以有多个层次,我们可以容易地处理存储的生命周期,比如每个事务,每个语句,每次扫描,每个元组.一次有着部分重叠的存储生命周期可以从内存环境森林的不同的树分配内存.(下一节有一些例子)
      为了方便,我们也希望有一些诸如"重设/删除一个给定的内存环境的所有子内存环境,但不重设/删除这个给定内存环境本身"之类的操作.

全局内存环境
      有几个周知的内存环境,这些内存环境通常通过全局变量引用.在任何时候,系统都会包括许多额外的内存环境,但是所有额外的内存环境都会直接或间接地是这些内存环境之一的子内存环境,以确保一旦有错误发生,不会发生内存泄露.
      TopMemoryContext--这是内存环境树实际的最高层级,其他每一个内存环境都是其直接或间接的子内存环境.在这里,分配内存与使用"malloc"一样,因为这个内存环境永远也不会重设或删除.这样做是为了那些永远存在或是在删除时需要非常仔细的情况.一个例子是,fd.c中打开文件的列表,以及内存环境自身的内存环境管理节点本身.,除非真正需要,应避免在这里分配内存.另外,特别要避免在运行时CurrentMemoryContext指向这里.
      PostmasterContext--这是postmaster正常的工作内存环境.后台进程建立之后,就可以删除PostmasterContext,从而释放postmaster不再需要的内存.(所有需要从postmaster传递到后台进程的数据都是通过TopMemoryContext内存环境来传递的.postmaster只有TopMemoryContext,PostmasterContext,以及ErrorContext--其余的最高层次的内存环境将在每个后台进程启动时创建.
      CacheMemoryContext--relcache,catcache以及相关模块的持久存储,无法重设或删除,所以无法真正地将其和TopMemoryContext区分开来.但是需要将两者区分开来以方便调试.(注意:CacheMemoryContext会有短生命周期的子内存环境.例如,有一个作为relcache项的最好的辅助存储的子内存环境.我们可以很容易地释放规则分析树,而无需创建一个可靠版本的freeObject函数来释放规则分析树占用的内存.
      MessageContext--此内存环境持有前台进程传递过来的当前命令消息,以及当前消息衍生出来的并且与当前消息生命周期相同的存储空间.(例如,在simple-Query模式下,分析树和计划树可以在此内存环境中).这个内存环境可以在PostgresMain的每一次循环中被重设,其子内存环境被删除.这与每一个事务和运行平台(portal)的内存环境分开,因为一个查询语句可能需要比单一一个事务或运行平台(portal)保留的时间长或短一些.
      TopTransactionContext--此内存环境一直持续到最高层事务结束的时候.在每一次最高层事务结束的时候,这个内存环境都会被重设,其所有的子内存环境都会被删除.在大多数情况下,你无须在这里分配内存,而应该在CurTransactionContext中分配.在此内存环境中的应该是管理多个子事务状态的控制信息.注意:此内存环境不会在出错时立即清除,而是到事务块通过调用COMMIT/ROLLBACK时清除.
      CurTransactionContext--此内存环境持有当前事务的数据直到当前事务结束,特别是在最高层事务提交时需要此内存环境.当我们处于一个最高层事务中时,此内存环境就跟TopTransactionContext一致,但是在一个子事务中,CurTransactionContext指向一个子内存环境.
      如果一个子事务中止(abort),在中止过程完成后,其CurTransactionContext将被丢弃,这很重要.但是一个已提交的子事务的CurTransactionContext会一直保留到最高层事务提交(当然,除非中间层次的某个子事务也中止了).这保证了我们不会为一个不再需要的已经中止的子事务保存数据.因为这个行为,你必须在子事务中止的过程中仔细地进行清理工作--子事务的状态必须从上级事务持有的指针或链表中释放出来,否则你可能会持有一个野指针,将导致在最高层事务提交时系统崩溃.一个例子是,最高层事务提交时会发送NOTIFY消息,但前提是子事务没有中止.
      PortalContext--这并不是一个独立的内存环境,而是一个全局变量,指向当前活动着的运行平台的per-portal内存环境.在需要分配与当前运行平台(portal)运行时长一致的存储空间时使用.
      ErrorContext--这个持久性的内存环境会在错误恢复过程中切换过去,然后在恢复结束时重设.我们安排了8K在任何时刻都有效的内存.这样,我们可以保证了,甚至在后台进程已经用尽了其所有内存,都会有一些内存可以用来进行错误恢复.这使得内存用尽可以被当做一个通常的错误(ERROR)情况,而不是一个崩溃(FATAL)情况.

预处理语句和运行平台(portal)的内存环境
      一个预处理语句对象有一个与之相关联的私有内存环境,预处理语句对象的查询的分析树和计划树存储在其中.因为这些树对执行器来说是只读的,所有预处理语句可以对此重复使用,而无需对这些树进行复制.
      当运行平台(portal)是活跃状态的时候,一个运行平台(execution-portal)对象有一个PortalContext指向的私有内存环境.在DECLARE CURSOR创建了运行平台(portal)的情况下,这个私有内存环境包含了查询分析树和计划树(没有其他的对象可以存储它们).从预处理语句创建的运行平台(portal)只是单纯引用预处理语句树,并且不需要在其私有内存环境中分配存储空间.

执行过程中的事务内存环境
      当创建一个预处理语句时,分析树和计划树在MessageContext的一个临时子内存环境中分配内存(所有,当遇到错误时会自动地清除).成功后,执行成功的计划会被复制到预处理语句的私有的内存环境中,并且刚才的临时内存环境被释放.这使得计划生成器的临时空间可以在计划开始执行之前恢复.简单查询(simple-Query)模式中,没有额外的复制步骤,所以计划生成器的临时空间会一直保留到查询的最后).
      最高层的执行器例程,以及"计划节点"的执行代码,将会在一个由ExecutorStart创建,由ExecutorEnd销毁的内存环境中运行.这个内存环境也会保存ExecutorStart创建的"计划状态"树.大多数在这些例程中分配的内存会在保留到查询结束.所以为了这个目的是合适的.执行器的最高层内存环境是PortalContext的一个子内存环境,也就是说,每个运行平台(portal)的内存环境表示了查询的执行.
      执行器中主要需要改善的是表达式求值--包括资格测试(qual testing)和目标列表项的计算--需要不能发生内存泄漏.为了达到这个目的,每个在执行器中创建的ExprContext(表达式求值内存环境)都需要有一个与之关联的私有内存环境.并且当ExprContext中有表达式需要求值的时候,我们就需要切换到这个私有内存环境中.当不再需要表达式求值的结果的时候,拥有ExprContext的计划节点负责将这个私有内存环境设置为空.通常,计划节点中每一次开始从元组中取数据时,都要对私有内存环境进行重设.
      需要注意的是,这样的设计让每一个计划节点都有其自己的表达式求值内存环境.这样可以正确地处理嵌套连接(nested join),因为一个外层的计划节点可能需要保留其计算的表达式的结果,然后从一个内层的节点获取下一个元组--但是,内层的节点可能会在返回一个元组之前执行许多对元组进行操作的循环,产生很多表达式.内层的节点必须能够比每一次外层元组循环更频繁地重设表达式内存环境.幸运地是,内存足够便宜去为每一个计划节点建立一个内存环境,这看来不是一个问题.
      一个关于运行索引访问和在一个查询周期环境中排序的问题是,这些操作会调用数据类型特定的比较函数,如果这些比较函数泄漏内存的话,那么这些内存一直到查询结束才会回收.这些比较函数都返回bool或int32类型,所以它们的结果数据没有问题,但是可能内部的临时数据会产生内存泄漏.特别的,针对TOAST数据类型的比较函数需要小心地不要去泄漏非TOAST版本的输入.这是很繁琐的一件事,但是看上去使得比较函数一致,比修正索引和比较例程更容易一些,这是我建议在7.1版所要做的事情.更多的清理工作可能在以后完成.
      有一些特殊的情况,比如说聚集函数(aggregate funciton).nodeAgg.c需要记住一个元组循环到另一个元组循环的聚集函数的运算的结果,所以不能就这样丢弃每一个循环中每个元组的状态.处理这个问题最简单的方法是,在一个聚集节点中创建两个元组内存环境,在这两个内存环境之间循环.这样,在每一次元组循环中,一个是活跃的内存环境,另一个持有上一个循环的函数产生的结果.
      切换活跃CurrentMemoryContext内存环境的执行器例程可能需要在返回之前将数据复制到其调用者的当前内存环境中.我想这不太需要,因为有着在一个执行循环的"开始"而不是结束时重设每一个元组的内存环境.有了这样的约定,一个执行节点可以返回在其元组内存环境中分配的元组.这个元组一直会保留到这个执行节点调用其他的元组或者执行结束.这与现有的情况差不多,因为一个扫描节点(scan node)可以返回一个指向元组的磁盘缓存的指针,这已经可以了.
      一个更普遍的原因是,复制的数据会作为每一个元组的内存环境中的结果传递到每一次运行的内存环境(per-run context).例如,一个Unique节点会在其per-run内存环境中保存最后一个distinct元组值,这只需要一个复制的步骤.
      另一个有趣的特殊情况是VACUUM,它需要分配工作空间,这个工作空间一直保留到强制事务提交,也会在错误的情况下释放.现在是通过一个"运行平台"(portal)来完成的,这个运行平台本质上是TopMemoryContext的一个子内存环境.虽然这样仍然可行,但是如果xact失败的话,需要特殊的处理过程用来删除运行平台.更好的办法是,使用一个PortalContext的子内存环境,然后就可以作为正常处理的一部分.(我们终究会有一个更好的来自嵌套事务的解决方案,但是现在这样还是挺好的.)

允许存在多种内存环境所采取的机制
      我们可能需要几种不同类型的内存环境,这些内存环境有着不同的内存分配策略,但有着共同的外部行为.为了达到这个目的,内存分配函数将会通过函数指针进行访问,并且我们让所有的内存环境类型都遵守这里给出的规约.(这与现有的代码相差不大).
      一个内存环境表示为类似如下的一个对象:
typedef struct MemoryContextData  
{  
    NodeTag        type;           /* 内存环境的种类 */  
    MemoryContextMethods methods;  
    MemoryContextData *parent;     /* 如果没有父内存环境,则为NULL(也就是说,这是一个最高层内存环境) */  
    MemoryContextData *firstchild; /* 子内存环境链接表的头指针 */  
    MemoryContextData *nextchild;  /* 父内存环境的下一个子内存环境 */  
    char          *name;           /* 内存环境的名称(只用作调试) */  
} MemoryContextData, *MemoryContext;  

      有一个抽象的父类,"方法"指针就是其虚函数表.特定的内存环境类型使用继承的结构,并且将父类作为其开始的一些域.一个特定类型所有的内存环境都有方法指针,这些指针指向同一个函数指针的静态表.类似如下:
typedef struct MemoryContextMethodsData  
{  
    Pointer     (*alloc) (MemoryContext c, Size size);  
    void        (*free_p) (Pointer chunk);  
    Pointer     (*realloc) (Pointer chunk, Size newsize);  
    void        (*reset) (MemoryContext c);  
    void        (*delete) (MemoryContext c);  
} MemoryContextMethodsData, *MemoryContextMethods;  

      分配,重设和删除请求需要将一个MemoryContext指针作为参数,所以它们很容易找到需要调用的函数.Free和realloc函数有一些技巧.为了让这两个函数能够工作,我们让所有内存环境的类型产生的内存片都有一个标准内存片头部.如下所示:
typedef struct StandardChunkHeader  
{  
    MemoryContext mycontext;         /* 链接到其所属的内存环境 */  
    Size          size;              /* 分配的内存片的大小 */  
};  

      现有的aset.c内存环境类型已经实现了.其他种类的内存环境也要有这样的数据以支持realloc函数,所以就不会创建额外的数据头.(注意,如果一个内存环境的类型需要更多的关于每一个分配的内存片的信息,可以在标准数据头之前有一个额外的非标准的数据头.所以我们不会太强迫内存环境类型的设计者.)
      有了这个,pfree例程类似于以下这样:
StandardChunkHeader * header =  (StandardChunkHeader *) ((char *) p - sizeof(StandardChunkHeader));  
(*header->mycontext->methods->free_p) (p);  

      我们可以将其作为一个宏,但是这个宏会计算其参数两次,看来是一个坏主意(现有的pfree宏不会这样).相比于现有的代码,已经省了两层函数调用,所以我们觉得已经可以了,没必要过于挤压那么一点点提升...

更多aset.c行为以外的控制
      当前,aset.c在一个内存环境中第一次分配内存时会分配一个8K大小的内存块,如果请求的内存大小超过了,则会加倍分配.如果一个内存环境中会有大量的数据,这是一个好的办法.如果只有一小部分的内存环境有数据头的话,也不错.有几十个而不是成百个更小的内存环境在系统中,我们可能有一些比较好的微调.
      内存环境在其创建时可以指定一个初始内存块大小和一个最大内存块大小.选取小的值可以防止内存环境中存储空间的浪费.这些内存环境本来就不会有很多存储空间.(一个例子就是relcache的每个关系都会有的内存环境).
      也可以指定一个最小的内存环境大小,如果这个值大于0,那么在内存环境创建的时候,一个像这样大小的内存块就会立刻分配.ErrorContext需要这个特性(参见上面的论述),但其他的内存环境可能不会使用.
      我们希望每个元组的内存环境频繁地重设并且在每一个循环中不会分配过多的内存.为了使这个使用模式更简便,在一个内存环境中分配的第一个内存块不会在重设时将其返回给malloc函数,只是单纯地消除.这防止了malloc函数的滥用.

其余的注意点
      原来的版本建议通过引用返回数据的函数应该返回一个在内存环境中分配的值,而不是一个指向输入值的指针.我们已经不用这个概念了,因为这样会导致错误.现在的建议是,可以找到内存片分配的内存环境(通过检查其标准内存片头部),所以nodeAgg可以决定它是否可以安全地重设其工作内存环境;不再需要依赖所期望的转换函数了.

作者: bumanji   发布时间: 2010-12-13

继续分析吧。我不会C,所以看不明白源代码实现啊。

作者: renxiao2003   发布时间: 2010-12-14